diff --git a/cli/updater/updater.go b/cli/updater/updater.go new file mode 100644 index 0000000..052cc86 --- /dev/null +++ b/cli/updater/updater.go @@ -0,0 +1,558 @@ +package updater + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + "strings" + + "coopcloud.tech/abra/cli/internal" + appPkg "coopcloud.tech/abra/pkg/app" + "coopcloud.tech/abra/pkg/client" + "coopcloud.tech/abra/pkg/deploy" + "coopcloud.tech/abra/pkg/envfile" + "coopcloud.tech/abra/pkg/i18n" + "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" + +// translators: `kadabra notify` aliases. use a comma separated list of aliases +// with no spaces in between +var notifyAliases = i18n.G("n") + +// NotifyCommand checks for available upgrades. +var NotifyCommand = &cobra.Command{ + // translators: `notify` command + Use: i18n.G("notify [flags]"), + Aliases: strings.Split(notifyAliases, ","), + // translators: Short description for `notify` command + Short: i18n.G("Check for available upgrades"), + Long: i18n.G(`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) + } + } + } + }, +} + +// translators: `kadabra upgrade` aliases. use a comma separated list of aliases with +// no spaces in between +var upgradeAliases = i18n.G("u") + +// UpgradeCommand upgrades apps. +var UpgradeCommand = &cobra.Command{ + // translators: `app upgrade` command + Use: i18n.G("upgrade [[stack] [recipe] | --all] [flags]"), + Aliases: strings.Split(upgradeAliases, ","), + // translators: Short description for `app upgrade` command + Short: i18n.G("Upgrade apps"), + Long: i18n.G(`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(i18n.G("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.Debug(i18n.G("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.Debug(i18n.G("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.Debug(i18n.G("can't separate key from value: %s (this variable is probably unset)", envString)) + continue + } + k := splitString[0] + v := splitString[1] + log.Debugf(i18n.G("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(i18n.G("no available upgrades for %s", stackName)) + return "", nil + } + + var chosenUpgrade string + if len(availableUpgrades) > 0 { + chosenUpgrade = availableUpgrades[len(availableUpgrades)-1] + log.Info(i18n.G("%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.Debug(i18n.G("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 "", errors.New(i18n.G("%s is not deployed?", stackName)) + } + + if deployMeta.Version == "unknown" { + return "", errors.New(i18n.G("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.Warn(i18n.G("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.Debug(i18n.G("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 +} + +// 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.Debug(i18n.G("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.Debug(i18n.G("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.Debug(i18n.G("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.Debug(i18n.G("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 = deploy.MergeAbraShEnv(app.Recipe, app.Env); err != nil { + return err + } + + compose, deployOpts, err := createDeployConfig(r, stackName, app.Env) + if err != nil { + return err + } + + log.Info(i18n.G("upgrade %s (%s) to version %s", stackName, recipeName, upgradeVersion)) + + serviceNames, err := appPkg.GetAppServiceNames(app.Name) + if err != nil { + return err + } + + f, err := app.Filters(true, false, serviceNames...) + if err != nil { + return err + } + + err = stack.RunDeploy( + cl, + deployOpts, + compose, + stackName, + app.Server, + true, + f, + ) + + return err +} + +func newKadabraApp(version, commit string) *cobra.Command { + rootCmd := &cobra.Command{ + // translators: `kadabra` binary name + Use: i18n.G("kadabra [cmd] [flags]"), + Version: fmt.Sprintf("%s-%s", version, commit[:7]), + // translators: Short description for `kababra` binary + Short: i18n.G("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.Debug(i18n.G("kadabra version %s, commit %s", version, commit)) + }, + } + + rootCmd.PersistentFlags().BoolVarP( + &internal.Debug, + i18n.G("debug"), + i18n.G("d"), + false, + i18n.G("show debug messages"), + ) + + rootCmd.PersistentFlags().BoolVarP( + &internal.NoInput, + i18n.G("no-input"), + i18n.G("n"), + false, + i18n.G("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, + i18n.G("chaos"), + i18n.G("C"), + false, + i18n.G("ignore uncommitted recipes changes"), + ) + + UpgradeCommand.Flags().BoolVarP( + &includeMajorUpdates, + i18n.G("major"), + i18n.G("m"), + false, + i18n.G("check for major updates"), + ) + + UpgradeCommand.Flags().BoolVarP( + &updateAll, + i18n.G("all"), + i18n.GC("a", "abra upgrade"), + false, + i18n.G("update all deployed apps"), + ) +} diff --git a/cmd/kadabra/main.go b/cmd/kadabra/main.go new file mode 100644 index 0000000..18e2224 --- /dev/null +++ b/cmd/kadabra/main.go @@ -0,0 +1,23 @@ +// Package main provides the command-line entrypoint. +package main + +import ( + "coopcloud.tech/abra/cli/updater" +) + +// Version is the current version of Kadabra. +var Version string + +// Commit is the current git commit of Kadabra. +var Commit string + +func main() { + if Version == "" { + Version = "dev" + } + if Commit == "" { + Commit = " " + } + + updater.RunApp(Version, Commit) +}