Skip to content

Commit 72d7ee4

Browse files
committed
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 <[email protected]>
1 parent 9452763 commit 72d7ee4

File tree

4 files changed

+166
-3
lines changed

4 files changed

+166
-3
lines changed

.github/workflows/ci.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ jobs:
6363
- name: Set up Go
6464
uses: actions/setup-go@v5
6565
with:
66-
go-version: "1.23.2"
67-
- name: Validate contributors
66+
go-version: "1.25.0"
67+
- name: Validate Reademde
6868
run: go build ./cmd/readmevalidation && ./readmevalidation
6969
- name: Remove build file artifact
7070
run: rm ./readmevalidation

cmd/readmevalidation/codermodules.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,87 @@ package main
33
import (
44
"bufio"
55
"context"
6+
"path/filepath"
7+
"regexp"
68
"strings"
79

810
"golang.org/x/xerrors"
911
)
1012

13+
var (
14+
terraformSourceRe = regexp.MustCompile(`^\s*source\s*=\s*"([^"]+)"`)
15+
)
16+
17+
func normalizeModuleName(name string) string {
18+
// Normalize module names by replacing hyphens with underscores for comparison
19+
// since Terraform allows both but directory names typically use hyphens
20+
return strings.ReplaceAll(name, "-", "_")
21+
}
22+
23+
func extractNamespaceAndModuleFromPath(filePath string) (string, string, error) {
24+
// Expected path format: registry/<namespace>/modules/<module-name>/README.md
25+
parts := strings.Split(filepath.Clean(filePath), string(filepath.Separator))
26+
if len(parts) < 5 || parts[0] != "registry" || parts[2] != "modules" || parts[4] != "README.md" {
27+
return "", "", xerrors.Errorf("invalid module path format: %s", filePath)
28+
}
29+
namespace := parts[1]
30+
moduleName := parts[3]
31+
return namespace, moduleName, nil
32+
}
33+
34+
func validateModuleSourceURL(body string, filePath string) []error {
35+
var errs []error
36+
37+
namespace, moduleName, err := extractNamespaceAndModuleFromPath(filePath)
38+
if err != nil {
39+
return []error{err}
40+
}
41+
42+
expectedSource := "registry.coder.com/" + namespace + "/" + moduleName + "/coder"
43+
44+
trimmed := strings.TrimSpace(body)
45+
foundCorrectSource := false
46+
isInsideTerraform := false
47+
firstTerraformBlock := true
48+
49+
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
50+
for lineScanner.Scan() {
51+
nextLine := lineScanner.Text()
52+
53+
if strings.HasPrefix(nextLine, "```") {
54+
if strings.HasPrefix(nextLine, "```tf") && firstTerraformBlock {
55+
isInsideTerraform = true
56+
firstTerraformBlock = false
57+
} else if isInsideTerraform {
58+
// End of first terraform block
59+
break
60+
}
61+
continue
62+
}
63+
64+
if isInsideTerraform {
65+
// Check for any source line in the first terraform block
66+
if matches := terraformSourceRe.FindStringSubmatch(nextLine); matches != nil {
67+
actualSource := matches[1]
68+
if actualSource == expectedSource {
69+
foundCorrectSource = true
70+
break
71+
} else if strings.HasPrefix(actualSource, "registry.coder.com/") && strings.Contains(actualSource, "/"+moduleName+"/coder") {
72+
// Found source for this module but with wrong namespace/format
73+
errs = append(errs, xerrors.Errorf("incorrect source URL format: found %q, expected %q", actualSource, expectedSource))
74+
return errs
75+
}
76+
}
77+
}
78+
}
79+
80+
if !foundCorrectSource {
81+
errs = append(errs, xerrors.Errorf("did not find correct source URL %q in first Terraform code block", expectedSource))
82+
}
83+
84+
return errs
85+
}
86+
1187
func validateCoderModuleReadmeBody(body string) []error {
1288
var errs []error
1389

@@ -94,6 +170,9 @@ func validateCoderModuleReadme(rm coderResourceReadme) []error {
94170
for _, err := range validateCoderModuleReadmeBody(rm.body) {
95171
errs = append(errs, addFilePathToError(rm.filePath, err))
96172
}
173+
for _, err := range validateModuleSourceURL(rm.body, rm.filePath) {
174+
errs = append(errs, addFilePathToError(rm.filePath, err))
175+
}
97176
for _, err := range validateResourceGfmAlerts(rm.body) {
98177
errs = append(errs, addFilePathToError(rm.filePath, err))
99178
}

cmd/readmevalidation/codermodules_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,86 @@ func TestValidateCoderResourceReadmeBody(t *testing.T) {
2020
}
2121
})
2222
}
23+
24+
func TestValidateModuleSourceURL(t *testing.T) {
25+
t.Parallel()
26+
27+
t.Run("Valid source URL format", func(t *testing.T) {
28+
t.Parallel()
29+
30+
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"
31+
filePath := "registry/test-namespace/modules/test-module/README.md"
32+
errs := validateModuleSourceURL(body, filePath)
33+
if len(errs) != 0 {
34+
t.Errorf("Expected no errors, got: %v", errs)
35+
}
36+
})
37+
38+
t.Run("Invalid source URL format - wrong namespace", func(t *testing.T) {
39+
t.Parallel()
40+
41+
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"
42+
filePath := "registry/test-namespace/modules/test-module/README.md"
43+
errs := validateModuleSourceURL(body, filePath)
44+
if len(errs) != 1 {
45+
t.Errorf("Expected 1 error, got %d: %v", len(errs), errs)
46+
}
47+
if len(errs) > 0 && !contains(errs[0].Error(), "incorrect source URL format") {
48+
t.Errorf("Expected source URL format error, got: %s", errs[0].Error())
49+
}
50+
})
51+
52+
t.Run("Missing source URL", func(t *testing.T) {
53+
t.Parallel()
54+
55+
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"
56+
filePath := "registry/test-namespace/modules/test-module/README.md"
57+
errs := validateModuleSourceURL(body, filePath)
58+
if len(errs) != 1 {
59+
t.Errorf("Expected 1 error, got %d: %v", len(errs), errs)
60+
}
61+
if len(errs) > 0 && !contains(errs[0].Error(), "did not find correct source URL") {
62+
t.Errorf("Expected missing source URL error, got: %s", errs[0].Error())
63+
}
64+
})
65+
66+
t.Run("Module name with hyphens vs underscores", func(t *testing.T) {
67+
t.Parallel()
68+
69+
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"
70+
filePath := "registry/test-namespace/modules/test-module/README.md"
71+
errs := validateModuleSourceURL(body, filePath)
72+
if len(errs) != 0 {
73+
t.Errorf("Expected no errors for hyphen/underscore variation, got: %v", errs)
74+
}
75+
})
76+
77+
t.Run("Invalid file path format", func(t *testing.T) {
78+
t.Parallel()
79+
80+
body := "# Test Module"
81+
filePath := "invalid/path/format"
82+
errs := validateModuleSourceURL(body, filePath)
83+
if len(errs) != 1 {
84+
t.Errorf("Expected 1 error, got %d: %v", len(errs), errs)
85+
}
86+
if len(errs) > 0 && !contains(errs[0].Error(), "invalid module path format") {
87+
t.Errorf("Expected path format error, got: %s", errs[0].Error())
88+
}
89+
})
90+
}
91+
92+
func contains(s, substr string) bool {
93+
return len(s) >= len(substr) && (s == substr || (len(s) > len(substr) &&
94+
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
95+
indexOfSubstring(s, substr) >= 0)))
96+
}
97+
98+
func indexOfSubstring(s, substr string) int {
99+
for i := 0; i <= len(s)-len(substr); i++ {
100+
if s[i:i+len(substr)] == substr {
101+
return i
102+
}
103+
}
104+
return -1
105+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"fmt:ci": "bun x prettier --check . && terraform fmt -check -recursive -diff",
66
"terraform-validate": "./scripts/terraform_validate.sh",
77
"test": "./scripts/terraform_test_all.sh",
8-
"update-version": "./update-version.sh"
8+
"update-version": "./update-version.sh",
9+
"validate-readme": "go build ./cmd/readmevalidation && ./readmevalidation"
910
},
1011
"devDependencies": {
1112
"@types/bun": "^1.2.21",

0 commit comments

Comments
 (0)