package recipe import ( "errors" "fmt" "os" "path" "path/filepath" "sort" "strconv" "strings" "text/template" "coopcloud.tech/abra/catalogue" "coopcloud.tech/abra/cli/formatter" "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/client" "coopcloud.tech/abra/client/stack" "coopcloud.tech/abra/config" "coopcloud.tech/tagcmp" loader "coopcloud.tech/abra/client/stack" "github.com/AlecAivazis/survey/v2" "github.com/docker/distribution/reference" "github.com/go-git/go-git/v5" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) var recipeListCommand = &cli.Command{ Name: "list", Usage: "List available recipes", Aliases: []string{"ls"}, Action: func(c *cli.Context) error { catl, err := catalogue.ReadRecipeCatalogue() if err != nil { logrus.Fatal(err.Error()) } recipes := catl.Flatten() sort.Sort(catalogue.ByRecipeName(recipes)) tableCol := []string{"Name", "Category", "Status"} table := formatter.CreateTable(tableCol) for _, recipe := range recipes { status := fmt.Sprintf("%v", recipe.Features.Status) tableRow := []string{recipe.Name, recipe.Category, status} table.Append(tableRow) } table.Render() return nil }, } var recipeVersionCommand = &cli.Command{ Name: "versions", Usage: "List recipe versions", Aliases: []string{"v"}, ArgsUsage: "", Action: func(c *cli.Context) error { recipe := c.Args().First() if recipe == "" { internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided")) } catalogue, err := catalogue.ReadRecipeCatalogue() if err != nil { logrus.Fatal(err) return nil } if recipe, ok := catalogue[recipe]; ok { tableCol := []string{"Version", "Service", "Image", "Digest"} table := formatter.CreateTable(tableCol) for version := range recipe.Versions { for service := range recipe.Versions[version] { meta := recipe.Versions[version][service] table.Append([]string{version, service, meta.Image, meta.Digest}) } } table.SetAutoMergeCells(true) table.Render() return nil } logrus.Fatalf("'%s' recipe doesn't exist?", recipe) return nil }, } var recipeCreateCommand = &cli.Command{ Name: "create", Usage: "Create a new recipe", Aliases: []string{"c"}, ArgsUsage: "", Action: func(c *cli.Context) error { recipe := c.Args().First() if recipe == "" { internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided")) } directory := path.Join(config.APPS_DIR, recipe) if _, err := os.Stat(directory); !os.IsNotExist(err) { logrus.Fatalf("'%s' recipe directory already exists?", directory) return nil } url := fmt.Sprintf("%s/example.git", config.REPOS_BASE_URL) _, err := git.PlainClone(directory, false, &git.CloneOptions{URL: url, Tags: git.AllTags}) if err != nil { logrus.Fatal(err) return nil } gitRepo := path.Join(config.APPS_DIR, recipe, ".git") if err := os.RemoveAll(gitRepo); err != nil { logrus.Fatal(err) return nil } toParse := []string{ path.Join(config.APPS_DIR, recipe, "README.md"), path.Join(config.APPS_DIR, recipe, ".env.sample"), path.Join(config.APPS_DIR, recipe, ".drone.yml"), } for _, path := range toParse { file, err := os.OpenFile(path, os.O_RDWR, 0755) if err != nil { logrus.Fatal(err) return nil } tpl, err := template.ParseFiles(path) if err != nil { logrus.Fatal(err) return nil } // TODO: ask for description and probably other things so that the // template repository is more "ready" to go than the current best-guess // mode of templating if err := tpl.Execute(file, struct { Name string Description string }{recipe, "TODO"}); err != nil { logrus.Fatal(err) return nil } } logrus.Infof( "New recipe '%s' created in %s, happy hacking!\n", recipe, path.Join(config.APPS_DIR, recipe), ) return nil }, } var recipeUpgradeCommand = &cli.Command{ Name: "upgrade", Usage: "Upgrade recipe image tags", Aliases: []string{"u"}, Description: ` This command reads and attempts to parse all image tags within the given configuration and prompt with more recent tags to upgrade to. It will update the relevant compose file tags on the local file system. Some image tags cannot be parsed because they do not follow some sort of semver-like convention. In this case, all possible tags will be listed and it is up to the end-user to decide. This is step 1 of upgrading a recipe. Step 2 is running "abra recipe sync ". `, ArgsUsage: "", Action: func(c *cli.Context) error { recipe := c.Args().First() if recipe == "" { internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided")) } appFiles, err := config.LoadAppFiles("") if err != nil { logrus.Fatal(err) } appEnv, err := config.GetApp(appFiles, recipe) if err != nil { logrus.Fatal(err) } compose, err := config.GetAppComposeConfig(recipe, stack.Deploy{}, appEnv.Env) if err != nil { logrus.Fatal(err) } for _, service := range compose.Services { catlVersions, err := catalogue.VersionsOfService(recipe, service.Name) if err != nil { logrus.Fatal(err) } img, err := reference.ParseNormalizedNamed(service.Image) if err != nil { logrus.Fatal(err) } image := reference.Path(img) regVersions, err := client.GetRegistryTags(image) if err != nil { logrus.Fatal(err) } if strings.Contains(image, "library") { // ParseNormalizedNamed prepends 'library' to images like nginx:, // postgres:, i.e. images which do not have a username in the // first position of the string image = strings.Split(image, "/")[1] } semverLikeTag := true if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) { semverLikeTag = false } tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag()) if err != nil && semverLikeTag { logrus.Fatal(err) } var compatible []tagcmp.Tag for _, regVersion := range regVersions { other, err := tagcmp.Parse(regVersion.Name) if err != nil { continue // skip tags that cannot be parsed } if tag.IsCompatible(other) && tag.IsLessThan(other) && !tag.Equals(other) { compatible = append(compatible, other) } } sort.Sort(tagcmp.ByTag(compatible)) if len(compatible) == 0 && semverLikeTag { logrus.Info(fmt.Sprintf("No new versions available for '%s', '%s' is the latest", image, tag)) continue // skip on to the next tag and don't update any compose files } var compatibleStrings []string for _, compat := range compatible { skip := false for _, catlVersion := range catlVersions { if compat.String() == catlVersion { skip = true } } if !skip { compatibleStrings = append(compatibleStrings, compat.String()) } } msg := fmt.Sprintf("Which tag would you like to upgrade to? (service: %s, tag: %s)", service.Name, tag) if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) { tag := img.(reference.NamedTagged).Tag() logrus.Warning(fmt.Sprintf("Unable to determine versioning semantics of '%s', listing all tags...", tag)) msg = fmt.Sprintf("Which tag would you like to upgrade to? (service: %s, tag: %s)", service.Name, tag) compatibleStrings = []string{} for _, regVersion := range regVersions { compatibleStrings = append(compatibleStrings, regVersion.Name) } } var upgradeTag string prompt := &survey.Select{ Message: msg, Options: compatibleStrings, } if err := survey.AskOne(prompt, &upgradeTag); err != nil { logrus.Fatal(err) } if err := config.UpdateAppComposeTag(recipe, image, upgradeTag, appEnv.Env); err != nil { logrus.Fatal(err) } } return nil }, } var recipeSyncCommand = &cli.Command{ Name: "sync", Usage: "Generate new recipe labels", Aliases: []string{"s"}, Description: ` This command will generate labels for each service which correspond to the following format: coop-cloud.${STACK_NAME}.${SERVICE_NAME}.version=${IMAGE_TAG}-${IMAGE_DIGEST} The configuration will be updated on the local file system. These labels are consumed by abra in other command invocations and used to determine the versioning metadata of up-and-running containers are. `, ArgsUsage: "", Action: func(c *cli.Context) error { recipe := c.Args().First() if recipe == "" { internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided")) } appFiles, err := config.LoadAppFiles("") if err != nil { logrus.Fatal(err) } appEnv, err := config.GetApp(appFiles, recipe) if err != nil { logrus.Fatal(err) } compose, err := config.GetAppComposeConfig(recipe, stack.Deploy{}, appEnv.Env) if err != nil { logrus.Fatal(err) } hasAppService := false for _, service := range compose.Services { if service.Name == "app" { hasAppService = true } } if !hasAppService { logrus.Fatal(fmt.Sprintf("No 'app' service defined in '%s', cannot proceed", recipe)) } for _, service := range compose.Services { img, _ := reference.ParseNormalizedNamed(service.Image) if err != nil { logrus.Fatal(err) } digest, err := client.GetTagDigest(img) if err != nil { logrus.Fatal(err) } tag := img.(reference.NamedTagged).Tag() label := fmt.Sprintf("coop-cloud.${STACK_NAME}.%s.version=%s-%s", service.Name, tag, digest) if err := config.UpdateAppComposeLabel(recipe, service.Name, label, appEnv.Env); err != nil { logrus.Fatal(err) } } return nil }, } var recipeLintCommand = &cli.Command{ Name: "lint", Usage: "Lint a recipe", Aliases: []string{"l"}, ArgsUsage: "", Action: func(c *cli.Context) error { recipe := c.Args().First() if recipe == "" { internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided")) } pattern := fmt.Sprintf("%s/%s/compose**yml", config.APPS_DIR, recipe) composeFiles, err := filepath.Glob(pattern) if err != nil { logrus.Fatal(err) } opts := stack.Deploy{Composefiles: composeFiles} compose, err := loader.LoadComposefile(opts, make(map[string]string)) if err != nil { logrus.Fatal(err) } expectedVersion := false if compose.Version == "3.8" { expectedVersion = true } envSampleProvided := false envSample := fmt.Sprintf("%s/%s/.env.sample", config.APPS_DIR, recipe) if _, err := os.Stat(envSample); !os.IsNotExist(err) { envSampleProvided = true } if err != nil { logrus.Fatal(err) } serviceNamedApp := false traefikEnabled := false healthChecksForAllServices := true allImagesTagged := true noUnstableTags := true semverLikeTags := true for _, service := range compose.Services { if service.Name == "app" { serviceNamedApp = true } for label := range service.Deploy.Labels { if label == "traefik.enable" { if service.Deploy.Labels[label] == "true" { traefikEnabled = true } } } img, err := reference.ParseNormalizedNamed(service.Image) if err != nil { logrus.Fatal(err) } if reference.IsNameOnly(img) { allImagesTagged = false } tag := img.(reference.NamedTagged).Tag() 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() return nil }, } // RecipeCommand defines the `abra recipe` command and ets subcommands var RecipeCommand = &cli.Command{ Name: "recipe", Usage: "Manage recipes", ArgsUsage: "", Aliases: []string{"r"}, Description: ` A recipe is a blueprint for an app. It is a bunch of configuration files which describe how to deploy and maintain an app. Recipes are maintained by the Co-op Cloud community and you can use Abra to read them and create apps for you. `, Subcommands: []*cli.Command{ recipeListCommand, recipeVersionCommand, recipeCreateCommand, recipeUpgradeCommand, recipeSyncCommand, recipeLintCommand, }, }