diff --git a/.gitignore b/.gitignore index e54a8a4b..7c362554 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .envrc .vscode/ abra +kadabra dist/ tests/integration/.abra/catalogue vendor/ diff --git a/Makefile b/Makefile index 731b3c1f..027ce51a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ ABRA := ./cmd/abra +KADABRA := ./cmd/kadabra COMMIT := $(shell git rev-list -1 HEAD) GOPATH := $(shell go env GOPATH) LDFLAGS := "-X 'main.Commit=$(COMMIT)'" @@ -18,9 +19,11 @@ build-dev: build: @go build -ldflags=$(DIST_LDFLAGS) $(ABRA) + @go build -ldflags=$(DIST_LDFLAGS) $(KADABRA) clean: @rm '$(GOPATH)/bin/abra' + @rm '$(GOPATH)/bin/kadabra' format: @gofmt -s -w . diff --git a/cli/updater/updater.go b/cli/updater/updater.go new file mode 100644 index 00000000..b51e2ff8 --- /dev/null +++ b/cli/updater/updater.go @@ -0,0 +1,277 @@ +package updater + +import ( + "context" + "fmt" + "os" + "strings" + + "coopcloud.tech/abra/cli/internal" + "coopcloud.tech/abra/pkg/client" + "coopcloud.tech/abra/pkg/config" + "coopcloud.tech/abra/pkg/recipe" + "coopcloud.tech/abra/pkg/upstream/convert" + "coopcloud.tech/abra/pkg/upstream/stack" + "coopcloud.tech/tagcmp" + "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" +) + +// 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, + internal.DontWaitConvergeFlag, + }, + 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) + } + upgrade(cl, stackName, recipeName) + 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, + internal.DontWaitConvergeFlag, + }, + 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) + } + // stacks := swarm.GetStacks(cl) + stacks, err := stack.GetStacks(cl) + if err != nil { + logrus.Fatal(err) + } + for _, stackInfo := range stacks { + stackName := stackInfo.Name + recipeName := getRecipe(cl, stackName) + // TODO: read chaos from docker label + if recipeName != "" { + logrus.Debugf("RecipeName: %s", recipeName) + upgrade(cl, stackName, recipeName) + } + } + return nil + }, +} + +// Read recipe from docker label +func getRecipe(cl *dockerclient.Client, stackName 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.recipe", stackName) + if recipeName, ok := service.Spec.Labels[labelKey]; ok { + return recipeName + } + } + logrus.Debugf("no recipe name found for %s", stackName) + return "" +} + +// 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("Env Key: %s Value: %s", k, v) + envMap[k] = v + } + } + return envMap +} + +func upgrade(cl *dockerclient.Client, stackName string, recipeName string) { + logrus.Debugf("Upgrade StackName: %s \n Recipe: %s", stackName, recipeName) + app := config.App{ + Name: stackName, + Recipe: recipeName, + Server: "localhost", + Env: getEnv(cl, stackName), + } + // Workaround, is there a better way? + app.Env["STACK_NAME"] = stackName + // TODO: read COMPOSE_FILE from docker label + // TODO: evaluate ENABLE_AUTO_UPDATE env var + + 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) + } + + 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 + if deployedVersion == "unknown" { + availableUpgrades = versions + logrus.Warnf("failed to determine version of deployed %s", stackName) + } + + if deployedVersion != "unknown" { + 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) + } + // Only update Patch/Minor updates + if 0 < versionDelta.UpgradeType() && versionDelta.UpgradeType() < 4 { + availableUpgrades = append(availableUpgrades, version) + } + } + + if len(availableUpgrades) == 0 { + logrus.Fatalf("no available upgrades, you're on latest (%s) ✌️", deployedVersion) + } + } + + availableUpgrades = internal.ReverseStringList(availableUpgrades) + + var chosenUpgrade string + if len(availableUpgrades) > 0 { + chosenUpgrade = availableUpgrades[len(availableUpgrades)-1] + logrus.Debugf("choosing %s as version to upgrade to", chosenUpgrade) + } + + if err := recipe.EnsureExists(recipeName); err != nil { + logrus.Fatal(err) + } + if err := recipe.EnsureVersion(recipeName, chosenUpgrade); err != nil { + logrus.Fatal(err) + } + + 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) + app.Env[k] = v + } + + composeFiles, err := config.GetAppComposeFiles(recipeName, app.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, app.Env) + if err != nil { + logrus.Fatal(err) + } + + 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 update daemon + ____ ____ _ _ + / ___|___ ___ _ __ / ___| | ___ _ _ __| | + | | / _ \ _____ / _ \| '_ \ | | | |/ _ \| | | |/ _' | + | |__| (_) |_____| (_) | |_) | | |___| | (_) | |_| | (_| | + \____\___/ \___/| .__/ \____|_|\___/ \__,_|\__,_| + |_| +`, + Version: fmt.Sprintf("%s-%s", version, commit[:7]), + Commands: []cli.Command{ + 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) + } +} diff --git a/pkg/upstream/stack/stack.go b/pkg/upstream/stack/stack.go index 4e766b34..4355462b 100644 --- a/pkg/upstream/stack/stack.go +++ b/pkg/upstream/stack/stack.go @@ -10,6 +10,7 @@ import ( "coopcloud.tech/abra/pkg/upstream/convert" "github.com/docker/cli/cli/command/service/progress" + "github.com/docker/cli/cli/command/stack/formatter" composetypes "github.com/docker/cli/cli/compose/types" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" @@ -484,3 +485,37 @@ If a service is failing to even start, try smoke out the error with: `, appName, timeout, appName, appName, appName)) } } + +// FIXME: Copypasta from https://github.com/docker/cli/blob/master/cli/command/stack/swarm/list.go because I could't import "github.com/docker/cli/cli/command/stack/swarm" +// GetStacks lists the swarm stacks. +func GetStacks(cl *dockerclient.Client) ([]*formatter.Stack, error) { + services, err := cl.ServiceList( + context.Background(), + types.ServiceListOptions{Filters: getAllStacksFilter()}) + if err != nil { + return nil, err + } + m := make(map[string]*formatter.Stack) + for _, service := range services { + labels := service.Spec.Labels + name, ok := labels[convert.LabelNamespace] + if !ok { + return nil, errors.Errorf("cannot get label %s for service %s", + convert.LabelNamespace, service.ID) + } + ztack, ok := m[name] + if !ok { + m[name] = &formatter.Stack{ + Name: name, + Services: 1, + } + } else { + ztack.Services++ + } + } + var stacks []*formatter.Stack + for _, stack := range m { + stacks = append(stacks, stack) + } + return stacks, nil +}