Skip to content

Commit fd074a5

Browse files
authored
fix: improve logic for existing README validation (#325)
Addresses part of #194 ## Description This PR beefs up the validation for the validation logic that we already had in place. This PR does not include adding validation for templates (which will be addressed in a second PR). ### Changes made - Added logic to reject unknown frontmatter fields for modules and contributor profile README files - Added logic to handle frontmatter fields that were previously missed in validation steps (GitHub username for contributors and Operating Systems for modules) - Updated a few comments (added some new comments, formatted existing comments to meet 100-column width) ### 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 40863c0 commit fd074a5

File tree

4 files changed

+142
-39
lines changed

4 files changed

+142
-39
lines changed

cmd/readmevalidation/coderresources.go

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
var (
1919
supportedResourceTypes = []string{"modules", "templates"}
20+
operatingSystems = []string{"windows", "macos", "linux"}
2021

2122
// TODO: This is a holdover from the validation logic used by the Coder Modules repo. It gives us some assurance, but
2223
// realistically, we probably want to parse any Terraform code snippets, and make some deeper guarantees about how it's
@@ -25,11 +26,21 @@ var (
2526
)
2627

2728
type coderResourceFrontmatter struct {
28-
Description string `yaml:"description"`
29-
IconURL string `yaml:"icon"`
30-
DisplayName *string `yaml:"display_name"`
31-
Verified *bool `yaml:"verified"`
32-
Tags []string `yaml:"tags"`
29+
Description string `yaml:"description"`
30+
IconURL string `yaml:"icon"`
31+
DisplayName *string `yaml:"display_name"`
32+
Verified *bool `yaml:"verified"`
33+
Tags []string `yaml:"tags"`
34+
OperatingSystems []string `yaml:"supported_os"`
35+
}
36+
37+
// A slice version of the struct tags from coderResourceFrontmatter. Might be worth using reflection to generate this
38+
// list at runtime in the future, but this should be okay for now
39+
var supportedCoderResourceStructKeys = []string{
40+
"description", "icon", "display_name", "verified", "tags", "supported_os",
41+
// TODO: This is an old, officially deprecated key from the archived coder/modules repo. We can remove this once we
42+
// make sure that the Registry Server is no longer checking this field.
43+
"maintainer_github",
3344
}
3445

3546
// coderResourceReadme represents a README describing a Terraform resource used
@@ -42,6 +53,17 @@ type coderResourceReadme struct {
4253
frontmatter coderResourceFrontmatter
4354
}
4455

56+
func validateSupportedOperatingSystems(systems []string) []error {
57+
var errs []error
58+
for _, s := range systems {
59+
if slices.Contains(operatingSystems, s) {
60+
continue
61+
}
62+
errs = append(errs, xerrors.Errorf("detected unknown operating system %q", s))
63+
}
64+
return errs
65+
}
66+
4567
func validateCoderResourceDisplayName(displayName *string) error {
4668
if displayName != nil && *displayName == "" {
4769
return xerrors.New("if defined, display_name must not be empty string")
@@ -211,19 +233,31 @@ func validateCoderResourceReadme(rm coderResourceReadme) []error {
211233
for _, err := range validateCoderResourceIconURL(rm.frontmatter.IconURL) {
212234
errs = append(errs, addFilePathToError(rm.filePath, err))
213235
}
236+
for _, err := range validateSupportedOperatingSystems(rm.frontmatter.OperatingSystems) {
237+
errs = append(errs, addFilePathToError(rm.filePath, err))
238+
}
214239

215240
return errs
216241
}
217242

218-
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, error) {
243+
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, []error) {
219244
fm, body, err := separateFrontmatter(rm.rawText)
220245
if err != nil {
221-
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
246+
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
247+
}
248+
249+
keyErrs := validateFrontmatterYamlKeys(fm, supportedCoderResourceStructKeys)
250+
if len(keyErrs) != 0 {
251+
remapped := []error{}
252+
for _, e := range keyErrs {
253+
remapped = append(remapped, addFilePathToError(rm.filePath, e))
254+
}
255+
return coderResourceReadme{}, remapped
222256
}
223257

224258
yml := coderResourceFrontmatter{}
225259
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
226-
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
260+
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
227261
}
228262

229263
return coderResourceReadme{
@@ -238,9 +272,9 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin
238272
resources := map[string]coderResourceReadme{}
239273
var yamlParsingErrs []error
240274
for _, rm := range rms {
241-
p, err := parseCoderResourceReadme(resourceType, rm)
242-
if err != nil {
243-
yamlParsingErrs = append(yamlParsingErrs, err)
275+
p, errs := parseCoderResourceReadme(resourceType, rm)
276+
if len(errs) != 0 {
277+
yamlParsingErrs = append(yamlParsingErrs, errs...)
244278
continue
245279
}
246280

cmd/readmevalidation/contributors.go

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@ type contributorProfileFrontmatter struct {
1919
Bio string `yaml:"bio"`
2020
ContributorStatus string `yaml:"status"`
2121
AvatarURL *string `yaml:"avatar"`
22+
GithubUsername *string `yaml:"github"`
2223
LinkedinURL *string `yaml:"linkedin"`
2324
WebsiteURL *string `yaml:"website"`
2425
SupportEmail *string `yaml:"support_email"`
2526
}
2627

28+
// A slice version of the struct tags from contributorProfileFrontmatter. Might be worth using reflection to generate
29+
// this list at runtime in the future, but this should be okay for now
30+
var supportedContributorProfileStructKeys = []string{"display_name", "bio", "status", "avatar", "linkedin", "github", "website", "support_email"}
31+
2732
type contributorProfileReadme struct {
2833
frontmatter contributorProfileFrontmatter
2934
namespace string
@@ -50,6 +55,22 @@ func validateContributorLinkedinURL(linkedinURL *string) error {
5055
return nil
5156
}
5257

58+
func validateGithubUsername(username *string) error {
59+
if username == nil {
60+
return nil
61+
}
62+
63+
name := *username
64+
trimmed := strings.TrimSpace(name)
65+
if trimmed == "" {
66+
return xerrors.New("username must have non-whitespace characters")
67+
}
68+
if name != trimmed {
69+
return xerrors.Errorf("username %q has extra whitespace", trimmed)
70+
}
71+
return nil
72+
}
73+
5374
// validateContributorSupportEmail does best effort validation of a contributors email address. We can't 100% validate
5475
// that this is correct without actually sending an email, especially because some contributors are individual developers
5576
// 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 {
153174
if err := validateContributorLinkedinURL(rm.frontmatter.LinkedinURL); err != nil {
154175
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
155176
}
177+
if err := validateGithubUsername(rm.frontmatter.GithubUsername); err != nil {
178+
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
179+
}
156180
if err := validateContributorWebsite(rm.frontmatter.WebsiteURL); err != nil {
157181
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
158182
}
@@ -170,15 +194,24 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
170194
return allErrs
171195
}
172196

173-
func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
197+
func parseContributorProfile(rm readme) (contributorProfileReadme, []error) {
174198
fm, _, err := separateFrontmatter(rm.rawText)
175199
if err != nil {
176-
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
200+
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
201+
}
202+
203+
keyErrs := validateFrontmatterYamlKeys(fm, supportedContributorProfileStructKeys)
204+
if len(keyErrs) != 0 {
205+
remapped := []error{}
206+
for _, e := range keyErrs {
207+
remapped = append(remapped, addFilePathToError(rm.filePath, e))
208+
}
209+
return contributorProfileReadme{}, remapped
177210
}
178211

179212
yml := contributorProfileFrontmatter{}
180213
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
181-
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
214+
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
182215
}
183216

184217
return contributorProfileReadme{
@@ -192,9 +225,9 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
192225
profilesByNamespace := map[string]contributorProfileReadme{}
193226
yamlParsingErrors := []error{}
194227
for _, rm := range readmeEntries {
195-
p, err := parseContributorProfile(rm)
196-
if err != nil {
197-
yamlParsingErrors = append(yamlParsingErrors, err)
228+
p, errs := parseContributorProfile(rm)
229+
if len(errs) != 0 {
230+
yamlParsingErrors = append(yamlParsingErrors, errs...)
198231
continue
199232
}
200233

cmd/readmevalidation/readmefiles.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bufio"
55
"fmt"
66
"regexp"
7+
"slices"
78
"strings"
89

910
"golang.org/x/xerrors"
@@ -39,7 +40,9 @@ const (
3940

4041
var (
4142
supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
42-
// Matches markdown headers, must be at the beginning of a line, such as "# " or "### ".
43+
// Matches markdown headers placed at the beginning of a line (e.g., "# " or "### "). To make the logic for
44+
// validateReadmeBody easier, this pattern deliberately matches on invalid headers (header levels must be in the
45+
// range 1–6 to be valid). The function has checks to see if the level is correct.
4346
readmeHeaderRe = regexp.MustCompile(`^(#+)(\s*)`)
4447
)
4548

@@ -168,3 +171,25 @@ func validateReadmeBody(body string) []error {
168171

169172
return errs
170173
}
174+
175+
func validateFrontmatterYamlKeys(frontmatter string, allowedKeys []string) []error {
176+
if len(allowedKeys) == 0 {
177+
return []error{xerrors.New("Set of allowed keys is empty")}
178+
}
179+
180+
var key string
181+
var cutOk bool
182+
var line string
183+
184+
var errs []error
185+
lineScanner := bufio.NewScanner(strings.NewReader(frontmatter))
186+
for lineScanner.Scan() {
187+
line = lineScanner.Text()
188+
key, _, cutOk = strings.Cut(line, ":")
189+
if !cutOk || slices.Contains(allowedKeys, key) {
190+
continue
191+
}
192+
errs = append(errs, xerrors.Errorf("detected unknown key %q", key))
193+
}
194+
return errs
195+
}

cmd/readmevalidation/repostructure.go

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,21 @@ import (
1010
"golang.org/x/xerrors"
1111
)
1212

13-
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".icons", ".images")
13+
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
1414

15+
// validateCoderResourceSubdirectory validates that the structure of a module or template within a namespace follows all
16+
// expected file conventions
1517
func validateCoderResourceSubdirectory(dirPath string) []error {
16-
subDir, err := os.Stat(dirPath)
18+
resourceDir, err := os.Stat(dirPath)
1719
if err != nil {
18-
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow specific rules.
20+
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow
21+
// specific rules.
1922
if !errors.Is(err, os.ErrNotExist) {
2023
return []error{addFilePathToError(dirPath, err)}
2124
}
2225
}
2326

24-
if !subDir.IsDir() {
27+
if !resourceDir.IsDir() {
2528
return []error{xerrors.Errorf("%q: path is not a directory", dirPath)}
2629
}
2730

@@ -30,10 +33,11 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
3033
return []error{addFilePathToError(dirPath, err)}
3134
}
3235

33-
errs := []error{}
36+
var errs []error
3437
for _, f := range files {
35-
// The .coder subdirectories are sometimes generated as part of Bun tests. These subdirectories will never be
36-
// committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over them.
38+
// The .coder subdirectories are sometimes generated as part of our Bun tests. These subdirectories will never
39+
// be committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over
40+
// them.
3741
if !f.IsDir() || f.Name() == ".coder" {
3842
continue
3943
}
@@ -59,56 +63,63 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
5963
return errs
6064
}
6165

66+
// validateRegistryDirectory validates that the contents of `/registry` follow all expected file conventions. This
67+
// includes the top-level structure of the individual namespace directories.
6268
func validateRegistryDirectory() []error {
63-
userDirs, err := os.ReadDir(rootRegistryPath)
69+
namespaceDirs, err := os.ReadDir(rootRegistryPath)
6470
if err != nil {
6571
return []error{err}
6672
}
6773

68-
allErrs := []error{}
69-
for _, d := range userDirs {
70-
dirPath := path.Join(rootRegistryPath, d.Name())
71-
if !d.IsDir() {
72-
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", dirPath))
74+
var allErrs []error
75+
for _, nDir := range namespaceDirs {
76+
namespacePath := path.Join(rootRegistryPath, nDir.Name())
77+
if !nDir.IsDir() {
78+
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", namespacePath))
7379
continue
7480
}
7581

76-
contributorReadmePath := path.Join(dirPath, "README.md")
82+
contributorReadmePath := path.Join(namespacePath, "README.md")
7783
if _, err := os.Stat(contributorReadmePath); err != nil {
7884
allErrs = append(allErrs, err)
7985
}
8086

81-
files, err := os.ReadDir(dirPath)
87+
files, err := os.ReadDir(namespacePath)
8288
if err != nil {
8389
allErrs = append(allErrs, err)
8490
continue
8591
}
8692

8793
for _, f := range files {
88-
// TODO: Decide if there's anything more formal that we want to ensure about non-directories scoped to user namespaces.
94+
// TODO: Decide if there's anything more formal that we want to ensure about non-directories at the top
95+
// level of each user namespace.
8996
if !f.IsDir() {
9097
continue
9198
}
9299

93100
segment := f.Name()
94-
filePath := path.Join(dirPath, segment)
101+
filePath := path.Join(namespacePath, segment)
95102

96103
if !slices.Contains(supportedUserNameSpaceDirectories, segment) {
97104
allErrs = append(allErrs, xerrors.Errorf("%q: only these sub-directories are allowed at top of user namespace: [%s]", filePath, strings.Join(supportedUserNameSpaceDirectories, ", ")))
98105
continue
99106
}
107+
if !slices.Contains(supportedResourceTypes, segment) {
108+
continue
109+
}
100110

101-
if slices.Contains(supportedResourceTypes, segment) {
102-
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
103-
allErrs = append(allErrs, errs...)
104-
}
111+
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
112+
allErrs = append(allErrs, errs...)
105113
}
106114
}
107115
}
108116

109117
return allErrs
110118
}
111119

120+
// validateRepoStructure validates that the structure of the repo is "correct enough" to do all necessary validation
121+
// checks. It is NOT an exhaustive validation of the entire repo structure – it only checks the parts of the repo that
122+
// are relevant for the main validation steps
112123
func validateRepoStructure() error {
113124
var errs []error
114125
if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 {

0 commit comments

Comments
 (0)