All checks were successful
continuous-integration/drone/push Build is passing
This implements proper modifier support in the env file using this new fork of the godotenv library. The modifier implementation is quite basic for but can be improved later if needed. See this commit for the actual implementation. Because we are now using proper modifer parsing, it does not affect the parsing of value, so this is possible again: ``` MY_VAR="#foo" ``` Closes coop-cloud/organising#535
434 lines
11 KiB
Go
434 lines
11 KiB
Go
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,
|
|
},
|
|
},
|
|
"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 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
|
|
}
|