package updater import ( "context" "fmt" "os" "strconv" "strings" "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/config" "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" 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/docker/cli/cli/command/stack/swarm" "github.com/sirupsen/logrus" "github.com/urfave/cli" ) var majorUpdate bool var majorFlag = &cli.BoolFlag{ Name: "major, m", Usage: "Also check for major updates", Destination: &majorUpdate, } // Check for available upgrades var Notify = cli.Command{ Name: "notify", Aliases: []string{"n"}, Usage: "Check for available upgrades", Flags: []cli.Flag{ internal.DebugFlag, majorFlag, }, Before: internal.SubCommandBefore, Description: `Check for available upgrades`, Action: func(c *cli.Context) error { cl, err := client.New("default") if err != nil { logrus.Fatal(err) } // can't import this lib: // stacks := swarm.GetStacks(cl) stacks, err := stack.GetStacks(cl) if err != nil { logrus.Fatal(err) } for _, stackInfo := range stacks { stackName := stackInfo.Name recipeName := getLabel(cl, stackName, "recipe") if recipeName != "" { getLatestUpgrade(cl, stackName, recipeName) } } return nil }, } // Upgrade a specific app var UpgradeApp = cli.Command{ Name: "appupgrade", Aliases: []string{"a"}, Usage: "Upgrade an app", ArgsUsage: " ", Flags: []cli.Flag{ internal.DebugFlag, internal.ForceFlag, majorFlag, }, Before: internal.SubCommandBefore, Description: `Upgrade an app`, Action: func(c *cli.Context) error { stackName := c.Args().Get(0) recipeName := c.Args().Get(1) cl, err := client.New("default") if err != nil { logrus.Fatal(err) } upgradeVersion := getLatestUpgrade(cl, stackName, recipeName) if upgradeVersion != "" { upgrade(cl, stackName, recipeName, upgradeVersion) } return nil }, } // Upgrade all apps var UpgradeAll = cli.Command{ Name: "upgrade", Aliases: []string{"u"}, Usage: "Upgrade all apps", Flags: []cli.Flag{ internal.DebugFlag, internal.ForceFlag, majorFlag, }, Before: internal.SubCommandBefore, Description: `Upgrade all deployed apps`, Action: func(c *cli.Context) error { cl, err := client.New("default") if err != nil { logrus.Fatal(err) } // can't import this lib: // stacks := swarm.GetStacks(cl) stacks, err := stack.GetStacks(cl) if err != nil { logrus.Fatal(err) } for _, stackInfo := range stacks { stackName := stackInfo.Name recipeName := getLabel(cl, stackName, "recipe") chaos := getBoolLabel(cl, stackName, "chaos") updatesEnabled := getBoolLabel(cl, stackName, "autoupdate") if recipeName != "" && updatesEnabled && (!chaos || internal.Force) { upgradeVersion := getLatestUpgrade(cl, stackName, recipeName) if upgradeVersion != "" { upgrade(cl, stackName, recipeName, upgradeVersion) } } else { logrus.Debugf("Don't update %s due to missing recipe name, disabled updates or chaos deployment", stackName) } } return nil }, } // Read docker label in the format coop-cloud.${STACK_NAME}.${LABEL} func getLabel(cl *dockerclient.Client, stackName string, label 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 { logrus.Fatal(err) } for _, service := range services { labelKey := fmt.Sprintf("coop-cloud.%s.%s", stackName, label) if labelValue, ok := service.Spec.Labels[labelKey]; ok { return labelValue } } logrus.Debugf("no %s label found for %s", label, stackName) return "" } // Read boolean docker label func getBoolLabel(cl *dockerclient.Client, stackName string, label string) bool { lableValue := getLabel(cl, stackName, label) if lableValue != "" { value, err := strconv.ParseBool(lableValue) if err != nil { logrus.Fatal(err) } return value } return false } // Read Env variables from docker services func getEnv(cl *dockerclient.Client, stackName string) config.AppEnv { 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 { logrus.Fatal(err) } for _, service := range services { envList := service.Spec.TaskTemplate.ContainerSpec.Env for _, envString := range envList { splitString := strings.SplitN(envString, "=", 2) if len(splitString) != 2 { logrus.Debugf("can't separate key from value: %s (this variable is probably unset)", envString) continue } k := splitString[0] v := splitString[1] logrus.Debugf("For %s read env %s with value: %s from docker service", stackName, k, v) envMap[k] = v } } return envMap } func getLatestUpgrade(cl *dockerclient.Client, stackName string, recipeName string) string { deployedVersion := getDeployedVersion(cl, stackName, recipeName) availableUpgrades := getAvailableUpgrades(cl, stackName, recipeName, deployedVersion) if len(availableUpgrades) == 0 { logrus.Debugf("no available upgrades for %s", stackName) return "" } // Uncomment to select the next version instead of the last version // availableUpgrades = internal.ReverseStringList(availableUpgrades) var chosenUpgrade string if len(availableUpgrades) > 0 { chosenUpgrade = availableUpgrades[len(availableUpgrades)-1] logrus.Infof("%s (%s) can be upgraded from version %s to %s", stackName, recipeName, deployedVersion, chosenUpgrade) } return chosenUpgrade } func getDeployedVersion(cl *dockerclient.Client, stackName string, recipeName string) string { logrus.Debugf("Retrieve deployed version whether %s is already deployed", stackName) isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName) if err != nil { logrus.Fatal(err) } if !isDeployed { logrus.Fatalf("%s is not deployed?", stackName) } if deployedVersion == "unknown" { logrus.Fatalf("failed to determine deployed version of %s", stackName) } return deployedVersion } func getAvailableUpgrades(cl *dockerclient.Client, stackName string, recipeName string, deployedVersion string) []string { catl, err := recipe.ReadRecipeCatalogue() if err != nil { logrus.Fatal(err) } versions, err := recipe.GetRecipeCatalogueVersions(recipeName, catl) if err != nil { logrus.Fatal(err) } if len(versions) == 0 { logrus.Fatalf("no published releases for %s in the recipe catalogue?", recipeName) } var availableUpgrades []string for _, version := range versions { parsedDeployedVersion, err := tagcmp.Parse(deployedVersion) if err != nil { logrus.Fatal(err) } parsedVersion, err := tagcmp.Parse(version) if err != nil { logrus.Fatal(err) } versionDelta, err := parsedDeployedVersion.UpgradeDelta(parsedVersion) if err != nil { logrus.Fatal(err) } if 0 < versionDelta.UpgradeType() && (versionDelta.UpgradeType() < 4 || majorUpdate) { availableUpgrades = append(availableUpgrades, version) } } logrus.Debugf("Available updates for %s: %s", stackName, availableUpgrades) return availableUpgrades } // clone, pull, checkout version and lint the recipe repository func processRecipeRepoVersion(recipeName string, version string) { if err := recipe.EnsureExists(recipeName); err != nil { logrus.Fatal(err) } if err := recipe.EnsureUpToDate(recipeName); err != nil { logrus.Fatal(err) } if err := recipe.EnsureVersion(recipeName, version); err != nil { logrus.Fatal(err) } if r, err := recipe.Get(recipeName); err != nil { logrus.Fatal(err) } else if err := lint.LintForErrors(r); err != nil { logrus.Fatal(err) } } // merge abra.sh env's into app env's func mergeAbraShEnv(recipeName string, env config.AppEnv) { abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, recipeName, "abra.sh") abraShEnv, err := config.ReadAbraShEnvVars(abraShPath) if err != nil { logrus.Fatal(err) } for k, v := range abraShEnv { logrus.Debugf("read v:%s k: %s", v, k) env[k] = v } } func createDeployConfig(recipeName string, stackName string, env config.AppEnv) (*composetypes.Config, stack.Deploy) { // Workaround, is there a better way? env["STACK_NAME"] = stackName composeFiles, err := config.GetAppComposeFiles(recipeName, env) if err != nil { logrus.Fatal(err) } deployOpts := stack.Deploy{ Composefiles: composeFiles, Namespace: stackName, Prune: false, ResolveImage: stack.ResolveImageAlways, } compose, err := config.GetAppComposeConfig(stackName, deployOpts, env) if err != nil { logrus.Fatal(err) } config.ExposeAllEnv(stackName, compose, env) // after the upgrade the deployment won't be in chaos state anymore config.SetChaosLabel(compose, stackName, false) config.SetRecipeLabel(compose, stackName, recipeName) config.SetUpdateLabel(compose, stackName, env) return compose, deployOpts } func upgrade(cl *dockerclient.Client, stackName string, recipeName string, upgradeVersion string) { app := config.App{ Name: stackName, Recipe: recipeName, Server: "localhost", Env: getEnv(cl, stackName), } processRecipeRepoVersion(recipeName, upgradeVersion) mergeAbraShEnv(recipeName, app.Env) compose, deployOpts := createDeployConfig(recipeName, stackName, app.Env) logrus.Infof("Upgrade %s (%s) to version %s", stackName, recipeName, upgradeVersion) if err := stack.RunDeploy(cl, deployOpts, compose, stackName, true); err != nil { logrus.Fatal(err) } } func newAbraApp(version, commit string) *cli.App { app := &cli.App{ Name: "kadabra", Usage: `The Co-op Cloud autoupdater ____ ____ _ _ / ___|___ ___ _ __ / ___| | ___ _ _ __| | | | / _ \ _____ / _ \| '_ \ | | | |/ _ \| | | |/ _' | | |__| (_) |_____| (_) | |_) | | |___| | (_) | |_| | (_| | \____\___/ \___/| .__/ \____|_|\___/ \__,_|\__,_| |_| `, Version: fmt.Sprintf("%s-%s", version, commit[:7]), Commands: []cli.Command{ Notify, UpgradeApp, UpgradeAll, }, } app.Before = func(c *cli.Context) error { logrus.Debugf("abra version %s, commit %s", version, commit) return nil } return app } // RunApp runs CLI abra app. func RunApp(version, commit string) { app := newAbraApp(version, commit) if err := app.Run(os.Args); err != nil { logrus.Fatal(err) } }