Skip to content

Commit ba6fea8

Browse files
chore: add logic to validate all modules (#11)
Closes coder/internal#531 ## Changes made - Added functionality to validate the structure of module README files (frontmatter and the README body) - Added a really basic snapshot-ish test for the module README body validation - Updated README files that were previously violating README requirements (the old modules validation logic wasn't catching these) - Changed `ValidationPhase` from an int to a string
1 parent 45dc925 commit ba6fea8

File tree

12 files changed

+633
-69
lines changed

12 files changed

+633
-69
lines changed
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"errors"
6+
"fmt"
7+
"log"
8+
"net/url"
9+
"os"
10+
"path"
11+
"regexp"
12+
"slices"
13+
"strings"
14+
15+
"gopkg.in/yaml.v3"
16+
)
17+
18+
var supportedResourceTypes = []string{"modules", "templates"}
19+
20+
type coderResourceFrontmatter struct {
21+
Description string `yaml:"description"`
22+
IconURL string `yaml:"icon"`
23+
DisplayName *string `yaml:"display_name"`
24+
Verified *bool `yaml:"verified"`
25+
Tags []string `yaml:"tags"`
26+
}
27+
28+
// coderResourceReadme represents a README describing a Terraform resource used
29+
// to help create Coder workspaces. As of 2025-04-15, this encapsulates both
30+
// Coder Modules and Coder Templates
31+
type coderResourceReadme struct {
32+
resourceType string
33+
filePath string
34+
body string
35+
frontmatter coderResourceFrontmatter
36+
}
37+
38+
func validateCoderResourceDisplayName(displayName *string) error {
39+
if displayName != nil && *displayName == "" {
40+
return errors.New("if defined, display_name must not be empty string")
41+
}
42+
return nil
43+
}
44+
45+
func validateCoderResourceDescription(description string) error {
46+
if description == "" {
47+
return errors.New("frontmatter description cannot be empty")
48+
}
49+
return nil
50+
}
51+
52+
func validateCoderResourceIconURL(iconURL string) []error {
53+
problems := []error{}
54+
55+
if iconURL == "" {
56+
problems = append(problems, errors.New("icon URL cannot be empty"))
57+
return problems
58+
}
59+
60+
isAbsoluteURL := !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/")
61+
if isAbsoluteURL {
62+
if _, err := url.ParseRequestURI(iconURL); err != nil {
63+
problems = append(problems, errors.New("absolute icon URL is not correctly formatted"))
64+
}
65+
if strings.Contains(iconURL, "?") {
66+
problems = append(problems, errors.New("icon URLs cannot contain query parameters"))
67+
}
68+
return problems
69+
}
70+
71+
// Would normally be skittish about having relative paths like this, but it
72+
// should be safe because we have guarantees about the structure of the
73+
// repo, and where this logic will run
74+
isPermittedRelativeURL := strings.HasPrefix(iconURL, "./") ||
75+
strings.HasPrefix(iconURL, "/") ||
76+
strings.HasPrefix(iconURL, "../../../../.icons")
77+
if !isPermittedRelativeURL {
78+
problems = append(problems, fmt.Errorf("relative icon URL %q must either be scoped to that module's directory, or the top-level /.icons directory (this can usually be done by starting the path with \"../../../.icons\")", iconURL))
79+
}
80+
81+
return problems
82+
}
83+
84+
func validateCoderResourceTags(tags []string) error {
85+
if tags == nil {
86+
return errors.New("provided tags array is nil")
87+
}
88+
if len(tags) == 0 {
89+
return nil
90+
}
91+
92+
// All of these tags are used for the module/template filter controls in the
93+
// Registry site. Need to make sure they can all be placed in the browser
94+
// URL without issue
95+
invalidTags := []string{}
96+
for _, t := range tags {
97+
if t != url.QueryEscape(t) {
98+
invalidTags = append(invalidTags, t)
99+
}
100+
}
101+
102+
if len(invalidTags) != 0 {
103+
return fmt.Errorf("found invalid tags (tags that cannot be used for filter state in the Registry website): [%s]", strings.Join(invalidTags, ", "))
104+
}
105+
return nil
106+
}
107+
108+
// Todo: This is a holdover from the validation logic used by the Coder Modules
109+
// repo. It gives us some assurance, but realistically, we probably want to
110+
// parse any Terraform code snippets, and make some deeper guarantees about how
111+
// it's structured. Just validating whether it *can* be parsed as Terraform
112+
// would be a big improvement.
113+
var terraformVersionRe = regexp.MustCompile("^\\s*\\bversion\\s+=")
114+
115+
func validateCoderResourceReadmeBody(body string) []error {
116+
trimmed := strings.TrimSpace(body)
117+
var errs []error
118+
errs = append(errs, validateReadmeBody(trimmed)...)
119+
120+
foundParagraph := false
121+
terraformCodeBlockCount := 0
122+
foundTerraformVersionRef := false
123+
124+
lineNum := 0
125+
isInsideCodeBlock := false
126+
isInsideTerraform := false
127+
128+
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
129+
for lineScanner.Scan() {
130+
lineNum++
131+
nextLine := lineScanner.Text()
132+
133+
// Code assumes that invalid headers would've already been handled by
134+
// the base validation function, so we don't need to check deeper if the
135+
// first line isn't an h1
136+
if lineNum == 1 {
137+
if !strings.HasPrefix(nextLine, "# ") {
138+
break
139+
}
140+
continue
141+
}
142+
143+
if strings.HasPrefix(nextLine, "```") {
144+
isInsideCodeBlock = !isInsideCodeBlock
145+
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
146+
if isInsideTerraform {
147+
terraformCodeBlockCount++
148+
}
149+
if strings.HasPrefix(nextLine, "```hcl") {
150+
errs = append(errs, errors.New("all .hcl language references must be converted to .tf"))
151+
}
152+
continue
153+
}
154+
155+
if isInsideCodeBlock {
156+
if isInsideTerraform {
157+
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
158+
}
159+
continue
160+
}
161+
162+
// Code assumes that we can treat this case as the end of the "h1
163+
// section" and don't need to process any further lines
164+
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
165+
break
166+
}
167+
168+
// Code assumes that if we've reached this point, the only other options
169+
// are: (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset
170+
// references made via [] syntax
171+
trimmedLine := strings.TrimSpace(nextLine)
172+
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
173+
foundParagraph = foundParagraph || isParagraph
174+
}
175+
176+
if terraformCodeBlockCount == 0 {
177+
errs = append(errs, errors.New("did not find Terraform code block within h1 section"))
178+
} else {
179+
if terraformCodeBlockCount > 1 {
180+
errs = append(errs, errors.New("cannot have more than one Terraform code block in h1 section"))
181+
}
182+
if !foundTerraformVersionRef {
183+
errs = append(errs, errors.New("did not find Terraform code block that specifies 'version' field"))
184+
}
185+
}
186+
if !foundParagraph {
187+
errs = append(errs, errors.New("did not find paragraph within h1 section"))
188+
}
189+
if isInsideCodeBlock {
190+
errs = append(errs, errors.New("code blocks inside h1 section do not all terminate before end of file"))
191+
}
192+
193+
return errs
194+
}
195+
196+
func validateCoderResourceReadme(rm coderResourceReadme) []error {
197+
var errs []error
198+
199+
for _, err := range validateCoderResourceReadmeBody(rm.body) {
200+
errs = append(errs, addFilePathToError(rm.filePath, err))
201+
}
202+
203+
if err := validateCoderResourceDisplayName(rm.frontmatter.DisplayName); err != nil {
204+
errs = append(errs, addFilePathToError(rm.filePath, err))
205+
}
206+
if err := validateCoderResourceDescription(rm.frontmatter.Description); err != nil {
207+
errs = append(errs, addFilePathToError(rm.filePath, err))
208+
}
209+
if err := validateCoderResourceTags(rm.frontmatter.Tags); err != nil {
210+
errs = append(errs, addFilePathToError(rm.filePath, err))
211+
}
212+
213+
for _, err := range validateCoderResourceIconURL(rm.frontmatter.IconURL) {
214+
errs = append(errs, addFilePathToError(rm.filePath, err))
215+
}
216+
217+
return errs
218+
}
219+
220+
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, error) {
221+
fm, body, err := separateFrontmatter(rm.rawText)
222+
if err != nil {
223+
return coderResourceReadme{}, fmt.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
224+
}
225+
226+
yml := coderResourceFrontmatter{}
227+
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
228+
return coderResourceReadme{}, fmt.Errorf("%q: failed to parse: %v", rm.filePath, err)
229+
}
230+
231+
return coderResourceReadme{
232+
resourceType: resourceType,
233+
filePath: rm.filePath,
234+
body: body,
235+
frontmatter: yml,
236+
}, nil
237+
}
238+
239+
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[string]coderResourceReadme, error) {
240+
resources := map[string]coderResourceReadme{}
241+
var yamlParsingErrs []error
242+
for _, rm := range rms {
243+
p, err := parseCoderResourceReadme(resourceType, rm)
244+
if err != nil {
245+
yamlParsingErrs = append(yamlParsingErrs, err)
246+
continue
247+
}
248+
249+
resources[p.filePath] = p
250+
}
251+
if len(yamlParsingErrs) != 0 {
252+
return nil, validationPhaseError{
253+
phase: validationPhaseReadmeParsing,
254+
errors: yamlParsingErrs,
255+
}
256+
}
257+
258+
yamlValidationErrors := []error{}
259+
for _, readme := range resources {
260+
errors := validateCoderResourceReadme(readme)
261+
if len(errors) > 0 {
262+
yamlValidationErrors = append(yamlValidationErrors, errors...)
263+
}
264+
}
265+
if len(yamlValidationErrors) != 0 {
266+
return nil, validationPhaseError{
267+
phase: validationPhaseReadmeParsing,
268+
errors: yamlValidationErrors,
269+
}
270+
}
271+
272+
return resources, nil
273+
}
274+
275+
// Todo: Need to beef up this function by grabbing each image/video URL from
276+
// the body's AST
277+
func validateCoderResourceRelativeUrls(resources map[string]coderResourceReadme) error {
278+
return nil
279+
}
280+
281+
func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
282+
registryFiles, err := os.ReadDir(rootRegistryPath)
283+
if err != nil {
284+
return nil, err
285+
}
286+
287+
var allReadmeFiles []readme
288+
var errs []error
289+
for _, rf := range registryFiles {
290+
if !rf.IsDir() {
291+
continue
292+
}
293+
294+
resourceRootPath := path.Join(rootRegistryPath, rf.Name(), resourceType)
295+
resourceDirs, err := os.ReadDir(resourceRootPath)
296+
if err != nil {
297+
if !errors.Is(err, os.ErrNotExist) {
298+
errs = append(errs, err)
299+
}
300+
continue
301+
}
302+
303+
for _, rd := range resourceDirs {
304+
if !rd.IsDir() || rd.Name() == ".coder" {
305+
continue
306+
}
307+
308+
resourceReadmePath := path.Join(resourceRootPath, rd.Name(), "README.md")
309+
rm, err := os.ReadFile(resourceReadmePath)
310+
if err != nil {
311+
errs = append(errs, err)
312+
continue
313+
}
314+
315+
allReadmeFiles = append(allReadmeFiles, readme{
316+
filePath: resourceReadmePath,
317+
rawText: string(rm),
318+
})
319+
}
320+
}
321+
322+
if len(errs) != 0 {
323+
return nil, validationPhaseError{
324+
phase: validationPhaseFileLoad,
325+
errors: errs,
326+
}
327+
}
328+
return allReadmeFiles, nil
329+
}
330+
331+
func validateAllCoderResourceFilesOfType(resourceType string) error {
332+
if !slices.Contains(supportedResourceTypes, resourceType) {
333+
return fmt.Errorf("resource type %q is not part of supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", "))
334+
}
335+
336+
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
337+
if err != nil {
338+
return err
339+
}
340+
341+
log.Printf("Processing %d README files\n", len(allReadmeFiles))
342+
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
343+
if err != nil {
344+
return err
345+
}
346+
log.Printf("Processed %d README files as valid Coder resources with type %q", len(resources), resourceType)
347+
348+
err = validateCoderResourceRelativeUrls(resources)
349+
if err != nil {
350+
return err
351+
}
352+
log.Printf("All relative URLs for %s READMEs are valid\n", resourceType)
353+
return nil
354+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package main
2+
3+
import (
4+
_ "embed"
5+
"testing"
6+
)
7+
8+
//go:embed testSamples/sampleReadmeBody.md
9+
var testBody string
10+
11+
func TestValidateCoderResourceReadmeBody(t *testing.T) {
12+
t.Parallel()
13+
14+
t.Run("Parses a valid README body with zero issues", func(t *testing.T) {
15+
t.Parallel()
16+
17+
errs := validateCoderResourceReadmeBody(testBody)
18+
for _, e := range errs {
19+
t.Error(e)
20+
}
21+
})
22+
}

0 commit comments

Comments
 (0)