recipe

package
v0.0.0-...-9d40120 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 5, 2026 License: GPL-2.0 Imports: 28 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
var RecipeCommand = &cobra.Command{

	Use:     i18n.G("recipe [cmd] [args] [flags]"),
	Aliases: strings.Split(recipeAliases, ","),

	Short: i18n.G("Manage recipes"),
	Long: i18n.G(`A recipe is a blueprint for an app.

It is a bunch of config 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, deploy them and create apps for you.

Anyone who uses a recipe can become a maintainer. Maintainers typically make
sure the recipe is in good working order and the config upgraded in a timely
manner.`),
}

RecipeCommand defines all recipe related sub-commands.

View Source
var RecipeDiffCommand = &cobra.Command{

	Use:     i18n.G("diff <recipe> [flags]"),
	Aliases: strings.Split(recipeDiffAliases, ","),

	Short: i18n.G("Show unstaged changes in recipe config"),
	Long:  i18n.G("This command requires /usr/bin/git."),
	Args:  cobra.MinimumNArgs(1),
	ValidArgsFunction: func(
		cmd *cobra.Command,
		args []string,
		toComplete string) ([]string, cobra.ShellCompDirective) {
		return autocomplete.RecipeNameComplete()
	},
	Run: func(cmd *cobra.Command, args []string) {
		r := internal.ValidateRecipe(args, cmd.Name())
		if err := gitPkg.DiffUnstaged(r.Dir); err != nil {
			log.Fatal(err)
		}
	},
}
View Source
var RecipeFetchCommand = &cobra.Command{

	Use:     i18n.G("fetch [recipe | --all] [flags]"),
	Aliases: strings.Split(recipeFetchAliases, ","),

	Short: i18n.G("Clone recipe(s) locally"),
	Long:  i18n.G(`Using "--force/-f" Git syncs an existing recipe. It does not erase unstaged changes.`),
	Args:  cobra.RangeArgs(0, 1),
	Example: i18n.G(`  # fetch from recipe catalogue
  abra recipe fetch gitea

  # fetch from remote recipe
  abra recipe fetch git.foo.org/recipes/myrecipe

  # fetch with ssh remote for hacking
  abra recipe fetch gitea --ssh`),
	ValidArgsFunction: func(
		cmd *cobra.Command,
		args []string,
		toComplete string) ([]string, cobra.ShellCompDirective) {
		return autocomplete.RecipeNameComplete()
	},
	Run: func(cmd *cobra.Command, args []string) {
		var recipeName string
		if len(args) > 0 {
			recipeName = args[0]
		}

		if recipeName == "" && !fetchAllRecipes {
			log.Fatal(i18n.G("missing [recipe] or --all/-a"))
		}

		if recipeName != "" && fetchAllRecipes {
			log.Fatal(i18n.G("cannot use [recipe] and --all/-a together"))
		}

		if recipeName != "" {
			r := recipe.Get(recipeName)
			if _, err := os.Stat(r.Dir); !os.IsNotExist(err) {
				if !force {
					log.Warn(i18n.G("%s is already fetched", r.Name))
					return
				}
			}

			r = internal.ValidateRecipe(args, cmd.Name())

			if sshRemote {
				if r.SSHURL == "" {
					log.Warn(i18n.G("unable to discover SSH remote for %s", r.Name))
					return
				}

				repo, err := git.PlainOpen(r.Dir)
				if err != nil {
					log.Fatal(i18n.G("unable to open %s: %s", r.Dir, err))
				}

				if err = repo.DeleteRemote("origin"); err != nil {
					log.Fatal(i18n.G("unable to remove default remote in %s: %s", r.Dir, err))
				}

				if _, err := repo.CreateRemote(&gitCfg.RemoteConfig{
					Name: "origin",
					URLs: []string{r.SSHURL},
				}); err != nil {
					log.Fatal(i18n.G("unable to set SSH remote in %s: %s", r.Dir, err))
				}
			}

			return
		}

		catalogue, err := recipe.ReadRecipeCatalogue(internal.Offline)
		if err != nil {
			log.Fatal(err)
		}

		catlBar := formatter.CreateProgressbar(len(catalogue), i18n.G("fetching latest recipes..."))
		ensureCtx := internal.GetEnsureContext()
		for recipeName := range catalogue {
			r := recipe.Get(recipeName)
			if err := r.Ensure(ensureCtx); err != nil {
				log.Error(err)
			}
			catlBar.Add(1)
		}
	},
}
View Source
var RecipeLintCommand = &cobra.Command{

	Use: i18n.G("lint <recipe> [flags]"),

	Short:   i18n.G("Lint a recipe"),
	Aliases: strings.Split(recipeLintAliases, ","),
	Args:    cobra.MinimumNArgs(1),
	ValidArgsFunction: func(
		cmd *cobra.Command,
		args []string,
		toComplete string) ([]string, cobra.ShellCompDirective) {
		return autocomplete.RecipeNameComplete()
	},
	Run: func(cmd *cobra.Command, args []string) {
		recipe := internal.ValidateRecipe(args, cmd.Name())

		if err := recipe.Ensure(internal.GetEnsureContext()); err != nil {
			log.Fatal(err)
		}

		headers := []string{
			i18n.G("ref"),
			i18n.G("rule"),
			i18n.G("severity"),
			i18n.G("satisfied"),
			i18n.G("skipped"),
			i18n.G("resolve"),
		}

		table, err := formatter.CreateTable()
		if err != nil {
			log.Fatal(err)
		}

		table.Headers(headers...)

		hasError := false
		var rows [][]string
		var warnMessages []string
		for level := range lint.LintRules {
			for _, rule := range lint.LintRules[level] {
				if onlyError && rule.Level != "error" {
					log.Debug(i18n.G("skipping %s, does not have level \"error\"", rule.Ref))
					continue
				}

				skipped := false
				if rule.Skip(recipe) {
					skipped = true
				}

				skippedOutput := "-"
				if skipped {
					skippedOutput = "✅"
				}

				satisfied := false
				if !skipped {
					ok, err := rule.Function(recipe)
					if err != nil {
						warnMessages = append(warnMessages, err.Error())
					}

					if !ok && rule.Level == i18n.G("error") {
						hasError = true
					}

					if ok {
						satisfied = true
					}
				}

				satisfiedOutput := "✅"
				if !satisfied {
					satisfiedOutput = "❌"
					if skipped {
						satisfiedOutput = "-"
					}
				}

				row := []string{
					rule.Ref,
					rule.Description,
					rule.Level,
					satisfiedOutput,
					skippedOutput,
					rule.HowToResolve,
				}

				rows = append(rows, row)
				table.Row(row...)
			}
		}

		if len(rows) > 0 {
			if err := formatter.PrintTable(table); err != nil {
				log.Fatal(err)
			}

			for _, warnMsg := range warnMessages {
				log.Warn(warnMsg)
			}

			if hasError {
				log.Warn(i18n.G("critical errors present in %s config", recipe.Name))
			}
		}
	},
}
View Source
var RecipeListCommand = &cobra.Command{

	Use: i18n.G("list"),

	Short:   i18n.G("List recipes"),
	Aliases: strings.Split(recipeListAliases, ","),
	Args:    cobra.NoArgs,
	Run: func(cmd *cobra.Command, args []string) {
		catl, err := recipe.ReadRecipeCatalogue(internal.Offline)
		if err != nil {
			log.Fatal(err)
		}

		recipes := catl.Flatten()
		sort.Sort(recipe.ByRecipeName(recipes))

		table, err := formatter.CreateTable()
		if err != nil {
			log.Fatal(err)
		}

		headers := []string{
			i18n.G("name"),
			i18n.G("category"),
			i18n.G("status"),
			i18n.G("healthcheck"),
			i18n.G("backups"),
			i18n.G("email"),
			i18n.G("tests"),
			i18n.G("SSO"),
		}

		table.Headers(headers...)

		var rows [][]string
		for _, recipe := range recipes {
			row := []string{
				recipe.Name,
				recipe.Category,
				strconv.Itoa(recipe.Features.Status),
				recipe.Features.Healthcheck,
				recipe.Features.Backups,
				recipe.Features.Email,
				recipe.Features.Tests,
				recipe.Features.SSO,
			}

			if pattern != "" {
				if strings.Contains(recipe.Name, pattern) {
					table.Row(row...)
					rows = append(rows, row)
				}
			} else {
				table.Row(row...)
				rows = append(rows, row)
			}
		}

		if len(rows) > 0 {
			if internal.MachineReadable {
				out, err := formatter.ToJSON(headers, rows)
				if err != nil {
					log.Fatal(i18n.G("unable to render to JSON: %s", err))
				}
				fmt.Println(out)
				return
			}

			if err := formatter.PrintTable(table); err != nil {
				log.Fatal(err)
			}
		}
	},
}
View Source
var RecipeNewCommand = &cobra.Command{

	Use:     i18n.G("new <recipe> [flags]"),
	Aliases: strings.Split(recipeNewAliases, ","),

	Short: i18n.G("Create a new recipe"),
	Long:  i18n.G(`A community managed recipe template is used.`),
	Args:  cobra.ExactArgs(1),
	ValidArgsFunction: func(
		cmd *cobra.Command,
		args []string,
		toComplete string) ([]string, cobra.ShellCompDirective) {
		return autocomplete.RecipeNameComplete()
	},
	Run: func(cmd *cobra.Command, args []string) {
		recipeName := args[0]

		r := recipe.Get(recipeName)
		if _, err := os.Stat(r.Dir); !os.IsNotExist(err) {
			log.Fatal(i18n.G("%s recipe directory already exists?", r.Dir))
		}

		url := i18n.G("%s/example.git", config.REPOS_BASE_URL)
		if err := git.Clone(r.Dir, url); err != nil {
			log.Fatal(err)
		}

		gitRepo := path.Join(r.Dir, ".git")
		if err := os.RemoveAll(gitRepo); err != nil {
			log.Fatal(err)
		}
		log.Debug(i18n.G("removed .git repo in %s", gitRepo))

		meta := newRecipeMeta(recipeName)

		for _, path := range []string{r.ReadmePath, r.SampleEnvPath} {
			tpl, err := template.ParseFiles(path)
			if err != nil {
				log.Fatal(err)
			}

			var templated bytes.Buffer
			if err := tpl.Execute(&templated, meta); err != nil {
				log.Fatal(err)
			}

			if err := os.WriteFile(path, templated.Bytes(), 0o644); err != nil {
				log.Fatal(err)
			}
		}

		if err := git.Init(r.Dir, true, gitName, gitEmail); err != nil {
			log.Fatal(err)
		}

		log.Info(i18n.G("new recipe '%s' created: %s", recipeName, path.Join(r.Dir)))
		log.Info(i18n.G("happy hacking 🎉"))
	},
}
View Source
var RecipeReleaseCommand = &cobra.Command{

	Use:     i18n.G("release <recipe> [version] [flags]"),
	Aliases: strings.Split(recipeReleaseAliases, ","),

	Short: i18n.G("Release a new recipe version"),
	Long: i18n.G(`Create a new version of a recipe.

These versions are then published on the Co-op Cloud recipe catalogue. These
versions take the following form:

    a.b.c+x.y.z

Where the "a.b.c" part is a semantic version determined by the maintainer. The
"x.y.z" part is the image tag of the recipe "app" service (the main container
which contains the software to be used, by naming convention).

We maintain a semantic versioning scheme ("a.b.c") alongside the recipe
versioning scheme ("x.y.z") in order to maximise the chances that the nature of
recipe updates are properly communicated. I.e. developers of an app might
publish a minor version but that might lead to changes in the recipe which are
major and therefore require intervention while doing the upgrade work.

This command will publish your new release to git.coopcloud.tech. This
requires that you have permission to git push to these repositories and have
your SSH keys configured on your account. Enable ssh-agent and make sure to add
your private key and enter your passphrase beforehand.

    eval ` + "`ssh-agent`" + `
    ssh-add ~/.ssh/<my-ssh-private-key-for-git-coopcloud-tech>`),
	Example: `  # publish release
  eval ` + "`ssh-agent`" + `
  ssh-add ~/.ssh/id_ed25519
  abra recipe release gitea`,
	Args: cobra.RangeArgs(1, 2),
	ValidArgsFunction: func(
		cmd *cobra.Command,
		args []string,
		toComplete string,
	) ([]string, cobra.ShellCompDirective) {
		switch l := len(args); l {
		case 0:
			return autocomplete.RecipeNameComplete()
		case 1:
			return autocomplete.RecipeVersionComplete(args[0])
		default:
			return nil, cobra.ShellCompDirectiveDefault
		}
	},
	Run: func(cmd *cobra.Command, args []string) {
		recipe := internal.ValidateRecipe(args, cmd.Name())

		imagesTmp, err := GetImageVersions(recipe)
		if err != nil {
			log.Fatal(err)
		}

		mainApp, err := internal.GetMainAppImage(recipe)
		if err != nil {
			log.Fatal(err)
		}

		mainAppVersion := imagesTmp[mainApp]
		if mainAppVersion == "" {
			log.Fatal(i18n.G("main app service version for %s is empty?", recipe.Name))
		}

		repo, err := git.PlainOpen(recipe.Dir)
		if err != nil {
			log.Fatal(err)
		}

		preCommitHead, err := repo.Head()
		if err != nil {
			log.Fatal(err)
		}

		isClean, err := gitPkg.IsClean(recipe.Dir)
		if err != nil {
			log.Fatal(err)
		}

		if !isClean {
			log.Fatal(i18n.G("working directory not clean in %s, aborting", recipe.Dir))
		}

		tags, err := recipe.Tags()
		if err != nil {
			log.Fatal(err)
		}

		var tagString string
		if len(args) == 2 {
			tagString = args[1]
		}

		if (internal.Major || internal.Minor || internal.Patch) && tagString != "" {
			log.Fatal(i18n.G("cannot specify tag and bump type at the same time"))
		}

		if len(tags) == 0 && tagString == "" {
			log.Warn(i18n.G("no git tags found for %s", recipe.Name))
			if internal.NoInput {
				log.Fatal(i18n.G("unable to continue, input required for initial version"))
			}
			fmt.Println(i18n.G(`
The following options are two types of initial semantic version that you can
pick for %s that will be published in the recipe catalogue. This follows the
semver convention (more on https://semver.org), here is a short cheatsheet

    0.1.0: development release, still hacking. when you make a major upgrade
           you increment the "y" part (i.e. 0.1.0 -> 0.2.0) and only move to
           using the "x" part when things are stable.

    1.0.0: public release, assumed to be working. you already have a stable
           and reliable deployment of this app and feel relatively confident
           about it.

If you want people to be able alpha test your current config for %s but don't
think it is quite reliable, go with 0.1.0 and people will know that things are
likely to change.

`, recipe.Name, recipe.Name))
			var chosenVersion string
			edPrompt := &survey.Select{
				Message: i18n.G("which version do you want to begin with?"),
				Options: []string{"0.1.0", "1.0.0"},
			}

			if err := survey.AskOne(edPrompt, &chosenVersion); err != nil {
				log.Fatal(err)
			}

			tagString = fmt.Sprintf("%s+%s", chosenVersion, mainAppVersion)
		}

		if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {

			catl, err := recipePkg.ReadRecipeCatalogue(false)
			if err != nil {
				log.Fatal(err)
			}

			changesTable, err := formatter.CreateTable()
			if err != nil {
				log.Fatal(err)
			}

			latestRelease := tags[len(tags)-1]
			latestRecipeVersion, err := getLatestVersion(recipe, catl)
			if err != nil && err != errEmptyVersionsInCatalogue {
				log.Fatal(err)
			}
			changesTable.Headers(i18n.G("SERVICE"), latestRelease, i18n.G("PROPOSED CHANGES"))

			allRecipeVersions := catl[recipe.Name].Versions
			for _, recipeVersion := range allRecipeVersions {
				if serviceVersions, ok := recipeVersion[latestRecipeVersion]; ok {
					for serviceName := range serviceVersions {
						serviceMeta := serviceVersions[serviceName]

						existingImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, serviceMeta.Tag)
						newImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, imagesTmp[serviceMeta.Image])

						if existingImageTag == newImageTag {
							continue
						}

						changesTable.Row([]string{serviceName, existingImageTag, newImageTag}...)
					}
				}
			}

			changeOverview := changesTable.Render()

			if err := internal.PromptBumpType("", latestRelease, changeOverview); err != nil {
				log.Fatal(err)
			}
		}

		if tagString == "" {
			var lastGitTag tagcmp.Tag
			iter, err := repo.Tags()
			if err != nil {
				log.Fatal(err)
			}

			if err := iter.ForEach(func(ref *plumbing.Reference) error {
				obj, err := repo.TagObject(ref.Hash())
				if err != nil {
					log.Fatal(i18n.G("tag at commit %s is unannotated or otherwise broken", ref.Hash()))
					return err
				}

				tagcmpTag, err := tagcmp.Parse(obj.Name)
				if err != nil {
					return err
				}

				if (lastGitTag == tagcmp.Tag{}) {
					lastGitTag = tagcmpTag
				} else if tagcmpTag.IsGreaterThan(lastGitTag) {
					lastGitTag = tagcmpTag
				}

				return nil
			}); err != nil {
				log.Fatal(err)
			}

			bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
			if bumpType != 0 {

				if (bumpType & (bumpType - 1)) != 0 {
					log.Fatal(i18n.G("you can only use one version flag: --major, --minor or --patch"))
				}
			}

			newTag := lastGitTag
			if bumpType > 0 {
				if internal.Patch {
					now, err := strconv.Atoi(newTag.Patch)
					if err != nil {
						log.Fatal(err)
					}

					newTag.Patch = strconv.Itoa(now + 1)
				} else if internal.Minor {
					now, err := strconv.Atoi(newTag.Minor)
					if err != nil {
						log.Fatal(err)
					}

					newTag.Patch = "0"
					newTag.Minor = strconv.Itoa(now + 1)
				} else if internal.Major {
					now, err := strconv.Atoi(newTag.Major)
					if err != nil {
						log.Fatal(err)
					}

					newTag.Patch = "0"
					newTag.Minor = "0"
					newTag.Major = strconv.Itoa(now + 1)
				}
			}

			newTag.Metadata = mainAppVersion
			log.Debug(i18n.G("choosing %s as new version for %s", newTag.String(), recipe.Name))
			tagString = newTag.String()
		}

		if _, err := tagcmp.Parse(tagString); err != nil {
			log.Fatal(i18n.G("invalid version %s specified", tagString))
		}

		for _, tag := range tags {
			previousTagLeftHand := strings.Split(tag, "+")[0]
			newTagStringLeftHand := strings.Split(tagString, "+")[0]
			if previousTagLeftHand == newTagStringLeftHand {
				log.Fatal(i18n.G("%s+... conflicts with a previous release: %s", newTagStringLeftHand, tag))
			}
		}

		if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
			if cleanErr := cleanTag(recipe, tagString); cleanErr != nil {
				log.Fatal(i18n.G("unable to clean up tag after failed release attempt: %s", cleanErr))
			}
			if resetErr := resetCommit(recipe, preCommitHead); resetErr != nil {
				log.Fatal(i18n.G("unable to reset commit after failed release attempt: %s", resetErr))
			}
			log.Error(err)
			log.Fatal(i18n.G("release failed. any changes made have been reverted"))
		}
	},
}
View Source
var RecipeResetCommand = &cobra.Command{

	Use:     i18n.G("reset <recipe> [flags]"),
	Aliases: strings.Split(recipeResetAliases, ","),

	Short: i18n.G("Remove all unstaged changes from recipe config"),
	Long:  i18n.G("WARNING: this will delete your changes. Be Careful."),
	Args:  cobra.ExactArgs(1),
	ValidArgsFunction: func(
		cmd *cobra.Command,
		args []string,
		toComplete string) ([]string, cobra.ShellCompDirective) {
		return autocomplete.RecipeNameComplete()
	},
	Run: func(cmd *cobra.Command, args []string) {
		r := internal.ValidateRecipe(args, cmd.Name())

		repo, err := git.PlainOpen(r.Dir)
		if err != nil {
			log.Fatal(err)
		}

		ref, err := repo.Head()
		if err != nil {
			log.Fatal(err)
		}

		worktree, err := repo.Worktree()
		if err != nil {
			log.Fatal(err)
		}

		opts := &git.ResetOptions{Commit: ref.Hash(), Mode: git.HardReset}
		if err := worktree.Reset(opts); err != nil {
			log.Fatal(err)
		}
	},
}
View Source
var RecipeUpgradeCommand = &cobra.Command{

	Use:     i18n.G("upgrade <recipe> [flags]"),
	Aliases: strings.Split(recipeUpgradeAliases, ","),

	Short: i18n.G("Upgrade recipe image tags"),
	Long: i18n.G(`Upgrade a given <recipe> configuration.

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.

The command is interactive and will show a select input which allows you to
make a seclection. Use the "?" key to see more help on navigating this
interface.`),
	Args: cobra.RangeArgs(0, 1),
	ValidArgsFunction: func(
		cmd *cobra.Command,
		args []string,
		toComplete string,
	) ([]string, cobra.ShellCompDirective) {
		return autocomplete.RecipeNameComplete()
	},
	Run: func(cmd *cobra.Command, args []string) {
		recipe := internal.ValidateRecipe(args, cmd.Name())

		if err := recipe.Ensure(internal.GetEnsureContext()); err != nil {
			log.Fatal(err)
		}

		bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
		if bumpType != 0 {

			if (bumpType & (bumpType - 1)) != 0 {
				log.Fatal(i18n.G("you can only use one of: --major, --minor, --patch."))
			}
		}

		if internal.MachineReadable {

			internal.NoInput = true
		}

		upgradeList := make(map[string]anUpgrade)

		versionsPresent := false
		versionsPath := path.Join(recipe.Dir, "versions")
		servicePins := make(map[string]imgPin)
		if _, err := os.Stat(versionsPath); err == nil {
			log.Debug(i18n.G("found versions file for %s", recipe.Name))
			file, err := os.Open(versionsPath)
			if err != nil {
				log.Fatal(err)
			}
			scanner := bufio.NewScanner(file)
			for scanner.Scan() {
				line := scanner.Text()
				splitLine := strings.Split(line, " ")
				if splitLine[0] != "pin" || len(splitLine) != 3 {
					log.Fatal(i18n.G("malformed version pin specification: %s", line))
				}
				pinSlice := strings.Split(splitLine[2], ":")
				pinTag, err := tagcmp.Parse(pinSlice[1])
				if err != nil {
					log.Fatal(err)
				}
				pin := imgPin{
					image:   pinSlice[0],
					version: pinTag,
				}
				servicePins[splitLine[1]] = pin
			}
			if err := scanner.Err(); err != nil {
				log.Error(err)
			}
			versionsPresent = true
		} else {
			log.Debug(i18n.G("did not find versions file for %s", recipe.Name))
		}

		config, err := recipe.GetComposeConfig(nil)
		if err != nil {
			log.Fatal(err)
		}

		for _, service := range config.Services {
			img, err := reference.ParseNormalizedNamed(service.Image)
			if err != nil {
				log.Fatal(err)
			}

			regVersions, err := client.GetRegistryTags(img)
			if err != nil {
				log.Fatal(err)
			}

			image := reference.Path(img)
			log.Debug(i18n.G("retrieved %s from remote registry for %s", regVersions, image))
			image = formatter.StripTagMeta(image)

			switch img.(type) {
			case reference.NamedTagged:
				if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
					log.Debug(i18n.G("%s not considered semver-like", img.(reference.NamedTagged).Tag()))
				}
			default:
				log.Warn(i18n.G("unable to read tag for image %s, is it missing? skipping upgrade for %s", image, service.Name))
				continue
			}

			tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag())
			if err != nil {
				log.Warn(i18n.G("unable to parse %s, error was: %s, skipping upgrade for %s", image, err.Error(), service.Name))
				continue
			}

			log.Debug(i18n.G("parsed %s for %s", tag, service.Name))

			var compatible []tagcmp.Tag
			for _, regVersion := range regVersions {
				other, err := tagcmp.Parse(regVersion)
				if err != nil {
					continue
				}

				if tag.IsCompatible(other) && tag.IsLessThan(other) && !tag.Equals(other) {
					compatible = append(compatible, other)
				}
			}

			log.Debug(i18n.G("detected potential upgradable tags %s for %s", compatible, service.Name))

			sort.Sort(tagcmp.ByTagDesc(compatible))

			if len(compatible) == 0 && !allTags {
				log.Info(i18n.G("no new versions available for %s, assuming %s is the latest (use -a/--all-tags to see all anyway)", image, tag))
				continue
			}

			catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name, internal.Offline)
			if err != nil {
				log.Fatal(err)
			}

			compatibleStrings := []string{"skip"}
			for _, compat := range compatible {
				skip := false
				for _, catlVersion := range catlVersions {
					if compat.String() == catlVersion {
						skip = true
					}
				}
				if !skip {
					compatibleStrings = append(compatibleStrings, compat.String())
				}
			}

			log.Debug(i18n.G("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name))

			var upgradeTag string
			_, ok := servicePins[service.Name]
			if versionsPresent && ok {
				pinnedTag := servicePins[service.Name].version
				if tag.IsLessThan(pinnedTag) {
					pinnedTagString := pinnedTag.String()
					contains := false
					for _, v := range compatible {
						if pinnedTag.IsUpgradeCompatible(v) {
							contains = true
							upgradeTag = v.String()
							break
						}
					}
					if contains {
						log.Info(i18n.G("upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString))
					} else {
						log.Info(i18n.G("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString))
						continue
					}
				} else {
					log.Fatal(i18n.G("service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String()))
					continue
				}
			} else {
				if bumpType != 0 {
					for _, upTag := range compatible {
						upElement, err := tag.UpgradeDelta(upTag)
						if err != nil {
							return
						}
						delta := upElement.UpgradeType()
						if delta <= bumpType {
							upgradeTag = upTag.String()
							break
						}
					}
					if upgradeTag == "" {
						log.Warn(i18n.G("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants", tag.String(), compatible[0].String(), image))
						continue
					}
				} else {
					msg := i18n.G("upgrade to which tag? (service: %s, image: %s, tag: %s)", service.Name, image, tag)
					if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) || allTags {
						tag := img.(reference.NamedTagged).Tag()
						if !allTags {
							log.Warn(i18n.G("unable to determine versioning semantics of %s, listing all tags", tag))
						}
						msg = i18n.G("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
						compatibleStrings = []string{"skip"}
						for _, regVersion := range regVersions {
							compatibleStrings = append(compatibleStrings, regVersion)
						}
					}

					upgradableTags := compatibleStrings[1:]
					upgrade := anUpgrade{
						Service:     service.Name,
						Image:       image,
						Tag:         tag.String(),
						UpgradeTags: make([]string, len(upgradableTags)),
					}

					for n, s := range upgradableTags {
						var sb strings.Builder
						if _, err := sb.WriteString(s); err != nil {
						}
						upgrade.UpgradeTags[n] = sb.String()
					}

					upgradeList[upgrade.Service] = upgrade

					if internal.NoInput {
						upgradeTag = "skip"
					} else {
						prompt := &survey.Select{
							Message: msg,
							Help:    i18n.G("enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled"),
							VimMode: true,
							Options: compatibleStrings,
						}
						if err := survey.AskOne(prompt, &upgradeTag); err != nil {
							log.Fatal(err)
						}
					}
				}
			}
			if upgradeTag != "skip" {
				ok, err := recipe.UpdateTag(image, upgradeTag)
				if err != nil {
					log.Fatal(err)
				}
				if ok {
					log.Info(i18n.G("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image))
				}
			} else {
				if !internal.NoInput {
					log.Warn(i18n.G("not upgrading %s, skipping as requested", image))
				}
			}
		}

		if internal.NoInput {
			if internal.MachineReadable {
				jsonstring, err := json.Marshal(upgradeList)
				if err != nil {
					log.Fatal(err)
				}

				fmt.Println(string(jsonstring))

				return
			}

			for _, upgrade := range upgradeList {
				log.Info(i18n.G("can upgrade service: %s, image: %s, tag: %s ::", upgrade.Service, upgrade.Image, upgrade.Tag))
				for _, utag := range upgrade.UpgradeTags {
					log.Infof("    %s", utag)
				}
			}
		}

		isClean, err := gitPkg.IsClean(recipe.Dir)
		if err != nil {
			log.Fatal(err)
		}
		if !isClean {
			log.Info(i18n.G("%s currently has these unstaged changes 👇", recipe.Name))
			if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
				log.Fatal(err)
			}

			if !internal.NoInput && !createCommit {
				prompt := &survey.Confirm{
					Message: i18n.G("commit changes?"),
					Default: true,
				}

				if err := survey.AskOne(prompt, &createCommit); err != nil {
					log.Fatal(err)
				}
			}

			if createCommit {
				msg := i18n.G("chore: update image tags")
				if err := gitPkg.Commit(recipe.Dir, msg, internal.Dry); err != nil {
					log.Fatal(err)
				}
				log.Info(i18n.G("committed changes as '%s'", msg))
			}

		} else {
			if createCommit {
				log.Warn(i18n.G("no changes, skip creating commit"))
			}
		}
	},
}
View Source
var RecipeVersionCommand = &cobra.Command{

	Use:     i18n.G("versions <recipe> [flags]"),
	Aliases: strings.Split(recipeVersionsAliases, ","),

	Short: i18n.G("List recipe versions"),
	Args:  cobra.ExactArgs(1),
	ValidArgsFunction: func(
		cmd *cobra.Command,
		args []string,
		toComplete string) ([]string, cobra.ShellCompDirective) {
		return autocomplete.RecipeNameComplete()
	},
	Run: func(cmd *cobra.Command, args []string) {
		var warnMessages []string

		recipe := internal.ValidateRecipe(args, cmd.Name())

		catl, err := recipePkg.ReadRecipeCatalogue(internal.Offline)
		if err != nil {
			log.Fatal(err)
		}

		recipeMeta, ok := catl[recipe.Name]
		if !ok {
			warnMessages = append(warnMessages, i18n.G("retrieved versions from local recipe repository"))

			recipeVersions, warnMsg, err := recipe.GetRecipeVersions()
			if err != nil {
				warnMessages = append(warnMessages, err.Error())
			}
			if len(warnMsg) > 0 {
				warnMessages = append(warnMessages, warnMsg...)
			}

			recipeMeta = recipePkg.RecipeMeta{Versions: recipeVersions}
		}

		if len(recipeMeta.Versions) == 0 {
			log.Fatal(i18n.G("%s has no published versions?", recipe.Name))
		}

		for i := len(recipeMeta.Versions) - 1; i >= 0; i-- {
			table, err := formatter.CreateTable()
			if err != nil {
				log.Fatal(err)
			}

			table.Headers(i18n.G("SERVICE"), i18n.G("IMAGE"), i18n.G("TAG"), i18n.G("VERSION"))

			for version, meta := range recipeMeta.Versions[i] {
				var allRows [][]string
				var rows [][]string

				for service, serviceMeta := range meta {
					recipeVersion := version
					if service != "app" {
						recipeVersion = ""
					}

					rows = append(rows, []string{
						service,
						serviceMeta.Image,
						serviceMeta.Tag,
						recipeVersion,
					})

					allRows = append(allRows, []string{
						version,
						service,
						serviceMeta.Image,
						serviceMeta.Tag,
						recipeVersion,
					})
				}

				sort.Slice(rows, sortServiceByName(rows))

				table.Rows(rows...)

				if !internal.MachineReadable {
					if err := formatter.PrintTable(table); err != nil {
						log.Fatal(err)
					}
					continue
				}

				if internal.MachineReadable {
					sort.Slice(allRows, sortServiceByName(allRows))
					headers := []string{i18n.G("VERSION"), i18n.G("SERVICE"), i18n.G("NAME"), i18n.G("TAG")}
					out, err := formatter.ToJSON(headers, allRows)
					if err != nil {
						log.Fatal(i18n.G("unable to render to JSON: %s", err))
					}
					fmt.Println(out)
					continue
				}
			}
		}

		if !internal.MachineReadable {
			for _, warnMsg := range warnMessages {
				log.Warn(warnMsg)
			}
		}
	},
}

Functions

func GetImageVersions

func GetImageVersions(recipe recipePkg.Recipe) (map[string]string, error)

GetImageVersions retrieves image versions for a recipe

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL