Skip to content

Commit 08ed594

Browse files
authored
chore: add validation for Coder Template README files (#326)
Closes #194 alongside #325 ## Description This PR adds the missing base layer of validation for all Coder template README files, ensuring that they all follow a consistent structure when processed by the Registry website's build step. It also updates a few README files to match the new standards. ## Type of Change - [ ] New module - [x] Bug fix - [ ] Feature/enhancement - [ ] Documentation - [x] Other ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun run fmt`) - [x] Changes tested locally
1 parent fd074a5 commit 08ed594

File tree

12 files changed

+326
-154
lines changed

12 files changed

+326
-154
lines changed

cmd/readmevalidation/codermodules.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"strings"
7+
8+
"golang.org/x/xerrors"
9+
)
10+
11+
func validateCoderModuleReadmeBody(body string) []error {
12+
var errs []error
13+
14+
trimmed := strings.TrimSpace(body)
15+
if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 {
16+
errs = append(errs, baseErrs...)
17+
}
18+
19+
foundParagraph := false
20+
terraformCodeBlockCount := 0
21+
foundTerraformVersionRef := false
22+
23+
lineNum := 0
24+
isInsideCodeBlock := false
25+
isInsideTerraform := false
26+
27+
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
28+
for lineScanner.Scan() {
29+
lineNum++
30+
nextLine := lineScanner.Text()
31+
32+
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
33+
// need to check deeper if the first line isn't an h1.
34+
if lineNum == 1 {
35+
if !strings.HasPrefix(nextLine, "# ") {
36+
break
37+
}
38+
continue
39+
}
40+
41+
if strings.HasPrefix(nextLine, "```") {
42+
isInsideCodeBlock = !isInsideCodeBlock
43+
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
44+
if isInsideTerraform {
45+
terraformCodeBlockCount++
46+
}
47+
if strings.HasPrefix(nextLine, "```hcl") {
48+
errs = append(errs, xerrors.New("all hcl code blocks must be converted to tf"))
49+
}
50+
continue
51+
}
52+
53+
if isInsideCodeBlock {
54+
if isInsideTerraform {
55+
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
56+
}
57+
continue
58+
}
59+
60+
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
61+
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
62+
break
63+
}
64+
65+
// Code assumes that if we've reached this point, the only other options are:
66+
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
67+
trimmedLine := strings.TrimSpace(nextLine)
68+
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
69+
foundParagraph = foundParagraph || isParagraph
70+
}
71+
72+
if terraformCodeBlockCount == 0 {
73+
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
74+
} else {
75+
if terraformCodeBlockCount > 1 {
76+
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
77+
}
78+
if !foundTerraformVersionRef {
79+
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
80+
}
81+
}
82+
if !foundParagraph {
83+
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
84+
}
85+
if isInsideCodeBlock {
86+
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
87+
}
88+
89+
return errs
90+
}
91+
92+
func validateCoderModuleReadme(rm coderResourceReadme) []error {
93+
var errs []error
94+
for _, err := range validateCoderModuleReadmeBody(rm.body) {
95+
errs = append(errs, addFilePathToError(rm.filePath, err))
96+
}
97+
if fmErrs := validateCoderResourceFrontmatter("modules", rm.filePath, rm.frontmatter); len(fmErrs) != 0 {
98+
errs = append(errs, fmErrs...)
99+
}
100+
return errs
101+
}
102+
103+
func validateAllCoderModuleReadmes(resources []coderResourceReadme) error {
104+
var yamlValidationErrors []error
105+
for _, readme := range resources {
106+
errs := validateCoderModuleReadme(readme)
107+
if len(errs) > 0 {
108+
yamlValidationErrors = append(yamlValidationErrors, errs...)
109+
}
110+
}
111+
if len(yamlValidationErrors) != 0 {
112+
return validationPhaseError{
113+
phase: validationPhaseReadme,
114+
errors: yamlValidationErrors,
115+
}
116+
}
117+
return nil
118+
}
119+
120+
func validateAllCoderModules() error {
121+
const resourceType = "modules"
122+
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
123+
if err != nil {
124+
return err
125+
}
126+
127+
logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles))
128+
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
129+
if err != nil {
130+
return err
131+
}
132+
err = validateAllCoderModuleReadmes(resources)
133+
if err != nil {
134+
return err
135+
}
136+
logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources))
137+
138+
if err := validateCoderResourceRelativeURLs(resources); err != nil {
139+
return err
140+
}
141+
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
142+
return nil
143+
}

