From 72d7ee418b0bcfd7a86f75b858ca9dfa7dec8692 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Mon, 1 Sep 2025 18:21:00 +0500 Subject: [PATCH 1/4] Add validation for Terraform module source URLs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 53b912b..eb3cf8b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -63,8 +63,8 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.23.2" - - name: Validate contributors + go-version: "1.25.0" + - name: Validate Reademde run: go build ./cmd/readmevalidation && ./readmevalidation - name: Remove build file artifact run: rm ./readmevalidation Signed-off-by: Muhammad Atif Ali --- .github/workflows/ci.yaml | 4 +- cmd/readmevalidation/codermodules.go | 79 +++++++++++++++++++++ cmd/readmevalidation/codermodules_test.go | 83 +++++++++++++++++++++++ package.json | 3 +- 4 files changed, 166 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 53b912bff..eb3cf8b3c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -63,8 +63,8 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.23.2" - - name: Validate contributors + go-version: "1.25.0" + - name: Validate Reademde run: go build ./cmd/readmevalidation && ./readmevalidation - name: Remove build file artifact run: rm ./readmevalidation diff --git a/cmd/readmevalidation/codermodules.go b/cmd/readmevalidation/codermodules.go index 005a98ee8..fe33a7ca0 100644 --- a/cmd/readmevalidation/codermodules.go +++ b/cmd/readmevalidation/codermodules.go @@ -3,11 +3,87 @@ package main import ( "bufio" "context" + "path/filepath" + "regexp" "strings" "golang.org/x/xerrors" ) +var ( + terraformSourceRe = regexp.MustCompile(`^\s*source\s*=\s*"([^"]+)"`) +) + +func normalizeModuleName(name string) string { + // Normalize module names by replacing hyphens with underscores for comparison + // since Terraform allows both but directory names typically use hyphens + return strings.ReplaceAll(name, "-", "_") +} + +func extractNamespaceAndModuleFromPath(filePath string) (string, string, error) { + // Expected path format: registry//modules//README.md + parts := strings.Split(filepath.Clean(filePath), string(filepath.Separator)) + if len(parts) < 5 || parts[0] != "registry" || parts[2] != "modules" || parts[4] != "README.md" { + return "", "", xerrors.Errorf("invalid module path format: %s", filePath) + } + namespace := parts[1] + moduleName := parts[3] + return namespace, moduleName, nil +} + +func validateModuleSourceURL(body string, filePath string) []error { + var errs []error + + namespace, moduleName, err := extractNamespaceAndModuleFromPath(filePath) + if err != nil { + return []error{err} + } + + expectedSource := "registry.coder.com/" + namespace + "/" + moduleName + "/coder" + + trimmed := strings.TrimSpace(body) + foundCorrectSource := false + isInsideTerraform := false + firstTerraformBlock := true + + lineScanner := bufio.NewScanner(strings.NewReader(trimmed)) + for lineScanner.Scan() { + nextLine := lineScanner.Text() + + if strings.HasPrefix(nextLine, "```") { + if strings.HasPrefix(nextLine, "```tf") && firstTerraformBlock { + isInsideTerraform = true + firstTerraformBlock = false + } else if isInsideTerraform { + // End of first terraform block + break + } + continue + } + + if isInsideTerraform { + // Check for any source line in the first terraform block + if matches := terraformSourceRe.FindStringSubmatch(nextLine); matches != nil { + actualSource := matches[1] + if actualSource == expectedSource { + foundCorrectSource = true + break + } else if strings.HasPrefix(actualSource, "registry.coder.com/") && strings.Contains(actualSource, "/"+moduleName+"/coder") { + // Found source for this module but with wrong namespace/format + errs = append(errs, xerrors.Errorf("incorrect source URL format: found %q, expected %q", actualSource, expectedSource)) + return errs + } + } + } + } + + if !foundCorrectSource { + errs = append(errs, xerrors.Errorf("did not find correct source URL %q in first Terraform code block", expectedSource)) + } + + return errs +} + func validateCoderModuleReadmeBody(body string) []error { var errs []error @@ -94,6 +170,9 @@ func validateCoderModuleReadme(rm coderResourceReadme) []error { for _, err := range validateCoderModuleReadmeBody(rm.body) { errs = append(errs, addFilePathToError(rm.filePath, err)) } + for _, err := range validateModuleSourceURL(rm.body, rm.filePath) { + errs = append(errs, addFilePathToError(rm.filePath, err)) + } for _, err := range validateResourceGfmAlerts(rm.body) { errs = append(errs, addFilePathToError(rm.filePath, err)) } diff --git a/cmd/readmevalidation/codermodules_test.go b/cmd/readmevalidation/codermodules_test.go index 194a861ec..ef34e9060 100644 --- a/cmd/readmevalidation/codermodules_test.go +++ b/cmd/readmevalidation/codermodules_test.go @@ -20,3 +20,86 @@ func TestValidateCoderResourceReadmeBody(t *testing.T) { } }) } + +func TestValidateModuleSourceURL(t *testing.T) { + t.Parallel() + + t.Run("Valid source URL format", func(t *testing.T) { + t.Parallel() + + body := "# Test Module\n\n```tf\nmodule \"test-module\" {\n source = \"registry.coder.com/test-namespace/test-module/coder\"\n version = \"1.0.0\"\n agent_id = coder_agent.example.id\n}\n```\n" + filePath := "registry/test-namespace/modules/test-module/README.md" + errs := validateModuleSourceURL(body, filePath) + if len(errs) != 0 { + t.Errorf("Expected no errors, got: %v", errs) + } + }) + + t.Run("Invalid source URL format - wrong namespace", func(t *testing.T) { + t.Parallel() + + body := "# Test Module\n\n```tf\nmodule \"test-module\" {\n source = \"registry.coder.com/wrong-namespace/test-module/coder\"\n version = \"1.0.0\"\n agent_id = coder_agent.example.id\n}\n```\n" + filePath := "registry/test-namespace/modules/test-module/README.md" + errs := validateModuleSourceURL(body, filePath) + if len(errs) != 1 { + t.Errorf("Expected 1 error, got %d: %v", len(errs), errs) + } + if len(errs) > 0 && !contains(errs[0].Error(), "incorrect source URL format") { + t.Errorf("Expected source URL format error, got: %s", errs[0].Error()) + } + }) + + t.Run("Missing source URL", func(t *testing.T) { + t.Parallel() + + body := "# Test Module\n\n```tf\nmodule \"other-module\" {\n source = \"registry.coder.com/other/other-module/coder\"\n version = \"1.0.0\"\n agent_id = coder_agent.example.id\n}\n```\n" + filePath := "registry/test-namespace/modules/test-module/README.md" + errs := validateModuleSourceURL(body, filePath) + if len(errs) != 1 { + t.Errorf("Expected 1 error, got %d: %v", len(errs), errs) + } + if len(errs) > 0 && !contains(errs[0].Error(), "did not find correct source URL") { + t.Errorf("Expected missing source URL error, got: %s", errs[0].Error()) + } + }) + + t.Run("Module name with hyphens vs underscores", func(t *testing.T) { + t.Parallel() + + body := "# Test Module\n\n```tf\nmodule \"test_module\" {\n source = \"registry.coder.com/test-namespace/test-module/coder\"\n version = \"1.0.0\"\n agent_id = coder_agent.example.id\n}\n```\n" + filePath := "registry/test-namespace/modules/test-module/README.md" + errs := validateModuleSourceURL(body, filePath) + if len(errs) != 0 { + t.Errorf("Expected no errors for hyphen/underscore variation, got: %v", errs) + } + }) + + t.Run("Invalid file path format", func(t *testing.T) { + t.Parallel() + + body := "# Test Module" + filePath := "invalid/path/format" + errs := validateModuleSourceURL(body, filePath) + if len(errs) != 1 { + t.Errorf("Expected 1 error, got %d: %v", len(errs), errs) + } + if len(errs) > 0 && !contains(errs[0].Error(), "invalid module path format") { + t.Errorf("Expected path format error, got: %s", errs[0].Error()) + } + }) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || (len(s) > len(substr) && + (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || + indexOfSubstring(s, substr) >= 0))) +} + +func indexOfSubstring(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/package.json b/package.json index d441e6ac4..1665a2bf8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "fmt:ci": "bun x prettier --check . && terraform fmt -check -recursive -diff", "terraform-validate": "./scripts/terraform_validate.sh", "test": "./scripts/terraform_test_all.sh", - "update-version": "./update-version.sh" + "update-version": "./update-version.sh", + "validate-readme": "go build ./cmd/readmevalidation && ./readmevalidation" }, "devDependencies": { "@types/bun": "^1.2.21", From 3b6b1ba4b92f1117d84358ce377dec4b5c3083b2 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Mon, 1 Sep 2025 18:36:13 +0500 Subject: [PATCH 2/4] Update README validation and Go version - Downgrade Go version in CI to 1.24 for consistency. - Fix naming and path issues in `readmevalidation` code. - Improve regex validation for module and namespace names. - Correct typos and improve comments for clarity. --- .github/workflows/ci.yaml | 4 ++-- cmd/readmevalidation/codermodules.go | 20 +++++++------------- cmd/readmevalidation/coderresources.go | 12 ++++++------ cmd/readmevalidation/contributors.go | 2 +- cmd/readmevalidation/repostructure.go | 11 +++++------ go.mod | 2 +- 6 files changed, 22 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index eb3cf8b3c..a60476455 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -63,8 +63,8 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.25.0" - - name: Validate Reademde + go-version: "1.24" + - name: Validate README run: go build ./cmd/readmevalidation && ./readmevalidation - name: Remove build file artifact run: rm ./readmevalidation diff --git a/cmd/readmevalidation/codermodules.go b/cmd/readmevalidation/codermodules.go index fe33a7ca0..3916f05b2 100644 --- a/cmd/readmevalidation/codermodules.go +++ b/cmd/readmevalidation/codermodules.go @@ -14,20 +14,14 @@ var ( terraformSourceRe = regexp.MustCompile(`^\s*source\s*=\s*"([^"]+)"`) ) -func normalizeModuleName(name string) string { - // Normalize module names by replacing hyphens with underscores for comparison - // since Terraform allows both but directory names typically use hyphens - return strings.ReplaceAll(name, "-", "_") -} - -func extractNamespaceAndModuleFromPath(filePath string) (string, string, error) { - // Expected path format: registry//modules//README.md +func extractNamespaceAndModuleFromPath(filePath string) (namespace string, moduleName string, err error) { + // Expected path format: registry//modules//README.md. parts := strings.Split(filepath.Clean(filePath), string(filepath.Separator)) if len(parts) < 5 || parts[0] != "registry" || parts[2] != "modules" || parts[4] != "README.md" { return "", "", xerrors.Errorf("invalid module path format: %s", filePath) } - namespace := parts[1] - moduleName := parts[3] + namespace = parts[1] + moduleName = parts[3] return namespace, moduleName, nil } @@ -55,21 +49,21 @@ func validateModuleSourceURL(body string, filePath string) []error { isInsideTerraform = true firstTerraformBlock = false } else if isInsideTerraform { - // End of first terraform block + // End of first terraform block. break } continue } if isInsideTerraform { - // Check for any source line in the first terraform block + // Check for any source line in the first terraform block. if matches := terraformSourceRe.FindStringSubmatch(nextLine); matches != nil { actualSource := matches[1] if actualSource == expectedSource { foundCorrectSource = true break } else if strings.HasPrefix(actualSource, "registry.coder.com/") && strings.Contains(actualSource, "/"+moduleName+"/coder") { - // Found source for this module but with wrong namespace/format + // Found source for this module but with wrong namespace/format. errs = append(errs, xerrors.Errorf("incorrect source URL format: found %q, expected %q", actualSource, expectedSource)) return errs } diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index 818d73c8c..0b076c2fa 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -25,7 +25,7 @@ var ( terraformVersionRe = regexp.MustCompile(`^\s*\bversion\s+=`) // Matches the format "> [!INFO]". Deliberately using a broad pattern to catch formatting issues that can mess up - // the renderer for the Registry website + // the renderer for the Registry website. gfmAlertRegex = regexp.MustCompile(`^>(\s*)\[!(\w+)\](\s*)(.*)`) ) @@ -39,7 +39,7 @@ type coderResourceFrontmatter struct { } // 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 +// 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 the archived coder/modules repo. We can remove this once we @@ -315,15 +315,15 @@ func validateResourceGfmAlerts(readmeBody string) []error { } // Nested GFM alerts is such a weird mistake that it's probably not really safe to keep trying to process the - // rest of the content, so this will prevent any other validations from happening for the given line + // rest of the content, so this will prevent any other validations from happening for the given line. if isInsideGfmQuotes { - errs = append(errs, errors.New("registry does not support nested GFM alerts")) + errs = append(errs, xerrors.New("registry does not support nested GFM alerts")) continue } leadingWhitespace := currentMatch[1] if len(leadingWhitespace) != 1 { - errs = append(errs, errors.New("GFM alerts must have one space between the '>' and the start of the GFM brackets")) + errs = append(errs, xerrors.New("GFM alerts must have one space between the '>' and the start of the GFM brackets")) } isInsideGfmQuotes = true @@ -347,7 +347,7 @@ func validateResourceGfmAlerts(readmeBody string) []error { } } - if gfmAlertRegex.Match([]byte(sourceLine)) { + if gfmAlertRegex.MatchString(sourceLine) { errs = append(errs, xerrors.Errorf("README has an incomplete GFM alert at the end of the file")) } diff --git a/cmd/readmevalidation/contributors.go b/cmd/readmevalidation/contributors.go index 8a9b195b7..b3c8a9ff5 100644 --- a/cmd/readmevalidation/contributors.go +++ b/cmd/readmevalidation/contributors.go @@ -26,7 +26,7 @@ type contributorProfileFrontmatter struct { } // 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 +// 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 { diff --git a/cmd/readmevalidation/repostructure.go b/cmd/readmevalidation/repostructure.go index c77c723b6..7b6d2e02c 100644 --- a/cmd/readmevalidation/repostructure.go +++ b/cmd/readmevalidation/repostructure.go @@ -13,12 +13,11 @@ import ( var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images") -// validNameRe validates that names contain only alphanumeric characters and hyphens +// validNameRe validates that names contain only alphanumeric characters and hyphens. var validNameRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$`) - // validateCoderResourceSubdirectory validates that the structure of a module or template within a namespace follows all -// expected file conventions +// expected file conventions. func validateCoderResourceSubdirectory(dirPath string) []error { resourceDir, err := os.Stat(dirPath) if err != nil { @@ -47,7 +46,7 @@ func validateCoderResourceSubdirectory(dirPath string) []error { continue } - // Validate module/template name + // Validate module/template name. if !validNameRe.MatchString(f.Name()) { errs = append(errs, xerrors.Errorf("%q: name contains invalid characters (only alphanumeric characters and hyphens are allowed)", path.Join(dirPath, f.Name()))) continue @@ -90,7 +89,7 @@ func validateRegistryDirectory() []error { continue } - // Validate namespace name + // Validate namespace name. if !validNameRe.MatchString(nDir.Name()) { allErrs = append(allErrs, xerrors.Errorf("%q: namespace name contains invalid characters (only alphanumeric characters and hyphens are allowed)", namespacePath)) continue @@ -136,7 +135,7 @@ func validateRegistryDirectory() []error { // 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 +// are relevant for the main validation steps. func validateRepoStructure() error { var errs []error if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 { diff --git a/go.mod b/go.mod index d3caf9128..4afa354e9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module coder.com/coder-registry -go 1.23.2 +go 1.24 require ( cdr.dev/slog v1.6.1 From 7e94395bd1ab06fe3193b34ee054c935f170fbce Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Mon, 1 Sep 2025 19:53:35 +0500 Subject: [PATCH 3/4] Remove master branch from lint trigger --- .github/workflows/golangci-lint.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 42f15e1fc..f3000096b 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -3,7 +3,6 @@ on: push: branches: - main - - master pull_request: permissions: From 9c00c576a36f13c6718b87b6e0b6d0ef8fcf7977 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 07:00:14 +0000 Subject: [PATCH 4/4] Address PR review comments - Make regex more specific for registry.coder.com patterns only - Refactor to add namespace and resourceName fields to coderResourceReadme struct - Inline path parsing logic into parseCoderResourceReadme - Update validateModuleSourceURL to use struct fields instead of filePath parameter - Simplify Terraform block detection logic - Reduce nesting with early continue statements - Add comment explaining regex pattern - Extract registry.coder.com into a constant - Improve test readability with extracted variables - Remove redundant checks in tests - Replace custom contains function with strings.Contains Co-authored-by: matifali --- cmd/readmevalidation/codermodules.go | 69 +++++++------- cmd/readmevalidation/codermodules_test.go | 108 +++++++++++++--------- cmd/readmevalidation/coderresources.go | 14 +++ 3 files changed, 111 insertions(+), 80 deletions(-) diff --git a/cmd/readmevalidation/codermodules.go b/cmd/readmevalidation/codermodules.go index 3916f05b2..4e0253f9f 100644 --- a/cmd/readmevalidation/codermodules.go +++ b/cmd/readmevalidation/codermodules.go @@ -3,7 +3,6 @@ package main import ( "bufio" "context" - "path/filepath" "regexp" "strings" @@ -11,63 +10,57 @@ import ( ) var ( - terraformSourceRe = regexp.MustCompile(`^\s*source\s*=\s*"([^"]+)"`) + // Matches Terraform source lines with registry.coder.com URLs + // Pattern: source = "registry.coder.com/namespace/module/coder" + terraformSourceRe = regexp.MustCompile(`^\s*source\s*=\s*"` + registryDomain + `/([^/]+)/([^/]+)/coder"`) ) -func extractNamespaceAndModuleFromPath(filePath string) (namespace string, moduleName string, err error) { - // Expected path format: registry//modules//README.md. - parts := strings.Split(filepath.Clean(filePath), string(filepath.Separator)) - if len(parts) < 5 || parts[0] != "registry" || parts[2] != "modules" || parts[4] != "README.md" { - return "", "", xerrors.Errorf("invalid module path format: %s", filePath) - } - namespace = parts[1] - moduleName = parts[3] - return namespace, moduleName, nil -} - -func validateModuleSourceURL(body string, filePath string) []error { +func validateModuleSourceURL(rm coderResourceReadme) []error { var errs []error - namespace, moduleName, err := extractNamespaceAndModuleFromPath(filePath) - if err != nil { - return []error{err} + // Skip validation if we couldn't parse namespace/resourceName from path + if rm.namespace == "" || rm.resourceName == "" { + return []error{xerrors.Errorf("invalid module path format: %s", rm.filePath)} } - expectedSource := "registry.coder.com/" + namespace + "/" + moduleName + "/coder" + expectedSource := registryDomain + "/" + rm.namespace + "/" + rm.resourceName + "/coder" - trimmed := strings.TrimSpace(body) + trimmed := strings.TrimSpace(rm.body) foundCorrectSource := false isInsideTerraform := false - firstTerraformBlock := true lineScanner := bufio.NewScanner(strings.NewReader(trimmed)) for lineScanner.Scan() { nextLine := lineScanner.Text() if strings.HasPrefix(nextLine, "```") { - if strings.HasPrefix(nextLine, "```tf") && firstTerraformBlock { + if strings.HasPrefix(nextLine, "```tf") { isInsideTerraform = true - firstTerraformBlock = false - } else if isInsideTerraform { - // End of first terraform block. + continue + } + if isInsideTerraform { break } continue } - if isInsideTerraform { - // Check for any source line in the first terraform block. - if matches := terraformSourceRe.FindStringSubmatch(nextLine); matches != nil { - actualSource := matches[1] - if actualSource == expectedSource { - foundCorrectSource = true - break - } else if strings.HasPrefix(actualSource, "registry.coder.com/") && strings.Contains(actualSource, "/"+moduleName+"/coder") { - // Found source for this module but with wrong namespace/format. - errs = append(errs, xerrors.Errorf("incorrect source URL format: found %q, expected %q", actualSource, expectedSource)) - return errs - } + if !isInsideTerraform { + continue + } + + // Check for source line in the first terraform block + if matches := terraformSourceRe.FindStringSubmatch(nextLine); matches != nil { + actualNamespace := matches[1] + actualModule := matches[2] + actualSource := registryDomain + "/" + actualNamespace + "/" + actualModule + "/coder" + + if actualSource == expectedSource { + foundCorrectSource = true + break } + // Found a registry.coder.com source but with wrong namespace/module + errs = append(errs, xerrors.Errorf("incorrect source URL format: found %q, expected %q", actualSource, expectedSource)) + return errs } } @@ -164,7 +157,7 @@ func validateCoderModuleReadme(rm coderResourceReadme) []error { for _, err := range validateCoderModuleReadmeBody(rm.body) { errs = append(errs, addFilePathToError(rm.filePath, err)) } - for _, err := range validateModuleSourceURL(rm.body, rm.filePath) { + for _, err := range validateModuleSourceURL(rm) { errs = append(errs, addFilePathToError(rm.filePath, err)) } for _, err := range validateResourceGfmAlerts(rm.body) { @@ -216,4 +209,4 @@ func validateAllCoderModules() error { } logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType) return nil -} +} \ No newline at end of file diff --git a/cmd/readmevalidation/codermodules_test.go b/cmd/readmevalidation/codermodules_test.go index ef34e9060..0c832f330 100644 --- a/cmd/readmevalidation/codermodules_test.go +++ b/cmd/readmevalidation/codermodules_test.go @@ -2,12 +2,42 @@ package main import ( _ "embed" + "strings" "testing" ) //go:embed testSamples/sampleReadmeBody.md var testBody string +// Test bodies extracted for better readability +var ( + validModuleBody = `# Test Module + +` + "```tf\n" + `module "test-module" { + source = "registry.coder.com/test-namespace/test-module/coder" + version = "1.0.0" + agent_id = coder_agent.example.id +} +` + "```\n" + + wrongNamespaceBody = `# Test Module + +` + "```tf\n" + `module "test-module" { + source = "registry.coder.com/wrong-namespace/test-module/coder" + version = "1.0.0" + agent_id = coder_agent.example.id +} +` + "```\n" + + missingSourceBody = `# Test Module + +` + "```tf\n" + `module "test-module" { + version = "1.0.0" + agent_id = coder_agent.example.id +} +` + "```\n" +) + func TestValidateCoderResourceReadmeBody(t *testing.T) { t.Parallel() @@ -27,9 +57,14 @@ func TestValidateModuleSourceURL(t *testing.T) { t.Run("Valid source URL format", func(t *testing.T) { t.Parallel() - body := "# Test Module\n\n```tf\nmodule \"test-module\" {\n source = \"registry.coder.com/test-namespace/test-module/coder\"\n version = \"1.0.0\"\n agent_id = coder_agent.example.id\n}\n```\n" - filePath := "registry/test-namespace/modules/test-module/README.md" - errs := validateModuleSourceURL(body, filePath) + rm := coderResourceReadme{ + resourceType: "modules", + filePath: "registry/test-namespace/modules/test-module/README.md", + namespace: "test-namespace", + resourceName: "test-module", + body: validModuleBody, + } + errs := validateModuleSourceURL(rm) if len(errs) != 0 { t.Errorf("Expected no errors, got: %v", errs) } @@ -38,13 +73,18 @@ func TestValidateModuleSourceURL(t *testing.T) { t.Run("Invalid source URL format - wrong namespace", func(t *testing.T) { t.Parallel() - body := "# Test Module\n\n```tf\nmodule \"test-module\" {\n source = \"registry.coder.com/wrong-namespace/test-module/coder\"\n version = \"1.0.0\"\n agent_id = coder_agent.example.id\n}\n```\n" - filePath := "registry/test-namespace/modules/test-module/README.md" - errs := validateModuleSourceURL(body, filePath) + rm := coderResourceReadme{ + resourceType: "modules", + filePath: "registry/test-namespace/modules/test-module/README.md", + namespace: "test-namespace", + resourceName: "test-module", + body: wrongNamespaceBody, + } + errs := validateModuleSourceURL(rm) if len(errs) != 1 { t.Errorf("Expected 1 error, got %d: %v", len(errs), errs) } - if len(errs) > 0 && !contains(errs[0].Error(), "incorrect source URL format") { + if !strings.Contains(errs[0].Error(), "incorrect source URL format") { t.Errorf("Expected source URL format error, got: %s", errs[0].Error()) } }) @@ -52,54 +92,38 @@ func TestValidateModuleSourceURL(t *testing.T) { t.Run("Missing source URL", func(t *testing.T) { t.Parallel() - body := "# Test Module\n\n```tf\nmodule \"other-module\" {\n source = \"registry.coder.com/other/other-module/coder\"\n version = \"1.0.0\"\n agent_id = coder_agent.example.id\n}\n```\n" - filePath := "registry/test-namespace/modules/test-module/README.md" - errs := validateModuleSourceURL(body, filePath) + rm := coderResourceReadme{ + resourceType: "modules", + filePath: "registry/test-namespace/modules/test-module/README.md", + namespace: "test-namespace", + resourceName: "test-module", + body: missingSourceBody, + } + errs := validateModuleSourceURL(rm) if len(errs) != 1 { t.Errorf("Expected 1 error, got %d: %v", len(errs), errs) } - if len(errs) > 0 && !contains(errs[0].Error(), "did not find correct source URL") { + if !strings.Contains(errs[0].Error(), "did not find correct source URL") { t.Errorf("Expected missing source URL error, got: %s", errs[0].Error()) } }) - t.Run("Module name with hyphens vs underscores", func(t *testing.T) { - t.Parallel() - - body := "# Test Module\n\n```tf\nmodule \"test_module\" {\n source = \"registry.coder.com/test-namespace/test-module/coder\"\n version = \"1.0.0\"\n agent_id = coder_agent.example.id\n}\n```\n" - filePath := "registry/test-namespace/modules/test-module/README.md" - errs := validateModuleSourceURL(body, filePath) - if len(errs) != 0 { - t.Errorf("Expected no errors for hyphen/underscore variation, got: %v", errs) - } - }) - t.Run("Invalid file path format", func(t *testing.T) { t.Parallel() - body := "# Test Module" - filePath := "invalid/path/format" - errs := validateModuleSourceURL(body, filePath) + rm := coderResourceReadme{ + resourceType: "modules", + filePath: "invalid/path/format", + namespace: "", // Empty because path parsing failed + resourceName: "", // Empty because path parsing failed + body: "# Test Module", + } + errs := validateModuleSourceURL(rm) if len(errs) != 1 { t.Errorf("Expected 1 error, got %d: %v", len(errs), errs) } - if len(errs) > 0 && !contains(errs[0].Error(), "invalid module path format") { + if !strings.Contains(errs[0].Error(), "invalid module path format") { t.Errorf("Expected path format error, got: %s", errs[0].Error()) } }) -} - -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || (len(s) > len(substr) && - (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || - indexOfSubstring(s, substr) >= 0))) -} - -func indexOfSubstring(s, substr string) int { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return i - } - } - return -1 -} +} \ No newline at end of file diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index 0b076c2fa..4b9963e67 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -18,6 +18,7 @@ var ( supportedResourceTypes = []string{"modules", "templates"} operatingSystems = []string{"windows", "macos", "linux"} gfmAlertTypes = []string{"NOTE", "IMPORTANT", "CAUTION", "WARNING", "TIP"} + registryDomain = "registry.coder.com" // 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 @@ -53,6 +54,8 @@ var supportedCoderResourceStructKeys = []string{ type coderResourceReadme struct { resourceType string filePath string + namespace string + resourceName string body string frontmatter coderResourceFrontmatter } @@ -183,9 +186,20 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)} } + // Extract namespace and resource name from file path + // Expected path format: registry////README.md + var namespace, resourceName string + parts := strings.Split(path.Clean(rm.filePath), "/") + if len(parts) >= 5 && parts[0] == "registry" && parts[2] == resourceType && parts[4] == "README.md" { + namespace = parts[1] + resourceName = parts[3] + } + return coderResourceReadme{ resourceType: resourceType, filePath: rm.filePath, + namespace: namespace, + resourceName: resourceName, body: body, frontmatter: yml, }, nil