package updater import ( "context" "fmt" "os" "strconv" "strings" "coopcloud.tech/abra/cli/internal" appPkg "coopcloud.tech/abra/pkg/app" "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/envfile" "coopcloud.tech/abra/pkg/lint" "coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/upstream/convert" "coopcloud.tech/abra/pkg/upstream/stack" "coopcloud.tech/tagcmp" charmLog "github.com/charmbracelet/log" composetypes "github.com/docker/cli/cli/compose/types" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" dockerclient "github.com/docker/docker/client" "github.com/spf13/cobra" "coopcloud.tech/abra/pkg/log" ) const SERVER = "localhost" // NotifyCommand checks for available upgrades. var NotifyCommand = &cobra.Command{ Use: "notify [flags]", Aliases: []string{"n"}, Short: "Check for available upgrades", Long: `Notify on new versions for deployed apps. If a new patch/minor version is available, a notification is printed. Use "--major/-m" to include new major versions.`, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { cl, err := client.New("default") if err != nil { log.Fatal(err) } stacks, err := stack.GetStacks(cl) if err != nil { log.Fatal(err) } for _, stackInfo := range stacks { stackName := stackInfo.Name recipeName, err := getLabel(cl, stackName, "recipe") if err != nil { log.Fatal(err) } if recipeName != "" { _, err = getLatestUpgrade(cl, stackName, recipeName) if err != nil { log.Fatal(err) } } } }, } // UpgradeCommand upgrades apps. var UpgradeCommand = &cobra.Command{ Use: "upgrade [[stack] [recipe] | --all] [flags]", Aliases: []string{"u"}, Short: "Upgrade apps", Long: `Upgrade an app by specifying stack name and recipe. Use "--all" to upgrade every deployed app. For each app with auto updates enabled, the deployed version is compared with the current recipe catalogue version. If a new patch/minor version is available, the app is upgraded. To include major versions use the "--major/-m" flag. You probably don't want that as it will break things. Only apps that are not deployed with "--chaos/-C" are upgraded, to update chaos deployments use the "--chaos/-C" flag. Use it with care.`, Args: cobra.RangeArgs(0, 2), // TODO(d1): complete stack/recipe // ValidArgsFunction: func( // cmd *cobra.Command, // args []string, // toComplete string) ([]string, cobra.ShellCompDirective) { // }, Run: func(cmd *cobra.Command, args []string) { cl, err := client.New("default") if err != nil { log.Fatal(err) } if !updateAll && len(args) != 2 { log.Fatal("missing arguments or --all/-a flag") } if !updateAll { stackName := args[0] recipeName := args[1] err = tryUpgrade(cl, stackName, recipeName) if err != nil { log.Fatal(err) } return } stacks, err := stack.GetStacks(cl) if err != nil { log.Fatal(err) } for _, stackInfo := range stacks { stackName := stackInfo.Name recipeName, err := getLabel(cl, stackName, "recipe") if err != nil { log.Fatal(err) } err = tryUpgrade(cl, stackName, recipeName) if err != nil { log.Fatal(err) } } }, } // getLabel reads docker labels from running services in the format of "coop-cloud.${STACK_NAME}.${LABEL}". func getLabel(cl *dockerclient.Client, stackName string, label string) (string, error) { filter := filters.NewArgs() filter.Add("label", fmt.Sprintf("%s=%s", convert.LabelNamespace, stackName)) services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter}) if err != nil { return "", err } for _, service := range services { labelKey := fmt.Sprintf("coop-cloud.%s.%s", stackName, label) if labelValue, ok := service.Spec.Labels[labelKey]; ok { return labelValue, nil } } log.Debugf("no %s label found for %s", label, stackName) return "", nil } // getBoolLabel reads a boolean docker label from running services func getBoolLabel(cl *dockerclient.Client, stackName string, label string) (bool, error) { lableValue, err := getLabel(cl, stackName, label) if err != nil { return false, err } if lableValue != "" { value, err := strconv.ParseBool(lableValue) if err != nil { return false, err } return value, nil } log.Debugf("boolean label %s could not be found for %s, set default to false.", label, stackName) return false, nil } // getEnv reads env variables from docker services. func getEnv(cl *dockerclient.Client, stackName string) (envfile.AppEnv, error) { envMap := make(map[string]string) filter := filters.NewArgs() filter.Add("label", fmt.Sprintf("%s=%s", convert.LabelNamespace, stackName)) services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter}) if err != nil { return nil, err } for _, service := range services { envList := service.Spec.TaskTemplate.ContainerSpec.Env for _, envString := range envList { splitString := strings.SplitN(envString, "=", 2) if len(splitString) != 2 { log.Debugf("can't separate key from value: %s (this variable is probably unset)", envString) continue } k := splitString[0] v := splitString[1] log.Debugf("for %s read env %s with value: %s from docker service", stackName, k, v) envMap[k] = v } } return envMap, nil } // getLatestUpgrade returns the latest available version for an app respecting // the "--major" flag if it is newer than the currently deployed version. func getLatestUpgrade(cl *dockerclient.Client, stackName string, recipeName string) (string, error) { deployedVersion, err := getDeployedVersion(cl, stackName, recipeName) if err != nil { return "", err } availableUpgrades, err := getAvailableUpgrades(cl, stackName, recipeName, deployedVersion) if err != nil { return "", err } if len(availableUpgrades) == 0 { log.Debugf("no available upgrades for %s", stackName) return "", nil } var chosenUpgrade string if len(availableUpgrades) > 0 { chosenUpgrade = availableUpgrades[len(availableUpgrades)-1] log.Infof("%s (%s) can be upgraded from version %s to %s", stackName, recipeName, deployedVersion, chosenUpgrade) } return chosenUpgrade, nil } // getDeployedVersion returns the currently deployed version of an app. func getDeployedVersion(cl *dockerclient.Client, stackName string, recipeName string) (string, error) { log.Debugf("retrieve deployed version whether %s is already deployed", stackName) deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName) if err != nil { return "", err } if !deployMeta.IsDeployed { return "", fmt.Errorf("%s is not deployed?", stackName) } if deployMeta.Version == "unknown" { return "", fmt.Errorf("failed to determine deployed version of %s", stackName) } return deployMeta.Version, nil } // getAvailableUpgrades returns all available versions of an app that are newer // than the deployed version. It only includes major upgrades if the "--major" // flag is set. func getAvailableUpgrades(cl *dockerclient.Client, stackName string, recipeName string, deployedVersion string) ([]string, error) { catl, err := recipe.ReadRecipeCatalogue(internal.Offline) if err != nil { return nil, err } versions, err := recipe.GetRecipeCatalogueVersions(recipeName, catl) if err != nil { return nil, err } if len(versions) == 0 { log.Warnf("no published releases for %s in the recipe catalogue?", recipeName) return nil, nil } var availableUpgrades []string for _, version := range versions { parsedDeployedVersion, err := tagcmp.Parse(deployedVersion) if err != nil { return nil, err } parsedVersion, err := tagcmp.Parse(version) if err != nil { return nil, err } versionDelta, err := parsedDeployedVersion.UpgradeDelta(parsedVersion) if err != nil { return nil, err } if 0 < versionDelta.UpgradeType() && (versionDelta.UpgradeType() < 4 || includeMajorUpdates) { availableUpgrades = append(availableUpgrades, version) } } log.Debugf("available updates for %s: %s", stackName, availableUpgrades) return availableUpgrades, nil } // processRecipeRepoVersion clones, pulls, checks out the version and lints the // recipe repository. func processRecipeRepoVersion(r recipe.Recipe, version string) error { if err := r.EnsureExists(); err != nil { return err } if err := r.EnsureUpToDate(); err != nil { return err } if _, err := r.EnsureVersion(version); err != nil { return err } if err := lint.LintForErrors(r); err != nil { return err } return nil } // mergeAbraShEnv merges abra.sh env vars into the app env vars. func mergeAbraShEnv(recipe recipe.Recipe, env envfile.AppEnv) error { abraShEnv, err := envfile.ReadAbraShEnvVars(recipe.AbraShPath) if err != nil { return err } for k, v := range abraShEnv { log.Debugf("read v:%s k: %s", v, k) env[k] = v } return nil } // createDeployConfig merges and enriches the compose config for the deployment. func createDeployConfig(r recipe.Recipe, stackName string, env envfile.AppEnv) (*composetypes.Config, stack.Deploy, error) { env["STACK_NAME"] = stackName deployOpts := stack.Deploy{ Namespace: stackName, Prune: false, ResolveImage: stack.ResolveImageAlways, Detach: false, } composeFiles, err := r.GetComposeFiles(env) if err != nil { return nil, deployOpts, err } deployOpts.Composefiles = composeFiles compose, err := appPkg.GetAppComposeConfig(stackName, deployOpts, env) if err != nil { return nil, deployOpts, err } appPkg.ExposeAllEnv(stackName, compose, env) // after the upgrade the deployment won't be in chaos state anymore appPkg.SetChaosLabel(compose, stackName, false) appPkg.SetRecipeLabel(compose, stackName, r.Name) appPkg.SetUpdateLabel(compose, stackName, env) return compose, deployOpts, nil } // tryUpgrade performs the upgrade if all the requirements are fulfilled. func tryUpgrade(cl *dockerclient.Client, stackName, recipeName string) error { if recipeName == "" { log.Debugf("don't update %s due to missing recipe name", stackName) return nil } chaos, err := getBoolLabel(cl, stackName, "chaos") if err != nil { return err } if chaos && !internal.Chaos { log.Debugf("don't update %s due to chaos deployment", stackName) return nil } updatesEnabled, err := getBoolLabel(cl, stackName, "autoupdate") if err != nil { return err } if !updatesEnabled { log.Debugf("don't update %s due to disabled auto updates or missing ENABLE_AUTO_UPDATE env", stackName) return nil } upgradeVersion, err := getLatestUpgrade(cl, stackName, recipeName) if err != nil { return err } if upgradeVersion == "" { log.Debugf("don't update %s due to no new version", stackName) return nil } err = upgrade(cl, stackName, recipeName, upgradeVersion) return err } // upgrade performs all necessary steps to upgrade an app. func upgrade(cl *dockerclient.Client, stackName, recipeName, upgradeVersion string) error { env, err := getEnv(cl, stackName) if err != nil { return err } app := appPkg.App{ Name: stackName, Recipe: recipe.Get(recipeName), Server: SERVER, Env: env, } r := recipe.Get(recipeName) if err = processRecipeRepoVersion(r, upgradeVersion); err != nil { return err } if err = mergeAbraShEnv(app.Recipe, app.Env); err != nil { return err } compose, deployOpts, err := createDeployConfig(r, stackName, app.Env) if err != nil { return err } log.Infof("upgrade %s (%s) to version %s", stackName, recipeName, upgradeVersion) err = stack.RunDeploy(cl, deployOpts, compose, stackName, true) return err } func newKadabraApp(version, commit string) *cobra.Command { rootCmd := &cobra.Command{ Use: "kadabra [cmd] [flags]", Version: fmt.Sprintf("%s-%s", version, commit[:7]), Short: "The Co-op Cloud auto-updater 🤖 🚀", PersistentPreRun: func(cmd *cobra.Command, args []string) { log.Logger.SetStyles(charmLog.DefaultStyles()) charmLog.SetDefault(log.Logger) if internal.Debug { log.SetLevel(log.DebugLevel) log.SetOutput(os.Stderr) log.SetReportCaller(true) } log.Debugf("kadabra version %s, commit %s", version, commit) }, } rootCmd.PersistentFlags().BoolVarP( &internal.Debug, "debug", "d", false, "show debug messages", ) rootCmd.PersistentFlags().BoolVarP( &internal.NoInput, "no-input", "n", false, "toggle non-interactive mode", ) rootCmd.AddCommand( NotifyCommand, UpgradeCommand, ) return rootCmd } // RunApp runs CLI abra app. func RunApp(version, commit string) { app := newKadabraApp(version, commit) if err := app.Execute(); err != nil { log.Fatal(err) } } var ( includeMajorUpdates bool updateAll bool ) func init() { NotifyCommand.Flags().BoolVarP( &includeMajorUpdates, "major", "m", false, "check for major updates", ) UpgradeCommand.Flags().BoolVarP( &internal.Chaos, "chaos", "C", false, "ignore uncommitted recipes changes", ) UpgradeCommand.Flags().BoolVarP( &includeMajorUpdates, "major", "m", false, "check for major updates", ) UpgradeCommand.Flags().BoolVarP( &updateAll, "all", "a", false, "update all deployed apps", ) }