From ac86912eaddcb0da799de4d54d4cd1ae5bd18199 Mon Sep 17 00:00:00 2001
From: decentral1se <lukewm@riseup.net>
Date: Wed, 1 Sep 2021 13:08:42 +0200
Subject: [PATCH] WIP chaos integrate deploy/deploy_composefile

The best in copy/pasta technology.

See https://github.com/docker/cli/tree/master/cli/command/stack/swarm
for more.
---
 cli/app/undeploy.go               |   4 +-
 client/{swarm => stack}/remove.go |   2 +-
 client/stack/stack.go             | 327 +++++++++++++++++++++++++++++-
 client/swarm/common.go            |  50 -----
 4 files changed, 326 insertions(+), 57 deletions(-)
 rename client/{swarm => stack}/remove.go (99%)
 delete mode 100644 client/swarm/common.go

diff --git a/cli/app/undeploy.go b/cli/app/undeploy.go
index 0a18ccacc..65e27005e 100644
--- a/cli/app/undeploy.go
+++ b/cli/app/undeploy.go
@@ -6,7 +6,7 @@ import (
 
 	"coopcloud.tech/abra/cli/internal"
 	"coopcloud.tech/abra/client"
-	"coopcloud.tech/abra/client/swarm"
+	stack "coopcloud.tech/abra/client/stack"
 	"coopcloud.tech/abra/config"
 	"github.com/docker/cli/cli/command/stack/options"
 	"github.com/sirupsen/logrus"
@@ -40,7 +40,7 @@ var appUndeployCommand = &cli.Command{
 		}
 
 		rmOpts := options.Remove{Namespaces: []string{appEnv.StackName()}}
-		if err := swarm.RunRemove(ctx, cl, rmOpts); err != nil {
+		if err := stack.RunRemove(ctx, cl, rmOpts); err != nil {
 			logrus.Fatal(err)
 		}
 
diff --git a/client/swarm/remove.go b/client/stack/remove.go
similarity index 99%
rename from client/swarm/remove.go
rename to client/stack/remove.go
index 6a0b3fd87..6d36e9b64 100644
--- a/client/swarm/remove.go
+++ b/client/stack/remove.go
@@ -1,4 +1,4 @@
-package swarm
+package stack
 
 import (
 	"context"
diff --git a/client/stack/stack.go b/client/stack/stack.go
index c959b40bc..68a17c191 100644
--- a/client/stack/stack.go
+++ b/client/stack/stack.go
@@ -2,18 +2,25 @@ package stack
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"strings"
 	"unicode"
 
 	abraClient "coopcloud.tech/abra/client"
-	"coopcloud.tech/abra/client/convert"
+	"github.com/docker/cli/cli/command/stack/options"
+	"github.com/docker/cli/cli/compose/convert"
+	composetypes "github.com/docker/cli/cli/compose/types"
 	"github.com/docker/cli/opts"
 	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/container"
 	"github.com/docker/docker/api/types/filters"
 	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/api/types/versions"
 	"github.com/docker/docker/client"
+	apiclient "github.com/docker/docker/client"
+	dockerclient "github.com/docker/docker/client"
+	"github.com/pkg/errors"
+	"github.com/sirupsen/logrus"
 )
 
 type StackStatus struct {
@@ -103,6 +110,17 @@ func checkDaemonIsSwarmManager(contextName string) error {
 	return nil
 }
 
+func checkDaemonIsSwarmManagerViaClient(ctx context.Context, cl *apiclient.Client) error {
+	info, err := cl.Info(ctx)
+	if err != nil {
+		return err
+	}
+	if !info.Swarm.ControlAvailable {
+		return errors.New("this node is not a swarm manager. Use \"docker swarm init\" or \"docker swarm join\" to connect this node to swarm and try again")
+	}
+	return nil
+}
+
 // validateStackName checks if the provided string is a valid stack name (namespace).
 // It currently only does a rudimentary check if the string is empty, or consists
 // of only whitespace and quoting characters.
@@ -121,8 +139,309 @@ func quotesOrWhitespace(r rune) bool {
 func DeployStack(namespace string) {
 }
 
-// TODO: Prune services from stack
+// pruneServices removes services that are no longer referenced in the source
+func pruneServices(ctx context.Context, cl *apiclient.Client, namespace convert.Namespace, services map[string]struct{}) {
+	oldServices, err := getStackServices(ctx, cl, namespace.Name())
+	if err != nil {
+		logrus.Infof("Failed to list services: %s\n", err)
+	}
 
-func pruneServices() error {
+	pruneServices := []swarm.Service{}
+	for _, service := range oldServices {
+		if _, exists := services[namespace.Descope(service.Spec.Name)]; !exists {
+			pruneServices = append(pruneServices, service)
+		}
+	}
+	removeServices(ctx, cl, pruneServices)
+}
+
+// Resolve image constants
+const (
+	defaultNetworkDriver = "overlay"
+	ResolveImageAlways   = "always"
+	ResolveImageChanged  = "changed"
+	ResolveImageNever    = "never"
+)
+
+// RunDeploy is the swarm implementation of docker stack deploy
+func RunDeploy(cl *apiclient.Client, opts options.Deploy, cfg *composetypes.Config) error {
+	ctx := context.Background()
+
+	if err := validateResolveImageFlag(&opts); err != nil {
+		return err
+	}
+	// client side image resolution should not be done when the supported
+	// server version is older than 1.30
+	if versions.LessThan(cl.ClientVersion(), "1.30") {
+		opts.ResolveImage = ResolveImageNever
+	}
+
+	return deployCompose(ctx, cl, opts, cfg)
+}
+
+// validateResolveImageFlag validates the opts.resolveImage command line option
+func validateResolveImageFlag(opts *options.Deploy) error {
+	switch opts.ResolveImage {
+	case ResolveImageAlways, ResolveImageChanged, ResolveImageNever:
+		return nil
+	default:
+		return errors.Errorf("Invalid option %s for flag --resolve-image", opts.ResolveImage)
+	}
+}
+
+func deployCompose(ctx context.Context, cl *apiclient.Client, opts options.Deploy, config *composetypes.Config) error {
+	if err := checkDaemonIsSwarmManagerViaClient(ctx, cl); err != nil {
+		return err
+	}
+
+	namespace := convert.NewNamespace(opts.Namespace)
+
+	if opts.Prune {
+		services := map[string]struct{}{}
+		for _, service := range config.Services {
+			services[service.Name] = struct{}{}
+		}
+		pruneServices(ctx, cl, namespace, services)
+	}
+
+	serviceNetworks := getServicesDeclaredNetworks(config.Services)
+	networks, externalNetworks := convert.Networks(namespace, config.Networks, serviceNetworks)
+	if err := validateExternalNetworks(ctx, cl, externalNetworks); err != nil {
+		return err
+	}
+	if err := createNetworks(ctx, cl, namespace, networks); err != nil {
+		return err
+	}
+
+	secrets, err := convert.Secrets(namespace, config.Secrets)
+	if err != nil {
+		return err
+	}
+	if err := createSecrets(ctx, cl, secrets); err != nil {
+		return err
+	}
+
+	configs, err := convert.Configs(namespace, config.Configs)
+	if err != nil {
+		return err
+	}
+	if err := createConfigs(ctx, cl, configs); err != nil {
+		return err
+	}
+
+	services, err := convert.Services(namespace, config, cl)
+	if err != nil {
+		return err
+	}
+	return deployServices(ctx, cl, services, namespace, opts.SendRegistryAuth, opts.ResolveImage)
+}
+
+func getServicesDeclaredNetworks(serviceConfigs []composetypes.ServiceConfig) map[string]struct{} {
+	serviceNetworks := map[string]struct{}{}
+	for _, serviceConfig := range serviceConfigs {
+		if len(serviceConfig.Networks) == 0 {
+			serviceNetworks["default"] = struct{}{}
+			continue
+		}
+		for network := range serviceConfig.Networks {
+			serviceNetworks[network] = struct{}{}
+		}
+	}
+	return serviceNetworks
+}
+
+func validateExternalNetworks(ctx context.Context, client dockerclient.NetworkAPIClient, externalNetworks []string) error {
+	for _, networkName := range externalNetworks {
+		if !container.NetworkMode(networkName).IsUserDefined() {
+			// Networks that are not user defined always exist on all nodes as
+			// local-scoped networks, so there's no need to inspect them.
+			continue
+		}
+		network, err := client.NetworkInspect(ctx, networkName, types.NetworkInspectOptions{})
+		switch {
+		case dockerclient.IsErrNotFound(err):
+			return errors.Errorf("network %q is declared as external, but could not be found. You need to create a swarm-scoped network before the stack is deployed", networkName)
+		case err != nil:
+			return err
+		case network.Scope != "swarm":
+			return errors.Errorf("network %q is declared as external, but it is not in the right scope: %q instead of \"swarm\"", networkName, network.Scope)
+		}
+	}
 	return nil
 }
+
+func createSecrets(ctx context.Context, cl *apiclient.Client, secrets []swarm.SecretSpec) error {
+	for _, secretSpec := range secrets {
+		secret, _, err := cl.SecretInspectWithRaw(ctx, secretSpec.Name)
+		switch {
+		case err == nil:
+			// secret already exists, then we update that
+			if err := cl.SecretUpdate(ctx, secret.ID, secret.Meta.Version, secretSpec); err != nil {
+				return errors.Wrapf(err, "failed to update secret %s", secretSpec.Name)
+			}
+		case apiclient.IsErrNotFound(err):
+			// secret does not exist, then we create a new one.
+			logrus.Infof("Creating secret %s\n", secretSpec.Name)
+			if _, err := cl.SecretCreate(ctx, secretSpec); err != nil {
+				return errors.Wrapf(err, "failed to create secret %s", secretSpec.Name)
+			}
+		default:
+			return err
+		}
+	}
+	return nil
+}
+
+func createConfigs(ctx context.Context, cl *apiclient.Client, configs []swarm.ConfigSpec) error {
+	for _, configSpec := range configs {
+		config, _, err := cl.ConfigInspectWithRaw(ctx, configSpec.Name)
+		switch {
+		case err == nil:
+			// config already exists, then we update that
+			if err := cl.ConfigUpdate(ctx, config.ID, config.Meta.Version, configSpec); err != nil {
+				return errors.Wrapf(err, "failed to update config %s", configSpec.Name)
+			}
+		case apiclient.IsErrNotFound(err):
+			// config does not exist, then we create a new one.
+			logrus.Infof("Creating config %s\n", configSpec.Name)
+			if _, err := cl.ConfigCreate(ctx, configSpec); err != nil {
+				return errors.Wrapf(err, "failed to create config %s", configSpec.Name)
+			}
+		default:
+			return err
+		}
+	}
+	return nil
+}
+
+func createNetworks(ctx context.Context, cl *apiclient.Client, namespace convert.Namespace, networks map[string]types.NetworkCreate) error {
+	existingNetworks, err := getStackNetworks(ctx, cl, namespace.Name())
+	if err != nil {
+		return err
+	}
+
+	existingNetworkMap := make(map[string]types.NetworkResource)
+	for _, network := range existingNetworks {
+		existingNetworkMap[network.Name] = network
+	}
+
+	for name, createOpts := range networks {
+		if _, exists := existingNetworkMap[name]; exists {
+			continue
+		}
+
+		if createOpts.Driver == "" {
+			createOpts.Driver = defaultNetworkDriver
+		}
+
+		logrus.Infof("Creating network %s\n", name)
+		if _, err := cl.NetworkCreate(ctx, name, createOpts); err != nil {
+			return errors.Wrapf(err, "failed to create network %s", name)
+		}
+	}
+	return nil
+}
+
+// nolint: gocyclo
+func deployServices(ctx context.Context, cl *apiclient.Client, services map[string]swarm.ServiceSpec, namespace convert.Namespace, sendAuth bool, resolveImage string) error {
+	existingServices, err := getStackServices(ctx, cl, namespace.Name())
+	if err != nil {
+		return err
+	}
+
+	existingServiceMap := make(map[string]swarm.Service)
+	for _, service := range existingServices {
+		existingServiceMap[service.Spec.Name] = service
+	}
+
+	for internalName, serviceSpec := range services {
+		var (
+			name        = namespace.Scope(internalName)
+			image       = serviceSpec.TaskTemplate.ContainerSpec.Image
+			encodedAuth string
+		)
+
+		// FIXME: disable for now as not sure how to avoid having a `dockerCli`
+		// instance here and would rather not copy/pasta that entire module in
+		// right now for something that we don't even support right now. Will skip
+		// this for now.
+		if sendAuth {
+			// Retrieve encoded auth token from the image reference
+			// encodedAuth, err = command.RetrieveAuthTokenFromImage(ctx, dockerCli, image)
+			// if err != nil {
+			// 	return err
+			// }
+		}
+
+		if service, exists := existingServiceMap[name]; exists {
+			logrus.Infof("Updating service %s (id: %s)\n", name, service.ID)
+
+			updateOpts := types.ServiceUpdateOptions{EncodedRegistryAuth: encodedAuth}
+
+			switch resolveImage {
+			case ResolveImageAlways:
+				// image should be updated by the server using QueryRegistry
+				updateOpts.QueryRegistry = true
+			case ResolveImageChanged:
+				if image != service.Spec.Labels[convert.LabelImage] {
+					// Query the registry to resolve digest for the updated image
+					updateOpts.QueryRegistry = true
+				} else {
+					// image has not changed; update the serviceSpec with the
+					// existing information that was set by QueryRegistry on the
+					// previous deploy. Otherwise this will trigger an incorrect
+					// service update.
+					serviceSpec.TaskTemplate.ContainerSpec.Image = service.Spec.TaskTemplate.ContainerSpec.Image
+				}
+			default:
+				if image == service.Spec.Labels[convert.LabelImage] {
+					// image has not changed; update the serviceSpec with the
+					// existing information that was set by QueryRegistry on the
+					// previous deploy. Otherwise this will trigger an incorrect
+					// service update.
+					serviceSpec.TaskTemplate.ContainerSpec.Image = service.Spec.TaskTemplate.ContainerSpec.Image
+				}
+			}
+
+			// Stack deploy does not have a `--force` option. Preserve existing
+			// ForceUpdate value so that tasks are not re-deployed if not updated.
+			// TODO move this to API client?
+			serviceSpec.TaskTemplate.ForceUpdate = service.Spec.TaskTemplate.ForceUpdate
+
+			response, err := cl.ServiceUpdate(ctx, service.ID, service.Version, serviceSpec, updateOpts)
+			if err != nil {
+				return errors.Wrapf(err, "failed to update service %s", name)
+			}
+
+			for _, warning := range response.Warnings {
+				logrus.Warn(warning)
+			}
+		} else {
+			logrus.Infof("Creating service %s\n", name)
+
+			createOpts := types.ServiceCreateOptions{EncodedRegistryAuth: encodedAuth}
+
+			// query registry if flag disabling it was not set
+			if resolveImage == ResolveImageAlways || resolveImage == ResolveImageChanged {
+				createOpts.QueryRegistry = true
+			}
+
+			if _, err := cl.ServiceCreate(ctx, serviceSpec, createOpts); err != nil {
+				return errors.Wrapf(err, "failed to create service %s", name)
+			}
+		}
+	}
+	return nil
+}
+
+func getStackNetworks(ctx context.Context, apiclient client.APIClient, namespace string) ([]types.NetworkResource, error) {
+	return apiclient.NetworkList(ctx, types.NetworkListOptions{Filters: getStackFilter(namespace)})
+}
+
+func getStackSecrets(ctx context.Context, apiclient client.APIClient, namespace string) ([]swarm.Secret, error) {
+	return apiclient.SecretList(ctx, types.SecretListOptions{Filters: getStackFilter(namespace)})
+}
+
+func getStackConfigs(ctx context.Context, apiclient client.APIClient, namespace string) ([]swarm.Config, error) {
+	return apiclient.ConfigList(ctx, types.ConfigListOptions{Filters: getStackFilter(namespace)})
+}
diff --git a/client/swarm/common.go b/client/swarm/common.go
deleted file mode 100644
index b4193df36..000000000
--- a/client/swarm/common.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package swarm
-
-import (
-	"context"
-
-	"github.com/docker/cli/cli/compose/convert"
-	"github.com/docker/cli/opts"
-	"github.com/docker/docker/api/types"
-	"github.com/docker/docker/api/types/filters"
-	"github.com/docker/docker/api/types/swarm"
-	"github.com/docker/docker/client"
-)
-
-func getStackFilter(namespace string) filters.Args {
-	filter := filters.NewArgs()
-	filter.Add("label", convert.LabelNamespace+"="+namespace)
-	return filter
-}
-
-func getStackServiceFilter(namespace string) filters.Args {
-	return getStackFilter(namespace)
-}
-
-func getStackFilterFromOpt(namespace string, opt opts.FilterOpt) filters.Args {
-	filter := opt.Value()
-	filter.Add("label", convert.LabelNamespace+"="+namespace)
-	return filter
-}
-
-func getAllStacksFilter() filters.Args {
-	filter := filters.NewArgs()
-	filter.Add("label", convert.LabelNamespace)
-	return filter
-}
-
-func getStackServices(ctx context.Context, apiclient client.APIClient, namespace string) ([]swarm.Service, error) {
-	return apiclient.ServiceList(ctx, types.ServiceListOptions{Filters: getStackServiceFilter(namespace)})
-}
-
-func getStackNetworks(ctx context.Context, apiclient client.APIClient, namespace string) ([]types.NetworkResource, error) {
-	return apiclient.NetworkList(ctx, types.NetworkListOptions{Filters: getStackFilter(namespace)})
-}
-
-func getStackSecrets(ctx context.Context, apiclient client.APIClient, namespace string) ([]swarm.Secret, error) {
-	return apiclient.SecretList(ctx, types.SecretListOptions{Filters: getStackFilter(namespace)})
-}
-
-func getStackConfigs(ctx context.Context, apiclient client.APIClient, namespace string) ([]swarm.Config, error) {
-	return apiclient.ConfigList(ctx, types.ConfigListOptions{Filters: getStackFilter(namespace)})
-}