forked from toolshed/abra
		
	
		
			
				
	
	
		
			468 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			468 lines
		
	
	
		
			12 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"
 | 
						|
	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),
 | 
						|
				)
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		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,
 | 
						|
		); 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"),
 | 
						|
	)
 | 
						|
}
 |