Some checks failed
continuous-integration/drone/push Build is failing
See #483
516 lines
12 KiB
Go
516 lines
12 KiB
Go
package lint
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
|
|
"coopcloud.tech/abra/pkg/i18n"
|
|
"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"
|
|
)
|
|
|
|
var (
|
|
Warn = i18n.G("warn")
|
|
Critical = i18n.G("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.Debug(i18n.G("%s: skip condition: %s", l.Ref, err))
|
|
}
|
|
if ok {
|
|
log.Debug(i18n.G("skipping %s based on skip condition", l.Ref))
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
var LintRules = map[string][]LintRule{
|
|
"warn": {
|
|
{
|
|
Ref: "R001",
|
|
Level: i18n.G("warn"),
|
|
Description: i18n.G("compose config has expected version"),
|
|
HowToResolve: i18n.G("ensure 'version: \"3.8\"' in compose configs"),
|
|
Function: LintComposeVersion,
|
|
},
|
|
{
|
|
Ref: "R002",
|
|
Level: i18n.G("warn"),
|
|
Description: i18n.G("healthcheck enabled for all services"),
|
|
HowToResolve: i18n.G("wire up healthchecks"),
|
|
Function: LintHealthchecks,
|
|
},
|
|
{
|
|
Ref: "R003",
|
|
Level: i18n.G("warn"),
|
|
Description: i18n.G("all images use a tag"),
|
|
HowToResolve: i18n.G("use a tag for all images"),
|
|
Function: LintAllImagesTagged,
|
|
},
|
|
{
|
|
Ref: "R004",
|
|
Level: i18n.G("warn"),
|
|
Description: i18n.G("no unstable tags"),
|
|
HowToResolve: i18n.G("tag all images with stable tags"),
|
|
Function: LintNoUnstableTags,
|
|
},
|
|
{
|
|
Ref: "R005",
|
|
Level: i18n.G("warn"),
|
|
Description: i18n.G("tags use semver-like format"),
|
|
HowToResolve: i18n.G("use semver-like tags"),
|
|
Function: LintSemverLikeTags,
|
|
},
|
|
{
|
|
Ref: "R006",
|
|
Level: i18n.G("warn"),
|
|
Description: i18n.G("has published catalogue version"),
|
|
HowToResolve: i18n.G("publish a recipe version to the catalogue"),
|
|
Function: LintHasPublishedVersion,
|
|
},
|
|
{
|
|
Ref: "R007",
|
|
Level: i18n.G("warn"),
|
|
Description: i18n.G("README.md metadata filled in"),
|
|
HowToResolve: i18n.G("fill out all the metadata"),
|
|
Function: LintMetadataFilledIn,
|
|
},
|
|
{
|
|
Ref: "R013",
|
|
Level: i18n.G("warn"),
|
|
Description: i18n.G("git.coopcloud.tech repo exists"),
|
|
HowToResolve: i18n.G("upload your recipe to git.coopcloud.tech/coop-cloud/..."),
|
|
Function: LintHasRecipeRepo,
|
|
},
|
|
{
|
|
Ref: "R015",
|
|
Level: i18n.G("warn"),
|
|
Description: i18n.G("long secret names"),
|
|
HowToResolve: i18n.G("reduce length of secret names to 12 chars"),
|
|
Function: LintSecretLengths,
|
|
},
|
|
},
|
|
"error": {
|
|
{
|
|
Ref: "R008",
|
|
Level: i18n.G("error"),
|
|
Description: i18n.G(".env.sample provided"),
|
|
HowToResolve: i18n.G("create an example .env.sample"),
|
|
Function: LintEnvConfigPresent,
|
|
},
|
|
{
|
|
Ref: "R009",
|
|
Level: i18n.G("error"),
|
|
Description: i18n.G("one service named 'app'"),
|
|
HowToResolve: i18n.G("name a servce 'app'"),
|
|
Function: LintAppService,
|
|
},
|
|
{
|
|
Ref: "R015",
|
|
Level: i18n.G("error"),
|
|
Description: i18n.G("deploy labels stanza present"),
|
|
HowToResolve: i18n.G("include \"deploy: labels: ...\" stanza"),
|
|
Function: LintDeployLabelsPresent,
|
|
},
|
|
{
|
|
Ref: "R010",
|
|
Level: i18n.G("error"),
|
|
Description: i18n.G("traefik routing enabled"),
|
|
HowToResolve: i18n.G("include \"traefik.enable=true\" deploy label"),
|
|
Function: LintTraefikEnabled,
|
|
SkipCondition: LintTraefikEnabledSkipCondition,
|
|
},
|
|
{
|
|
Ref: "R011",
|
|
Level: i18n.G("error"),
|
|
Description: i18n.G("all services have images"),
|
|
HowToResolve: i18n.G("ensure \"image: ...\" set on all services"),
|
|
Function: LintImagePresent,
|
|
},
|
|
{
|
|
Ref: "R012",
|
|
Level: i18n.G("error"),
|
|
Description: i18n.G("config version are vendored"),
|
|
HowToResolve: i18n.G("vendor config versions in an abra.sh"),
|
|
Function: LintAbraShVendors,
|
|
},
|
|
{
|
|
Ref: "R014",
|
|
Level: i18n.G("error"),
|
|
Description: i18n.G("only annotated tags used for recipe version"),
|
|
HowToResolve: i18n.G("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.Debug(i18n.G("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 += i18n.G("\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(i18n.G("recipe '%s' failed lint checks:\n%s", recipe.Name, errs[1:]))
|
|
}
|
|
|
|
log.Debug(i18n.G("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(i18n.G("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(i18n.G("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(i18n.G("unable to open %s: %s", recipe.Dir, err))
|
|
}
|
|
|
|
iter, err := repo.Tags()
|
|
if err != nil {
|
|
log.Fatal(i18n.G("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(i18n.G("invalid lightweight tag detected"))
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return false, nil
|
|
}
|
|
|
|
return true, nil
|
|
}
|