cmd/readmevalidation/coderresources_test.go renamed to cmd/readmevalidation/codermodules_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ func TestValidateCoderResourceReadmeBody(t *testing.T) {
1414
t.Run("Parses a valid README body with zero issues", func(t *testing.T) {
1515
t.Parallel()
1616

17-
errs := validateCoderResourceReadmeBody(testBody)
17+
errs := validateCoderModuleReadmeBody(testBody)
1818
for _, e := range errs {
1919
t.Error(e)
2020
}

cmd/readmevalidation/coderresources.go

Lines changed: 33 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package main
22

33
import (
4-
"bufio"
5-
"context"
64
"errors"
75
"net/url"
86
"os"
@@ -89,7 +87,7 @@ func validateCoderResourceIconURL(iconURL string) []error {
8987
return []error{xerrors.New("icon URL cannot be empty")}
9088
}
9189

92-
errs := []error{}
90+
var errs []error
9391

9492
// If the URL does not have a relative path.
9593
if !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") {
@@ -120,7 +118,7 @@ func validateCoderResourceTags(tags []string) error {
120118

121119
// All of these tags are used for the module/template filter controls in the Registry site. Need to make sure they
122120
// can all be placed in the browser URL without issue.
123-
invalidTags := []string{}
121+
var invalidTags []string
124122
for _, t := range tags {
125123
if t != url.QueryEscape(t) {
126124
invalidTags = append(invalidTags, t)
@@ -133,108 +131,27 @@ func validateCoderResourceTags(tags []string) error {
133131
return nil
134132
}
135133

136-
func validateCoderResourceReadmeBody(body string) []error {
137-
var errs []error
138-
139-
trimmed := strings.TrimSpace(body)
140-
// TODO: this may cause unexpected behavior since the errors slice may have a 0 length. Add a test.
141-
errs = append(errs, validateReadmeBody(trimmed)...)
142-
143-
foundParagraph := false
144-
terraformCodeBlockCount := 0
145-
foundTerraformVersionRef := false
146-
147-
lineNum := 0
148-
isInsideCodeBlock := false
149-
isInsideTerraform := false
150-
151-
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
152-
for lineScanner.Scan() {
153-
lineNum++
154-
nextLine := lineScanner.Text()
155-
156-
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
157-
// need to check deeper if the first line isn't an h1.
158-
if lineNum == 1 {
159-
if !strings.HasPrefix(nextLine, "# ") {
160-
break
161-
}
162-
continue
163-
}
164-
165-
if strings.HasPrefix(nextLine, "```") {
166-
isInsideCodeBlock = !isInsideCodeBlock
167-
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
168-
if isInsideTerraform {
169-
terraformCodeBlockCount++
170-
}
171-
if strings.HasPrefix(nextLine, "```hcl") {
172-
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
173-
}
174-
continue
175-
}
176-
177-
if isInsideCodeBlock {
178-
if isInsideTerraform {
179-
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
180-
}
181-
continue
182-
}
183-
184-
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
185-
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
186-
break
187-
}
188-
189-
// Code assumes that if we've reached this point, the only other options are:
190-
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
191-
trimmedLine := strings.TrimSpace(nextLine)
192-
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
193-
foundParagraph = foundParagraph || isParagraph
194-
}
195-
196-
if terraformCodeBlockCount == 0 {
197-
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
198-
} else {
199-
if terraformCodeBlockCount > 1 {
200-
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
201-
}
202-
if !foundTerraformVersionRef {
203-
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
204-
}
205-
}
206-
if !foundParagraph {
207-
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
208-
}
209-
if isInsideCodeBlock {
210-
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
134+
func validateCoderResourceFrontmatter(resourceType string, filePath string, fm coderResourceFrontmatter) []error {
135+
if !slices.Contains(supportedResourceTypes, resourceType) {
136+
return []error{xerrors.Errorf("cannot process unknown resource type %q", resourceType)}
211137
}
212138

213-
return errs
214-
}
215-
216-
func validateCoderResourceReadme(rm coderResourceReadme) []error {
217139
var errs []error
218-
219-
for _, err := range validateCoderResourceReadmeBody(rm.body) {
220-
errs = append(errs, addFilePathToError(rm.filePath, err))
221-
}
222-
223-
if err := validateCoderResourceDisplayName(rm.frontmatter.DisplayName); err != nil {
224-
errs = append(errs, addFilePathToError(rm.filePath, err))
140+
if err := validateCoderResourceDisplayName(fm.DisplayName); err != nil {
141+
errs = append(errs, addFilePathToError(filePath, err))
225142
}
226-
if err := validateCoderResourceDescription(rm.frontmatter.Description); err != nil {
227-
errs = append(errs, addFilePathToError(rm.filePath, err))
143+
if err := validateCoderResourceDescription(fm.Description); err != nil {
144+
errs = append(errs, addFilePathToError(filePath, err))
228145
}
229-
if err := validateCoderResourceTags(rm.frontmatter.Tags); err != nil {
230-
errs = append(errs, addFilePathToError(rm.filePath, err))
146+
if err := validateCoderResourceTags(fm.Tags); err != nil {
147+
errs = append(errs, addFilePathToError(filePath, err))
231148
}
232149

233-
for _, err := range validateCoderResourceIconURL(rm.frontmatter.IconURL) {
234-
errs = append(errs, addFilePathToError(rm.filePath, err))
150+
for _, err := range validateCoderResourceIconURL(fm.IconURL) {
151+
errs = append(errs, addFilePathToError(filePath, err))
235152
}
236-
for _, err := range validateSupportedOperatingSystems(rm.frontmatter.OperatingSystems) {
237-
errs = append(errs, addFilePathToError(rm.filePath, err))
153+
for _, err := range validateSupportedOperatingSystems(fm.OperatingSystems) {
154+
errs = append(errs, addFilePathToError(filePath, err))
238155
}
239156

240157
return errs
@@ -248,7 +165,7 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead
248165

249166
keyErrs := validateFrontmatterYamlKeys(fm, supportedCoderResourceStructKeys)
250167
if len(keyErrs) != 0 {
251-
remapped := []error{}
168+
var remapped []error
252169
for _, e := range keyErrs {
253170
remapped = append(remapped, addFilePathToError(rm.filePath, e))
254171
}
@@ -268,7 +185,11 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead
268185
}, nil
269186
}
270187

271-
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[string]coderResourceReadme, error) {
188+
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) ([]coderResourceReadme, error) {
189+
if !slices.Contains(supportedResourceTypes, resourceType) {
190+
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
191+
}
192+
272193
resources := map[string]coderResourceReadme{}
273194
var yamlParsingErrs []error
274195
for _, rm := range rms {
@@ -287,30 +208,27 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin
287208
}
288209
}
289210

290-
yamlValidationErrors := []error{}
291-
for _, readme := range resources {
292-
errs := validateCoderResourceReadme(readme)
293-
if len(errs) > 0 {
294-
yamlValidationErrors = append(yamlValidationErrors, errs...)
295-
}
211+
var serialized []coderResourceReadme
212+
for _, r := range resources {
213+
serialized = append(serialized, r)
296214
}
297-
if len(yamlValidationErrors) != 0 {
298-
return nil, validationPhaseError{
299-
phase: validationPhaseReadme,
300-
errors: yamlValidationErrors,
301-
}
302-
}
303-
304-
return resources, nil
215+
slices.SortFunc(serialized, func(r1 coderResourceReadme, r2 coderResourceReadme) int {
216+
return strings.Compare(r1.filePath, r2.filePath)
217+
})
218+
return serialized, nil
305219
}
306220

307221
// Todo: Need to beef up this function by grabbing each image/video URL from
308222
// the body's AST.
309-
func validateCoderResourceRelativeURLs(_ map[string]coderResourceReadme) error {
223+
func validateCoderResourceRelativeURLs(_ []coderResourceReadme) error {
310224
return nil
311225
}
312226

313227
func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
228+
if !slices.Contains(supportedResourceTypes, resourceType) {
229+
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
230+
}
231+
314232
registryFiles, err := os.ReadDir(rootRegistryPath)
315233
if err != nil {
316234
return nil, err
@@ -359,27 +277,3 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
359277
}
360278
return allReadmeFiles, nil
361279
}
362-
363-
func validateAllCoderResourceFilesOfType(resourceType string) error {
364-
if !slices.Contains(supportedResourceTypes, resourceType) {
365-
return xerrors.Errorf("resource type %q is not part of supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", "))
366-
}
367-
368-
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
369-
if err != nil {
370-
return err
371-
}
372-
373-
logger.Info(context.Background(), "processing README files", "num_files", len(allReadmeFiles))
374-
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
375-
if err != nil {
376-
return err
377-
}
378-
logger.Info(context.Background(), "processed README files as valid Coder resources", "num_files", len(resources), "type", resourceType)
379-
380-
if err := validateCoderResourceRelativeURLs(resources); err != nil {
381-
return err
382-
}
383-
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "type", resourceType)
384-
return nil
385-
}

0 commit comments

Comments
 (0)