2021-12-25 23:00:19 +00:00
|
|
|
package lint
|
2021-12-25 22:11:32 +00:00
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"net/http"
|
|
|
|
"os"
|
|
|
|
"path"
|
|
|
|
|
|
|
|
"coopcloud.tech/abra/pkg/config"
|
2021-12-25 23:00:19 +00:00
|
|
|
"coopcloud.tech/abra/pkg/recipe"
|
2021-12-27 15:40:59 +00:00
|
|
|
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
2021-12-25 22:11:32 +00:00
|
|
|
"coopcloud.tech/tagcmp"
|
|
|
|
"github.com/docker/distribution/reference"
|
2023-07-25 18:38:29 +00:00
|
|
|
"github.com/go-git/go-git/v5"
|
|
|
|
"github.com/go-git/go-git/v5/plumbing"
|
2021-12-25 22:35:45 +00:00
|
|
|
"github.com/sirupsen/logrus"
|
2021-12-25 22:11:32 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
var Warn = "warn"
|
|
|
|
var Critical = "critical"
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
type LintFunction func(recipe.Recipe) (bool, error)
|
2021-12-25 22:11:32 +00:00
|
|
|
|
2023-01-22 11:24:09 +00:00
|
|
|
// 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.
|
2021-12-25 22:11:32 +00:00
|
|
|
type LintRule struct {
|
2023-01-22 11:24:09 +00:00
|
|
|
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
|
2021-12-25 22:11:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
var LintRules = map[string][]LintRule{
|
2021-12-31 16:12:09 +00:00
|
|
|
"warn": {
|
|
|
|
{
|
2021-12-25 22:11:32 +00:00
|
|
|
Ref: "R001",
|
|
|
|
Level: "warn",
|
|
|
|
Description: "compose config has expected version",
|
|
|
|
HowToResolve: "ensure 'version: \"3.8\"' in compose configs",
|
|
|
|
Function: LintComposeVersion,
|
|
|
|
},
|
2021-12-31 16:12:09 +00:00
|
|
|
{
|
2021-12-25 22:11:32 +00:00
|
|
|
Ref: "R002",
|
|
|
|
Level: "warn",
|
|
|
|
Description: "healthcheck enabled for all services",
|
|
|
|
HowToResolve: "wire up healthchecks",
|
|
|
|
Function: LintHealthchecks,
|
|
|
|
},
|
2021-12-31 16:12:09 +00:00
|
|
|
{
|
2021-12-25 22:11:32 +00:00
|
|
|
Ref: "R003",
|
|
|
|
Level: "warn",
|
|
|
|
Description: "all images use a tag",
|
|
|
|
HowToResolve: "use a tag for all images",
|
|
|
|
Function: LintAllImagesTagged,
|
|
|
|
},
|
2021-12-31 16:12:09 +00:00
|
|
|
{
|
2021-12-25 22:11:32 +00:00
|
|
|
Ref: "R004",
|
|
|
|
Level: "warn",
|
|
|
|
Description: "no unstable tags",
|
|
|
|
HowToResolve: "tag all images with stable tags",
|
|
|
|
Function: LintNoUnstableTags,
|
|
|
|
},
|
2021-12-31 16:12:09 +00:00
|
|
|
{
|
2021-12-25 22:11:32 +00:00
|
|
|
Ref: "R005",
|
|
|
|
Level: "warn",
|
|
|
|
Description: "tags use semver-like format",
|
|
|
|
HowToResolve: "use semver-like tags",
|
|
|
|
Function: LintSemverLikeTags,
|
|
|
|
},
|
2021-12-31 16:12:09 +00:00
|
|
|
{
|
2021-12-25 22:11:32 +00:00
|
|
|
Ref: "R006",
|
|
|
|
Level: "warn",
|
|
|
|
Description: "has published catalogue version",
|
|
|
|
HowToResolve: "publish a recipe version to the catalogue",
|
|
|
|
Function: LintHasPublishedVersion,
|
|
|
|
},
|
2021-12-31 16:12:09 +00:00
|
|
|
{
|
2021-12-25 22:11:32 +00:00
|
|
|
Ref: "R007",
|
|
|
|
Level: "warn",
|
|
|
|
Description: "README.md metadata filled in",
|
|
|
|
HowToResolve: "fill out all the metadata",
|
|
|
|
Function: LintMetadataFilledIn,
|
|
|
|
},
|
2022-03-18 09:13:24 +00:00
|
|
|
{
|
|
|
|
Ref: "R013",
|
|
|
|
Level: "warn",
|
|
|
|
Description: "git.coopcloud.tech repo exists",
|
|
|
|
HowToResolve: "upload your recipe to git.coopcloud.tech/coop-cloud/...",
|
|
|
|
Function: LintHasRecipeRepo,
|
|
|
|
},
|
2024-04-06 21:41:37 +00:00
|
|
|
{
|
|
|
|
Ref: "R014",
|
|
|
|
Level: "warn",
|
|
|
|
Description: "Long secret names",
|
|
|
|
HowToResolve: "Reduce the length of secret names to 12 characters.",
|
|
|
|
Function: LintSecretLengths,
|
|
|
|
},
|
2021-12-25 22:11:32 +00:00
|
|
|
},
|
2021-12-31 16:12:09 +00:00
|
|
|
"error": {
|
|
|
|
{
|
2021-12-25 22:11:32 +00:00
|
|
|
Ref: "R008",
|
|
|
|
Level: "error",
|
|
|
|
Description: ".env.sample provided",
|
|
|
|
HowToResolve: "create an example .env.sample",
|
|
|
|
Function: LintEnvConfigPresent,
|
|
|
|
},
|
2021-12-31 16:12:09 +00:00
|
|
|
{
|
2021-12-25 22:11:32 +00:00
|
|
|
Ref: "R009",
|
|
|
|
Level: "error",
|
|
|
|
Description: "one service named 'app'",
|
|
|
|
HowToResolve: "name a servce 'app'",
|
|
|
|
Function: LintAppService,
|
|
|
|
},
|
2021-12-31 16:12:09 +00:00
|
|
|
{
|
2023-01-22 11:24:09 +00:00
|
|
|
Ref: "R010",
|
|
|
|
Level: "error",
|
|
|
|
Description: "traefik routing enabled",
|
|
|
|
HowToResolve: "include \"traefik.enable=true\" deploy label",
|
|
|
|
Function: LintTraefikEnabled,
|
|
|
|
SkipCondition: LintTraefikEnabledSkipCondition,
|
2021-12-25 22:11:32 +00:00
|
|
|
},
|
2021-12-31 16:12:09 +00:00
|
|
|
{
|
2021-12-25 22:11:32 +00:00
|
|
|
Ref: "R011",
|
|
|
|
Level: "error",
|
|
|
|
Description: "all services have images",
|
|
|
|
HowToResolve: "ensure \"image: ...\" set on all services",
|
|
|
|
Function: LintImagePresent,
|
|
|
|
},
|
2021-12-31 16:12:09 +00:00
|
|
|
{
|
2021-12-25 22:11:32 +00:00
|
|
|
Ref: "R012",
|
|
|
|
Level: "error",
|
|
|
|
Description: "config version are vendored",
|
|
|
|
HowToResolve: "vendor config versions in an abra.sh",
|
|
|
|
Function: LintAbraShVendors,
|
|
|
|
},
|
2023-07-25 18:38:29 +00:00
|
|
|
{
|
|
|
|
Ref: "R014",
|
|
|
|
Level: "error",
|
2023-07-26 07:58:30 +00:00
|
|
|
Description: "only annotated tags used for recipe version",
|
|
|
|
HowToResolve: "replace lightweight tag with annotated tag",
|
2023-07-25 18:38:29 +00:00
|
|
|
Function: LintValidTags,
|
|
|
|
},
|
2021-12-25 22:11:32 +00:00
|
|
|
},
|
|
|
|
}
|
|
|
|
|
2023-01-22 11:24:09 +00:00
|
|
|
// 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.
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintForErrors(recipe recipe.Recipe) error {
|
2021-12-25 22:35:45 +00:00
|
|
|
logrus.Debugf("linting for critical errors in %s configs", recipe.Name)
|
|
|
|
|
|
|
|
for level := range LintRules {
|
|
|
|
if level != "error" {
|
|
|
|
continue
|
|
|
|
}
|
2023-01-22 11:24:09 +00:00
|
|
|
|
2021-12-25 22:35:45 +00:00
|
|
|
for _, rule := range LintRules[level] {
|
2023-01-22 11:24:09 +00:00
|
|
|
if rule.Skip(recipe) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2021-12-25 22:35:45 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintComposeVersion(recipe recipe.Recipe) (bool, error) {
|
2021-12-25 22:11:32 +00:00
|
|
|
if recipe.Config.Version == "3.8" {
|
|
|
|
return true, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return true, nil
|
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintEnvConfigPresent(recipe recipe.Recipe) (bool, error) {
|
2021-12-25 22:11:32 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintAppService(recipe recipe.Recipe) (bool, error) {
|
2021-12-25 22:11:32 +00:00
|
|
|
for _, service := range recipe.Config.Services {
|
|
|
|
if service.Name == "app" {
|
|
|
|
return true, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return false, nil
|
|
|
|
}
|
|
|
|
|
2023-01-22 11:24:09 +00:00
|
|
|
// 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")
|
2023-11-29 17:35:01 +00:00
|
|
|
sampleEnv, err := config.ReadEnv(envSamplePath)
|
2023-01-22 11:24:09 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintTraefikEnabled(recipe recipe.Recipe) (bool, error) {
|
2021-12-25 22:11:32 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintHealthchecks(recipe recipe.Recipe) (bool, error) {
|
2021-12-25 22:11:32 +00:00
|
|
|
for _, service := range recipe.Config.Services {
|
|
|
|
if service.HealthCheck == nil {
|
|
|
|
return false, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return true, nil
|
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) {
|
2021-12-25 22:11:32 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintNoUnstableTags(recipe recipe.Recipe) (bool, error) {
|
2021-12-25 22:11:32 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintSemverLikeTags(recipe recipe.Recipe) (bool, error) {
|
2021-12-25 22:11:32 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintImagePresent(recipe recipe.Recipe) (bool, error) {
|
2021-12-25 22:11:32 +00:00
|
|
|
for _, service := range recipe.Config.Services {
|
|
|
|
if service.Image == "" {
|
|
|
|
return false, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return true, nil
|
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintHasPublishedVersion(recipe recipe.Recipe) (bool, error) {
|
2023-09-07 16:50:25 +00:00
|
|
|
catl, err := recipePkg.ReadRecipeCatalogue(false)
|
2021-12-25 23:00:19 +00:00
|
|
|
if err != nil {
|
|
|
|
logrus.Fatal(err)
|
2021-12-25 22:11:32 +00:00
|
|
|
}
|
|
|
|
|
2021-12-27 15:40:59 +00:00
|
|
|
versions, err := recipePkg.GetRecipeCatalogueVersions(recipe.Name, catl)
|
2021-12-25 22:11:32 +00:00
|
|
|
if err != nil {
|
2021-12-25 23:00:19 +00:00
|
|
|
logrus.Fatal(err)
|
2021-12-25 22:11:32 +00:00
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
if len(versions) == 0 {
|
2021-12-25 22:11:32 +00:00
|
|
|
return false, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return true, nil
|
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintMetadataFilledIn(r recipe.Recipe) (bool, error) {
|
|
|
|
features, category, err := recipe.GetRecipeFeaturesAndCategory(r.Name)
|
2021-12-25 22:11:32 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintAbraShVendors(recipe recipe.Recipe) (bool, error) {
|
2021-12-25 22:11:32 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-12-25 23:00:19 +00:00
|
|
|
func LintHasRecipeRepo(recipe recipe.Recipe) (bool, error) {
|
2021-12-25 22:11:32 +00:00
|
|
|
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
|
|
|
|
}
|
2023-07-25 18:38:29 +00:00
|
|
|
|
2024-04-06 21:41:37 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-07-25 18:38:29 +00:00
|
|
|
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
|
|
|
|
}
|