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