package lint import ( "fmt" "net/http" "os" "path" "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/recipe" recipePkg "coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/tagcmp" "github.com/docker/distribution/reference" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/sirupsen/logrus" ) var Warn = "warn" var Critical = "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 { logrus.Debugf("%s: skip condition: %s", l.Ref, err) } if ok { logrus.Debugf("skipping %s based on skip condition", l.Ref) return true } } return false } var LintRules = map[string][]LintRule{ "warn": { { Ref: "R001", Level: "warn", Description: "compose config has expected version", HowToResolve: "ensure 'version: \"3.8\"' in compose configs", Function: LintComposeVersion, }, { Ref: "R002", Level: "warn", Description: "healthcheck enabled for all services", HowToResolve: "wire up healthchecks", Function: LintHealthchecks, }, { Ref: "R003", Level: "warn", Description: "all images use a tag", HowToResolve: "use a tag for all images", Function: LintAllImagesTagged, }, { Ref: "R004", Level: "warn", Description: "no unstable tags", HowToResolve: "tag all images with stable tags", Function: LintNoUnstableTags, }, { Ref: "R005", Level: "warn", Description: "tags use semver-like format", HowToResolve: "use semver-like tags", Function: LintSemverLikeTags, }, { Ref: "R006", Level: "warn", Description: "has published catalogue version", HowToResolve: "publish a recipe version to the catalogue", Function: LintHasPublishedVersion, }, { Ref: "R007", Level: "warn", Description: "README.md metadata filled in", HowToResolve: "fill out all the metadata", Function: LintMetadataFilledIn, }, { Ref: "R013", Level: "warn", Description: "git.coopcloud.tech repo exists", HowToResolve: "upload your recipe to git.coopcloud.tech/coop-cloud/...", Function: LintHasRecipeRepo, }, { Ref: "R015", Level: "warn", Description: "long secret names", HowToResolve: "reduce length of secret names to 12 chars", Function: LintSecretLengths, }, }, "error": { { Ref: "R008", Level: "error", Description: ".env.sample provided", HowToResolve: "create an example .env.sample", Function: LintEnvConfigPresent, }, { Ref: "R009", Level: "error", Description: "one service named 'app'", HowToResolve: "name a servce 'app'", Function: LintAppService, }, { Ref: "R010", Level: "error", Description: "traefik routing enabled", HowToResolve: "include \"traefik.enable=true\" deploy label", Function: LintTraefikEnabled, SkipCondition: LintTraefikEnabledSkipCondition, }, { Ref: "R011", Level: "error", Description: "all services have images", HowToResolve: "ensure \"image: ...\" set on all services", Function: LintImagePresent, }, { Ref: "R012", Level: "error", Description: "config version are vendored", HowToResolve: "vendor config versions in an abra.sh", Function: LintAbraShVendors, }, { Ref: "R014", Level: "error", Description: "only annotated tags used for recipe version", HowToResolve: "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 { logrus.Debugf("linting for critical errors in %s configs", recipe.Name) 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 { return err } if !ok { return fmt.Errorf("lint error in %s configs: \"%s\" failed lint checks (%s)", recipe.Name, rule.Description, rule.Ref) } } } logrus.Debugf("linting successful, %s is well configured", recipe.Name) return nil } func LintComposeVersion(recipe recipe.Recipe) (bool, error) { if recipe.Config.Version == "3.8" { return true, nil } return true, nil } func LintEnvConfigPresent(recipe recipe.Recipe) (bool, error) { envSample := fmt.Sprintf("%s/%s/.env.sample", config.RECIPES_DIR, recipe.Name) if _, err := os.Stat(envSample); !os.IsNotExist(err) { return true, nil } return false, nil } func LintAppService(recipe recipe.Recipe) (bool, error) { for _, service := range recipe.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(recipe recipe.Recipe) (bool, error) { envSamplePath := path.Join(config.RECIPES_DIR, recipe.Name, ".env.sample") sampleEnv, err := config.ReadEnv(envSamplePath) if err != nil { return false, fmt.Errorf("Unable to discover .env.sample for %s", recipe.Name) } if _, ok := sampleEnv["DOMAIN"]; !ok { return true, nil } return false, nil } func LintTraefikEnabled(recipe recipe.Recipe) (bool, error) { for _, service := range recipe.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 LintHealthchecks(recipe recipe.Recipe) (bool, error) { for _, service := range recipe.Config.Services { if service.HealthCheck == nil { return false, nil } } return true, nil } func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) { for _, service := range recipe.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) { for _, service := range recipe.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) { for _, service := range recipe.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) { for _, service := range recipe.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 { logrus.Fatal(err) } versions, err := recipePkg.GetRecipeCatalogueVersions(recipe.Name, catl) if err != nil { logrus.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.Name) 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) { for _, service := range recipe.Config.Services { if len(service.Configs) > 0 { abraSh := path.Join(config.RECIPES_DIR, recipe.Name, "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) { url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, recipe.Name) res, err := http.Get(url) if err != nil { return false, err } if res.StatusCode != 200 { return false, err } return true, nil } func LintSecretLengths(recipe recipe.Recipe) (bool, error) { for name := range recipe.Config.Secrets { if len(name) > 12 { return false, fmt.Errorf("secret %s is longer than 12 characters", name) } } return true, nil } func LintValidTags(recipe recipe.Recipe) (bool, error) { recipeDir := path.Join(config.RECIPES_DIR, recipe.Name) repo, err := git.PlainOpen(recipeDir) if err != nil { return false, fmt.Errorf("unable to open %s: %s", recipeDir, err) } iter, err := repo.Tags() if err != nil { logrus.Fatalf("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 fmt.Errorf("invalid lightweight tag detected") default: return err } } return nil }); err != nil { return false, nil } return true, nil }