package app import ( "context" "fmt" "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/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" ) var AppUpgradeCommand = &cobra.Command{ Use: "upgrade [version] [flags]", Aliases: []string{"up"}, Short: "Upgrade an app", Long: `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 { errMsg := fmt.Sprintf("autocomplete failed: %s", err) return []string{errMsg}, 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(versions, &availableUpgrades, deployMeta) if err != nil { log.Fatal(err) } if !upgradeAvailable { log.Info("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("unknown deployed version, unable to upgrade") } log.Debugf("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, fmt.Sprintf("%s missing from %s.env", envVar.Name, app.Domain), ) } } if showReleaseNotes { fmt.Print(upgradeReleaseNotes) return } if upgradeReleaseNotes != "" && chosenUpgrade != "" { fmt.Print(upgradeReleaseNotes) } else { 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) } log.Debugf("set waiting timeout to %d second(s)", stack.WaitTimeout) if err := stack.RunDeploy(cl, deployOpts, compose, stackName, internal.DontWaitConverge); err != nil { log.Fatal(err) } postDeployCmds, ok := app.Env["POST_UPGRADE_CMDS"] if ok && !internal.DontWaitConverge { log.Debugf("run the following post-deploy commands: %s", postDeployCmds) if err := internal.PostCmds(cl, app, postDeployCmds); err != nil { log.Fatalf("attempting to run post deploy commands, saw: %s", err) } } if err := app.WriteRecipeVersion(chosenUpgrade, false); err != nil { log.Fatalf("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 := fmt.Sprintf("please select an upgrade (version: %s):", deployMeta.Version) if deployMeta.IsChaos { chaosVersion := formatter.BoldDirtyDefault(deployMeta.ChaosVersion) msg = fmt.Sprintf( "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 err } parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version) if err != nil { return err } for _, version := range internal.SortVersionsDesc(versions) { parsedVersion, err := tagcmp.Parse(version) if err != nil { return err } if parsedVersion.IsGreaterThan(parsedDeployedVersion) && parsedVersion.IsLessThan(parsedChosenUpgrade) { note, err := app.Recipe.GetReleaseNotes(version) if err != nil { return err } if note != "" { *upgradeReleaseNotes += fmt.Sprintf("%s\n", note) } } } return nil } // ensureUpgradesAvailable ensures that there are available upgrades. func ensureUpgradesAvailable( versions []string, availableUpgrades *[]string, deployMeta stack.DeployMeta, ) (bool, error) { parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version) if err != nil { return false, err } for _, version := range versions { parsedVersion, err := tagcmp.Parse(version) if err != nil { return false, 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 fmt.Errorf("'%s' is not a known version for %s", specificVersion, app.Recipe.Name) } parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version) if err != nil { return fmt.Errorf("'%s' is not a known version", deployMeta.Version) } if parsedSpecificVersion.IsLessThan(parsedDeployedVersion) && !parsedSpecificVersion.Equals(parsedDeployedVersion) { return fmt.Errorf("%s is not an upgrade for %s?", deployMeta.Version, specificVersion) } if parsedSpecificVersion.Equals(parsedDeployedVersion) && !internal.Force { return fmt.Errorf("%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.Debugf("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{}, fmt.Errorf("%s is not deployed?", app.Name) } return deployMeta, nil } var showReleaseNotes bool func init() { AppUpgradeCommand.Flags().BoolVarP( &internal.Force, "force", "f", false, "perform action without further prompt", ) AppUpgradeCommand.Flags().BoolVarP( &internal.NoDomainChecks, "no-domain-checks", "D", false, "disable public DNS checks", ) AppUpgradeCommand.Flags().BoolVarP( &internal.DontWaitConverge, "no-converge-checks", "c", false, "disable converge logic checks", ) AppUpgradeCommand.Flags().BoolVarP( &showReleaseNotes, "releasenotes", "r", false, "only show release notes", ) }