From ac86912eaddcb0da799de4d54d4cd1ae5bd18199 Mon Sep 17 00:00:00 2001 From: decentral1se 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 0a18ccac..65e27005 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 6a0b3fd8..6d36e9b6 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 c959b40b..68a17c19 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 b4193df3..00000000 --- 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)}) -}