forked from toolshed/abra
		
	
		
			
				
	
	
		
			502 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			502 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package app
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"strings"
 | |
| 
 | |
| 	"coopcloud.tech/abra/cli/internal"
 | |
| 	"coopcloud.tech/abra/pkg/app"
 | |
| 	appPkg "coopcloud.tech/abra/pkg/app"
 | |
| 	"coopcloud.tech/abra/pkg/autocomplete"
 | |
| 	"coopcloud.tech/abra/pkg/client"
 | |
| 	"coopcloud.tech/abra/pkg/config"
 | |
| 	"coopcloud.tech/abra/pkg/envfile"
 | |
| 	"coopcloud.tech/abra/pkg/formatter"
 | |
| 	"coopcloud.tech/abra/pkg/i18n"
 | |
| 	"coopcloud.tech/abra/pkg/lint"
 | |
| 	"coopcloud.tech/abra/pkg/log"
 | |
| 	"coopcloud.tech/abra/pkg/recipe"
 | |
| 	stack "coopcloud.tech/abra/pkg/upstream/stack"
 | |
| 	"coopcloud.tech/tagcmp"
 | |
| 	"github.com/AlecAivazis/survey/v2"
 | |
| 	"github.com/docker/docker/api/types"
 | |
| 	dockerClient "github.com/docker/docker/client"
 | |
| 	"github.com/spf13/cobra"
 | |
| )
 | |
| 
 | |
| // translators: `abra app upgrade` aliases. use a comma separated list of aliases with
 | |
| // no spaces in between
 | |
| var appUpgradeAliases = i18n.G("up")
 | |
| 
 | |
| var AppUpgradeCommand = &cobra.Command{
 | |
| 	// translators: `app upgrade` command
 | |
| 	Use:     i18n.G("upgrade <domain> [version] [flags]"),
 | |
| 	Aliases: strings.Split(appUpgradeAliases, ","),
 | |
| 	// translators: Short description for `app upgrade` command
 | |
| 	Short: i18n.G("Upgrade an app"),
 | |
| 	Long: i18n.G(`Upgrade an app.
 | |
| 
 | |
| Unlike "abra app deploy", chaos operations are not supported here. Only recipe
 | |
| versions are supported values for "[version]".
 | |
| 
 | |
| It is possible to "--force/-f" an upgrade if you want to re-deploy a specific
 | |
| version.
 | |
| 
 | |
| Only the deployed version is consulted when trying to determine what upgrades
 | |
| are available. The live deployment version is the "source of truth" in this
 | |
| case. The stored .env version is not consulted.
 | |
| 
 | |
| An upgrade can be destructive, please ensure you have a copy of your app data
 | |
| beforehand. See "abra app backup" for more.`),
 | |
| 	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.AppNameComplete()
 | |
| 		case 1:
 | |
| 			app, err := appPkg.Get(args[0])
 | |
| 			if err != nil {
 | |
| 				return []string{i18n.G("autocomplete failed: %s", err)}, cobra.ShellCompDirectiveError
 | |
| 			}
 | |
| 			return autocomplete.RecipeVersionComplete(app.Recipe.Name)
 | |
| 		default:
 | |
| 			return nil, cobra.ShellCompDirectiveError
 | |
| 		}
 | |
| 	},
 | |
