package lint import ( "errors" "fmt" "net/http" "os" "path" "coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/recipe" recipePkg "coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/tagcmp" "github.com/distribution/reference" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/leonelquinteros/gotext" ) var ( Warn = gotext.Get("warn") Critical = gotext.Get("critical") ) type LintFunction func(recipe.Recipe) (bool, error) // SkipFunction determines whether the LintFunction is run or not. It should // not take the lint rule level into account because some rules are always an // error but may depend on some additional context of the recipe configuration. // This function aims to cover those additional cases. type SkipFunction func(recipe.Recipe) (bool, error) // LintRule is a linting rule which helps a recipe maintainer avoid common // problems in their recipe configurations. We aim to highlight things that // might result in critical errors or hours lost in debugging obscure // Docker-isms. Humans make the final call on these rules, please raise an // issue if you disagree. type LintRule struct { Ref string // Reference of the linting rule Level string // Level of the warning Description string // Description of the issue HowToResolve string // Documentation for recipe maintainer Function LintFunction // Rule implementation SkipCondition SkipFunction // Whether or not to execute the lint rule } // Skip implements the SkipFunction for the lint rule. func (l LintRule) Skip(recipe recipe.Recipe) bool { if l.SkipCondition != nil { ok, err := l.SkipCondition(recipe) if err != nil { log.Debugf(gotext.Get("%s: skip condition: %s", l.Ref, err)) } if ok { log.Debugf(gotext.Get("skipping %s based on skip condition", l.Ref)) return true } } return false } var LintRules = map[string][]LintRule{ "warn": { { Ref: "R001", Level: gotext.Get("warn"), Description: gotext.Get("compose config has expected version"), HowToResolve: gotext.Get("ensure 'version: \"3.8\"' in compose configs"), Function: LintComposeVersion, }, { Ref: "R002", Level: gotext.Get("warn"), Description: gotext.Get("healthcheck enabled for all services"), HowToResolve: gotext.Get("wire up healthchecks"), Function: LintHealthchecks, }, { Ref: "R003", Level: gotext.Get("warn"), Description: gotext.Get("all images use a tag"), HowToResolve: gotext.Get("use a tag for all images"), Function: LintAllImagesTagged, }, { Ref: "R004", Level: gotext.Get("warn"), Description: gotext.Get("no unstable tags"), HowToResolve: gotext.Get("tag all images with stable tags"), Function: LintNoUnstableTags, }, { Ref: "R005", Level: gotext.Get("warn"), Description: gotext.Get("tags use semver-like format"), HowToResolve: gotext.Get("use semver-like tags"), Function: LintSemverLikeTags, }, { Ref: "R006", Level: gotext.Get("warn"), Description: gotext.Get("has published catalogue version"), HowToResolve: gotext.Get("publish a recipe version to the catalogue"), Function: LintHasPublishedVersion, }, { Ref: "R007", Level: gotext.Get("warn"), Description: gotext.Get("README.md metadata filled in"), HowToResolve: gotext.Get("fill out all the metadata"), Function: LintMetadataFilledIn, }, { Ref: "R013", Level: gotext.Get("warn"), Description: gotext.Get("git.coopcloud.tech repo exists"), HowToResolve: gotext.Get("upload your recipe to git.coopcloud.tech/coop-cloud/..."), Function: LintHasRecipeRepo, }, { Ref: "R015", Level: gotext.Get("warn"), Description: gotext.Get("long secret names"), HowToResolve: gotext.Get("reduce length of secret names to 12 chars"), Function: LintSecretLengths, }, }, "error": { { Ref: "R008", Level: gotext.Get("error"), Description: gotext.Get(".env.sample provided"), HowToResolve: gotext.Get("create an example .env.sample"), Function: LintEnvConfigPresent, }, { Ref: "R009", Level: gotext.Get("error"), Description: gotext.Get("one service named 'app'"), HowToResolve: gotext.Get("name a servce 'app'"), Function: LintAppService, }, { Ref: "R015", Level: gotext.Get("error"), Description: gotext.Get("deploy labels stanza present"), HowToResolve: gotext.Get("include \"deploy: labels: ...\" stanza"), Function: LintDeployLabelsPresent, }, { Ref: "R010", Level: gotext.Get("error"), Description: gotext.Get("traefik routing enabled"), HowToResolve: gotext.Get("include \"traefik.enable=true\" deploy label"), Function: LintTraefikEnabled, SkipCondition: LintTraefikEnabledSkipCondition, }, { Ref: "R011", Level: gotext.Get("error"), Description: gotext.Get("all services have images"), HowToResolve: gotext.Get("ensure \"image: ...\" set on all services"), Function: LintImagePresent, }, { Ref: "R012", Level: gotext.Get("error"), Description: gotext.Get("config version are vendored"), HowToResolve: gotext.Get("vendor config versions in an abra.sh"), Function: LintAbraShVendors, }, { Ref: "R014", Level: gotext.Get("error"), Description: gotext.Get("only annotated tags used for recipe version"), HowToResolve: gotext.Get("replace lightweight tag with annotated tag"), Function: LintValidTags, }, }, } // LintForErrors lints specifically for errors and not other levels. This is // used in code paths such as "app deploy" to avoid nasty surprises but not for // the typical linting commands, which do handle other levels. func LintForErrors(recipe recipe.Recipe) error { log.Debugf(gotext.Get("linting for critical errors in %s configs", recipe.Name)) var errs string for level := range LintRules { if level != "error" { continue } for _, rule := range LintRules[level] { if rule.Skip(recipe) { continue } ok, err := rule.Function(recipe) if err != nil { errs += gotext.Get("\nlint %s: %s", rule.Ref, err) } if !ok { errs += fmt.Sprintf("\n * %s (%s)", rule.Description, rule.Ref) } } } if len(errs) > 0 { return errors.New(gotext.Get("recipe '%s' failed lint checks:\n"+errs[1:], recipe.Name)) } log.Debugf(gotext.Get("linting successful, %s is well configured", recipe.Name)) return nil } func LintComposeVersion(recipe recipe.Recipe) (bool, error) { config, err := recipe.GetComposeConfig(nil) if err != nil { return false, err } if config.Version == "3.8" { return true, nil } return true, nil } func LintEnvConfigPresent(r recipe.Recipe) (bool, error) { if _, err := os.Stat(r.SampleEnvPath); !os.IsNotExist(err) { return true, nil } return false, nil } func LintAppService(recipe recipe.Recipe) (bool, error) { config, err := recipe.GetComposeConfig(nil) if err != nil { return false, err } for _, service := range config.Services { if service.Name == "app" { return true, nil } } return false, nil } // LintTraefikEnabledSkipCondition signals a skip for this linting rule if it // confirms that there is no "DOMAIN=..." in the .env.sample configuration of // the recipe. This typically means that no domain is required to deploy and // therefore no matching traefik deploy label will be present. func LintTraefikEnabledSkipCondition(r recipe.Recipe) (bool, error) { sampleEnv, err := r.SampleEnv() if err != nil { return false, errors.New(gotext.Get("unable to discover .env.sample for %s", r.Name)) } if _, ok := sampleEnv["DOMAIN"]; !ok { return true, nil } return false, nil } func LintTraefikEnabled(recipe recipe.Recipe) (bool, error) { config, err := recipe.GetComposeConfig(nil) if err != nil { return false, err } for _, service := range config.Services { for label := range service.Deploy.Labels { if label == "traefik.enable" { if service.Deploy.Labels[label] == "true" { return true, nil } } } } return false, nil } func LintDeployLabelsPresent(recipe recipe.Recipe) (bool, error) { config, err := recipe.GetComposeConfig(nil) if err != nil { return false, err } for _, service := range config.Services { if service.Name == "app" && service.Deploy.Labels != nil { return true, nil } } return false, nil } func LintHealthchecks(recipe recipe.Recipe) (bool, error) { config, err := recipe.GetComposeConfig(nil) if err != nil { return false, err } for _, service := range config.Services { if service.HealthCheck == nil { return false, nil } } return true, nil } func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) { config, err := recipe.GetComposeConfig(nil) if err != nil { return false, err } for _, service := range config.Services { img, err := reference.ParseNormalizedNamed(service.Image) if err != nil { return false, err } if reference.IsNameOnly(img) { return false, nil } } return true, nil } func LintNoUnstableTags(recipe recipe.Recipe) (bool, error) { config, err := recipe.GetComposeConfig(nil) if err != nil { return false, err } for _, service := range config.Services { img, err := reference.ParseNormalizedNamed(service.Image) if err != nil { return false, err } var tag string switch img.(type) { case reference.NamedTagged: tag = img.(reference.NamedTagged).Tag() case reference.Named: return false, nil } if tag == "latest" { return false, nil } } return true, nil } func LintSemverLikeTags(recipe recipe.Recipe) (bool, error) { config, err := recipe.GetComposeConfig(nil) if err != nil { return false, err } for _, service := range config.Services { img, err := reference.ParseNormalizedNamed(service.Image) if err != nil { return false, err } var tag string switch img.(type) { case reference.NamedTagged: tag = img.(reference.NamedTagged).Tag() case reference.Named: return false, nil } if !tagcmp.IsParsable(tag) { return false, nil } } return true, nil } func LintImagePresent(recipe recipe.Recipe) (bool, error) { config, err := recipe.GetComposeConfig(nil) if err != nil { return false, err } for _, service := range config.Services { if service.Image == "" { return false, nil } } return true, nil } func LintHasPublishedVersion(recipe recipe.Recipe) (bool, error) { catl, err := recipePkg.ReadRecipeCatalogue(false) if err != nil { log.Fatal(err) } versions, err := recipePkg.GetRecipeCatalogueVersions(recipe.Name, catl) if err != nil { log.Fatal(err) } if len(versions) == 0 { return false, nil } return true, nil } func LintMetadataFilledIn(r recipe.Recipe) (bool, error) { features, category, _, err := recipe.GetRecipeFeaturesAndCategory(r) if err != nil { return false, err } if category == "" { return false, nil } if features.Backups == "" || features.Email == "" || features.Healthcheck == "" || features.Image.Image == "" || features.SSO == "" { return false, nil } return true, nil } func LintAbraShVendors(recipe recipe.Recipe) (bool, error) { config, err := recipe.GetComposeConfig(nil) if err != nil { return false, err } for _, service := range config.Services { if len(service.Configs) > 0 { abraSh := path.Join(recipe.Dir, "abra.sh") if _, err := os.Stat(abraSh); err != nil { if os.IsNotExist(err) { return false, err } return false, err } } } return true, nil } func LintHasRecipeRepo(recipe recipe.Recipe) (bool, error) { res, err := http.Get(recipe.GitURL) if err != nil { return false, err } if res.StatusCode != 200 { return false, err } return true, nil } func LintSecretLengths(recipe recipe.Recipe) (bool, error) { config, err := recipe.GetComposeConfig(nil) if err != nil { return false, err } for name := range config.Secrets { if len(name) > 12 { return false, errors.New(gotext.Get("secret %s is longer than 12 characters", name)) } } return true, nil } func LintValidTags(recipe recipe.Recipe) (bool, error) { repo, err := git.PlainOpen(recipe.Dir) if err != nil { return false, errors.New(gotext.Get("unable to open %s: %s", recipe.Dir, err)) } iter, err := repo.Tags() if err != nil { log.Fatalf(gotext.Get("unable to list local tags for %s", recipe.Name)) } if err := iter.ForEach(func(ref *plumbing.Reference) error { _, err := repo.TagObject(ref.Hash()) if err != nil { switch err { case plumbing.ErrObjectNotFound: return errors.New(gotext.Get("invalid lightweight tag detected")) default: return err } } return nil }); err != nil { return false, nil } return true, nil }