Skip to content

Commit 30c1577

Browse files
authored
Merge branch 'coder:main' into sourcegraph-amp-module
2 parents f3c68a4 + fd074a5 commit 30c1577

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)