From 42cf35dee15ed8470daceda466d482a76e84d7de Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Tue, 12 Aug 2025 22:46:31 +0000 Subject: [PATCH 01/14] fix: remove .icons from namespaces --- cmd/readmevalidation/repostructure.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/readmevalidation/repostructure.go b/cmd/readmevalidation/repostructure.go index 1841d79b..c4df2d91 100644 --- a/cmd/readmevalidation/repostructure.go +++ b/cmd/readmevalidation/repostructure.go @@ -10,7 +10,7 @@ import ( "golang.org/x/xerrors" ) -var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".icons", ".images") +var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images") func validateCoderResourceSubdirectory(dirPath string) []error { subDir, err := os.Stat(dirPath) From 583ec7fd55a105fce5399b52d049d3ed64e73d27 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Tue, 12 Aug 2025 22:58:22 +0000 Subject: [PATCH 02/14] refactor: clean up variable names --- cmd/readmevalidation/repostructure.go | 44 +++++++++++++++------------ 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/cmd/readmevalidation/repostructure.go b/cmd/readmevalidation/repostructure.go index c4df2d91..f6a2d8d0 100644 --- a/cmd/readmevalidation/repostructure.go +++ b/cmd/readmevalidation/repostructure.go @@ -13,15 +13,16 @@ import ( var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images") func validateCoderResourceSubdirectory(dirPath string) []error { - subDir, err := os.Stat(dirPath) + resourceDir, err := os.Stat(dirPath) if err != nil { - // It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow specific rules. + // It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow + // specific rules. if !errors.Is(err, os.ErrNotExist) { return []error{addFilePathToError(dirPath, err)} } } - if !subDir.IsDir() { + if !resourceDir.IsDir() { return []error{xerrors.Errorf("%q: path is not a directory", dirPath)} } @@ -30,10 +31,11 @@ func validateCoderResourceSubdirectory(dirPath string) []error { return []error{addFilePathToError(dirPath, err)} } - errs := []error{} + var errs []error for _, f := range files { - // The .coder subdirectories are sometimes generated as part of Bun tests. These subdirectories will never be - // committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over them. + // The .coder subdirectories are sometimes generated as part of our Bun tests. These subdirectories will never + // be committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over + // them. if !f.IsDir() || f.Name() == ".coder" { continue } @@ -60,48 +62,50 @@ func validateCoderResourceSubdirectory(dirPath string) []error { } func validateRegistryDirectory() []error { - userDirs, err := os.ReadDir(rootRegistryPath) + namespaceDirs, err := os.ReadDir(rootRegistryPath) if err != nil { return []error{err} } - allErrs := []error{} - for _, d := range userDirs { - dirPath := path.Join(rootRegistryPath, d.Name()) - if !d.IsDir() { - allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", dirPath)) + var allErrs []error + for _, nDir := range namespaceDirs { + namespacePath := path.Join(rootRegistryPath, nDir.Name()) + if !nDir.IsDir() { + allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", namespacePath)) continue } - contributorReadmePath := path.Join(dirPath, "README.md") + contributorReadmePath := path.Join(namespacePath, "README.md") if _, err := os.Stat(contributorReadmePath); err != nil { allErrs = append(allErrs, err) } - files, err := os.ReadDir(dirPath) + files, err := os.ReadDir(namespacePath) if err != nil { allErrs = append(allErrs, err) continue } for _, f := range files { - // TODO: Decide if there's anything more formal that we want to ensure about non-directories scoped to user namespaces. + // TODO: Decide if there's anything more formal that we want to ensure about non-directories at the top + // level of each user namespace. if !f.IsDir() { continue } segment := f.Name() - filePath := path.Join(dirPath, segment) + filePath := path.Join(namespacePath, segment) if !slices.Contains(supportedUserNameSpaceDirectories, segment) { allErrs = append(allErrs, xerrors.Errorf("%q: only these sub-directories are allowed at top of user namespace: [%s]", filePath, strings.Join(supportedUserNameSpaceDirectories, ", "))) continue } + if !slices.Contains(supportedResourceTypes, segment) { + continue + } - if slices.Contains(supportedResourceTypes, segment) { - if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 { - allErrs = append(allErrs, errs...) - } + if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 { + allErrs = append(allErrs, errs...) } } } From 20dc407dc3da406bb3eef77114817150f5b5e652 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Tue, 12 Aug 2025 23:01:33 +0000 Subject: [PATCH 03/14] docs: add function comment --- cmd/readmevalidation/repostructure.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/readmevalidation/repostructure.go b/cmd/readmevalidation/repostructure.go index f6a2d8d0..2e46bb59 100644 --- a/cmd/readmevalidation/repostructure.go +++ b/cmd/readmevalidation/repostructure.go @@ -113,6 +113,9 @@ func validateRegistryDirectory() []error { return allErrs } +// validateRepoStructure validates that the structure of the repo is "correct enough" to do all necessary validation +// checks. It is NOT an exhaustive validation of the entire repo structure – it only checks the parts of the repo that +// are relevant for the main validation steps func validateRepoStructure() error { var errs []error if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 { From 4b3217204e1ce423661fb61b491488d05da6d2c8 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Tue, 12 Aug 2025 23:13:26 +0000 Subject: [PATCH 04/14] docs: add more function comments --- cmd/readmevalidation/repostructure.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/readmevalidation/repostructure.go b/cmd/readmevalidation/repostructure.go index 2e46bb59..7a529c01 100644 --- a/cmd/readmevalidation/repostructure.go +++ b/cmd/readmevalidation/repostructure.go @@ -12,6 +12,8 @@ import ( var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images") +// validateCoderResourceSubdirectory validates that the structure of a module or template within a namespace follows all +// expected file conventions func validateCoderResourceSubdirectory(dirPath string) []error { resourceDir, err := os.Stat(dirPath) if err != nil { @@ -61,6 +63,8 @@ func validateCoderResourceSubdirectory(dirPath string) []error { return errs } +// validateRegistryDirectory validates that the contents of `/registry` follow all expected file conventions. This +// includes the top-level structure of the individual namespace directories. func validateRegistryDirectory() []error { namespaceDirs, err := os.ReadDir(rootRegistryPath) if err != nil { From af2016a9f4beed509b0c9dca43054ec40c365777 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Tue, 12 Aug 2025 23:19:22 +0000 Subject: [PATCH 05/14] docs: add more comments --- cmd/readmevalidation/readmefiles.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/readmevalidation/readmefiles.go b/cmd/readmevalidation/readmefiles.go index c55806a8..fc5537c4 100644 --- a/cmd/readmevalidation/readmefiles.go +++ b/cmd/readmevalidation/readmefiles.go @@ -39,7 +39,9 @@ const ( var ( supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"} - // Matches markdown headers, must be at the beginning of a line, such as "# " or "### ". + // Matches markdown headers placed at the beginning of a line (e.g., "# " or "### "). To make the logic for + // validateReadmeBody easier, this pattern deliberately matches on invalid headers (header levels must be in the + // range 1–6 to be valid). The function has checks to see if the level is correct. readmeHeaderRe = regexp.MustCompile(`^(#+)(\s*)`) ) From 3ac0c628b034923a2aac1455deb6ac16bf151a36 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 13 Aug 2025 00:03:09 +0000 Subject: [PATCH 06/14] fix: add missing validation for GitHub usernames --- cmd/readmevalidation/contributors.go | 45 ++++++++++++++++++++++++---- cmd/readmevalidation/readmefiles.go | 23 ++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/cmd/readmevalidation/contributors.go b/cmd/readmevalidation/contributors.go index aeab7ffa..c2e95e8a 100644 --- a/cmd/readmevalidation/contributors.go +++ b/cmd/readmevalidation/contributors.go @@ -19,11 +19,16 @@ type contributorProfileFrontmatter struct { Bio string `yaml:"bio"` ContributorStatus string `yaml:"status"` AvatarURL *string `yaml:"avatar"` + GithubUsername *string `yaml:"github"` LinkedinURL *string `yaml:"linkedin"` WebsiteURL *string `yaml:"website"` SupportEmail *string `yaml:"support_email"` } +// A slice version of the struct tags from contributorProfileFrontmatter. Might be worth using reflection to generate +// this list at runtime in the future, but this should be okay for now +var supportedContributorProfileStructKeys = []string{"display_name", "bio", "status", "avatar", "linkedin", "github", "website", "support_email"} + type contributorProfileReadme struct { frontmatter contributorProfileFrontmatter namespace string @@ -50,6 +55,22 @@ func validateContributorLinkedinURL(linkedinURL *string) error { return nil } +func validateGithubUsername(username *string) error { + if username == nil { + return nil + } + + name := *username + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return xerrors.New("username must have non-whitespace characters") + } + if name != trimmed { + return xerrors.Errorf("username %q has extra whitespace", trimmed) + } + return nil +} + // validateContributorSupportEmail does best effort validation of a contributors email address. We can't 100% validate // that this is correct without actually sending an email, especially because some contributors are individual developers // and we don't want to do that on every single run of the CI pipeline. The best we can do is verify the general structure. @@ -153,6 +174,9 @@ func validateContributorReadme(rm contributorProfileReadme) []error { if err := validateContributorLinkedinURL(rm.frontmatter.LinkedinURL); err != nil { allErrs = append(allErrs, addFilePathToError(rm.filePath, err)) } + if err := validateGithubUsername(rm.frontmatter.GithubUsername); err != nil { + allErrs = append(allErrs, addFilePathToError(rm.filePath, err)) + } if err := validateContributorWebsite(rm.frontmatter.WebsiteURL); err != nil { allErrs = append(allErrs, addFilePathToError(rm.filePath, err)) } @@ -170,15 +194,24 @@ func validateContributorReadme(rm contributorProfileReadme) []error { return allErrs } -func parseContributorProfile(rm readme) (contributorProfileReadme, error) { +func parseContributorProfile(rm readme) (contributorProfileReadme, []error) { fm, _, err := separateFrontmatter(rm.rawText) if err != nil { - return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err) + return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)} + } + + keyErrs := validateFrontmatterYamlKeys(fm, supportedContributorProfileStructKeys) + if len(keyErrs) != 0 { + remapped := []error{} + for _, e := range keyErrs { + remapped = append(remapped, addFilePathToError(rm.filePath, e)) + } + return contributorProfileReadme{}, remapped } yml := contributorProfileFrontmatter{} if err := yaml.Unmarshal([]byte(fm), &yml); err != nil { - return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err) + return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)} } return contributorProfileReadme{ @@ -192,9 +225,9 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil profilesByNamespace := map[string]contributorProfileReadme{} yamlParsingErrors := []error{} for _, rm := range readmeEntries { - p, err := parseContributorProfile(rm) - if err != nil { - yamlParsingErrors = append(yamlParsingErrors, err) + p, errs := parseContributorProfile(rm) + if len(errs) != 0 { + yamlParsingErrors = append(yamlParsingErrors, errs...) continue } diff --git a/cmd/readmevalidation/readmefiles.go b/cmd/readmevalidation/readmefiles.go index fc5537c4..70580a77 100644 --- a/cmd/readmevalidation/readmefiles.go +++ b/cmd/readmevalidation/readmefiles.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "regexp" + "slices" "strings" "golang.org/x/xerrors" @@ -170,3 +171,25 @@ func validateReadmeBody(body string) []error { return errs } + +func validateFrontmatterYamlKeys(frontmatter string, allowedKeys []string) []error { + if len(allowedKeys) == 0 { + return []error{xerrors.New("Set of allowed keys is empty")} + } + + var key string + var cutOk bool + var line string + + var errs []error + lineScanner := bufio.NewScanner(strings.NewReader(frontmatter)) + for lineScanner.Scan() { + line = lineScanner.Text() + key, _, cutOk = strings.Cut(line, ":") + if !cutOk || slices.Contains(allowedKeys, key) { + continue + } + errs = append(errs, xerrors.Errorf("detected unknown key %q", key)) + } + return errs +} From 0c0c901ecb85c094b2697d390ea48651f1ebdbce Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 13 Aug 2025 00:17:27 +0000 Subject: [PATCH 07/14] fix: add required key checks for coder resources --- cmd/readmevalidation/coderresources.go | 56 +++++++++++++++++++++----- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index 32b123e8..602e263f 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -17,6 +17,7 @@ import ( var ( supportedResourceTypes = []string{"modules", "templates"} + operatingSystems = []string{"windows", "macos", "linux"} // TODO: This is a holdover from the validation logic used by the Coder Modules repo. It gives us some assurance, but // realistically, we probably want to parse any Terraform code snippets, and make some deeper guarantees about how it's @@ -25,11 +26,21 @@ var ( ) type coderResourceFrontmatter struct { - Description string `yaml:"description"` - IconURL string `yaml:"icon"` - DisplayName *string `yaml:"display_name"` - Verified *bool `yaml:"verified"` - Tags []string `yaml:"tags"` + Description string `yaml:"description"` + IconURL string `yaml:"icon"` + DisplayName *string `yaml:"display_name"` + Verified *bool `yaml:"verified"` + Tags []string `yaml:"tags"` + OperatingSystems []string `yaml:"supported_os"` +} + +// A slice version of the struct tags from coderResourceFrontmatter. Might be worth using reflection to generate this +// list at runtime in the future, but this should be okay for now +var supportedCoderResourceStructKeys = []string{ + "description", "icon", "display_name", "verified", "tags", "supported_os", + // TODO: This is an old, officially deprecated key from an older version of the repo. We can remove this once we + // make sure that the Registry Server is no longer checking this field. + "maintainer_github", } // coderResourceReadme represents a README describing a Terraform resource used @@ -42,6 +53,17 @@ type coderResourceReadme struct { frontmatter coderResourceFrontmatter } +func validateSupportedOperatingSystems(systems []string) []error { + var errs []error + for _, s := range systems { + if slices.Contains(operatingSystems, s) { + continue + } + errs = append(errs, xerrors.Errorf("detected unknown operating system %q", s)) + } + return errs +} + func validateCoderResourceDisplayName(displayName *string) error { if displayName != nil && *displayName == "" { return xerrors.New("if defined, display_name must not be empty string") @@ -211,19 +233,31 @@ func validateCoderResourceReadme(rm coderResourceReadme) []error { for _, err := range validateCoderResourceIconURL(rm.frontmatter.IconURL) { errs = append(errs, addFilePathToError(rm.filePath, err)) } + for _, err := range validateSupportedOperatingSystems(rm.frontmatter.OperatingSystems) { + errs = append(errs, addFilePathToError(rm.filePath, err)) + } return errs } -func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, error) { +func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, []error) { fm, body, err := separateFrontmatter(rm.rawText) if err != nil { - return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err) + return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)} + } + + keyErrs := validateFrontmatterYamlKeys(fm, supportedCoderResourceStructKeys) + if len(keyErrs) != 0 { + remapped := []error{} + for _, e := range keyErrs { + remapped = append(remapped, addFilePathToError(rm.filePath, e)) + } + return coderResourceReadme{}, remapped } yml := coderResourceFrontmatter{} if err := yaml.Unmarshal([]byte(fm), &yml); err != nil { - return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err) + return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)} } return coderResourceReadme{ @@ -238,9 +272,9 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin resources := map[string]coderResourceReadme{} var yamlParsingErrs []error for _, rm := range rms { - p, err := parseCoderResourceReadme(resourceType, rm) - if err != nil { - yamlParsingErrs = append(yamlParsingErrs, err) + p, errs := parseCoderResourceReadme(resourceType, rm) + if len(errs) != 0 { + yamlParsingErrs = append(yamlParsingErrs, errs...) continue } From ca47cfb2647553ae064a499076f590f25e5c6eae Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 13 Aug 2025 00:18:41 +0000 Subject: [PATCH 08/14] docs: rewrite comment for clarity --- cmd/readmevalidation/coderresources.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index 602e263f..7cf9259a 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -38,7 +38,7 @@ type coderResourceFrontmatter struct { // list at runtime in the future, but this should be okay for now var supportedCoderResourceStructKeys = []string{ "description", "icon", "display_name", "verified", "tags", "supported_os", - // TODO: This is an old, officially deprecated key from an older version of the repo. We can remove this once we + // TODO: This is an old, officially deprecated key from the archived coder/modules repo. We can remove this once we // make sure that the Registry Server is no longer checking this field. "maintainer_github", } From 7d6dd246086f8fed2c9708b8eaa1cac9e32a01fe Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 13 Aug 2025 01:28:12 +0000 Subject: [PATCH 09/14] refactor: split up module/template validation logic --- cmd/readmevalidation/coderresources.go | 38 ++++++++++++++++++++++---- cmd/readmevalidation/main.go | 6 +++- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index 7cf9259a..994e0b1e 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -269,6 +269,10 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead } func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[string]coderResourceReadme, error) { + if !slices.Contains(supportedResourceTypes, resourceType) { + return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType) + } + resources := map[string]coderResourceReadme{} var yamlParsingErrs []error for _, rm := range rms { @@ -311,6 +315,10 @@ func validateCoderResourceRelativeURLs(_ map[string]coderResourceReadme) error { } func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) { + if !slices.Contains(supportedResourceTypes, resourceType) { + return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType) + } + registryFiles, err := os.ReadDir(rootRegistryPath) if err != nil { return nil, err @@ -360,26 +368,44 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) { return allReadmeFiles, nil } -func validateAllCoderResourceFilesOfType(resourceType string) error { - if !slices.Contains(supportedResourceTypes, resourceType) { - return xerrors.Errorf("resource type %q is not part of supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", ")) +func validateAllCoderModules() error { + const resourceType = "modules" + allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType) + if err != nil { + return err } + logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles)) + resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles) + if err != nil { + return err + } + logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources)) + + if err := validateCoderResourceRelativeURLs(resources); err != nil { + return err + } + logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType) + return nil +} + +func validateAllCoderTemplates() error { + const resourceType = "templates" allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType) if err != nil { return err } - logger.Info(context.Background(), "processing README files", "num_files", len(allReadmeFiles)) + logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles)) resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles) if err != nil { return err } - logger.Info(context.Background(), "processed README files as valid Coder resources", "num_files", len(resources), "type", resourceType) + logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources)) if err := validateCoderResourceRelativeURLs(resources); err != nil { return err } - logger.Info(context.Background(), "all relative URLs for READMEs are valid", "type", resourceType) + logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType) return nil } diff --git a/cmd/readmevalidation/main.go b/cmd/readmevalidation/main.go index cce66df9..49bfaf83 100644 --- a/cmd/readmevalidation/main.go +++ b/cmd/readmevalidation/main.go @@ -31,7 +31,11 @@ func main() { if err != nil { errs = append(errs, err) } - err = validateAllCoderResourceFilesOfType("modules") + err = validateAllCoderModules() + if err != nil { + errs = append(errs, err) + } + err = validateAllCoderTemplates() if err != nil { errs = append(errs, err) } From 28d5af5df4e71234aa3a678cfeca0506fcd6f53f Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 13 Aug 2025 02:08:31 +0000 Subject: [PATCH 10/14] refactor: get majority of functionality split off --- cmd/readmevalidation/coderresources.go | 122 ++++++++++++++------ cmd/readmevalidation/coderresources_test.go | 2 +- cmd/readmevalidation/contributors.go | 16 +-- 3 files changed, 98 insertions(+), 42 deletions(-) diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index 994e0b1e..8e858657 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -89,7 +89,7 @@ func validateCoderResourceIconURL(iconURL string) []error { return []error{xerrors.New("icon URL cannot be empty")} } - errs := []error{} + var errs []error // If the URL does not have a relative path. if !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") { @@ -120,7 +120,7 @@ func validateCoderResourceTags(tags []string) error { // All of these tags are used for the module/template filter controls in the Registry site. Need to make sure they // can all be placed in the browser URL without issue. - invalidTags := []string{} + var invalidTags []string for _, t := range tags { if t != url.QueryEscape(t) { invalidTags = append(invalidTags, t) @@ -133,7 +133,7 @@ func validateCoderResourceTags(tags []string) error { return nil } -func validateCoderResourceReadmeBody(body string) []error { +func validateCoderModuleReadmeBody(body string) []error { var errs []error trimmed := strings.TrimSpace(body) @@ -213,33 +213,88 @@ func validateCoderResourceReadmeBody(body string) []error { return errs } -func validateCoderResourceReadme(rm coderResourceReadme) []error { - var errs []error +func validateCoderResourceFrontmatter(resourceType string, filePath string, fm coderResourceFrontmatter) []error { + if !slices.Contains(supportedResourceTypes, resourceType) { + return []error{xerrors.Errorf("cannot process unknown resource type %q", resourceType)} + } - for _, err := range validateCoderResourceReadmeBody(rm.body) { - errs = append(errs, addFilePathToError(rm.filePath, err)) + var errs []error + if err := validateCoderResourceDisplayName(fm.DisplayName); err != nil { + errs = append(errs, addFilePathToError(filePath, err)) + } + if err := validateCoderResourceDescription(fm.Description); err != nil { + errs = append(errs, addFilePathToError(filePath, err)) + } + if err := validateCoderResourceTags(fm.Tags); err != nil { + errs = append(errs, addFilePathToError(filePath, err)) } - if err := validateCoderResourceDisplayName(rm.frontmatter.DisplayName); err != nil { - errs = append(errs, addFilePathToError(rm.filePath, err)) + for _, err := range validateCoderResourceIconURL(fm.IconURL) { + errs = append(errs, addFilePathToError(filePath, err)) } - if err := validateCoderResourceDescription(rm.frontmatter.Description); err != nil { - errs = append(errs, addFilePathToError(rm.filePath, err)) + for _, err := range validateSupportedOperatingSystems(fm.OperatingSystems) { + errs = append(errs, addFilePathToError(filePath, err)) } - if err := validateCoderResourceTags(rm.frontmatter.Tags); err != nil { + + return errs +} + +func validateCoderModuleReadme(rm coderResourceReadme) []error { + var errs []error + for _, err := range validateCoderModuleReadmeBody(rm.body) { errs = append(errs, addFilePathToError(rm.filePath, err)) } + if fmErrs := validateCoderResourceFrontmatter("modules", rm.filePath, rm.frontmatter); len(fmErrs) != 0 { + errs = append(errs, fmErrs...) + } + return errs +} - for _, err := range validateCoderResourceIconURL(rm.frontmatter.IconURL) { - errs = append(errs, addFilePathToError(rm.filePath, err)) +func validateAllCoderModuleReadmes(resources []coderResourceReadme) error { + var yamlValidationErrors []error + for _, readme := range resources { + errs := validateCoderModuleReadme(readme) + if len(errs) > 0 { + yamlValidationErrors = append(yamlValidationErrors, errs...) + } } - for _, err := range validateSupportedOperatingSystems(rm.frontmatter.OperatingSystems) { - errs = append(errs, addFilePathToError(rm.filePath, err)) + if len(yamlValidationErrors) != 0 { + return validationPhaseError{ + phase: validationPhaseReadme, + errors: yamlValidationErrors, + } } + return nil +} +func validateCoderTemplateReadme(rm coderResourceReadme) []error { + var errs []error + // for _, err := range validateCoderResourceReadmeBody(rm.body) { + // errs = append(errs, addFilePathToError(rm.filePath, err)) + // } + if fmErrs := validateCoderResourceFrontmatter("modules", rm.filePath, rm.frontmatter); len(fmErrs) != 0 { + errs = append(errs, fmErrs...) + } return errs } +func validateAllCoderTemplateReadmes(resources []coderResourceReadme) error { + var yamlValidationErrors []error + for _, readme := range resources { + errs := validateCoderTemplateReadme(readme) + if len(errs) > 0 { + yamlValidationErrors = append(yamlValidationErrors, errs...) + } + } + if len(yamlValidationErrors) != 0 { + return validationPhaseError{ + phase: validationPhaseReadme, + errors: yamlValidationErrors, + } + } + return nil +} + func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, []error) { fm, body, err := separateFrontmatter(rm.rawText) if err != nil { @@ -248,7 +303,7 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead keyErrs := validateFrontmatterYamlKeys(fm, supportedCoderResourceStructKeys) if len(keyErrs) != 0 { - remapped := []error{} + var remapped []error for _, e := range keyErrs { remapped = append(remapped, addFilePathToError(rm.filePath, e)) } @@ -268,7 +323,7 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead }, nil } -func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[string]coderResourceReadme, error) { +func parseCoderResourceReadmeFiles(resourceType string, rms []readme) ([]coderResourceReadme, error) { if !slices.Contains(supportedResourceTypes, resourceType) { return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType) } @@ -291,26 +346,19 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin } } - yamlValidationErrors := []error{} - for _, readme := range resources { - errs := validateCoderResourceReadme(readme) - if len(errs) > 0 { - yamlValidationErrors = append(yamlValidationErrors, errs...) - } - } - if len(yamlValidationErrors) != 0 { - return nil, validationPhaseError{ - phase: validationPhaseReadme, - errors: yamlValidationErrors, - } + var serialized []coderResourceReadme + for _, r := range resources { + serialized = append(serialized, r) } - - return resources, nil + slices.SortFunc(serialized, func(r1 coderResourceReadme, r2 coderResourceReadme) int { + return strings.Compare(r1.filePath, r2.filePath) + }) + return serialized, nil } // Todo: Need to beef up this function by grabbing each image/video URL from // the body's AST. -func validateCoderResourceRelativeURLs(_ map[string]coderResourceReadme) error { +func validateCoderResourceRelativeURLs(_ []coderResourceReadme) error { return nil } @@ -380,6 +428,10 @@ func validateAllCoderModules() error { if err != nil { return err } + err = validateAllCoderModuleReadmes(resources) + if err != nil { + return err + } logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources)) if err := validateCoderResourceRelativeURLs(resources); err != nil { @@ -401,6 +453,10 @@ func validateAllCoderTemplates() error { if err != nil { return err } + err = validateAllCoderTemplateReadmes(resources) + if err != nil { + return err + } logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources)) if err := validateCoderResourceRelativeURLs(resources); err != nil { diff --git a/cmd/readmevalidation/coderresources_test.go b/cmd/readmevalidation/coderresources_test.go index 71ec75f4..194a861e 100644 --- a/cmd/readmevalidation/coderresources_test.go +++ b/cmd/readmevalidation/coderresources_test.go @@ -14,7 +14,7 @@ func TestValidateCoderResourceReadmeBody(t *testing.T) { t.Run("Parses a valid README body with zero issues", func(t *testing.T) { t.Parallel() - errs := validateCoderResourceReadmeBody(testBody) + errs := validateCoderModuleReadmeBody(testBody) for _, e := range errs { t.Error(e) } diff --git a/cmd/readmevalidation/contributors.go b/cmd/readmevalidation/contributors.go index c2e95e8a..8a9b195b 100644 --- a/cmd/readmevalidation/contributors.go +++ b/cmd/readmevalidation/contributors.go @@ -79,7 +79,7 @@ func validateContributorSupportEmail(email *string) []error { return nil } - errs := []error{} + var errs []error username, server, ok := strings.Cut(*email, "@") if !ok { @@ -140,7 +140,7 @@ func validateContributorAvatarURL(avatarURL *string) []error { return []error{xerrors.New("avatar URL must be omitted or non-empty string")} } - errs := []error{} + var errs []error // Have to use .Parse instead of .ParseRequestURI because this is the one field that's allowed to be a relative URL. if _, err := url.Parse(*avatarURL); err != nil { errs = append(errs, xerrors.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL)) @@ -166,7 +166,7 @@ func validateContributorAvatarURL(avatarURL *string) []error { } func validateContributorReadme(rm contributorProfileReadme) []error { - allErrs := []error{} + var allErrs []error if err := validateContributorDisplayName(rm.frontmatter.DisplayName); err != nil { allErrs = append(allErrs, addFilePathToError(rm.filePath, err)) @@ -202,7 +202,7 @@ func parseContributorProfile(rm readme) (contributorProfileReadme, []error) { keyErrs := validateFrontmatterYamlKeys(fm, supportedContributorProfileStructKeys) if len(keyErrs) != 0 { - remapped := []error{} + var remapped []error for _, e := range keyErrs { remapped = append(remapped, addFilePathToError(rm.filePath, e)) } @@ -223,7 +223,7 @@ func parseContributorProfile(rm readme) (contributorProfileReadme, []error) { func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfileReadme, error) { profilesByNamespace := map[string]contributorProfileReadme{} - yamlParsingErrors := []error{} + var yamlParsingErrors []error for _, rm := range readmeEntries { p, errs := parseContributorProfile(rm) if len(errs) != 0 { @@ -244,7 +244,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil } } - yamlValidationErrors := []error{} + var yamlValidationErrors []error for _, p := range profilesByNamespace { if errors := validateContributorReadme(p); len(errors) > 0 { yamlValidationErrors = append(yamlValidationErrors, errors...) @@ -267,8 +267,8 @@ func aggregateContributorReadmeFiles() ([]readme, error) { return nil, err } - allReadmeFiles := []readme{} - errs := []error{} + var allReadmeFiles []readme + var errs []error dirPath := "" for _, e := range dirEntries { if !e.IsDir() { From 4b7d152dc149aa83af4d435fb21febe75273cf24 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 13 Aug 2025 02:16:48 +0000 Subject: [PATCH 11/14] refactor: start splitting up body validation --- cmd/readmevalidation/coderresources.go | 94 ++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 6 deletions(-) diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index 8e858657..b4bb6768 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -137,8 +137,90 @@ func validateCoderModuleReadmeBody(body string) []error { var errs []error trimmed := strings.TrimSpace(body) - // TODO: this may cause unexpected behavior since the errors slice may have a 0 length. Add a test. - errs = append(errs, validateReadmeBody(trimmed)...) + if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 { + errs = append(errs, baseErrs...) + } + + foundParagraph := false + terraformCodeBlockCount := 0 + foundTerraformVersionRef := false + + lineNum := 0 + isInsideCodeBlock := false + isInsideTerraform := false + + lineScanner := bufio.NewScanner(strings.NewReader(trimmed)) + for lineScanner.Scan() { + lineNum++ + nextLine := lineScanner.Text() + + // Code assumes that invalid headers would've already been handled by the base validation function, so we don't + // need to check deeper if the first line isn't an h1. + if lineNum == 1 { + if !strings.HasPrefix(nextLine, "# ") { + break + } + continue + } + + if strings.HasPrefix(nextLine, "```") { + isInsideCodeBlock = !isInsideCodeBlock + isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf") + if isInsideTerraform { + terraformCodeBlockCount++ + } + if strings.HasPrefix(nextLine, "```hcl") { + errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf")) + } + continue + } + + if isInsideCodeBlock { + if isInsideTerraform { + foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine) + } + continue + } + + // Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines. + if lineNum > 1 && strings.HasPrefix(nextLine, "#") { + break + } + + // Code assumes that if we've reached this point, the only other options are: + // (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax. + trimmedLine := strings.TrimSpace(nextLine) + isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<") + foundParagraph = foundParagraph || isParagraph + } + + if terraformCodeBlockCount == 0 { + errs = append(errs, xerrors.New("did not find Terraform code block within h1 section")) + } else { + if terraformCodeBlockCount > 1 { + errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section")) + } + if !foundTerraformVersionRef { + errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field")) + } + } + if !foundParagraph { + errs = append(errs, xerrors.New("did not find paragraph within h1 section")) + } + if isInsideCodeBlock { + errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file")) + } + + return errs +} + +func validateCoderTemplateReadmeBody(body string) []error { + var errs []error + + trimmed := strings.TrimSpace(body) + if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 { + errs = append(errs, baseErrs...) + } foundParagraph := false terraformCodeBlockCount := 0 @@ -269,10 +351,10 @@ func validateAllCoderModuleReadmes(resources []coderResourceReadme) error { func validateCoderTemplateReadme(rm coderResourceReadme) []error { var errs []error - // for _, err := range validateCoderResourceReadmeBody(rm.body) { - // errs = append(errs, addFilePathToError(rm.filePath, err)) - // } - if fmErrs := validateCoderResourceFrontmatter("modules", rm.filePath, rm.frontmatter); len(fmErrs) != 0 { + for _, err := range validateCoderTemplateReadmeBody(rm.body) { + errs = append(errs, addFilePathToError(rm.filePath, err)) + } + if fmErrs := validateCoderResourceFrontmatter("templates", rm.filePath, rm.frontmatter); len(fmErrs) != 0 { errs = append(errs, fmErrs...) } return errs From 153027e9340006ea57038238dbcdb304c3ac084a Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 13 Aug 2025 02:24:20 +0000 Subject: [PATCH 12/14] fix: update all README files to match new submission standards --- cmd/readmevalidation/coderresources.go | 30 ++----------------- .../templates/gcp-devcontainer/README.md | 2 ++ registry/coder/templates/gcp-linux/README.md | 2 ++ .../templates/gcp-vm-container/README.md | 2 ++ .../coder/templates/gcp-windows/README.md | 2 ++ .../templates/kubernetes-envbox/README.md | 2 ++ .../templates/docker-claude/README.md | 12 ++++---- 7 files changed, 20 insertions(+), 32 deletions(-) diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index b4bb6768..b860abb6 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -222,18 +222,15 @@ func validateCoderTemplateReadmeBody(body string) []error { errs = append(errs, baseErrs...) } + var nextLine string foundParagraph := false - terraformCodeBlockCount := 0 - foundTerraformVersionRef := false - - lineNum := 0 isInsideCodeBlock := false - isInsideTerraform := false + lineNum := 0 lineScanner := bufio.NewScanner(strings.NewReader(trimmed)) for lineScanner.Scan() { lineNum++ - nextLine := lineScanner.Text() + nextLine = lineScanner.Text() // Code assumes that invalid headers would've already been handled by the base validation function, so we don't // need to check deeper if the first line isn't an h1. @@ -246,23 +243,12 @@ func validateCoderTemplateReadmeBody(body string) []error { if strings.HasPrefix(nextLine, "```") { isInsideCodeBlock = !isInsideCodeBlock - isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf") - if isInsideTerraform { - terraformCodeBlockCount++ - } if strings.HasPrefix(nextLine, "```hcl") { errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf")) } continue } - if isInsideCodeBlock { - if isInsideTerraform { - foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine) - } - continue - } - // Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines. if lineNum > 1 && strings.HasPrefix(nextLine, "#") { break @@ -275,16 +261,6 @@ func validateCoderTemplateReadmeBody(body string) []error { foundParagraph = foundParagraph || isParagraph } - if terraformCodeBlockCount == 0 { - errs = append(errs, xerrors.New("did not find Terraform code block within h1 section")) - } else { - if terraformCodeBlockCount > 1 { - errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section")) - } - if !foundTerraformVersionRef { - errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field")) - } - } if !foundParagraph { errs = append(errs, xerrors.New("did not find paragraph within h1 section")) } diff --git a/registry/coder/templates/gcp-devcontainer/README.md b/registry/coder/templates/gcp-devcontainer/README.md index 329dad97..f1963294 100644 --- a/registry/coder/templates/gcp-devcontainer/README.md +++ b/registry/coder/templates/gcp-devcontainer/README.md @@ -8,6 +8,8 @@ tags: [vm, linux, gcp, devcontainer] # Remote Development in a Devcontainer on Google Compute Engine +Provision a Devcontainer on Google Compute Engine instances as Coder workspaces + ![Architecture Diagram](../../.images/gcp-devcontainer-architecture.svg) ## Prerequisites diff --git a/registry/coder/templates/gcp-linux/README.md b/registry/coder/templates/gcp-linux/README.md index 04eb3ad7..4c57900f 100644 --- a/registry/coder/templates/gcp-linux/README.md +++ b/registry/coder/templates/gcp-linux/README.md @@ -8,6 +8,8 @@ tags: [vm, linux, gcp] # Remote Development on Google Compute Engine (Linux) +Provision Google Compute Engine instances as Coder workspaces + ## Prerequisites ### Authentication diff --git a/registry/coder/templates/gcp-vm-container/README.md b/registry/coder/templates/gcp-vm-container/README.md index ea991bcb..5023f32a 100644 --- a/registry/coder/templates/gcp-vm-container/README.md +++ b/registry/coder/templates/gcp-vm-container/README.md @@ -8,6 +8,8 @@ tags: [vm-container, linux, gcp] # Remote Development on Google Compute Engine (VM Container) +Provision Google Compute Engine instances as Coder workspaces. + ## Prerequisites ### Authentication diff --git a/registry/coder/templates/gcp-windows/README.md b/registry/coder/templates/gcp-windows/README.md index a206745d..e7e8d29a 100644 --- a/registry/coder/templates/gcp-windows/README.md +++ b/registry/coder/templates/gcp-windows/README.md @@ -8,6 +8,8 @@ tags: [vm, windows, gcp] # Remote Development on Google Compute Engine (Windows) +Provision Google Compute Engine instances as Coder workspaces + ## Prerequisites ### Authentication diff --git a/registry/coder/templates/kubernetes-envbox/README.md b/registry/coder/templates/kubernetes-envbox/README.md index 4e440086..eb26745c 100644 --- a/registry/coder/templates/kubernetes-envbox/README.md +++ b/registry/coder/templates/kubernetes-envbox/README.md @@ -8,6 +8,8 @@ tags: [kubernetes, containers, docker-in-docker] # envbox +Provision envbox pods as Coder workspaces + ## Introduction `envbox` is an image that enables creating non-privileged containers capable of running system-level software (e.g. `dockerd`, `systemd`, etc) in Kubernetes. diff --git a/registry/sharkymark/templates/docker-claude/README.md b/registry/sharkymark/templates/docker-claude/README.md index d6829362..e0eca3af 100644 --- a/registry/sharkymark/templates/docker-claude/README.md +++ b/registry/sharkymark/templates/docker-claude/README.md @@ -1,27 +1,29 @@ --- display_name: "Claude Code AI Agent Template" -description: The goal is to try the experimental ai agent integration with Claude CodeAI agent +description: An experimental AI agent integration with Claude CodeAI agent icon: "../../../../.icons/claude.svg" verified: false tags: ["ai", "docker", "container", "claude", "agent", "tasks"] --- -# ai agent template for a workspace in a container on a Docker host +# AI agent template for a workspace in a container on a Docker host -### Docker image +An experimental AI agent integration with Claude CodeAI agent + +## Docker image 1. Based on Coder-managed image `codercom/example-universal:ubuntu` [Image on DockerHub](https://hub.docker.com/r/codercom/example-universal) -### Apps included +## Apps included 1. A web-based terminal 1. code-server Web IDE 1. A [sample app](https://github.com/gothinkster/realworld) to test the environment 1. [Claude Code AI agent](https://www.anthropic.com/claude-code) to assist with development tasks -### Resources +## Resources [Coder docs on AI agents and tasks](https://coder.com/docs/ai-coder/tasks) From 45cf01b789c50d2cfa764274bec7d8b9b46b50d7 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 13 Aug 2025 02:29:16 +0000 Subject: [PATCH 13/14] refactor: split up functionality across more files --- cmd/readmevalidation/codermodules.go | 143 ++++++++++ ...resources_test.go => codermodules_test.go} | 0 cmd/readmevalidation/coderresources.go | 246 ------------------ cmd/readmevalidation/codertemplates.go | 119 +++++++++ 4 files changed, 262 insertions(+), 246 deletions(-) create mode 100644 cmd/readmevalidation/codermodules.go rename cmd/readmevalidation/{coderresources_test.go => codermodules_test.go} (100%) create mode 100644 cmd/readmevalidation/codertemplates.go diff --git a/cmd/readmevalidation/codermodules.go b/cmd/readmevalidation/codermodules.go new file mode 100644 index 00000000..64885359 --- /dev/null +++ b/cmd/readmevalidation/codermodules.go @@ -0,0 +1,143 @@ +package main + +import ( + "bufio" + "context" + "strings" + + "golang.org/x/xerrors" +) + +func validateCoderModuleReadmeBody(body string) []error { + var errs []error + + trimmed := strings.TrimSpace(body) + if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 { + errs = append(errs, baseErrs...) + } + + foundParagraph := false + terraformCodeBlockCount := 0 + foundTerraformVersionRef := false + + lineNum := 0 + isInsideCodeBlock := false + isInsideTerraform := false + + lineScanner := bufio.NewScanner(strings.NewReader(trimmed)) + for lineScanner.Scan() { + lineNum++ + nextLine := lineScanner.Text() + + // Code assumes that invalid headers would've already been handled by the base validation function, so we don't + // need to check deeper if the first line isn't an h1. + if lineNum == 1 { + if !strings.HasPrefix(nextLine, "# ") { + break + } + continue + } + + if strings.HasPrefix(nextLine, "```") { + isInsideCodeBlock = !isInsideCodeBlock + isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf") + if isInsideTerraform { + terraformCodeBlockCount++ + } + if strings.HasPrefix(nextLine, "```hcl") { + errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf")) + } + continue + } + + if isInsideCodeBlock { + if isInsideTerraform { + foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine) + } + continue + } + + // Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines. + if lineNum > 1 && strings.HasPrefix(nextLine, "#") { + break + } + + // Code assumes that if we've reached this point, the only other options are: + // (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax. + trimmedLine := strings.TrimSpace(nextLine) + isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<") + foundParagraph = foundParagraph || isParagraph + } + + if terraformCodeBlockCount == 0 { + errs = append(errs, xerrors.New("did not find Terraform code block within h1 section")) + } else { + if terraformCodeBlockCount > 1 { + errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section")) + } + if !foundTerraformVersionRef { + errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field")) + } + } + if !foundParagraph { + errs = append(errs, xerrors.New("did not find paragraph within h1 section")) + } + if isInsideCodeBlock { + errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file")) + } + + return errs +} + +func validateCoderModuleReadme(rm coderResourceReadme) []error { + var errs []error + for _, err := range validateCoderModuleReadmeBody(rm.body) { + errs = append(errs, addFilePathToError(rm.filePath, err)) + } + if fmErrs := validateCoderResourceFrontmatter("modules", rm.filePath, rm.frontmatter); len(fmErrs) != 0 { + errs = append(errs, fmErrs...) + } + return errs +} + +func validateAllCoderModuleReadmes(resources []coderResourceReadme) error { + var yamlValidationErrors []error + for _, readme := range resources { + errs := validateCoderModuleReadme(readme) + if len(errs) > 0 { + yamlValidationErrors = append(yamlValidationErrors, errs...) + } + } + if len(yamlValidationErrors) != 0 { + return validationPhaseError{ + phase: validationPhaseReadme, + errors: yamlValidationErrors, + } + } + return nil +} + +func validateAllCoderModules() error { + const resourceType = "modules" + allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType) + if err != nil { + return err + } + + logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles)) + resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles) + if err != nil { + return err + } + err = validateAllCoderModuleReadmes(resources) + if err != nil { + return err + } + logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources)) + + if err := validateCoderResourceRelativeURLs(resources); err != nil { + return err + } + logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType) + return nil +} diff --git a/cmd/readmevalidation/coderresources_test.go b/cmd/readmevalidation/codermodules_test.go similarity index 100% rename from cmd/readmevalidation/coderresources_test.go rename to cmd/readmevalidation/codermodules_test.go diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index b860abb6..f7237a36 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -1,8 +1,6 @@ package main import ( - "bufio" - "context" "errors" "net/url" "os" @@ -133,144 +131,6 @@ func validateCoderResourceTags(tags []string) error { return nil } -func validateCoderModuleReadmeBody(body string) []error { - var errs []error - - trimmed := strings.TrimSpace(body) - if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 { - errs = append(errs, baseErrs...) - } - - foundParagraph := false - terraformCodeBlockCount := 0 - foundTerraformVersionRef := false - - lineNum := 0 - isInsideCodeBlock := false - isInsideTerraform := false - - lineScanner := bufio.NewScanner(strings.NewReader(trimmed)) - for lineScanner.Scan() { - lineNum++ - nextLine := lineScanner.Text() - - // Code assumes that invalid headers would've already been handled by the base validation function, so we don't - // need to check deeper if the first line isn't an h1. - if lineNum == 1 { - if !strings.HasPrefix(nextLine, "# ") { - break - } - continue - } - - if strings.HasPrefix(nextLine, "```") { - isInsideCodeBlock = !isInsideCodeBlock - isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf") - if isInsideTerraform { - terraformCodeBlockCount++ - } - if strings.HasPrefix(nextLine, "```hcl") { - errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf")) - } - continue - } - - if isInsideCodeBlock { - if isInsideTerraform { - foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine) - } - continue - } - - // Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines. - if lineNum > 1 && strings.HasPrefix(nextLine, "#") { - break - } - - // Code assumes that if we've reached this point, the only other options are: - // (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax. - trimmedLine := strings.TrimSpace(nextLine) - isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<") - foundParagraph = foundParagraph || isParagraph - } - - if terraformCodeBlockCount == 0 { - errs = append(errs, xerrors.New("did not find Terraform code block within h1 section")) - } else { - if terraformCodeBlockCount > 1 { - errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section")) - } - if !foundTerraformVersionRef { - errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field")) - } - } - if !foundParagraph { - errs = append(errs, xerrors.New("did not find paragraph within h1 section")) - } - if isInsideCodeBlock { - errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file")) - } - - return errs -} - -func validateCoderTemplateReadmeBody(body string) []error { - var errs []error - - trimmed := strings.TrimSpace(body) - if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 { - errs = append(errs, baseErrs...) - } - - var nextLine string - foundParagraph := false - isInsideCodeBlock := false - lineNum := 0 - - lineScanner := bufio.NewScanner(strings.NewReader(trimmed)) - for lineScanner.Scan() { - lineNum++ - nextLine = lineScanner.Text() - - // Code assumes that invalid headers would've already been handled by the base validation function, so we don't - // need to check deeper if the first line isn't an h1. - if lineNum == 1 { - if !strings.HasPrefix(nextLine, "# ") { - break - } - continue - } - - if strings.HasPrefix(nextLine, "```") { - isInsideCodeBlock = !isInsideCodeBlock - if strings.HasPrefix(nextLine, "```hcl") { - errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf")) - } - continue - } - - // Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines. - if lineNum > 1 && strings.HasPrefix(nextLine, "#") { - break - } - - // Code assumes that if we've reached this point, the only other options are: - // (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax. - trimmedLine := strings.TrimSpace(nextLine) - isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<") - foundParagraph = foundParagraph || isParagraph - } - - if !foundParagraph { - errs = append(errs, xerrors.New("did not find paragraph within h1 section")) - } - if isInsideCodeBlock { - errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file")) - } - - return errs -} - func validateCoderResourceFrontmatter(resourceType string, filePath string, fm coderResourceFrontmatter) []error { if !slices.Contains(supportedResourceTypes, resourceType) { return []error{xerrors.Errorf("cannot process unknown resource type %q", resourceType)} @@ -297,62 +157,6 @@ func validateCoderResourceFrontmatter(resourceType string, filePath string, fm c return errs } -func validateCoderModuleReadme(rm coderResourceReadme) []error { - var errs []error - for _, err := range validateCoderModuleReadmeBody(rm.body) { - errs = append(errs, addFilePathToError(rm.filePath, err)) - } - if fmErrs := validateCoderResourceFrontmatter("modules", rm.filePath, rm.frontmatter); len(fmErrs) != 0 { - errs = append(errs, fmErrs...) - } - return errs -} - -func validateAllCoderModuleReadmes(resources []coderResourceReadme) error { - var yamlValidationErrors []error - for _, readme := range resources { - errs := validateCoderModuleReadme(readme) - if len(errs) > 0 { - yamlValidationErrors = append(yamlValidationErrors, errs...) - } - } - if len(yamlValidationErrors) != 0 { - return validationPhaseError{ - phase: validationPhaseReadme, - errors: yamlValidationErrors, - } - } - return nil -} - -func validateCoderTemplateReadme(rm coderResourceReadme) []error { - var errs []error - for _, err := range validateCoderTemplateReadmeBody(rm.body) { - errs = append(errs, addFilePathToError(rm.filePath, err)) - } - if fmErrs := validateCoderResourceFrontmatter("templates", rm.filePath, rm.frontmatter); len(fmErrs) != 0 { - errs = append(errs, fmErrs...) - } - return errs -} - -func validateAllCoderTemplateReadmes(resources []coderResourceReadme) error { - var yamlValidationErrors []error - for _, readme := range resources { - errs := validateCoderTemplateReadme(readme) - if len(errs) > 0 { - yamlValidationErrors = append(yamlValidationErrors, errs...) - } - } - if len(yamlValidationErrors) != 0 { - return validationPhaseError{ - phase: validationPhaseReadme, - errors: yamlValidationErrors, - } - } - return nil -} - func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, []error) { fm, body, err := separateFrontmatter(rm.rawText) if err != nil { @@ -473,53 +277,3 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) { } return allReadmeFiles, nil } - -func validateAllCoderModules() error { - const resourceType = "modules" - allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType) - if err != nil { - return err - } - - logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles)) - resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles) - if err != nil { - return err - } - err = validateAllCoderModuleReadmes(resources) - if err != nil { - return err - } - logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources)) - - if err := validateCoderResourceRelativeURLs(resources); err != nil { - return err - } - logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType) - return nil -} - -func validateAllCoderTemplates() error { - const resourceType = "templates" - allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType) - if err != nil { - return err - } - - logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles)) - resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles) - if err != nil { - return err - } - err = validateAllCoderTemplateReadmes(resources) - if err != nil { - return err - } - logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources)) - - if err := validateCoderResourceRelativeURLs(resources); err != nil { - return err - } - logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType) - return nil -} diff --git a/cmd/readmevalidation/codertemplates.go b/cmd/readmevalidation/codertemplates.go new file mode 100644 index 00000000..df0c6c0b --- /dev/null +++ b/cmd/readmevalidation/codertemplates.go @@ -0,0 +1,119 @@ +package main + +import ( + "bufio" + "context" + "strings" + + "golang.org/x/xerrors" +) + +func validateCoderTemplateReadmeBody(body string) []error { + var errs []error + + trimmed := strings.TrimSpace(body) + if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 { + errs = append(errs, baseErrs...) + } + + var nextLine string + foundParagraph := false + isInsideCodeBlock := false + lineNum := 0 + + lineScanner := bufio.NewScanner(strings.NewReader(trimmed)) + for lineScanner.Scan() { + lineNum++ + nextLine = lineScanner.Text() + + // Code assumes that invalid headers would've already been handled by the base validation function, so we don't + // need to check deeper if the first line isn't an h1. + if lineNum == 1 { + if !strings.HasPrefix(nextLine, "# ") { + break + } + continue + } + + if strings.HasPrefix(nextLine, "```") { + isInsideCodeBlock = !isInsideCodeBlock + if strings.HasPrefix(nextLine, "```hcl") { + errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf")) + } + continue + } + + // Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines. + if lineNum > 1 && strings.HasPrefix(nextLine, "#") { + break + } + + // Code assumes that if we've reached this point, the only other options are: + // (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax. + trimmedLine := strings.TrimSpace(nextLine) + isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<") + foundParagraph = foundParagraph || isParagraph + } + + if !foundParagraph { + errs = append(errs, xerrors.New("did not find paragraph within h1 section")) + } + if isInsideCodeBlock { + errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file")) + } + + return errs +} + +func validateCoderTemplateReadme(rm coderResourceReadme) []error { + var errs []error + for _, err := range validateCoderTemplateReadmeBody(rm.body) { + errs = append(errs, addFilePathToError(rm.filePath, err)) + } + if fmErrs := validateCoderResourceFrontmatter("templates", rm.filePath, rm.frontmatter); len(fmErrs) != 0 { + errs = append(errs, fmErrs...) + } + return errs +} + +func validateAllCoderTemplateReadmes(resources []coderResourceReadme) error { + var yamlValidationErrors []error + for _, readme := range resources { + errs := validateCoderTemplateReadme(readme) + if len(errs) > 0 { + yamlValidationErrors = append(yamlValidationErrors, errs...) + } + } + if len(yamlValidationErrors) != 0 { + return validationPhaseError{ + phase: validationPhaseReadme, + errors: yamlValidationErrors, + } + } + return nil +} + +func validateAllCoderTemplates() error { + const resourceType = "templates" + allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType) + if err != nil { + return err + } + + logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles)) + resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles) + if err != nil { + return err + } + err = validateAllCoderTemplateReadmes(resources) + if err != nil { + return err + } + logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources)) + + if err := validateCoderResourceRelativeURLs(resources); err != nil { + return err + } + logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType) + return nil +} From f72bb252bd5f34145382854fd846b27121fbe6ef Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 13 Aug 2025 18:32:18 +0000 Subject: [PATCH 14/14] fix: update message --- cmd/readmevalidation/codermodules.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/readmevalidation/codermodules.go b/cmd/readmevalidation/codermodules.go index 64885359..4c9d854d 100644 --- a/cmd/readmevalidation/codermodules.go +++ b/cmd/readmevalidation/codermodules.go @@ -45,7 +45,7 @@ func validateCoderModuleReadmeBody(body string) []error { terraformCodeBlockCount++ } if strings.HasPrefix(nextLine, "```hcl") { - errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf")) + errs = append(errs, xerrors.New("all hcl code blocks must be converted to tf")) } continue }