diff --git a/cli/catalogue/catalogue.go b/cli/catalogue/catalogue.go index cb38c3f9..03503371 100644 --- a/cli/catalogue/catalogue.go +++ b/cli/catalogue/catalogue.go @@ -14,6 +14,7 @@ import ( "coopcloud.tech/abra/pkg/config" gitPkg "coopcloud.tech/abra/pkg/git" "coopcloud.tech/abra/pkg/limit" + "coopcloud.tech/abra/pkg/recipe" "github.com/AlecAivazis/survey/v2" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" @@ -176,7 +177,7 @@ A new catalogue copy can be published to the recipes repository by passing the logrus.Fatal(err) } - features, category, err := catalogue.GetRecipeFeaturesAndCategory(recipeMeta.Name) + features, category, err := recipe.GetRecipeFeaturesAndCategory(recipeMeta.Name) if err != nil { logrus.Warn(err) } diff --git a/cli/internal/common.go b/cli/internal/common.go index a0cac362..00b50184 100644 --- a/cli/internal/common.go +++ b/cli/internal/common.go @@ -437,6 +437,15 @@ var WatchFlag = &cli.BoolFlag{ Destination: &Watch, } +var OnlyErrors bool +var OnlyErrorFlag = &cli.BoolFlag{ + Name: "errors", + Aliases: []string{"e"}, + Value: false, + Usage: "Only show errors", + Destination: &OnlyErrors, +} + // SSHFailMsg is a hopefully helpful SSH failure message var SSHFailMsg = ` Woops, Abra is unable to connect to connect to %s. diff --git a/cli/recipe/lint.go b/cli/recipe/lint.go index 07cda681..bbc91f35 100644 --- a/cli/recipe/lint.go +++ b/cli/recipe/lint.go @@ -2,101 +2,69 @@ package recipe import ( "fmt" - "os" - "strconv" "coopcloud.tech/abra/cli/formatter" "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/autocomplete" - "coopcloud.tech/abra/pkg/config" - "coopcloud.tech/tagcmp" - "github.com/docker/distribution/reference" + recipePkg "coopcloud.tech/abra/pkg/recipe" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) var recipeLintCommand = &cli.Command{ - Name: "lint", - Usage: "Lint a recipe", - Aliases: []string{"l"}, - ArgsUsage: "", + Name: "lint", + Usage: "Lint a recipe", + Aliases: []string{"l"}, + ArgsUsage: "", + Flags: []cli.Flag{internal.OnlyErrorFlag}, + BashComplete: autocomplete.RecipeNameComplete, Action: func(c *cli.Context) error { recipe := internal.ValidateRecipe(c) - expectedVersion := false - if recipe.Config.Version == "3.8" { - expectedVersion = true - } + tableCol := []string{"ref", "rule", "satisfied", "severity", "resolve"} + table := formatter.CreateTable(tableCol) - envSampleProvided := false - envSample := fmt.Sprintf("%s/%s/.env.sample", config.RECIPES_DIR, recipe.Name) - if _, err := os.Stat(envSample); !os.IsNotExist(err) { - envSampleProvided = true - } else if err != nil { - logrus.Fatal(err) - } + hasError := false + bar := formatter.CreateProgressbar(-1, "running recipe lint rules...") + for level := range recipePkg.LintRules { + for _, rule := range recipePkg.LintRules[level] { + ok, err := rule.Function(recipe) + if err != nil { + logrus.Warn(err) + } - serviceNamedApp := false - traefikEnabled := false - healthChecksForAllServices := true - allImagesTagged := true - noUnstableTags := true - semverLikeTags := true - for _, service := range recipe.Config.Services { - if service.Name == "app" { - serviceNamedApp = true - } + if !ok && rule.Level == "error" { + hasError = true + } - for label := range service.Deploy.Labels { - if label == "traefik.enable" { - if service.Deploy.Labels[label] == "true" { - traefikEnabled = true + var result string + if ok { + result = "yes" + } else { + result = "NO" + } + + if internal.OnlyErrors { + if !ok && rule.Level == "error" { + table.Append([]string{rule.Ref, rule.Description, result, rule.Level, rule.HowToResolve}) + bar.Add(1) } + } else { + table.Append([]string{rule.Ref, rule.Description, result, rule.Level, rule.HowToResolve}) + bar.Add(1) } } - - img, err := reference.ParseNormalizedNamed(service.Image) - if err != nil { - logrus.Fatal(err) - } - if reference.IsNameOnly(img) { - allImagesTagged = false - } - - var tag string - switch img.(type) { - case reference.NamedTagged: - tag = img.(reference.NamedTagged).Tag() - case reference.Named: - noUnstableTags = false - } - - if tag == "latest" { - noUnstableTags = false - } - - if !tagcmp.IsParsable(tag) { - semverLikeTags = false - } - - if service.HealthCheck == nil { - healthChecksForAllServices = false - } } - tableCol := []string{"rule", "satisfied"} - table := formatter.CreateTable(tableCol) - table.Append([]string{"compose files have the expected version", strconv.FormatBool(expectedVersion)}) - table.Append([]string{"environment configuration is provided", strconv.FormatBool(envSampleProvided)}) - table.Append([]string{"recipe contains a service named 'app'", strconv.FormatBool(serviceNamedApp)}) - table.Append([]string{"traefik routing enabled on at least one service", strconv.FormatBool(traefikEnabled)}) - table.Append([]string{"all services have a healthcheck enabled", strconv.FormatBool(healthChecksForAllServices)}) - table.Append([]string{"all images are using a tag", strconv.FormatBool(allImagesTagged)}) - table.Append([]string{"no usage of unstable 'latest' tags", strconv.FormatBool(noUnstableTags)}) - table.Append([]string{"all tags are using a semver-like format", strconv.FormatBool(semverLikeTags)}) - table.Render() + if table.NumLines() > 0 { + fmt.Println() + table.Render() + } + + if hasError { + logrus.Warn("watch out, some critical errors are present in your recipe config") + } return nil }, - BashComplete: autocomplete.RecipeNameComplete, } diff --git a/pkg/catalogue/catalogue.go b/pkg/catalogue/catalogue.go index cfc9e8df..119bca21 100644 --- a/pkg/catalogue/catalogue.go +++ b/pkg/catalogue/catalogue.go @@ -30,26 +30,6 @@ const RecipeCatalogueURL = "https://apps.coopcloud.tech" // ReposMetadataURL is the recipe repository metadata const ReposMetadataURL = "https://git.coopcloud.tech/api/v1/orgs/coop-cloud/repos" -// image represents a recipe container image. -type image struct { - Image string `json:"image"` - Rating string `json:"rating"` - Source string `json:"source"` - URL string `json:"url"` -} - -// features represent what top-level features a recipe supports (e.g. does this -// recipe support backups?). -type features struct { - Backups string `json:"backups"` - Email string `json:"email"` - Healthcheck string `json:"healthcheck"` - Image image `json:"image"` - Status int `json:"status"` - Tests string `json:"tests"` - SSO string `json:"sso"` -} - // tag represents a git tag. type tag = string @@ -68,15 +48,15 @@ type RecipeVersions []map[tag]map[service]ServiceMeta // RecipeMeta represents metadata for a recipe in the abra catalogue. type RecipeMeta struct { - Category string `json:"category"` - DefaultBranch string `json:"default_branch"` - Description string `json:"description"` - Features features `json:"features"` - Icon string `json:"icon"` - Name string `json:"name"` - Repository string `json:"repository"` - Versions RecipeVersions `json:"versions"` - Website string `json:"website"` + Category string `json:"category"` + DefaultBranch string `json:"default_branch"` + Description string `json:"description"` + Features recipe.Features `json:"features"` + Icon string `json:"icon"` + Name string `json:"name"` + Repository string `json:"repository"` + Versions RecipeVersions `json:"versions"` + Website string `json:"website"` } // LatestVersion returns the latest version of a recipe. @@ -380,131 +360,6 @@ func ReadReposMetadata() (RepoCatalogue, error) { return reposMeta, nil } -func GetStringInBetween(str, start, end string) (result string, err error) { - // GetStringInBetween returns empty string if no start or end string found - s := strings.Index(str, start) - if s == -1 { - return "", fmt.Errorf("marker string %s not found", start) - } - s += len(start) - e := strings.Index(str[s:], end) - if e == -1 { - return "", fmt.Errorf("end marker %s not found", end) - } - return str[s : s+e], nil -} - -func GetImageMetadata(imageRowString, recipeName string) (image, error) { - img := image{} - - imgFields := strings.Split(imageRowString, ",") - - for i, elem := range imgFields { - imgFields[i] = strings.TrimSpace(elem) - } - - if len(imgFields) < 3 { - if imageRowString != "" { - logrus.Warnf("%s image meta has incorrect format: %s", recipeName, imageRowString) - } else { - logrus.Warnf("%s image meta is empty?", recipeName) - } - return img, nil - } - - img.Rating = imgFields[1] - img.Source = imgFields[2] - - imgString := imgFields[0] - - imageName, err := GetStringInBetween(imgString, "[", "]") - if err != nil { - logrus.Fatal(err) - } - img.Image = strings.ReplaceAll(imageName, "`", "") - - imageURL, err := GetStringInBetween(imgString, "(", ")") - if err != nil { - logrus.Fatal(err) - } - img.URL = imageURL - - return img, nil -} - -func GetRecipeFeaturesAndCategory(recipeName string) (features, string, error) { - feat := features{} - - var category string - - readmePath := path.Join(config.RECIPES_DIR, recipeName, "README.md") - - logrus.Debugf("attempting to open %s for recipe metadata parsing", readmePath) - - readmeFS, err := ioutil.ReadFile(readmePath) - if err != nil { - return feat, category, err - } - - readmeMetadata, err := GetStringInBetween( // Find text between delimiters - string(readmeFS), - "", "", - ) - if err != nil { - return feat, category, err - } - - readmeLines := strings.Split( // Array item from lines - strings.ReplaceAll( // Remove \t tabs - readmeMetadata, "\t", "", - ), - "\n") - - for _, val := range readmeLines { - if strings.Contains(val, "**Category**") { - category = strings.TrimSpace( - strings.TrimPrefix(val, "* **Category**:"), - ) - } - if strings.Contains(val, "**Backups**") { - feat.Backups = strings.TrimSpace( - strings.TrimPrefix(val, "* **Backups**:"), - ) - } - if strings.Contains(val, "**Email**") { - feat.Email = strings.TrimSpace( - strings.TrimPrefix(val, "* **Email**:"), - ) - } - if strings.Contains(val, "**SSO**") { - feat.SSO = strings.TrimSpace( - strings.TrimPrefix(val, "* **SSO**:"), - ) - } - if strings.Contains(val, "**Healthcheck**") { - feat.Healthcheck = strings.TrimSpace( - strings.TrimPrefix(val, "* **Healthcheck**:"), - ) - } - if strings.Contains(val, "**Tests**") { - feat.Tests = strings.TrimSpace( - strings.TrimPrefix(val, "* **Tests**:"), - ) - } - if strings.Contains(val, "**Image**") { - imageMetadata, err := GetImageMetadata(strings.TrimSpace( - strings.TrimPrefix(val, "* **Image**:"), - ), recipeName) - if err != nil { - continue - } - feat.Image = imageMetadata - } - } - - return feat, category, nil -} - // GetRecipeVersions retrieves all recipe versions. func GetRecipeVersions(recipeName string) (RecipeVersions, error) { versions := RecipeVersions{} diff --git a/pkg/recipe/lint.go b/pkg/recipe/lint.go new file mode 100644 index 00000000..f6cf8593 --- /dev/null +++ b/pkg/recipe/lint.go @@ -0,0 +1,311 @@ +package recipe + +import ( + "fmt" + "net/http" + "os" + "path" + + "coopcloud.tech/abra/pkg/config" + "coopcloud.tech/tagcmp" + "github.com/docker/distribution/reference" +) + +var Warn = "warn" +var Critical = "critical" + +type LintFunction func(Recipe) (bool, error) + +type LintRule struct { + Ref string + Level string + Description string + HowToResolve string + Function LintFunction +} + +var LintRules = map[string][]LintRule{ + "warn": []LintRule{ + LintRule{ + Ref: "R001", + Level: "warn", + Description: "compose config has expected version", + HowToResolve: "ensure 'version: \"3.8\"' in compose configs", + Function: LintComposeVersion, + }, + LintRule{ + Ref: "R002", + Level: "warn", + Description: "healthcheck enabled for all services", + HowToResolve: "wire up healthchecks", + Function: LintHealthchecks, + }, + LintRule{ + Ref: "R003", + Level: "warn", + Description: "all images use a tag", + HowToResolve: "use a tag for all images", + Function: LintAllImagesTagged, + }, + LintRule{ + Ref: "R004", + Level: "warn", + Description: "no unstable tags", + HowToResolve: "tag all images with stable tags", + Function: LintNoUnstableTags, + }, + LintRule{ + Ref: "R005", + Level: "warn", + Description: "tags use semver-like format", + HowToResolve: "use semver-like tags", + Function: LintSemverLikeTags, + }, + LintRule{ + Ref: "R006", + Level: "warn", + Description: "has published catalogue version", + HowToResolve: "publish a recipe version to the catalogue", + Function: LintHasPublishedVersion, + }, + LintRule{ + Ref: "R007", + Level: "warn", + Description: "README.md metadata filled in", + HowToResolve: "fill out all the metadata", + Function: LintMetadataFilledIn, + }, + }, + "error": []LintRule{ + LintRule{ + Ref: "R008", + Level: "error", + Description: ".env.sample provided", + HowToResolve: "create an example .env.sample", + Function: LintEnvConfigPresent, + }, + LintRule{ + Ref: "R009", + Level: "error", + Description: "one service named 'app'", + HowToResolve: "name a servce 'app'", + Function: LintAppService, + }, + LintRule{ + Ref: "R010", + Level: "error", + Description: "traefik routing enabled", + HowToResolve: "include \"traefik.enable=true\" deploy label", + Function: LintTraefikEnabled, + }, + LintRule{ + Ref: "R011", + Level: "error", + Description: "all services have images", + HowToResolve: "ensure \"image: ...\" set on all services", + Function: LintImagePresent, + }, + LintRule{ + Ref: "R012", + Level: "error", + Description: "config version are vendored", + HowToResolve: "vendor config versions in an abra.sh", + Function: LintAbraShVendors, + }, + LintRule{ + Ref: "R013", + Level: "error", + Description: "git.coopcloud.tech repo exists", + HowToResolve: "upload your recipe to git.coopcloud.tech/coop-cloud/...", + Function: LintHasRecipeRepo, + }, + }, +} + +func LintComposeVersion(recipe Recipe) (bool, error) { + if recipe.Config.Version == "3.8" { + return true, nil + } + + return true, nil +} + +func LintEnvConfigPresent(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) (bool, error) { + for _, service := range recipe.Config.Services { + if service.Name == "app" { + return true, nil + } + } + + return false, nil +} + +func LintTraefikEnabled(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) (bool, error) { + for _, service := range recipe.Config.Services { + if service.HealthCheck == nil { + return false, nil + } + } + + return true, nil +} + +func LintAllImagesTagged(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) (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) (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) (bool, error) { + for _, service := range recipe.Config.Services { + if service.Image == "" { + return false, nil + } + } + return true, nil +} + +func LintHasPublishedVersion(recipe Recipe) (bool, error) { + if err := EnsureUpToDate(recipe.Name); err != nil { + return false, err + } + + tags, err := recipe.Tags() + if err != nil { + return false, err + } + + if len(tags) == 0 { + return false, nil + } + + return true, nil +} + +func LintMetadataFilledIn(recipe Recipe) (bool, error) { + features, category, err := GetRecipeFeaturesAndCategory(recipe.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) (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) (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 +} diff --git a/pkg/recipe/recipe.go b/pkg/recipe/recipe.go index cdf460d4..3a0c82ff 100644 --- a/pkg/recipe/recipe.go +++ b/pkg/recipe/recipe.go @@ -2,6 +2,7 @@ package recipe import ( "fmt" + "io/ioutil" "os" "path" "path/filepath" @@ -18,6 +19,25 @@ import ( "github.com/sirupsen/logrus" ) +// Image represents a recipe container image. +type Image struct { + Image string `json:"image"` + Rating string `json:"rating"` + Source string `json:"source"` + URL string `json:"url"` +} + +// Features represent what top-level features a recipe supports (e.g. does this recipe support backups?). +type Features struct { + Backups string `json:"backups"` + Email string `json:"email"` + Healthcheck string `json:"healthcheck"` + Image Image `json:"image"` + Status int `json:"status"` + Tests string `json:"tests"` + SSO string `json:"sso"` +} + // Recipe represents a recipe. type Recipe struct { Name string @@ -333,3 +353,131 @@ func EnsureUpToDate(recipeName string) error { return nil } + +func GetRecipeFeaturesAndCategory(recipeName string) (Features, string, error) { + feat := Features{} + + var category string + + readmePath := path.Join(config.RECIPES_DIR, recipeName, "README.md") + + logrus.Debugf("attempting to open %s for recipe metadata parsing", readmePath) + + readmeFS, err := ioutil.ReadFile(readmePath) + if err != nil { + return feat, category, err + } + + readmeMetadata, err := GetStringInBetween( // Find text between delimiters + string(readmeFS), + "", "", + ) + if err != nil { + return feat, category, err + } + + readmeLines := strings.Split( // Array item from lines + strings.ReplaceAll( // Remove \t tabs + readmeMetadata, "\t", "", + ), + "\n") + + for _, val := range readmeLines { + if strings.Contains(val, "**Category**") { + category = strings.TrimSpace( + strings.TrimPrefix(val, "* **Category**:"), + ) + } + if strings.Contains(val, "**Backups**") { + feat.Backups = strings.TrimSpace( + strings.TrimPrefix(val, "* **Backups**:"), + ) + } + if strings.Contains(val, "**Email**") { + feat.Email = strings.TrimSpace( + strings.TrimPrefix(val, "* **Email**:"), + ) + } + if strings.Contains(val, "**SSO**") { + feat.SSO = strings.TrimSpace( + strings.TrimPrefix(val, "* **SSO**:"), + ) + } + if strings.Contains(val, "**Healthcheck**") { + feat.Healthcheck = strings.TrimSpace( + strings.TrimPrefix(val, "* **Healthcheck**:"), + ) + } + if strings.Contains(val, "**Tests**") { + feat.Tests = strings.TrimSpace( + strings.TrimPrefix(val, "* **Tests**:"), + ) + } + if strings.Contains(val, "**Image**") { + imageMetadata, err := GetImageMetadata(strings.TrimSpace( + strings.TrimPrefix(val, "* **Image**:"), + ), recipeName) + if err != nil { + continue + } + feat.Image = imageMetadata + } + } + + return feat, category, nil +} + +func GetImageMetadata(imageRowString, recipeName string) (Image, error) { + img := Image{} + + imgFields := strings.Split(imageRowString, ",") + + for i, elem := range imgFields { + imgFields[i] = strings.TrimSpace(elem) + } + + if len(imgFields) < 3 { + if imageRowString != "" { + logrus.Warnf("%s image meta has incorrect format: %s", recipeName, imageRowString) + } else { + logrus.Warnf("%s image meta is empty?", recipeName) + } + return img, nil + } + + img.Rating = imgFields[1] + img.Source = imgFields[2] + + imgString := imgFields[0] + + imageName, err := GetStringInBetween(imgString, "[", "]") + if err != nil { + logrus.Fatal(err) + } + img.Image = strings.ReplaceAll(imageName, "`", "") + + imageURL, err := GetStringInBetween(imgString, "(", ")") + if err != nil { + logrus.Fatal(err) + } + img.URL = imageURL + + return img, nil +} + +// GetStringInBetween returns empty string if no start or end string found +func GetStringInBetween(str, start, end string) (result string, err error) { + s := strings.Index(str, start) + if s == -1 { + return "", fmt.Errorf("marker string %s not found", start) + } + + s += len(start) + e := strings.Index(str[s:], end) + + if e == -1 { + return "", fmt.Errorf("end marker %s not found", end) + } + + return str[s : s+e], nil +}