Files
abra/pkg/lint/recipe.go
decentral1se 396f0f4406
Some checks failed
continuous-integration/drone/push Build is failing
WIP: feat: translation support
See #483
2025-08-23 16:19:22 +02:00

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
}