| 	Run: func(cmd *cobra.Command, args []string) {
 | |
| 		var (
 | |
| 			upgradeWarnMessages []string
 | |
| 			chosenUpgrade       string
 | |
| 			availableUpgrades   []string
 | |
| 			upgradeReleaseNotes string
 | |
| 		)
 | |
| 
 | |
| 		app := internal.ValidateApp(args)
 | |
| 
 | |
| 		if err := app.Recipe.Ensure(recipe.EnsureContext{
 | |
| 			Chaos:   internal.Chaos,
 | |
| 			Offline: internal.Offline,
 | |
| 			// Ignore the env version for now, to make sure we are at the latest commit.
 | |
| 			// This enables us to get release notes, that were added after a release.
 | |
| 			IgnoreEnvVersion: true,
 | |
| 		}); err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		cl, err := client.New(app.Server)
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		deployMeta, err := ensureDeployed(cl, app)
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		if err := lint.LintForErrors(app.Recipe); err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		versions, err := app.Recipe.Tags()
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		// NOTE(d1): we've no idea what the live deployment version is, so every
 | |
| 		// possible upgrade can be shown. it's up to the user to make the choice
 | |
| 		if deployMeta.Version == config.UNKNOWN_DEFAULT {
 | |
| 			availableUpgrades = versions
 | |
| 		}
 | |
| 
 | |
| 		if len(args) == 2 && args[1] != "" {
 | |
| 			chosenUpgrade = args[1]
 | |
| 
 | |
| 			if err := validateUpgradeVersionArg(chosenUpgrade, app, deployMeta); err != nil {
 | |
| 				log.Fatal(err)
 | |
| 			}
 | |
| 
 | |
| 			availableUpgrades = append(availableUpgrades, chosenUpgrade)
 | |
| 		}
 | |
| 
 | |
| 		if deployMeta.Version != config.UNKNOWN_DEFAULT && chosenUpgrade == "" {
 | |
| 			upgradeAvailable, err := ensureUpgradesAvailable(app, versions, &availableUpgrades, deployMeta)
 | |
| 			if err != nil {
 | |
| 				log.Fatal(err)
 | |
| 			}
 | |
| 
 | |
| 			if !upgradeAvailable {
 | |
| 				log.Info(i18n.G("no available upgrades"))
 | |
| 				return
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		if internal.Force || internal.NoInput || chosenUpgrade != "" {
 | |
| 			if len(availableUpgrades) > 0 {
 | |
| 				chosenUpgrade = availableUpgrades[len(availableUpgrades)-1]
 | |
| 			}
 | |
| 		} else {
 | |
| 			if err := chooseUpgrade(availableUpgrades, deployMeta, &chosenUpgrade); err != nil {
 | |
| 				log.Fatal(err)
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		if internal.Force &&
 | |
| 			chosenUpgrade == "" &&
 | |
| 			deployMeta.Version != config.UNKNOWN_DEFAULT {
 | |
| 			chosenUpgrade = deployMeta.Version
 | |
| 		}
 | |
| 
 | |
| 		if chosenUpgrade == "" {
 | |
| 			log.Fatal(i18n.G("unknown deployed version, unable to upgrade"))
 | |
| 		}
 | |
| 
 | |
| 		log.Debug(i18n.G("choosing %s as version to upgrade", chosenUpgrade))
 | |
| 
 | |
| 		// Get the release notes before checking out the new version in the
 | |
| 		// recipe. This enables us to get release notes, that were added after
 | |
| 		// a release.
 | |
| 		if err := getReleaseNotes(app, versions, chosenUpgrade, deployMeta, &upgradeReleaseNotes); err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		if _, err := app.Recipe.EnsureVersion(chosenUpgrade); err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		abraShEnv, err := envfile.ReadAbraShEnvVars(app.Recipe.AbraShPath)
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 		for k, v := range abraShEnv {
 | |
| 			app.Env[k] = v
 | |
| 		}
 | |
| 
 | |
| 		composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		stackName := app.StackName()
 | |
| 		deployOpts := stack.Deploy{
 | |
| 			Composefiles: composeFiles,
 | |
| 			Namespace:    stackName,
 | |
| 			Prune:        false,
 | |
| 			ResolveImage: stack.ResolveImageAlways,
 | |
| 			Detach:       false,
 | |
| 		}
 | |
| 
 | |
| 		compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		appPkg.ExposeAllEnv(stackName, compose, app.Env)
 | |
| 		appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
 | |
| 		appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
 | |
| 		if internal.Chaos {
 | |
| 			appPkg.SetChaosVersionLabel(compose, stackName, chosenUpgrade)
 | |
| 		}
 | |
| 		appPkg.SetUpdateLabel(compose, stackName, app.Env)
 | |
| 
 | |
| 		envVars, err := appPkg.CheckEnv(app)
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		for _, envVar := range envVars {
 | |
| 			if !envVar.Present {
 | |
| 				upgradeWarnMessages = append(upgradeWarnMessages,
 | |
| 					i18n.G("%s missing from %s.env", envVar.Name, app.Domain),
 | |
| 				)
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		filters, err := app.Filters(false, false)
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters})
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		var secretStrings []string
 | |
| 		for _, cont := range secretList {
 | |
| 			secretStrings = append(secretStrings, cont.Spec.Name)
 | |
| 		}
 | |
| 
 | |
| 		configList, err := cl.ConfigList(context.Background(), types.ConfigListOptions{Filters: filters})
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		var configStrings []string
 | |
| 		for _, config := range configList {
 | |
| 			configStrings = append(configStrings, config.Spec.Name)
 | |
| 		}
 | |
| 
 | |
| 		var imageStrings []string
 | |
| 		for _, service := range compose.Services {
 | |
| 			imageStrings = append(imageStrings, service.Image)
 | |
| 		}
 | |
| 
 | |
| 		if showReleaseNotes {
 | |
| 			fmt.Print(upgradeReleaseNotes)
 | |
| 			return
 | |
| 		}
 | |
| 
 | |
| 		if upgradeReleaseNotes == "" {
 | |
| 			upgradeWarnMessages = append(
 | |
| 				upgradeWarnMessages,
 | |
| 				fmt.Sprintf("no release notes available for %s", chosenUpgrade),
 | |
| 			)
 | |
| 		}
 | |
| 
 | |
| 		if err := internal.DeployOverview(
 | |
| 			app,
 | |
| 			deployMeta.Version,
 | |
| 			chosenUpgrade,
 | |
| 			upgradeReleaseNotes,
 | |
| 			upgradeWarnMessages,
 | |
| 			strings.Join(secretStrings, "\n"),
 | |
| 			strings.Join(configStrings, "\n"),
 | |
| 			strings.Join(imageStrings, "\n"),
 | |
| 		); err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		serviceNames, err := appPkg.GetAppServiceNames(app.Name)
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		f, err := app.Filters(true, false, serviceNames...)
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		if err := stack.RunDeploy(
 | |
| 			cl,
 | |
| 			deployOpts,
 | |
| 			compose,
 | |
| 			stackName,
 | |
| 			app.Server,
 | |
| 			internal.DontWaitConverge,
 | |
| 			f,
 | |
| 		); err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		postDeployCmds, ok := app.Env["POST_UPGRADE_CMDS"]
 | |
| 		if ok && !internal.DontWaitConverge {
 | |
| 			log.Debug(i18n.G("run the following post-deploy commands: %s", postDeployCmds))
 | |
| 
 | |
| 			if err := internal.PostCmds(cl, app, postDeployCmds); err != nil {
 | |
| 				log.Fatal(i18n.G("attempting to run post deploy commands, saw: %s", err))
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		if err := app.WriteRecipeVersion(chosenUpgrade, false); err != nil {
 | |
| 			log.Fatal(i18n.G("writing recipe version failed: %s", err))
 | |
| 		}
 | |
| 	},
 | |
| }
 | |
| 
 | |
| // chooseUpgrade prompts the user to choose an upgrade interactively.
 | |
| func chooseUpgrade(
 | |
| 	availableUpgrades []string,
 | |
| 	deployMeta stack.DeployMeta,
 | |
| 	chosenUpgrade *string,
 | |
| ) error {
 | |
| 	msg := i18n.G("please select an upgrade (version: %s):", deployMeta.Version)
 | |
| 
 | |
| 	if deployMeta.IsChaos {
 | |
| 		chaosVersion := formatter.BoldDirtyDefault(deployMeta.ChaosVersion)
 | |
| 
 | |
| 		msg = i18n.G(
 | |
| 			"please select an upgrade (version: %s, chaos: %s):",
 | |
| 			deployMeta.Version,
 | |
| 			chaosVersion,
 | |
| 		)
 | |
| 	}
 | |
| 
 | |
| 	prompt := &survey.Select{
 | |
| 		Message: msg,
 | |
| 		Options: internal.SortVersionsDesc(availableUpgrades),
 | |
| 	}
 | |
| 
 | |
| 	if err := survey.AskOne(prompt, chosenUpgrade); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func getReleaseNotes(
 | |
| 	app app.App,
 | |
| 	versions []string,
 | |
| 	chosenUpgrade string,
 | |
| 	deployMeta stack.DeployMeta,
 | |
| 	upgradeReleaseNotes *string,
 | |
| ) error {
 | |
| 	parsedChosenUpgrade, err := tagcmp.Parse(chosenUpgrade)
 | |
| 	if err != nil {
 | |
| 		return errors.New(i18n.G("parsing chosen upgrade version failed: %s", err))
 | |
| 	}
 | |
| 
 | |
| 	parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
 | |
| 	if err != nil {
 | |
| 		return errors.New(i18n.G("parsing deployment version failed: %s", err))
 | |
| 	}
 | |
| 
 | |
| 	for _, version := range internal.SortVersionsDesc(versions) {
 | |
| 		parsedVersion, err := tagcmp.Parse(version)
 | |
| 		if err != nil {
 | |
| 			return errors.New(i18n.G("parsing recipe version failed: %s", err))
 | |
| 		}
 | |
| 
 | |
| 		if parsedVersion.IsGreaterThan(parsedDeployedVersion) &&
 | |
| 			parsedVersion.IsLessThan(parsedChosenUpgrade) {
 | |
| 			note, err := app.Recipe.GetReleaseNotes(version, app.Domain)
 | |
| 			if err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 
 | |
| 			if note != "" {
 | |
| 				// NOTE(d1): trim any final newline on the end of the note itself before
 | |
| 				//           we manually handle newlines (for multiple release notes and
 | |
| 				//           ensuring space between the warning messages)
 | |
| 				note = strings.TrimSuffix(note, "\n")
 | |
| 
 | |
| 				*upgradeReleaseNotes += fmt.Sprintf("%s\n", note)
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // ensureUpgradesAvailable ensures that there are available upgrades.
 | |
| func ensureUpgradesAvailable(
 | |
| 	app app.App,
 | |
| 	versions []string,
 | |
| 	availableUpgrades *[]string,
 | |
| 	deployMeta stack.DeployMeta,
 | |
| ) (bool, error) {
 | |
| 	parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
 | |
| 	if err != nil {
 | |
| 		return false, errors.New(i18n.G("parsing deployed version failed: %s", err))
 | |
| 	}
 | |
| 
 | |
| 	for _, version := range versions {
 | |
| 		parsedVersion, err := tagcmp.Parse(version)
 | |
| 		if err != nil {
 | |
| 			return false, errors.New(i18n.G("parsing recipe version failed: %s", err))
 | |
| 		}
 | |
| 
 | |
| 		if parsedVersion.IsGreaterThan(parsedDeployedVersion) &&
 | |
| 			!(parsedVersion.Equals(parsedDeployedVersion)) {
 | |
| 			*availableUpgrades = append(*availableUpgrades, version)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if len(*availableUpgrades) == 0 && !internal.Force {
 | |
| 		return false, nil
 | |
| 	}
 | |
| 
 | |
| 	return true, nil
 | |
| }
 | |
| 
 | |
| // validateUpgradeVersionArg validates the specific version.
 | |
| func validateUpgradeVersionArg(
 | |
| 	specificVersion string,
 | |
| 	app app.App,
 | |
| 	deployMeta stack.DeployMeta,
 | |
| ) error {
 | |
| 	parsedSpecificVersion, err := tagcmp.Parse(specificVersion)
 | |
| 	if err != nil {
 | |
| 		return errors.New(i18n.G("'%s' is not a known version for %s", specificVersion, app.Recipe.Name))
 | |
| 	}
 | |
| 
 | |
| 	parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
 | |
| 	if err != nil {
 | |
| 		return errors.New(i18n.G("'%s' is not a known version", deployMeta.Version))
 | |
| 	}
 | |
| 
 | |
| 	if parsedSpecificVersion.IsLessThan(parsedDeployedVersion) &&
 | |
| 		!parsedSpecificVersion.Equals(parsedDeployedVersion) {
 | |
| 		return errors.New(i18n.G("%s is not an upgrade for %s?", deployMeta.Version, specificVersion))
 | |
| 	}
 | |
| 
 | |
| 	if parsedSpecificVersion.Equals(parsedDeployedVersion) && !internal.Force {
 | |
| 		return errors.New(i18n.G("%s is not an upgrade for %s?", deployMeta.Version, specificVersion))
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // ensureDeployed ensures the app is deployed and if so, returns deployment
 | |
| // meta info.
 | |
| func ensureDeployed(cl *dockerClient.Client, app app.App) (stack.DeployMeta, error) {
 | |
| 	log.Debug(i18n.G("checking whether %s is already deployed", app.StackName()))
 | |
| 
 | |
| 	deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
 | |
| 	if err != nil {
 | |
| 		return stack.DeployMeta{}, err
 | |
| 	}
 | |
| 
 | |
| 	if !deployMeta.IsDeployed {
 | |
| 		return stack.DeployMeta{}, errors.New(i18n.G("%s is not deployed?", app.Name))
 | |
| 	}
 | |
| 
 | |
| 	return deployMeta, nil
 | |
| }
 | |
| 
 | |
| var showReleaseNotes bool
 | |
| 
 | |
| func init() {
 | |
| 	AppUpgradeCommand.Flags().BoolVarP(
 | |
| 		&internal.Force,
 | |
| 		i18n.G("force"),
 | |
| 		i18n.G("f"),
 | |
| 		false,
 | |
| 		i18n.G("perform action without further prompt"),
 | |
| 	)
 | |
| 
 | |
| 	AppUpgradeCommand.Flags().BoolVarP(
 | |
| 		&internal.NoDomainChecks,
 | |
| 		i18n.G("no-domain-checks"),
 | |
| 		i18n.G("D"),
 | |
| 		false,
 | |
| 		i18n.G("disable public DNS checks"),
 | |
| 	)
 | |
| 
 | |
| 	AppUpgradeCommand.Flags().BoolVarP(
 | |
| 		&internal.DontWaitConverge,
 | |
| 		i18n.G("no-converge-checks"),
 | |
| 		i18n.G("c"),
 | |
| 		false,
 | |
| 		i18n.G("disable converge logic checks"),
 | |
| 	)
 | |
| 
 | |
| 	AppUpgradeCommand.Flags().BoolVarP(
 | |
| 		&showReleaseNotes,
 | |
| 		i18n.G("releasenotes"),
 | |
| 		i18n.G("r"),
 | |
| 		false,
 | |
| 		i18n.G("only show release notes"),
 | |
| 	)
 | |
| }
 |