diff --git a/README.md b/README.md index 11f1b0a..f704754 100644 --- a/README.md +++ b/README.md @@ -75,11 +75,15 @@ k6registry [flags] [file] ### Flags ``` - -c, --compact compact instead of pretty-printed output - -h, --help help for k6registry -o, --out string write output to file instead of stdout + -m, --mute no output, only validation + --loose skip JSON schema validation + --lint enable built-in linter + -c, --compact compact instead of pretty-printed output -r, --raw output raw strings, not JSON texts + -y, --yaml output YAML instead of JSON -V, --version print version + -h, --help help for k6registry ``` diff --git a/action.yml b/action.yml index 378f2b2..b61c164 100644 --- a/action.yml +++ b/action.yml @@ -20,6 +20,18 @@ inputs: description: output file name required: false + mute: + description: no output, only validation + required: false + + loose: + description: skip JSON schema validation + required: false + + lint: + description: enable built-in linter + required: false + compact: description: compact instead of pretty-printed output required: false diff --git a/cmd/cmd.go b/cmd/cmd.go index 73d9786..2464f00 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -22,6 +22,8 @@ type options struct { raw bool yaml bool mute bool + loose bool + lint bool } // New creates new cobra command for exec command. @@ -61,6 +63,8 @@ func New() (*cobra.Command, error) { flags.StringVarP(&opts.out, "out", "o", "", "write output to file instead of stdout") flags.BoolVarP(&opts.mute, "mute", "m", false, "no output, only validation") + flags.BoolVar(&opts.loose, "loose", false, "skip JSON schema validation") + flags.BoolVar(&opts.lint, "lint", false, "enable built-in linter") flags.BoolVarP(&opts.compact, "compact", "c", false, "compact instead of pretty-printed output") flags.BoolVarP(&opts.raw, "raw", "r", false, "output raw strings, not JSON texts") flags.BoolVarP(&opts.yaml, "yaml", "y", false, "output YAML instead of JSON") @@ -109,7 +113,7 @@ func run(ctx context.Context, args []string, opts *options) error { output = file } - registry, err := load(ctx, input) + registry, err := load(ctx, input, opts.loose, opts.lint) if err != nil { return err } diff --git a/cmd/k6registry/main.go b/cmd/k6registry/main.go index 28b820a..229cf2f 100644 --- a/cmd/k6registry/main.go +++ b/cmd/k6registry/main.go @@ -49,6 +49,18 @@ func getArgs() []string { var args []string + if out := getenv("INPUT_MUTE", "false"); len(out) != 0 { + args = append(args, "--mute", out) + } + + if out := getenv("INPUT_LOOSE", "false"); len(out) != 0 { + args = append(args, "--loose", out) + } + + if out := getenv("INPUT_LINT", "false"); len(out) != 0 { + args = append(args, "--lint", out) + } + if getenv("INPUT_COMPACT", "false") == "true" { args = append(args, "--compact") } diff --git a/cmd/load.go b/cmd/load.go index 2c1fa49..8b92733 100644 --- a/cmd/load.go +++ b/cmd/load.go @@ -12,18 +12,35 @@ import ( "gopkg.in/yaml.v3" ) -func load(ctx context.Context, in io.Reader) (interface{}, error) { - decoder := yaml.NewDecoder(in) +func load(ctx context.Context, in io.Reader, loose bool, lint bool) (interface{}, error) { + var ( + raw []byte + err error + ) + + if loose { + raw, err = io.ReadAll(in) + } else { + raw, err = validateWithSchema(in) + } + + if err != nil { + return nil, err + } var registry k6registry.Registry - if err := decoder.Decode(®istry); err != nil { + if err := yaml.Unmarshal(raw, ®istry); err != nil { return nil, err } registry = append(registry, k6registry.Extension{Module: k6Module, Description: k6Description}) for idx, ext := range registry { + if ext.Repo != nil { + continue + } + if strings.HasPrefix(ext.Module, k6Module) || strings.HasPrefix(ext.Module, ghModulePrefix) { repo, err := loadGitHub(ctx, ext.Module) if err != nil { @@ -34,6 +51,12 @@ func load(ctx context.Context, in io.Reader) (interface{}, error) { } } + if lint { + if err := validateWithLinter(registry); err != nil { + return nil, err + } + } + bin, err := json.Marshal(registry) if err != nil { return nil, err @@ -84,6 +107,8 @@ func loadGitHub(ctx context.Context, module string) (*k6registry.Repository, err repo.Homepage = repo.Url } + repo.Archived = rep.GetArchived() + repo.Description = rep.GetDescription() repo.Stars = rep.GetStargazersCount() @@ -91,6 +116,8 @@ func loadGitHub(ctx context.Context, module string) (*k6registry.Repository, err repo.License = lic.GetSPDXID() } + repo.Public = rep.GetVisibility() == "public" + tags, _, err := client.Repositories.ListTags(ctx, owner, name, &github.ListOptions{PerPage: 100}) if err != nil { return nil, err @@ -99,12 +126,7 @@ func loadGitHub(ctx context.Context, module string) (*k6registry.Repository, err for _, tag := range tags { name := tag.GetName() - if name[0] != 'v' { - continue - } - - _, err := semver.NewVersion(name) - if err != nil { + if _, err := semver.NewVersion(name); err != nil { continue } diff --git a/cmd/validate.go b/cmd/validate.go new file mode 100644 index 0000000..e10f9e7 --- /dev/null +++ b/cmd/validate.go @@ -0,0 +1,196 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/grafana/k6registry" + "github.com/xeipuuv/gojsonschema" + "gopkg.in/yaml.v3" +) + +func yaml2json(input []byte) ([]byte, error) { + var data interface{} + + if err := yaml.Unmarshal(input, &data); err != nil { + return nil, err + } + + return json.Marshal(data) +} + +func validateWithSchema(input io.Reader) ([]byte, error) { + yamlRaw, err := io.ReadAll(input) + if err != nil { + return nil, err + } + + jsonRaw, err := yaml2json(yamlRaw) + if err != nil { + return nil, err + } + + documentLoader := gojsonschema.NewBytesLoader(jsonRaw) + schemaLoader := gojsonschema.NewBytesLoader(k6registry.Schema) + + result, err := gojsonschema.Validate(schemaLoader, documentLoader) + if err != nil { + return nil, err + } + + if result.Valid() { + return yamlRaw, nil + } + + var buff strings.Builder + + for _, desc := range result.Errors() { + buff.WriteString(fmt.Sprintf(" - %s\n", desc.String())) + } + + return nil, fmt.Errorf("%w: schema validation failed\n%s", errInvalidRegistry, buff.String()) +} + +func validateWithLinter(registry k6registry.Registry) error { + var buff strings.Builder + + for _, ext := range registry { + if ok, msgs := lintExtension(ext); !ok { + for _, msg := range msgs { + buff.WriteString(fmt.Sprintf(" - %s\n", msg)) + } + } + } + + if buff.Len() == 0 { + return nil + } + + return fmt.Errorf("%w: linter validation failed\n%s", errInvalidRegistry, buff.String()) +} + +func hasTopic(ext k6registry.Extension) bool { + found := false + + for _, topic := range ext.Repo.Topics { + if topic == "xk6" { + found = true + + break + } + } + + return found +} + +func lintExtension(ext k6registry.Extension) (bool, []string) { + if ext.Repo == nil { + return false, []string{"unsupported module: " + ext.Module} + } + + var msgs []string + + if len(ext.Repo.Versions) == 0 { + msgs = append(msgs, "no released versions: "+ext.Module) + } + + if ext.Repo.Public { + if !hasTopic(ext) && ext.Module != k6Module { + msgs = append(msgs, "missing xk6 topic: "+ext.Module) + } + + if len(ext.Repo.License) == 0 { + msgs = append(msgs, "missing license: "+ext.Module) + } else if _, ok := validLicenses[ext.Repo.License]; !ok { + msgs = append(msgs, "unsupported license: "+ext.Repo.License+" "+ext.Module) + } + + if ext.Repo.Archived { + msgs = append(msgs, "repository is archived: "+ext.Module) + } + } + + if len(msgs) == 0 { + return true, nil + } + + return false, msgs +} + +var errInvalidRegistry = errors.New("invalid registry") + +// source: https://spdx.org/licenses/ +// both FSF Free and OSI Approved licenses +var validLicenses = map[string]struct{}{ //nolint:gochecknoglobals + "AFL-1.1": {}, + "AFL-1.2": {}, + "AFL-2.0": {}, + "AFL-2.1": {}, + "AFL-3.0": {}, + "AGPL-3.0": {}, + "AGPL-3.0-only": {}, + "AGPL-3.0-or-later": {}, + "Apache-1.1": {}, + "Apache-2.0": {}, + "APSL-2.0": {}, + "Artistic-2.0": {}, + "BSD-2-Clause": {}, + "BSD-3-Clause": {}, + "BSL-1.0": {}, + "CDDL-1.0": {}, + "CPAL-1.0": {}, + "CPL-1.0": {}, + "ECL-2.0": {}, + "EFL-2.0": {}, + "EPL-1.0": {}, + "EPL-2.0": {}, + "EUDatagrid": {}, + "EUPL-1.1": {}, + "EUPL-1.2": {}, + "GPL-2.0-only": {}, + "GPL-2.0": {}, + "GPL-2.0-or-later": {}, + "GPL-3.0-only": {}, + "GPL-3.0": {}, + "GPL-3.0-or-later": {}, + "HPND": {}, + "Intel": {}, + "IPA": {}, + "IPL-1.0": {}, + "ISC": {}, + "LGPL-2.1": {}, + "LGPL-2.1-only": {}, + "LGPL-2.1-or-later": {}, + "LGPL-3.0": {}, + "LGPL-3.0-only": {}, + "LGPL-3.0-or-later": {}, + "LPL-1.02": {}, + "MIT": {}, + "MPL-1.1": {}, + "MPL-2.0": {}, + "MS-PL": {}, + "MS-RL": {}, + "NCSA": {}, + "Nokia": {}, + "OFL-1.1": {}, + "OSL-1.0": {}, + "OSL-2.0": {}, + "OSL-2.1": {}, + "OSL-3.0": {}, + "PHP-3.01": {}, + "Python-2.0": {}, + "QPL-1.0": {}, + "RPSL-1.0": {}, + "SISSL": {}, + "Sleepycat": {}, + "SPL-1.0": {}, + "Unlicense": {}, + "UPL-1.0": {}, + "W3C": {}, + "Zlib": {}, + "ZPL-2.0": {}, + "ZPL-2.1": {}, +} diff --git a/docs/registry.md b/docs/registry.md index 38f0f75..35c4dce 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -89,6 +89,10 @@ The `name` property contains the name of the extension's git repository. The `license` property contains the SPDX ID of the extension's license. For more information about SPDX, visit https://spdx.org/licenses/ +#### Public + +The `true` value of the `public` flag indicates that the repository is public, available to anyone. + #### URL The `url` property contains the URL of the repository. The `url` is provided by the repository manager and can be displayed in a browser. @@ -109,6 +113,12 @@ The `topics` property contains the repository topics. Topics make it easier to f The `versions` property contains the list of supported versions. Versions are tags whose format meets the requirements of semantic versioning. Version tags often start with the letter `v`, which is not part of the semantic version. +#### Archived + +The `true` value of the `archived` flag indicates that the repository is archived, read only. + +If a repository is archived, it usually means that the owner has no intention of maintaining it. Such extensions should be removed from the registry. + ## Registry Processing The source of the registry is a YAML file optimized for human use. For programs that use the registry, it is advisable to generate an output in JSON format optimized for the given application (for example, an extension catalog for the k6build service). @@ -131,13 +141,16 @@ The input of the processing is the extension registry supplemented with reposito ### Registry Validation -The registry is validated using [JSON schema](https://grafana.github.io/k6registry/registry.schema.json). Requirements that cannot be validated using the JSON schema are validated using custom logic. +The registry is validated using [JSON schema](https://grafana.github.io/k6registry/registry.schema.json). Requirements that cannot be validated using the JSON schema are validated using custom linter. -Custom validation logic checks the following for each extension: +Custom linter checks the following for each extension: - Is the go module path valid? - Is there at least one versioned release? + - Is a valid license configured? + - Is the xk6 topic set for the repository? + - Is the repository not archived? -Validation is always done before processing. The noop filter ('.') can be used for validation only by ignoring the output. +Schema based validation is always done before processing. The noop filter ('.') can be used for validation only by ignoring (or muting) the output. -It is strongly recommended to validate the extension registry after each modification, but at least before approving the change. +It is strongly recommended to lint the extension registry after each modification, but at least before approving the change. diff --git a/go.mod b/go.mod index 08566f9..a12b3d0 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/grafana/clireadme v0.1.0 github.com/itchyny/gojq v0.12.16 github.com/spf13/cobra v1.8.1 + github.com/xeipuuv/gojsonschema v1.2.0 golang.org/x/term v0.22.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -29,6 +30,8 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect golang.org/x/sys v0.22.0 // indirect golang.org/x/text v0.13.0 // indirect ) diff --git a/go.sum b/go.sum index 0a24c08..8876987 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,7 @@ github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJ github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -53,10 +54,18 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= diff --git a/registry.go b/registry.go index 67eb0b3..f76656d 100644 --- a/registry.go +++ b/registry.go @@ -1,2 +1,9 @@ // Package k6registry contains the data model of the k6 extensions registry. package k6registry + +import _ "embed" + +// Schema contains JSON schema for registry JSON. +// +//go:embed docs/registry.schema.json +var Schema []byte diff --git a/registry_gen.go b/registry_gen.go index 5be4741..125a429 100644 --- a/registry_gen.go +++ b/registry_gen.go @@ -99,6 +99,15 @@ type Registry []Extension // are not registered, they are queried at runtime using the repository manager // API. type Repository struct { + // Archived repository flag. + // + // A `true` value indicates that the repository is archived, read only. + // + // If a repository is archived, it usually means that the owner has no intention + // of maintaining it. Such extensions should be removed from the registry. + // + Archived bool `json:"archived,omitempty" yaml:"archived,omitempty" mapstructure:"archived,omitempty"` + // Repository description. // Description string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"` @@ -123,6 +132,12 @@ type Repository struct { // Owner string `json:"owner" yaml:"owner" mapstructure:"owner"` + // Public repository flag. + // + // A `true` value indicates that the repository is public, available to anyone. + // + Public bool `json:"public,omitempty" yaml:"public,omitempty" mapstructure:"public,omitempty"` + // The number of stars in the extension's repository. // // The extension's popularity is indicated by how many users have starred the