From 48bcc9cb360cde3e66e3c881e1f0cc95bf045044 Mon Sep 17 00:00:00 2001 From: decentral1se Date: Sun, 5 Sep 2021 22:33:07 +0200 Subject: [PATCH] refactor: break up recipe cli package --- cli/recipe/create.go | 84 ++++++++ cli/recipe/lint.go | 108 +++++++++++ cli/recipe/list.go | 34 ++++ cli/recipe/recipe.go | 436 +----------------------------------------- cli/recipe/sync.go | 79 ++++++++ cli/recipe/upgrade.go | 148 ++++++++++++++ cli/recipe/version.go | 42 ++++ 7 files changed, 496 insertions(+), 435 deletions(-) create mode 100644 cli/recipe/create.go create mode 100644 cli/recipe/lint.go create mode 100644 cli/recipe/list.go create mode 100644 cli/recipe/sync.go create mode 100644 cli/recipe/upgrade.go create mode 100644 cli/recipe/version.go diff --git a/cli/recipe/create.go b/cli/recipe/create.go new file mode 100644 index 00000000..9b01234b --- /dev/null +++ b/cli/recipe/create.go @@ -0,0 +1,84 @@ +package recipe + +import ( + "errors" + "fmt" + "os" + "path" + "text/template" + + "coopcloud.tech/abra/cli/internal" + "coopcloud.tech/abra/pkg/config" + "github.com/go-git/go-git/v5" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" +) + +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 + }, +} diff --git a/cli/recipe/lint.go b/cli/recipe/lint.go new file mode 100644 index 00000000..d4e1e72f --- /dev/null +++ b/cli/recipe/lint.go @@ -0,0 +1,108 @@ +package recipe + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + + "coopcloud.tech/abra/cli/formatter" + "coopcloud.tech/abra/cli/internal" + "coopcloud.tech/abra/pkg/client/stack" + loader "coopcloud.tech/abra/pkg/client/stack" + "coopcloud.tech/abra/pkg/config" + "coopcloud.tech/tagcmp" + "github.com/docker/distribution/reference" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" +) + +var recipeLintCommand = &cli.Command{ + Name: "lint", + Usage: "Lint a recipe", + Aliases: []string{"l"}, + ArgsUsage: "", + Action: func(c *cli.Context) error { + recipe := internal.ValidateRecipeArg(c) + + 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 + }, +} diff --git a/cli/recipe/list.go b/cli/recipe/list.go new file mode 100644 index 00000000..345772f8 --- /dev/null +++ b/cli/recipe/list.go @@ -0,0 +1,34 @@ +package recipe + +import ( + "fmt" + "sort" + + "coopcloud.tech/abra/cli/formatter" + "coopcloud.tech/abra/pkg/catalogue" + "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 + }, +} diff --git a/cli/recipe/recipe.go b/cli/recipe/recipe.go index 1ade7f79..f0fedd8b 100644 --- a/cli/recipe/recipe.go +++ b/cli/recipe/recipe.go @@ -1,444 +1,10 @@ package recipe import ( - "errors" - "fmt" - "os" - "path" - "path/filepath" - "sort" - "strconv" - "strings" - "text/template" - - "coopcloud.tech/abra/cli/formatter" - "coopcloud.tech/abra/cli/internal" - "coopcloud.tech/abra/pkg/catalogue" - "coopcloud.tech/abra/pkg/client" - "coopcloud.tech/abra/pkg/client/stack" - "coopcloud.tech/abra/pkg/config" - "coopcloud.tech/tagcmp" - - loader "coopcloud.tech/abra/pkg/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 := internal.ValidateRecipeArg(c) - - 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 := internal.ValidateRecipeArg(c) - - 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 := internal.ValidateRecipeArg(c) - - 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 := internal.ValidateRecipeArg(c) - - 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 +// RecipeCommand defines all recipe related sub-commands. var RecipeCommand = &cli.Command{ Name: "recipe", Usage: "Manage recipes", diff --git a/cli/recipe/sync.go b/cli/recipe/sync.go new file mode 100644 index 00000000..46c6db88 --- /dev/null +++ b/cli/recipe/sync.go @@ -0,0 +1,79 @@ +package recipe + +import ( + "fmt" + + "coopcloud.tech/abra/cli/internal" + "coopcloud.tech/abra/pkg/client" + "coopcloud.tech/abra/pkg/client/stack" + "coopcloud.tech/abra/pkg/config" + "github.com/docker/distribution/reference" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" +) + +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 := internal.ValidateRecipeArg(c) + + 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 + }, +} diff --git a/cli/recipe/upgrade.go b/cli/recipe/upgrade.go new file mode 100644 index 00000000..f340e623 --- /dev/null +++ b/cli/recipe/upgrade.go @@ -0,0 +1,148 @@ +package recipe + +import ( + "fmt" + "sort" + "strings" + + "coopcloud.tech/abra/cli/internal" + "coopcloud.tech/abra/pkg/catalogue" + "coopcloud.tech/abra/pkg/client" + "coopcloud.tech/abra/pkg/client/stack" + "coopcloud.tech/abra/pkg/config" + "coopcloud.tech/tagcmp" + "github.com/AlecAivazis/survey/v2" + "github.com/docker/distribution/reference" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" +) + +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 := internal.ValidateRecipeArg(c) + + 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 + }, +} diff --git a/cli/recipe/version.go b/cli/recipe/version.go new file mode 100644 index 00000000..2bb0f884 --- /dev/null +++ b/cli/recipe/version.go @@ -0,0 +1,42 @@ +package recipe + +import ( + "coopcloud.tech/abra/cli/formatter" + "coopcloud.tech/abra/cli/internal" + "coopcloud.tech/abra/pkg/catalogue" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" +) + +var recipeVersionCommand = &cli.Command{ + Name: "versions", + Usage: "List recipe versions", + Aliases: []string{"v"}, + ArgsUsage: "", + Action: func(c *cli.Context) error { + recipe := internal.ValidateRecipeArg(c) + + 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 + }, +}