package main import ( "encoding/json" "errors" "fmt" "io" "os" "path" "path/filepath" "reflect" "strconv" "strings" "github.com/Masterminds/semver/v3" "github.com/artifacthub/hub/internal/hub" "github.com/artifacthub/hub/internal/pkg" "github.com/artifacthub/hub/internal/tracker/source/generic" "github.com/artifacthub/hub/internal/tracker/source/helm" "github.com/artifacthub/hub/internal/tracker/source/helmplugin" "github.com/artifacthub/hub/internal/tracker/source/krew" "github.com/artifacthub/hub/internal/tracker/source/olm" "github.com/artifacthub/hub/internal/tracker/source/tekton" "github.com/hashicorp/go-multierror" "github.com/spf13/cobra" "helm.sh/helm/v3/pkg/chart/loader" "helm.sh/helm/v3/pkg/plugin" ) const ( sepLen = 120 success = '✓' failure = '✗' warning = '!' provided = "PROVIDED" notProvided = "*** NOT PROVIDED ***" ) // lintDesc represents the long description of the lint command. var lintDesc = `Check the repository's packages are ready for Artifact Hub Use this command to check that the packages in your repository are ready to be listed on Artifact Hub. This command checks that the packages metadata provided is valid and displays some information about the data that will be collected so that you can verify everything looks right.` var ( // errLintFailed indicates that the lint command failed. This happens // when errors are found in any of the packages available in the path // provided. errLintFailed = errors.New("lint failed") // errNoPackagesFound indicates that no packages were found in the provided // path. errNoPackagesFound = errors.New("no packages found") ) // lintOptions represents the options that can be passed to the lint command. type lintOptions struct { // kind represents the repository kind. kind string // path represents the base path to walk looking for packages. path string } // lintReport represents the results of checking all the packages found in the // provided path. For each package, an entry is created with some information // about the package and the errors found on it during the check. type lintReport struct { entries []*lintReportEntry } // lintReportEntry represents an entry of the lint report. A lint report // entry contains a package and the errors found on it during the check. type lintReportEntry struct { pkg *hub.Package path string result *multierror.Error } // newLintCmd creates a new lint command. func newLintCmd() *cobra.Command { opts := &lintOptions{} lintCmd := &cobra.Command{ Use: "lint", Short: "Check the repository's packages are ready for Artifact Hub", Long: lintDesc, RunE: func(cmd *cobra.Command, args []string) error { return lint(opts, &output{cmd.OutOrStdout()}) }, } lintCmd.Flags().StringVarP(&opts.kind, "kind", "k", "helm", "repository kind: argo-template, backstage, coredns, falco, gatekeeper, headlamp, helm, helm-plugin, inspektor-gadget, kcl, keda-scaler, keptn, knative-client-plugin, krew, kubearmor, kubewarden, kyverno, olm, opa, tbaction, tekton-task, tekton-pipeline, tekton-stepaction") lintCmd.Flags().StringVarP(&opts.path, "path", "p", ".", "repository's packages path") return lintCmd } // lint checks that the packages found in the path provided are ready to be // listed on Artifact Hub. The resulting lint report will be printed to the // output provided. func lint(opts *lintOptions, out *output) error { // Check all packages available in the path provided. Different kinds may // use a specific linter. The linter will return a lint report that will be // printed once the check has finished. kind, err := hub.GetKindFromName(opts.kind) if err != nil { return err } var report *lintReport switch kind { case hub.ArgoTemplate, hub.Backstage, hub.CoreDNS, hub.Falco, hub.Gatekeeper, hub.Headlamp, hub.InspektorGadget, hub.KCL, hub.KedaScaler, hub.Keptn, hub.KnativeClientPlugin, hub.KubeArmor, hub.Kubewarden, hub.Kyverno, hub.OPA, hub.TBAction: report = lintGeneric(opts.path, kind) case hub.Helm: report = lintHelm(opts.path) case hub.HelmPlugin: report = lintHelmPlugin(opts.path) case hub.Krew: report = lintKrew(opts.path) case hub.OLM: report = lintOLM(opts.path) case hub.TektonTask, hub.TektonPipeline, hub.TektonStepAction: report = lintTekton(opts.path, kind) default: return errors.New("kind not supported yet") } // Print lint report and return the corresponding error if len(report.entries) == 0 { return errNoPackagesFound } out.printReport(report) for _, entry := range report.entries { if entry.result.ErrorOrNil() != nil { return errLintFailed } } return nil } // lintGeneric checks if the packages available in the path provided are ready // to be processed by the generic tracker source and listed on Artifact Hub. func lintGeneric(basePath string, kind hub.RepositoryKind) *lintReport { report := &lintReport{} // Walk the path provided looking for available packages _ = filepath.Walk(basePath, func(pkgPath string, info os.FileInfo, err error) error { // If an error is raised while visiting a path or the path is not a // directory, we skip it if err != nil || !info.IsDir() { return nil } // Initialize report entry. If a package is found in the current path, // errors found while processing it will be added to the report. e := &lintReportEntry{ path: pkgPath, } // Get package version metadata and prepare entry package mdFilePath := filepath.Join(pkgPath, hub.PackageMetadataFile) md, err := pkg.GetPackageMetadata(kind, mdFilePath) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil } e.result = multierror.Append(e.result, err) } else { e.pkg, err = generic.PreparePackage(&hub.Repository{Kind: kind}, md, pkgPath) if err != nil { e.result = multierror.Append(e.result, err) } } report.entries = append(report.entries, e) return nil }) return report } // lintHelm checks if the Helm charts available in the path provided are ready // to be processed by the Helm tracker source and listed on Artifact Hub. func lintHelm(basePath string) *lintReport { report := &lintReport{} // Walk the path provided looking for available charts _ = filepath.Walk(basePath, func(chartPath string, info os.FileInfo, err error) error { // If an error is raised while visiting a path or the path is not a // directory, we skip it if err != nil || !info.IsDir() { return nil } // Initialize report entry. If a package is found in the current path, // errors found while processing it will be added to the report. e := &lintReportEntry{ path: chartPath, } // Try loading chart in the current path (may or may not be found) chrt, err := loader.LoadDir(chartPath) if err != nil { if chrt != nil && chrt.Metadata != nil { // A chart was found in the current path, but it is not valid. // We have enough information to keep checking other pieces of // data, so we track the error and continue. e.result = multierror.Append(e.result, err) } else { // A chart was not found in the current path return nil } } // Prepare entry package from Helm chart information e.pkg = &hub.Package{ Name: chrt.Metadata.Name, Version: chrt.Metadata.Version, LogoURL: chrt.Metadata.Icon, Repository: &hub.Repository{Kind: hub.Helm}, } helm.EnrichPackageFromChart(e.pkg, chrt) err = helm.EnrichPackageFromAnnotations(e.pkg, chrt.Metadata.Annotations) e.result = multierror.Append(e.result, err) report.entries = append(report.entries, e) return nil }) return report } // lintHelmPlugin checks if the Helm plugins available in the path provided are // ready to be processed by the Helm plugins tracker source and listed on // Artifact Hub. func lintHelmPlugin(basePath string) *lintReport { report := &lintReport{} // Walk the path provided looking for available plugins _ = filepath.Walk(basePath, func(pkgPath string, info os.FileInfo, err error) error { // If an error is raised while visiting a path or the path is not a // directory, we skip it if err != nil || !info.IsDir() { return nil } // Initialize report entry. If a package is found in the current path, // errors found while processing it will be added to the report. e := &lintReportEntry{ path: pkgPath, } // Get Helm plugin metadata and prepare package mdFilePath := filepath.Join(pkgPath, plugin.PluginFileName) md, err := helmplugin.GetMetadata(mdFilePath) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil } e.result = multierror.Append(e.result, err) } else { repo := &hub.Repository{ Kind: hub.HelmPlugin, URL: "https://github.com/user/repo/path", } e.pkg, err = helmplugin.PreparePackage(repo, md, pkgPath) if err != nil { e.result = multierror.Append(e.result, err) } } report.entries = append(report.entries, e) return nil }) return report } // lintKrew checks if the Krew plugins available in the path provided are ready // to be processed by the Krew tracker source and listed on Artifact Hub. func lintKrew(basePath string) *lintReport { report := &lintReport{} // Process plugins available in the path provided pluginsPath := filepath.Join(basePath, "plugins") pluginManifestFiles, err := os.ReadDir(pluginsPath) if err != nil { return report } for _, file := range pluginManifestFiles { // Only process plugins files if !file.Type().IsRegular() || filepath.Ext(file.Name()) != ".yaml" { continue } // Initialize report entry. If a package is found in the current path, // errors found while processing it will be added to the report. pluginPath := filepath.Join(pluginsPath, file.Name()) e := &lintReportEntry{ path: pluginPath, } // Get Krew plugin manifest and prepare package manifest, manifestRaw, err := krew.GetManifest(filepath.Join(pluginsPath, file.Name())) if err != nil { e.result = multierror.Append(e.result, err) } else { e.pkg, err = krew.PreparePackage(&hub.Repository{Kind: hub.Krew}, manifest, manifestRaw) if err != nil { e.result = multierror.Append(e.result, err) } } report.entries = append(report.entries, e) } return report } // lintOLM checks if the OLM operators available in the path provided are ready // to be processed by the OLM tracker source and listed on Artifact Hub. func lintOLM(basePath string) *lintReport { report := &lintReport{} // Walk the path provided looking for available OLM operators _ = filepath.Walk(basePath, func(pkgPath string, info os.FileInfo, err error) error { // If an error is raised while visiting a path or the path is not a // directory, we skip it if err != nil || !info.IsDir() { return nil } // Initialize report entry. If a package is found in the current path, // errors found while processing it will be added to the report. e := &lintReportEntry{ path: pkgPath, } // Get metadata and prepare package md, err := olm.GetMetadata(pkgPath) switch { case err != nil: e.result = multierror.Append(e.result, err) case md == nil: // Package manifest not found, not a package path return nil default: repo := &hub.Repository{ Kind: hub.OLM, } e.pkg, err = olm.PreparePackage(repo, md) if err != nil { e.result = multierror.Append(e.result, err) } } report.entries = append(report.entries, e) return nil }) return report } // lintTekton checks if the Tekton tasks, pipelines or stepactions available in // the path provided are ready to be processed by the Tekton tracker source and // listed on Artifact Hub. func lintTekton(basePath string, kind hub.RepositoryKind) *lintReport { report := &lintReport{} repository := &hub.Repository{ Kind: kind, URL: "https://github.com/user/repo/path", Data: json.RawMessage(fmt.Sprintf(`{"versioning": "%s"}`, hub.TektonDirBasedVersioning)), } // Read catalog path to get available packages packages, err := os.ReadDir(basePath) if err != nil { return report } for _, p := range packages { // If the path is not a directory, we skip it if !p.IsDir() { continue } // Read package versions pkgName := p.Name() pkgBasePath := path.Join(basePath, pkgName) versions, err := os.ReadDir(pkgBasePath) if err != nil { continue } for _, v := range versions { // If the path is not a directory or a ~valid semver version, we skip it if !p.IsDir() { continue } sv, err := semver.NewVersion(v.Name()) if err != nil { continue } // Initialize report entry. If a package is found in the current path, // errors found while processing it will be added to the report. pkgPath := path.Join(pkgBasePath, v.Name()) e := &lintReportEntry{ path: pkgPath, } // Get package manifest manifest, manifestRaw, err := tekton.GetManifest(kind, pkgName, pkgPath) if err != nil { e.result = multierror.Append(e.result, err) } else { // Prepare package version e.pkg, err = tekton.PreparePackage(&tekton.PreparePackageInput{ R: repository, Tag: "", Manifest: manifest, ManifestRaw: manifestRaw, BasePath: basePath, PkgName: pkgName, PkgPath: pkgPath, PkgVersion: sv.String(), }) if err != nil { e.result = multierror.Append(e.result, err) } } report.entries = append(report.entries, e) } } return report } // output represents a wrapper around an io.Writer used to print lint reports. type output struct { io.Writer } // printReport prints the provided lint report to the receiver output. func (out *output) printReport(report *lintReport) { // Print packages checks results for _, e := range report.entries { // Setup minimal skeleton package if not provided if e.pkg == nil { e.pkg = &hub.Package{ Name: "name: ?", Version: "version: ?", } } // Header var mark rune if e.result.ErrorOrNil() != nil { mark = failure } else { mark = success } fmt.Fprintf(out, "\n%s\n", strings.Repeat("-", sepLen)) fmt.Fprintf(out, "%c %s %s (%s)\n", mark, e.pkg.Name, e.pkg.Version, e.path) fmt.Fprintf(out, "%s\n\n", strings.Repeat("-", sepLen)) // Details if e.result.ErrorOrNil() == nil { fmt.Fprintf(out, "Package lint SUCCEEDED!\n\n") out.printPkgDetails(e.pkg) } else { fmt.Fprintf(out, "Package lint FAILED. %d error(s) occurred:\n\n", len(e.result.Errors)) for _, err := range e.result.Errors { fmt.Fprintf(out, " * %s\n", strings.TrimSpace(err.Error())) } } } // Print footer summary var pkgsWithErrors int for _, e := range report.entries { if e.result.ErrorOrNil() != nil { pkgsWithErrors++ } } fmt.Fprintf(out, "\n%s\n", strings.Repeat("-", sepLen)) fmt.Fprintf(out, "\n%d package(s) found, %d package(s) with errors\n\n", len(report.entries), pkgsWithErrors) } // printPkgDetails prints the details of the package provided to the receiver // output. A mark will be added to each of the fields to indicate if the value // was provided or not. func (out *output) printPkgDetails(pkg *hub.Package) { // General out.print("Name", pkg.Name) out.print("Display name", pkg.DisplayName) out.print("Version", pkg.Version) out.print("App version", pkg.AppVersion) out.print("Description", pkg.Description) out.print("Keywords", pkg.Keywords) out.print("License", pkg.License) out.print("Logo URL", pkg.LogoURL) out.print("Home URL", pkg.HomeURL) out.print("Deprecated", strconv.FormatBool(pkg.Deprecated)) out.print("Pre-release", strconv.FormatBool(pkg.Prerelease)) out.print("Contains security updates", strconv.FormatBool(pkg.ContainsSecurityUpdates)) out.print("Provider", pkg.Provider) // Readme if pkg.Readme != "" { fmt.Fprintf(out, "%c Readme: %s\n", success, provided) } else { fmt.Fprintf(out, "%c Readme: %s\n", warning, notProvided) } // Keywords if len(pkg.Keywords) > 0 { fmt.Fprintf(out, "%c Keywords:\n", success) for _, keyword := range pkg.Keywords { fmt.Fprintf(out, " - %s\n", keyword) } } else { fmt.Fprintf(out, "%c Keywords: %s\n", warning, notProvided) } // Links if len(pkg.Links) > 0 { fmt.Fprintf(out, "%c Links:\n", success) for _, l := range pkg.Links { fmt.Fprintf(out, " - Name: %s | URL: %s\n", l.Name, l.URL) } } else { fmt.Fprintf(out, "%c Links: %s\n", warning, notProvided) } // Maintainers if len(pkg.Maintainers) > 0 { fmt.Fprintf(out, "%c Maintainers:\n", success) for _, m := range pkg.Maintainers { fmt.Fprintf(out, " - Name: %s | Email: %s\n", m.Name, m.Email) } } else { fmt.Fprintf(out, "%c Maintainers: %s\n", warning, notProvided) } // Containers images if len(pkg.ContainersImages) > 0 { fmt.Fprintf(out, "%c Containers images:\n", success) for _, i := range pkg.ContainersImages { fmt.Fprintf(out, " - Name: %s | Image: %s\n", i.Name, i.Image) } } else { fmt.Fprintf(out, "%c Containers images: %s\n", warning, notProvided) } // Changes if len(pkg.Changes) > 0 { fmt.Fprintf(out, "%c Changes:\n", success) for _, c := range pkg.Changes { fmt.Fprintf(out, " - Kind: %s | Description: %s\n", c.Kind, c.Description) if len(c.Links) > 0 { fmt.Fprintf(out, " - Links:\n") for _, l := range c.Links { fmt.Fprintf(out, " - Name: %s | URL: %s\n", l.Name, l.URL) } } } } else { fmt.Fprintf(out, "%c Changes: %s\n", warning, notProvided) } // Recommendations if len(pkg.Recommendations) > 0 { fmt.Fprintf(out, "%c Recommendations:\n", success) for _, r := range pkg.Recommendations { fmt.Fprintf(out, " - %s\n", r.URL) } } else { fmt.Fprintf(out, "%c Recommendations: %s\n", warning, notProvided) } // Screenshots if len(pkg.Screenshots) > 0 { fmt.Fprintf(out, "%c Screenshots:\n", success) for _, s := range pkg.Screenshots { fmt.Fprintf(out, " - Title: %s | URL: %s\n", s.Title, s.URL) } } else { fmt.Fprintf(out, "%c Screenshots: %s\n", warning, notProvided) } // Operator out.print("Operator", strconv.FormatBool(pkg.IsOperator)) if pkg.IsOperator { out.print("Operator capabilities", pkg.Capabilities) out.print("CRDs", pkg.CRDs) out.print("CRDs examples", pkg.CRDsExamples) } // Values specific to a repository kind switch pkg.Repository.Kind { case hub.ArgoTemplate, hub.Backstage, hub.CoreDNS, hub.Falco, hub.Gatekeeper, hub.Headlamp, hub.InspektorGadget, hub.KCL, hub.KedaScaler, hub.Keptn, hub.KnativeClientPlugin, hub.KubeArmor, hub.Kubewarden, hub.Kyverno, hub.OPA, hub.TBAction: // Install if pkg.Install != "" { fmt.Fprintf(out, "%c Install: %s\n", success, provided) } else { fmt.Fprintf(out, "%c Install: %s\n", warning, notProvided) } switch pkg.Repository.Kind { case hub.Falco: // Rules files fmt.Fprintf(out, "%c Rules: %s\n", success, provided) for name := range pkg.Data[generic.FalcoRulesKey].(map[string]string) { fmt.Fprintf(out, " - %s\n", name) } case hub.OPA: // Policies files fmt.Fprintf(out, "%c Policies: %s\n", success, provided) for name := range pkg.Data[generic.OPAPoliciesKey].(map[string]string) { fmt.Fprintf(out, " - %s\n", name) } } case hub.Helm: out.print("Sign key", pkg.SignKey) // Values schema if pkg.ValuesSchema != nil { fmt.Fprintf(out, "%c Values schema: %s\n", success, provided) } else { fmt.Fprintf(out, "%c Values schema: %s\n", warning, notProvided) } case hub.Krew: // Platforms if v, ok := pkg.Data[krew.PlatformsKey]; ok { platforms, ok := v.([]string) if ok && len(platforms) > 0 { fmt.Fprintf(out, "%c Platforms: %s\n", success, provided) for _, platform := range platforms { fmt.Fprintf(out, " - %s\n", platform) } } else { fmt.Fprintf(out, "%c Platforms: %s\n", warning, notProvided) } } case hub.OLM: out.print("Default channel", pkg.DefaultChannel) // Channels if len(pkg.Channels) > 0 { fmt.Fprintf(out, "%c Channels:\n", success) for _, channel := range pkg.Channels { fmt.Fprintf(out, " - %s -> %s\n", channel.Name, channel.Version) } } else { fmt.Fprintf(out, "%c Channels: %s\n", warning, notProvided) } } } // print is a helper function used to print the label and values provided with // a success or warning mark that indicates if the value was provided or not. func (out *output) print(label string, value interface{}) { switch reflect.TypeOf(value).Kind() { case reflect.String: if value != "" { fmt.Fprintf(out, "%c %s: %s\n", success, label, value) } else { fmt.Fprintf(out, "%c %s: %s\n", warning, label, notProvided) } case reflect.Ptr: if !reflect.ValueOf(value).IsNil() { fmt.Fprintf(out, "%c %s: %s\n", success, label, provided) } else { fmt.Fprintf(out, "%c %s: %s\n", warning, label, notProvided) } } }