Add support for kubernetes in docker cli
- Add support for kubernetes for docker stack command - Update to go 1.9 - Add kubernetes to vendors - Print orchestrator in docker version command Signed-off-by: Vincent Demeester <vincent@sbr.pm> Signed-off-by: Silvin Lubecki <silvin.lubecki@docker.com>
This commit is contained in:
committed by
Silvin Lubecki
parent
70db7cc0fc
commit
8417e49792
239
cli/command/stack/swarm/client_test.go
Normal file
239
cli/command/stack/swarm/client_test.go
Normal file
@ -0,0 +1,239 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli/compose/convert"
|
||||
"github.com/docker/docker/api"
|
||||
"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"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
type fakeClient struct {
|
||||
client.Client
|
||||
|
||||
version string
|
||||
|
||||
services []string
|
||||
networks []string
|
||||
secrets []string
|
||||
configs []string
|
||||
|
||||
removedServices []string
|
||||
removedNetworks []string
|
||||
removedSecrets []string
|
||||
removedConfigs []string
|
||||
|
||||
serviceListFunc func(options types.ServiceListOptions) ([]swarm.Service, error)
|
||||
networkListFunc func(options types.NetworkListOptions) ([]types.NetworkResource, error)
|
||||
secretListFunc func(options types.SecretListOptions) ([]swarm.Secret, error)
|
||||
configListFunc func(options types.ConfigListOptions) ([]swarm.Config, error)
|
||||
nodeListFunc func(options types.NodeListOptions) ([]swarm.Node, error)
|
||||
taskListFunc func(options types.TaskListOptions) ([]swarm.Task, error)
|
||||
nodeInspectWithRaw func(ref string) (swarm.Node, []byte, error)
|
||||
|
||||
serviceUpdateFunc func(serviceID string, version swarm.Version, service swarm.ServiceSpec, options types.ServiceUpdateOptions) (types.ServiceUpdateResponse, error)
|
||||
|
||||
serviceRemoveFunc func(serviceID string) error
|
||||
networkRemoveFunc func(networkID string) error
|
||||
secretRemoveFunc func(secretID string) error
|
||||
configRemoveFunc func(configID string) error
|
||||
}
|
||||
|
||||
func (cli *fakeClient) ServerVersion(ctx context.Context) (types.Version, error) {
|
||||
return types.Version{
|
||||
Version: "docker-dev",
|
||||
APIVersion: api.DefaultVersion,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cli *fakeClient) ClientVersion() string {
|
||||
return cli.version
|
||||
}
|
||||
|
||||
func (cli *fakeClient) ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
if cli.serviceListFunc != nil {
|
||||
return cli.serviceListFunc(options)
|
||||
}
|
||||
|
||||
namespace := namespaceFromFilters(options.Filters)
|
||||
servicesList := []swarm.Service{}
|
||||
for _, name := range cli.services {
|
||||
if belongToNamespace(name, namespace) {
|
||||
servicesList = append(servicesList, serviceFromName(name))
|
||||
}
|
||||
}
|
||||
return servicesList, nil
|
||||
}
|
||||
|
||||
func (cli *fakeClient) NetworkList(ctx context.Context, options types.NetworkListOptions) ([]types.NetworkResource, error) {
|
||||
if cli.networkListFunc != nil {
|
||||
return cli.networkListFunc(options)
|
||||
}
|
||||
|
||||
namespace := namespaceFromFilters(options.Filters)
|
||||
networksList := []types.NetworkResource{}
|
||||
for _, name := range cli.networks {
|
||||
if belongToNamespace(name, namespace) {
|
||||
networksList = append(networksList, networkFromName(name))
|
||||
}
|
||||
}
|
||||
return networksList, nil
|
||||
}
|
||||
|
||||
func (cli *fakeClient) SecretList(ctx context.Context, options types.SecretListOptions) ([]swarm.Secret, error) {
|
||||
if cli.secretListFunc != nil {
|
||||
return cli.secretListFunc(options)
|
||||
}
|
||||
|
||||
namespace := namespaceFromFilters(options.Filters)
|
||||
secretsList := []swarm.Secret{}
|
||||
for _, name := range cli.secrets {
|
||||
if belongToNamespace(name, namespace) {
|
||||
secretsList = append(secretsList, secretFromName(name))
|
||||
}
|
||||
}
|
||||
return secretsList, nil
|
||||
}
|
||||
|
||||
func (cli *fakeClient) ConfigList(ctx context.Context, options types.ConfigListOptions) ([]swarm.Config, error) {
|
||||
if cli.configListFunc != nil {
|
||||
return cli.configListFunc(options)
|
||||
}
|
||||
|
||||
namespace := namespaceFromFilters(options.Filters)
|
||||
configsList := []swarm.Config{}
|
||||
for _, name := range cli.configs {
|
||||
if belongToNamespace(name, namespace) {
|
||||
configsList = append(configsList, configFromName(name))
|
||||
}
|
||||
}
|
||||
return configsList, nil
|
||||
}
|
||||
|
||||
func (cli *fakeClient) TaskList(ctx context.Context, options types.TaskListOptions) ([]swarm.Task, error) {
|
||||
if cli.taskListFunc != nil {
|
||||
return cli.taskListFunc(options)
|
||||
}
|
||||
return []swarm.Task{}, nil
|
||||
}
|
||||
|
||||
func (cli *fakeClient) NodeList(ctx context.Context, options types.NodeListOptions) ([]swarm.Node, error) {
|
||||
if cli.nodeListFunc != nil {
|
||||
return cli.nodeListFunc(options)
|
||||
}
|
||||
return []swarm.Node{}, nil
|
||||
}
|
||||
|
||||
func (cli *fakeClient) NodeInspectWithRaw(ctx context.Context, ref string) (swarm.Node, []byte, error) {
|
||||
if cli.nodeInspectWithRaw != nil {
|
||||
return cli.nodeInspectWithRaw(ref)
|
||||
}
|
||||
return swarm.Node{}, nil, nil
|
||||
}
|
||||
|
||||
func (cli *fakeClient) ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options types.ServiceUpdateOptions) (types.ServiceUpdateResponse, error) {
|
||||
if cli.serviceUpdateFunc != nil {
|
||||
return cli.serviceUpdateFunc(serviceID, version, service, options)
|
||||
}
|
||||
|
||||
return types.ServiceUpdateResponse{}, nil
|
||||
}
|
||||
|
||||
func (cli *fakeClient) ServiceRemove(ctx context.Context, serviceID string) error {
|
||||
if cli.serviceRemoveFunc != nil {
|
||||
return cli.serviceRemoveFunc(serviceID)
|
||||
}
|
||||
|
||||
cli.removedServices = append(cli.removedServices, serviceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cli *fakeClient) NetworkRemove(ctx context.Context, networkID string) error {
|
||||
if cli.networkRemoveFunc != nil {
|
||||
return cli.networkRemoveFunc(networkID)
|
||||
}
|
||||
|
||||
cli.removedNetworks = append(cli.removedNetworks, networkID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cli *fakeClient) SecretRemove(ctx context.Context, secretID string) error {
|
||||
if cli.secretRemoveFunc != nil {
|
||||
return cli.secretRemoveFunc(secretID)
|
||||
}
|
||||
|
||||
cli.removedSecrets = append(cli.removedSecrets, secretID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cli *fakeClient) ConfigRemove(ctx context.Context, configID string) error {
|
||||
if cli.configRemoveFunc != nil {
|
||||
return cli.configRemoveFunc(configID)
|
||||
}
|
||||
|
||||
cli.removedConfigs = append(cli.removedConfigs, configID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func serviceFromName(name string) swarm.Service {
|
||||
return swarm.Service{
|
||||
ID: "ID-" + name,
|
||||
Spec: swarm.ServiceSpec{
|
||||
Annotations: swarm.Annotations{Name: name},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func networkFromName(name string) types.NetworkResource {
|
||||
return types.NetworkResource{
|
||||
ID: "ID-" + name,
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
||||
func secretFromName(name string) swarm.Secret {
|
||||
return swarm.Secret{
|
||||
ID: "ID-" + name,
|
||||
Spec: swarm.SecretSpec{
|
||||
Annotations: swarm.Annotations{Name: name},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func configFromName(name string) swarm.Config {
|
||||
return swarm.Config{
|
||||
ID: "ID-" + name,
|
||||
Spec: swarm.ConfigSpec{
|
||||
Annotations: swarm.Annotations{Name: name},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func namespaceFromFilters(filters filters.Args) string {
|
||||
label := filters.Get("label")[0]
|
||||
return strings.TrimPrefix(label, convert.LabelNamespace+"=")
|
||||
}
|
||||
|
||||
func belongToNamespace(id, namespace string) bool {
|
||||
return strings.HasPrefix(id, namespace+"_")
|
||||
}
|
||||
|
||||
func objectName(namespace, name string) string {
|
||||
return namespace + "_" + name
|
||||
}
|
||||
|
||||
func objectID(name string) string {
|
||||
return "ID-" + name
|
||||
}
|
||||
|
||||
func buildObjectIDs(objectNames []string) []string {
|
||||
IDs := make([]string, len(objectNames))
|
||||
for i, name := range objectNames {
|
||||
IDs[i] = objectID(name)
|
||||
}
|
||||
return IDs
|
||||
}
|
||||
22
cli/command/stack/swarm/cmd.go
Normal file
22
cli/command/stack/swarm/cmd.go
Normal file
@ -0,0 +1,22 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// AddStackCommands adds `stack` subcommands
|
||||
func AddStackCommands(root *cobra.Command, dockerCli command.Cli) {
|
||||
root.AddCommand(
|
||||
newDeployCommand(dockerCli),
|
||||
newListCommand(dockerCli),
|
||||
newRemoveCommand(dockerCli),
|
||||
newServicesCommand(dockerCli),
|
||||
newPsCommand(dockerCli),
|
||||
)
|
||||
}
|
||||
|
||||
// NewTopLevelDeployCommand returns a command for `docker deploy`
|
||||
func NewTopLevelDeployCommand(dockerCli command.Cli) *cobra.Command {
|
||||
return newDeployCommand(dockerCli)
|
||||
}
|
||||
73
cli/command/stack/swarm/common.go
Normal file
73
cli/command/stack/swarm/common.go
Normal file
@ -0,0 +1,73 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"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"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
func getStackFilter(namespace string) filters.Args {
|
||||
filter := filters.NewArgs()
|
||||
filter.Add("label", convert.LabelNamespace+"="+namespace)
|
||||
return filter
|
||||
}
|
||||
|
||||
func getServiceFilter(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 getServices(
|
||||
ctx context.Context,
|
||||
apiclient client.APIClient,
|
||||
namespace string,
|
||||
) ([]swarm.Service, error) {
|
||||
return apiclient.ServiceList(
|
||||
ctx,
|
||||
types.ServiceListOptions{Filters: getServiceFilter(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)})
|
||||
}
|
||||
123
cli/command/stack/swarm/deploy.go
Normal file
123
cli/command/stack/swarm/deploy.go
Normal file
@ -0,0 +1,123 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/command/stack/common"
|
||||
"github.com/docker/cli/cli/compose/convert"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/api/types/versions"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultNetworkDriver = "overlay"
|
||||
resolveImageAlways = "always"
|
||||
resolveImageChanged = "changed"
|
||||
resolveImageNever = "never"
|
||||
)
|
||||
|
||||
type deployOptions struct {
|
||||
bundlefile string
|
||||
composefile string
|
||||
namespace string
|
||||
resolveImage string
|
||||
sendRegistryAuth bool
|
||||
prune bool
|
||||
}
|
||||
|
||||
func newDeployCommand(dockerCli command.Cli) *cobra.Command {
|
||||
var opts deployOptions
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "deploy [OPTIONS] STACK",
|
||||
Aliases: []string{"up"},
|
||||
Short: "Deploy a new stack or update an existing stack",
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.namespace = args[0]
|
||||
return runDeploy(dockerCli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
common.AddBundlefileFlag(&opts.bundlefile, flags)
|
||||
common.AddComposefileFlag(&opts.composefile, flags)
|
||||
common.AddRegistryAuthFlag(&opts.sendRegistryAuth, flags)
|
||||
flags.BoolVar(&opts.prune, "prune", false, "Prune services that are no longer referenced")
|
||||
flags.SetAnnotation("prune", "version", []string{"1.27"})
|
||||
flags.StringVar(&opts.resolveImage, "resolve-image", resolveImageAlways,
|
||||
`Query the registry to resolve image digest and supported platforms ("`+resolveImageAlways+`"|"`+resolveImageChanged+`"|"`+resolveImageNever+`")`)
|
||||
flags.SetAnnotation("resolve-image", "version", []string{"1.30"})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDeploy(dockerCli command.Cli, opts deployOptions) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if err := validateResolveImageFlag(dockerCli, &opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case opts.bundlefile == "" && opts.composefile == "":
|
||||
return errors.Errorf("Please specify either a bundle file (with --bundle-file) or a Compose file (with --compose-file).")
|
||||
case opts.bundlefile != "" && opts.composefile != "":
|
||||
return errors.Errorf("You cannot specify both a bundle file and a Compose file.")
|
||||
case opts.bundlefile != "":
|
||||
return deployBundle(ctx, dockerCli, opts)
|
||||
default:
|
||||
return deployCompose(ctx, dockerCli, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// validateResolveImageFlag validates the opts.resolveImage command line option
|
||||
// and also turns image resolution off if the version is older than 1.30
|
||||
func validateResolveImageFlag(dockerCli command.Cli, opts *deployOptions) error {
|
||||
if opts.resolveImage != resolveImageAlways && opts.resolveImage != resolveImageChanged && opts.resolveImage != resolveImageNever {
|
||||
return errors.Errorf("Invalid option %s for flag --resolve-image", opts.resolveImage)
|
||||
}
|
||||
// client side image resolution should not be done when the supported
|
||||
// server version is older than 1.30
|
||||
if versions.LessThan(dockerCli.Client().ClientVersion(), "1.30") {
|
||||
opts.resolveImage = resolveImageNever
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDaemonIsSwarmManager does an Info API call to verify that the daemon is
|
||||
// a swarm manager. This is necessary because we must create networks before we
|
||||
// create services, but the API call for creating a network does not return a
|
||||
// proper status code when it can't create a network in the "global" scope.
|
||||
func checkDaemonIsSwarmManager(ctx context.Context, dockerCli command.Cli) error {
|
||||
info, err := dockerCli.Client().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
|
||||
}
|
||||
|
||||
// pruneServices removes services that are no longer referenced in the source
|
||||
func pruneServices(ctx context.Context, dockerCli command.Cli, namespace convert.Namespace, services map[string]struct{}) {
|
||||
client := dockerCli.Client()
|
||||
|
||||
oldServices, err := getServices(ctx, client, namespace.Name())
|
||||
if err != nil {
|
||||
fmt.Fprintf(dockerCli.Err(), "Failed to list services: %s", err)
|
||||
}
|
||||
|
||||
pruneServices := []swarm.Service{}
|
||||
for _, service := range oldServices {
|
||||
if _, exists := services[namespace.Descope(service.Spec.Name)]; !exists {
|
||||
pruneServices = append(pruneServices, service)
|
||||
}
|
||||
}
|
||||
removeServices(ctx, dockerCli, pruneServices)
|
||||
}
|
||||
92
cli/command/stack/swarm/deploy_bundlefile.go
Normal file
92
cli/command/stack/swarm/deploy_bundlefile.go
Normal file
@ -0,0 +1,92 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"golang.org/x/net/context"
|
||||
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/command/stack/common"
|
||||
"github.com/docker/cli/cli/compose/convert"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
)
|
||||
|
||||
func deployBundle(ctx context.Context, dockerCli command.Cli, opts deployOptions) error {
|
||||
bundle, err := common.LoadBundlefile(dockerCli.Err(), opts.namespace, opts.bundlefile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := checkDaemonIsSwarmManager(ctx, dockerCli); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
namespace := convert.NewNamespace(opts.namespace)
|
||||
|
||||
if opts.prune {
|
||||
services := map[string]struct{}{}
|
||||
for service := range bundle.Services {
|
||||
services[service] = struct{}{}
|
||||
}
|
||||
pruneServices(ctx, dockerCli, namespace, services)
|
||||
}
|
||||
|
||||
networks := make(map[string]types.NetworkCreate)
|
||||
for _, service := range bundle.Services {
|
||||
for _, networkName := range service.Networks {
|
||||
networks[networkName] = types.NetworkCreate{
|
||||
Labels: convert.AddStackLabel(namespace, nil),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
services := make(map[string]swarm.ServiceSpec)
|
||||
for internalName, service := range bundle.Services {
|
||||
name := namespace.Scope(internalName)
|
||||
|
||||
var ports []swarm.PortConfig
|
||||
for _, portSpec := range service.Ports {
|
||||
ports = append(ports, swarm.PortConfig{
|
||||
Protocol: swarm.PortConfigProtocol(portSpec.Protocol),
|
||||
TargetPort: portSpec.Port,
|
||||
})
|
||||
}
|
||||
|
||||
nets := []swarm.NetworkAttachmentConfig{}
|
||||
for _, networkName := range service.Networks {
|
||||
nets = append(nets, swarm.NetworkAttachmentConfig{
|
||||
Target: namespace.Scope(networkName),
|
||||
Aliases: []string{internalName},
|
||||
})
|
||||
}
|
||||
|
||||
serviceSpec := swarm.ServiceSpec{
|
||||
Annotations: swarm.Annotations{
|
||||
Name: name,
|
||||
Labels: convert.AddStackLabel(namespace, service.Labels),
|
||||
},
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
Image: service.Image,
|
||||
Command: service.Command,
|
||||
Args: service.Args,
|
||||
Env: service.Env,
|
||||
// Service Labels will not be copied to Containers
|
||||
// automatically during the deployment so we apply
|
||||
// it here.
|
||||
Labels: convert.AddStackLabel(namespace, nil),
|
||||
},
|
||||
},
|
||||
EndpointSpec: &swarm.EndpointSpec{
|
||||
Ports: ports,
|
||||
},
|
||||
Networks: nets,
|
||||
}
|
||||
|
||||
services[internalName] = serviceSpec
|
||||
}
|
||||
|
||||
if err := createNetworks(ctx, dockerCli, namespace, networks); err != nil {
|
||||
return err
|
||||
}
|
||||
return deployServices(ctx, dockerCli, services, namespace, opts.sendRegistryAuth, opts.resolveImage)
|
||||
}
|
||||
382
cli/command/stack/swarm/deploy_composefile.go
Normal file
382
cli/command/stack/swarm/deploy_composefile.go
Normal file
@ -0,0 +1,382 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/compose/convert"
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
composetypes "github.com/docker/cli/cli/compose/types"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
apiclient "github.com/docker/docker/client"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
func deployCompose(ctx context.Context, dockerCli command.Cli, opts deployOptions) error {
|
||||
configDetails, err := getConfigDetails(opts.composefile, dockerCli.In())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := loader.Load(configDetails)
|
||||
if err != nil {
|
||||
if fpe, ok := err.(*loader.ForbiddenPropertiesError); ok {
|
||||
return errors.Errorf("Compose file contains unsupported options:\n\n%s\n",
|
||||
propertyWarnings(fpe.Properties))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
unsupportedProperties := loader.GetUnsupportedProperties(configDetails)
|
||||
if len(unsupportedProperties) > 0 {
|
||||
fmt.Fprintf(dockerCli.Err(), "Ignoring unsupported options: %s\n\n",
|
||||
strings.Join(unsupportedProperties, ", "))
|
||||
}
|
||||
|
||||
deprecatedProperties := loader.GetDeprecatedProperties(configDetails)
|
||||
if len(deprecatedProperties) > 0 {
|
||||
fmt.Fprintf(dockerCli.Err(), "Ignoring deprecated options:\n\n%s\n\n",
|
||||
propertyWarnings(deprecatedProperties))
|
||||
}
|
||||
|
||||
if err := checkDaemonIsSwarmManager(ctx, dockerCli); 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, dockerCli, namespace, services)
|
||||
}
|
||||
|
||||
serviceNetworks := getServicesDeclaredNetworks(config.Services)
|
||||
networks, externalNetworks := convert.Networks(namespace, config.Networks, serviceNetworks)
|
||||
if err := validateExternalNetworks(ctx, dockerCli.Client(), externalNetworks); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createNetworks(ctx, dockerCli, namespace, networks); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
secrets, err := convert.Secrets(namespace, config.Secrets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createSecrets(ctx, dockerCli, secrets); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configs, err := convert.Configs(namespace, config.Configs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createConfigs(ctx, dockerCli, configs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
services, err := convert.Services(namespace, config, dockerCli.Client())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return deployServices(ctx, dockerCli, 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 propertyWarnings(properties map[string]string) string {
|
||||
var msgs []string
|
||||
for name, description := range properties {
|
||||
msgs = append(msgs, fmt.Sprintf("%s: %s", name, description))
|
||||
}
|
||||
sort.Strings(msgs)
|
||||
return strings.Join(msgs, "\n\n")
|
||||
}
|
||||
|
||||
func getConfigDetails(composefile string, stdin io.Reader) (composetypes.ConfigDetails, error) {
|
||||
var details composetypes.ConfigDetails
|
||||
|
||||
if composefile == "-" {
|
||||
workingDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return details, err
|
||||
}
|
||||
details.WorkingDir = workingDir
|
||||
} else {
|
||||
absPath, err := filepath.Abs(composefile)
|
||||
if err != nil {
|
||||
return details, err
|
||||
}
|
||||
details.WorkingDir = filepath.Dir(absPath)
|
||||
}
|
||||
|
||||
configFile, err := getConfigFile(composefile, stdin)
|
||||
if err != nil {
|
||||
return details, err
|
||||
}
|
||||
// TODO: support multiple files
|
||||
details.ConfigFiles = []composetypes.ConfigFile{*configFile}
|
||||
details.Environment, err = buildEnvironment(os.Environ())
|
||||
return details, err
|
||||
}
|
||||
|
||||
func buildEnvironment(env []string) (map[string]string, error) {
|
||||
result := make(map[string]string, len(env))
|
||||
for _, s := range env {
|
||||
// if value is empty, s is like "K=", not "K".
|
||||
if !strings.Contains(s, "=") {
|
||||
return result, errors.Errorf("unexpected environment %q", s)
|
||||
}
|
||||
kv := strings.SplitN(s, "=", 2)
|
||||
result[kv[0]] = kv[1]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func getConfigFile(filename string, stdin io.Reader) (*composetypes.ConfigFile, error) {
|
||||
var bytes []byte
|
||||
var err error
|
||||
|
||||
if filename == "-" {
|
||||
bytes, err = ioutil.ReadAll(stdin)
|
||||
} else {
|
||||
bytes, err = ioutil.ReadFile(filename)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := loader.ParseYAML(bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &composetypes.ConfigFile{
|
||||
Filename: filename,
|
||||
Config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
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,
|
||||
dockerCli command.Cli,
|
||||
secrets []swarm.SecretSpec,
|
||||
) error {
|
||||
client := dockerCli.Client()
|
||||
|
||||
for _, secretSpec := range secrets {
|
||||
secret, _, err := client.SecretInspectWithRaw(ctx, secretSpec.Name)
|
||||
switch {
|
||||
case err == nil:
|
||||
// secret already exists, then we update that
|
||||
if err := client.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.
|
||||
fmt.Fprintf(dockerCli.Out(), "Creating secret %s\n", secretSpec.Name)
|
||||
if _, err := client.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,
|
||||
dockerCli command.Cli,
|
||||
configs []swarm.ConfigSpec,
|
||||
) error {
|
||||
client := dockerCli.Client()
|
||||
|
||||
for _, configSpec := range configs {
|
||||
config, _, err := client.ConfigInspectWithRaw(ctx, configSpec.Name)
|
||||
switch {
|
||||
case err == nil:
|
||||
// config already exists, then we update that
|
||||
if err := client.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.
|
||||
fmt.Fprintf(dockerCli.Out(), "Creating config %s\n", configSpec.Name)
|
||||
if _, err := client.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,
|
||||
dockerCli command.Cli,
|
||||
namespace convert.Namespace,
|
||||
networks map[string]types.NetworkCreate,
|
||||
) error {
|
||||
client := dockerCli.Client()
|
||||
|
||||
existingNetworks, err := getStackNetworks(ctx, client, namespace.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingNetworkMap := make(map[string]types.NetworkResource)
|
||||
for _, network := range existingNetworks {
|
||||
existingNetworkMap[network.Name] = network
|
||||
}
|
||||
|
||||
for internalName, createOpts := range networks {
|
||||
name := namespace.Scope(internalName)
|
||||
if _, exists := existingNetworkMap[name]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if createOpts.Driver == "" {
|
||||
createOpts.Driver = defaultNetworkDriver
|
||||
}
|
||||
|
||||
fmt.Fprintf(dockerCli.Out(), "Creating network %s\n", name)
|
||||
if _, err := client.NetworkCreate(ctx, name, createOpts); err != nil {
|
||||
return errors.Wrapf(err, "failed to create network %s", internalName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deployServices(
|
||||
ctx context.Context,
|
||||
dockerCli command.Cli,
|
||||
services map[string]swarm.ServiceSpec,
|
||||
namespace convert.Namespace,
|
||||
sendAuth bool,
|
||||
resolveImage string,
|
||||
) error {
|
||||
apiClient := dockerCli.Client()
|
||||
out := dockerCli.Out()
|
||||
|
||||
existingServices, err := getServices(ctx, apiClient, 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 {
|
||||
name := namespace.Scope(internalName)
|
||||
|
||||
encodedAuth := ""
|
||||
image := serviceSpec.TaskTemplate.ContainerSpec.Image
|
||||
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 {
|
||||
fmt.Fprintf(out, "Updating service %s (id: %s)\n", name, service.ID)
|
||||
|
||||
updateOpts := types.ServiceUpdateOptions{EncodedRegistryAuth: encodedAuth}
|
||||
|
||||
switch {
|
||||
case resolveImage == resolveImageAlways || (resolveImage == resolveImageChanged && image != service.Spec.Labels[convert.LabelImage]):
|
||||
// image should be updated by the server using QueryRegistry
|
||||
updateOpts.QueryRegistry = true
|
||||
case 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
|
||||
}
|
||||
response, err := apiClient.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 {
|
||||
fmt.Fprintln(dockerCli.Err(), warning)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(out, "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 := apiClient.ServiceCreate(ctx, serviceSpec, createOpts); err != nil {
|
||||
return errors.Wrapf(err, "failed to create service %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
105
cli/command/stack/swarm/deploy_composefile_test.go
Normal file
105
cli/command/stack/swarm/deploy_composefile_test.go
Normal file
@ -0,0 +1,105 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/internal/test/network"
|
||||
"github.com/docker/cli/internal/test/testutil"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/gotestyourself/gotestyourself/fs"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
func TestGetConfigDetails(t *testing.T) {
|
||||
content := `
|
||||
version: "3.0"
|
||||
services:
|
||||
foo:
|
||||
image: alpine:3.5
|
||||
`
|
||||
file := fs.NewFile(t, "test-get-config-details", fs.WithContent(content))
|
||||
defer file.Remove()
|
||||
|
||||
details, err := getConfigDetails(file.Path(), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Dir(file.Path()), details.WorkingDir)
|
||||
require.Len(t, details.ConfigFiles, 1)
|
||||
assert.Equal(t, "3.0", details.ConfigFiles[0].Config["version"])
|
||||
assert.Len(t, details.Environment, len(os.Environ()))
|
||||
}
|
||||
|
||||
func TestGetConfigDetailsStdin(t *testing.T) {
|
||||
content := `
|
||||
version: "3.0"
|
||||
services:
|
||||
foo:
|
||||
image: alpine:3.5
|
||||
`
|
||||
details, err := getConfigDetails("-", strings.NewReader(content))
|
||||
require.NoError(t, err)
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cwd, details.WorkingDir)
|
||||
require.Len(t, details.ConfigFiles, 1)
|
||||
assert.Equal(t, "3.0", details.ConfigFiles[0].Config["version"])
|
||||
assert.Len(t, details.Environment, len(os.Environ()))
|
||||
}
|
||||
|
||||
type notFound struct {
|
||||
error
|
||||
}
|
||||
|
||||
func (n notFound) NotFound() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func TestValidateExternalNetworks(t *testing.T) {
|
||||
var testcases = []struct {
|
||||
inspectResponse types.NetworkResource
|
||||
inspectError error
|
||||
expectedMsg string
|
||||
network string
|
||||
}{
|
||||
{
|
||||
inspectError: notFound{},
|
||||
expectedMsg: "could not be found. You need to create a swarm-scoped network",
|
||||
},
|
||||
{
|
||||
inspectError: errors.New("Unexpected"),
|
||||
expectedMsg: "Unexpected",
|
||||
},
|
||||
{
|
||||
inspectError: errors.New("host net does not exist on swarm classic"),
|
||||
network: "host",
|
||||
},
|
||||
{
|
||||
network: "user",
|
||||
expectedMsg: "is not in the right scope",
|
||||
},
|
||||
{
|
||||
network: "user",
|
||||
inspectResponse: types.NetworkResource{Scope: "swarm"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testcase := range testcases {
|
||||
fakeClient := &network.FakeClient{
|
||||
NetworkInspectFunc: func(_ context.Context, _ string, _ types.NetworkInspectOptions) (types.NetworkResource, error) {
|
||||
return testcase.inspectResponse, testcase.inspectError
|
||||
},
|
||||
}
|
||||
networks := []string{testcase.network}
|
||||
err := validateExternalNetworks(context.Background(), fakeClient, networks)
|
||||
if testcase.expectedMsg == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
testutil.ErrorContains(t, err, testcase.expectedMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
103
cli/command/stack/swarm/deploy_test.go
Normal file
103
cli/command/stack/swarm/deploy_test.go
Normal file
@ -0,0 +1,103 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/cli/compose/convert"
|
||||
"github.com/docker/cli/internal/test"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
func TestPruneServices(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
namespace := convert.NewNamespace("foo")
|
||||
services := map[string]struct{}{
|
||||
"new": {},
|
||||
"keep": {},
|
||||
}
|
||||
client := &fakeClient{services: []string{objectName("foo", "keep"), objectName("foo", "remove")}}
|
||||
dockerCli := test.NewFakeCli(client)
|
||||
|
||||
pruneServices(ctx, dockerCli, namespace, services)
|
||||
assert.Equal(t, buildObjectIDs([]string{objectName("foo", "remove")}), client.removedServices)
|
||||
}
|
||||
|
||||
// TestServiceUpdateResolveImageChanged tests that the service's
|
||||
// image digest is preserved if the image did not change in the compose file
|
||||
func TestServiceUpdateResolveImageChanged(t *testing.T) {
|
||||
namespace := convert.NewNamespace("mystack")
|
||||
|
||||
var (
|
||||
receivedOptions types.ServiceUpdateOptions
|
||||
receivedService swarm.ServiceSpec
|
||||
)
|
||||
|
||||
client := test.NewFakeCli(&fakeClient{
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return []swarm.Service{
|
||||
{
|
||||
Spec: swarm.ServiceSpec{
|
||||
Annotations: swarm.Annotations{
|
||||
Name: namespace.Name() + "_myservice",
|
||||
Labels: map[string]string{"com.docker.stack.image": "foobar:1.2.3"},
|
||||
},
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
Image: "foobar:1.2.3@sha256:deadbeef",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
serviceUpdateFunc: func(serviceID string, version swarm.Version, service swarm.ServiceSpec, options types.ServiceUpdateOptions) (types.ServiceUpdateResponse, error) {
|
||||
receivedOptions = options
|
||||
receivedService = service
|
||||
return types.ServiceUpdateResponse{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
var testcases = []struct {
|
||||
image string
|
||||
expectedQueryRegistry bool
|
||||
expectedImage string
|
||||
}{
|
||||
// Image not changed
|
||||
{
|
||||
image: "foobar:1.2.3",
|
||||
expectedQueryRegistry: false,
|
||||
expectedImage: "foobar:1.2.3@sha256:deadbeef",
|
||||
},
|
||||
// Image changed
|
||||
{
|
||||
image: "foobar:1.2.4",
|
||||
expectedQueryRegistry: true,
|
||||
expectedImage: "foobar:1.2.4",
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
for _, testcase := range testcases {
|
||||
t.Logf("Testing image %q", testcase.image)
|
||||
spec := map[string]swarm.ServiceSpec{
|
||||
"myservice": {
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
Image: testcase.image,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := deployServices(ctx, client, spec, namespace, false, resolveImageChanged)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testcase.expectedQueryRegistry, receivedOptions.QueryRegistry)
|
||||
assert.Equal(t, testcase.expectedImage, receivedService.TaskTemplate.ContainerSpec.Image)
|
||||
|
||||
receivedService = swarm.ServiceSpec{}
|
||||
receivedOptions = types.ServiceUpdateOptions{}
|
||||
}
|
||||
}
|
||||
96
cli/command/stack/swarm/list.go
Normal file
96
cli/command/stack/swarm/list.go
Normal file
@ -0,0 +1,96 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/command/formatter"
|
||||
"github.com/docker/cli/cli/compose/convert"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/net/context"
|
||||
"vbom.ml/util/sortorder"
|
||||
)
|
||||
|
||||
type listOptions struct {
|
||||
format string
|
||||
}
|
||||
|
||||
func newListCommand(dockerCli command.Cli) *cobra.Command {
|
||||
opts := listOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List stacks",
|
||||
Args: cli.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runList(dockerCli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.StringVar(&opts.format, "format", "", "Pretty-print stacks using a Go template")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runList(dockerCli command.Cli, opts listOptions) error {
|
||||
client := dockerCli.Client()
|
||||
ctx := context.Background()
|
||||
|
||||
stacks, err := getStacks(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
format := opts.format
|
||||
if len(format) == 0 {
|
||||
format = formatter.TableFormatKey
|
||||
}
|
||||
stackCtx := formatter.Context{
|
||||
Output: dockerCli.Out(),
|
||||
Format: formatter.NewStackFormat(format),
|
||||
}
|
||||
sort.Sort(byName(stacks))
|
||||
return formatter.StackWrite(stackCtx, stacks)
|
||||
}
|
||||
|
||||
type byName []*formatter.Stack
|
||||
|
||||
func (n byName) Len() int { return len(n) }
|
||||
func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
|
||||
func (n byName) Less(i, j int) bool { return sortorder.NaturalLess(n[i].Name, n[j].Name) }
|
||||
|
||||
func getStacks(ctx context.Context, apiclient client.APIClient) ([]*formatter.Stack, error) {
|
||||
services, err := apiclient.ServiceList(
|
||||
ctx,
|
||||
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
|
||||
}
|
||||
147
cli/command/stack/swarm/list_test.go
Normal file
147
cli/command/stack/swarm/list_test.go
Normal file
@ -0,0 +1,147 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/internal/test"
|
||||
// Import builders to get the builder function as package function
|
||||
. "github.com/docker/cli/internal/test/builders"
|
||||
"github.com/docker/cli/internal/test/testutil"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/gotestyourself/gotestyourself/golden"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestListErrors(t *testing.T) {
|
||||
testCases := []struct {
|
||||
args []string
|
||||
flags map[string]string
|
||||
serviceListFunc func(options types.ServiceListOptions) ([]swarm.Service, error)
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
args: []string{"foo"},
|
||||
expectedError: "accepts no argument",
|
||||
},
|
||||
{
|
||||
flags: map[string]string{
|
||||
"format": "{{invalid format}}",
|
||||
},
|
||||
expectedError: "Template parsing error",
|
||||
},
|
||||
{
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return []swarm.Service{}, errors.Errorf("error getting services")
|
||||
},
|
||||
expectedError: "error getting services",
|
||||
},
|
||||
{
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return []swarm.Service{*Service()}, nil
|
||||
},
|
||||
expectedError: "cannot get label",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
cmd := newListCommand(test.NewFakeCli(&fakeClient{
|
||||
serviceListFunc: tc.serviceListFunc,
|
||||
}))
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOutput(ioutil.Discard)
|
||||
for key, value := range tc.flags {
|
||||
cmd.Flags().Set(key, value)
|
||||
}
|
||||
testutil.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithFormat(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return []swarm.Service{
|
||||
*Service(
|
||||
ServiceLabels(map[string]string{
|
||||
"com.docker.stack.namespace": "service-name-foo",
|
||||
}),
|
||||
)}, nil
|
||||
},
|
||||
})
|
||||
cmd := newListCommand(cli)
|
||||
cmd.Flags().Set("format", "{{ .Name }}")
|
||||
assert.NoError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), "stack-list-with-format.golden")
|
||||
}
|
||||
|
||||
func TestListWithoutFormat(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return []swarm.Service{
|
||||
*Service(
|
||||
ServiceLabels(map[string]string{
|
||||
"com.docker.stack.namespace": "service-name-foo",
|
||||
}),
|
||||
)}, nil
|
||||
},
|
||||
})
|
||||
cmd := newListCommand(cli)
|
||||
assert.NoError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), "stack-list-without-format.golden")
|
||||
}
|
||||
|
||||
func TestListOrder(t *testing.T) {
|
||||
usecases := []struct {
|
||||
golden string
|
||||
swarmServices []swarm.Service
|
||||
}{
|
||||
{
|
||||
golden: "stack-list-sort.golden",
|
||||
swarmServices: []swarm.Service{
|
||||
*Service(
|
||||
ServiceLabels(map[string]string{
|
||||
"com.docker.stack.namespace": "service-name-foo",
|
||||
}),
|
||||
),
|
||||
*Service(
|
||||
ServiceLabels(map[string]string{
|
||||
"com.docker.stack.namespace": "service-name-bar",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
golden: "stack-list-sort-natural.golden",
|
||||
swarmServices: []swarm.Service{
|
||||
*Service(
|
||||
ServiceLabels(map[string]string{
|
||||
"com.docker.stack.namespace": "service-name-1-foo",
|
||||
}),
|
||||
),
|
||||
*Service(
|
||||
ServiceLabels(map[string]string{
|
||||
"com.docker.stack.namespace": "service-name-10-foo",
|
||||
}),
|
||||
),
|
||||
*Service(
|
||||
ServiceLabels(map[string]string{
|
||||
"com.docker.stack.namespace": "service-name-2-foo",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, uc := range usecases {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return uc.swarmServices, nil
|
||||
},
|
||||
})
|
||||
cmd := newListCommand(cli)
|
||||
assert.NoError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), uc.golden)
|
||||
}
|
||||
}
|
||||
69
cli/command/stack/swarm/ps.go
Normal file
69
cli/command/stack/swarm/ps.go
Normal file
@ -0,0 +1,69 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/command/idresolver"
|
||||
"github.com/docker/cli/cli/command/task"
|
||||
"github.com/docker/cli/opts"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
type psOptions struct {
|
||||
filter opts.FilterOpt
|
||||
noTrunc bool
|
||||
namespace string
|
||||
noResolve bool
|
||||
quiet bool
|
||||
format string
|
||||
}
|
||||
|
||||
func newPsCommand(dockerCli command.Cli) *cobra.Command {
|
||||
options := psOptions{filter: opts.NewFilterOpt()}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ps [OPTIONS] STACK",
|
||||
Short: "List the tasks in the stack",
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
options.namespace = args[0]
|
||||
return runPS(dockerCli, options)
|
||||
},
|
||||
}
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVar(&options.noTrunc, "no-trunc", false, "Do not truncate output")
|
||||
flags.BoolVar(&options.noResolve, "no-resolve", false, "Do not map IDs to Names")
|
||||
flags.VarP(&options.filter, "filter", "f", "Filter output based on conditions provided")
|
||||
flags.BoolVarP(&options.quiet, "quiet", "q", false, "Only display task IDs")
|
||||
flags.StringVar(&options.format, "format", "", "Pretty-print tasks using a Go template")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runPS(dockerCli command.Cli, options psOptions) error {
|
||||
namespace := options.namespace
|
||||
client := dockerCli.Client()
|
||||
ctx := context.Background()
|
||||
|
||||
filter := getStackFilterFromOpt(options.namespace, options.filter)
|
||||
|
||||
tasks, err := client.TaskList(ctx, types.TaskListOptions{Filters: filter})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
return fmt.Errorf("nothing found in stack: %s", namespace)
|
||||
}
|
||||
|
||||
format := options.format
|
||||
if len(format) == 0 {
|
||||
format = task.DefaultFormat(dockerCli.ConfigFile(), options.quiet)
|
||||
}
|
||||
|
||||
return task.Print(ctx, dockerCli, tasks, idresolver.New(client, options.noResolve), !options.noTrunc, options.quiet, format)
|
||||
}
|
||||
164
cli/command/stack/swarm/ps_test.go
Normal file
164
cli/command/stack/swarm/ps_test.go
Normal file
@ -0,0 +1,164 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/cli/cli/config/configfile"
|
||||
"github.com/docker/cli/internal/test"
|
||||
// Import builders to get the builder function as package function
|
||||
. "github.com/docker/cli/internal/test/builders"
|
||||
"github.com/docker/cli/internal/test/testutil"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/gotestyourself/gotestyourself/golden"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStackPsErrors(t *testing.T) {
|
||||
testCases := []struct {
|
||||
args []string
|
||||
taskListFunc func(options types.TaskListOptions) ([]swarm.Task, error)
|
||||
expectedError string
|
||||
}{
|
||||
|
||||
{
|
||||
args: []string{},
|
||||
expectedError: "requires exactly 1 argument",
|
||||
},
|
||||
{
|
||||
args: []string{"foo", "bar"},
|
||||
expectedError: "requires exactly 1 argument",
|
||||
},
|
||||
{
|
||||
args: []string{"foo"},
|
||||
taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
|
||||
return nil, errors.Errorf("error getting tasks")
|
||||
},
|
||||
expectedError: "error getting tasks",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
cmd := newPsCommand(test.NewFakeCli(&fakeClient{
|
||||
taskListFunc: tc.taskListFunc,
|
||||
}))
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOutput(ioutil.Discard)
|
||||
testutil.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStackPsEmptyStack(t *testing.T) {
|
||||
fakeCli := test.NewFakeCli(&fakeClient{
|
||||
taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
|
||||
return []swarm.Task{}, nil
|
||||
},
|
||||
})
|
||||
cmd := newPsCommand(fakeCli)
|
||||
cmd.SetArgs([]string{"foo"})
|
||||
cmd.SetOutput(ioutil.Discard)
|
||||
|
||||
assert.Error(t, cmd.Execute())
|
||||
assert.EqualError(t, cmd.Execute(), "nothing found in stack: foo")
|
||||
assert.Equal(t, "", fakeCli.OutBuffer().String())
|
||||
}
|
||||
|
||||
func TestStackPsWithQuietOption(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
|
||||
return []swarm.Task{*Task(TaskID("id-foo"))}, nil
|
||||
},
|
||||
})
|
||||
cmd := newPsCommand(cli)
|
||||
cmd.SetArgs([]string{"foo"})
|
||||
cmd.Flags().Set("quiet", "true")
|
||||
assert.NoError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), "stack-ps-with-quiet-option.golden")
|
||||
|
||||
}
|
||||
|
||||
func TestStackPsWithNoTruncOption(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
|
||||
return []swarm.Task{*Task(TaskID("xn4cypcov06f2w8gsbaf2lst3"))}, nil
|
||||
},
|
||||
})
|
||||
cmd := newPsCommand(cli)
|
||||
cmd.SetArgs([]string{"foo"})
|
||||
cmd.Flags().Set("no-trunc", "true")
|
||||
cmd.Flags().Set("format", "{{ .ID }}")
|
||||
assert.NoError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), "stack-ps-with-no-trunc-option.golden")
|
||||
}
|
||||
|
||||
func TestStackPsWithNoResolveOption(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
|
||||
return []swarm.Task{*Task(
|
||||
TaskNodeID("id-node-foo"),
|
||||
)}, nil
|
||||
},
|
||||
nodeInspectWithRaw: func(ref string) (swarm.Node, []byte, error) {
|
||||
return *Node(NodeName("node-name-bar")), nil, nil
|
||||
},
|
||||
})
|
||||
cmd := newPsCommand(cli)
|
||||
cmd.SetArgs([]string{"foo"})
|
||||
cmd.Flags().Set("no-resolve", "true")
|
||||
cmd.Flags().Set("format", "{{ .Node }}")
|
||||
assert.NoError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), "stack-ps-with-no-resolve-option.golden")
|
||||
}
|
||||
|
||||
func TestStackPsWithFormat(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
|
||||
return []swarm.Task{*Task(TaskServiceID("service-id-foo"))}, nil
|
||||
},
|
||||
})
|
||||
cmd := newPsCommand(cli)
|
||||
cmd.SetArgs([]string{"foo"})
|
||||
cmd.Flags().Set("format", "{{ .Name }}")
|
||||
assert.NoError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), "stack-ps-with-format.golden")
|
||||
}
|
||||
|
||||
func TestStackPsWithConfigFormat(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
|
||||
return []swarm.Task{*Task(TaskServiceID("service-id-foo"))}, nil
|
||||
},
|
||||
})
|
||||
cli.SetConfigFile(&configfile.ConfigFile{
|
||||
TasksFormat: "{{ .Name }}",
|
||||
})
|
||||
cmd := newPsCommand(cli)
|
||||
cmd.SetArgs([]string{"foo"})
|
||||
assert.NoError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), "stack-ps-with-config-format.golden")
|
||||
}
|
||||
|
||||
func TestStackPsWithoutFormat(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
|
||||
return []swarm.Task{*Task(
|
||||
TaskID("id-foo"),
|
||||
TaskServiceID("service-id-foo"),
|
||||
TaskNodeID("id-node"),
|
||||
WithTaskSpec(TaskImage("myimage:mytag")),
|
||||
TaskDesiredState(swarm.TaskStateReady),
|
||||
WithStatus(TaskState(swarm.TaskStateFailed), Timestamp(time.Now().Add(-2*time.Hour))),
|
||||
)}, nil
|
||||
},
|
||||
nodeInspectWithRaw: func(ref string) (swarm.Node, []byte, error) {
|
||||
return *Node(NodeName("node-name-bar")), nil, nil
|
||||
},
|
||||
})
|
||||
cmd := newPsCommand(cli)
|
||||
cmd.SetArgs([]string{"foo"})
|
||||
assert.NoError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), "stack-ps-without-format.golden")
|
||||
}
|
||||
161
cli/command/stack/swarm/remove.go
Normal file
161
cli/command/stack/swarm/remove.go
Normal file
@ -0,0 +1,161 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/api/types/versions"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
type removeOptions struct {
|
||||
namespaces []string
|
||||
}
|
||||
|
||||
func newRemoveCommand(dockerCli command.Cli) *cobra.Command {
|
||||
var opts removeOptions
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm STACK [STACK...]",
|
||||
Aliases: []string{"remove", "down"},
|
||||
Short: "Remove one or more stacks",
|
||||
Args: cli.RequiresMinArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.namespaces = args
|
||||
return runRemove(dockerCli, opts)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runRemove(dockerCli command.Cli, opts removeOptions) error {
|
||||
namespaces := opts.namespaces
|
||||
client := dockerCli.Client()
|
||||
ctx := context.Background()
|
||||
|
||||
var errs []string
|
||||
for _, namespace := range namespaces {
|
||||
services, err := getServices(ctx, client, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
networks, err := getStackNetworks(ctx, client, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var secrets []swarm.Secret
|
||||
if versions.GreaterThanOrEqualTo(client.ClientVersion(), "1.25") {
|
||||
secrets, err = getStackSecrets(ctx, client, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var configs []swarm.Config
|
||||
if versions.GreaterThanOrEqualTo(client.ClientVersion(), "1.30") {
|
||||
configs, err = getStackConfigs(ctx, client, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(services)+len(networks)+len(secrets)+len(configs) == 0 {
|
||||
fmt.Fprintf(dockerCli.Err(), "Nothing found in stack: %s\n", namespace)
|
||||
continue
|
||||
}
|
||||
|
||||
hasError := removeServices(ctx, dockerCli, services)
|
||||
hasError = removeSecrets(ctx, dockerCli, secrets) || hasError
|
||||
hasError = removeConfigs(ctx, dockerCli, configs) || hasError
|
||||
hasError = removeNetworks(ctx, dockerCli, networks) || hasError
|
||||
|
||||
if hasError {
|
||||
errs = append(errs, fmt.Sprintf("Failed to remove some resources from stack: %s", namespace))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Errorf(strings.Join(errs, "\n"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortServiceByName(services []swarm.Service) func(i, j int) bool {
|
||||
return func(i, j int) bool {
|
||||
return services[i].Spec.Name < services[j].Spec.Name
|
||||
}
|
||||
}
|
||||
|
||||
func removeServices(
|
||||
ctx context.Context,
|
||||
dockerCli command.Cli,
|
||||
services []swarm.Service,
|
||||
) bool {
|
||||
var hasError bool
|
||||
sort.Slice(services, sortServiceByName(services))
|
||||
for _, service := range services {
|
||||
fmt.Fprintf(dockerCli.Out(), "Removing service %s\n", service.Spec.Name)
|
||||
if err := dockerCli.Client().ServiceRemove(ctx, service.ID); err != nil {
|
||||
hasError = true
|
||||
fmt.Fprintf(dockerCli.Err(), "Failed to remove service %s: %s", service.ID, err)
|
||||
}
|
||||
}
|
||||
return hasError
|
||||
}
|
||||
|
||||
func removeNetworks(
|
||||
ctx context.Context,
|
||||
dockerCli command.Cli,
|
||||
networks []types.NetworkResource,
|
||||
) bool {
|
||||
var hasError bool
|
||||
for _, network := range networks {
|
||||
fmt.Fprintf(dockerCli.Out(), "Removing network %s\n", network.Name)
|
||||
if err := dockerCli.Client().NetworkRemove(ctx, network.ID); err != nil {
|
||||
hasError = true
|
||||
fmt.Fprintf(dockerCli.Err(), "Failed to remove network %s: %s", network.ID, err)
|
||||
}
|
||||
}
|
||||
return hasError
|
||||
}
|
||||
|
||||
func removeSecrets(
|
||||
ctx context.Context,
|
||||
dockerCli command.Cli,
|
||||
secrets []swarm.Secret,
|
||||
) bool {
|
||||
var hasError bool
|
||||
for _, secret := range secrets {
|
||||
fmt.Fprintf(dockerCli.Out(), "Removing secret %s\n", secret.Spec.Name)
|
||||
if err := dockerCli.Client().SecretRemove(ctx, secret.ID); err != nil {
|
||||
hasError = true
|
||||
fmt.Fprintf(dockerCli.Err(), "Failed to remove secret %s: %s", secret.ID, err)
|
||||
}
|
||||
}
|
||||
return hasError
|
||||
}
|
||||
|
||||
func removeConfigs(
|
||||
ctx context.Context,
|
||||
dockerCli command.Cli,
|
||||
configs []swarm.Config,
|
||||
) bool {
|
||||
var hasError bool
|
||||
for _, config := range configs {
|
||||
fmt.Fprintf(dockerCli.Out(), "Removing config %s\n", config.Spec.Name)
|
||||
if err := dockerCli.Client().ConfigRemove(ctx, config.ID); err != nil {
|
||||
hasError = true
|
||||
fmt.Fprintf(dockerCli.Err(), "Failed to remove config %s: %s", config.ID, err)
|
||||
}
|
||||
}
|
||||
return hasError
|
||||
}
|
||||
157
cli/command/stack/swarm/remove_test.go
Normal file
157
cli/command/stack/swarm/remove_test.go
Normal file
@ -0,0 +1,157 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/internal/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func fakeClientForRemoveStackTest(version string) *fakeClient {
|
||||
allServices := []string{
|
||||
objectName("foo", "service1"),
|
||||
objectName("foo", "service2"),
|
||||
objectName("bar", "service1"),
|
||||
objectName("bar", "service2"),
|
||||
}
|
||||
allNetworks := []string{
|
||||
objectName("foo", "network1"),
|
||||
objectName("bar", "network1"),
|
||||
}
|
||||
allSecrets := []string{
|
||||
objectName("foo", "secret1"),
|
||||
objectName("foo", "secret2"),
|
||||
objectName("bar", "secret1"),
|
||||
}
|
||||
allConfigs := []string{
|
||||
objectName("foo", "config1"),
|
||||
objectName("foo", "config2"),
|
||||
objectName("bar", "config1"),
|
||||
}
|
||||
return &fakeClient{
|
||||
version: version,
|
||||
services: allServices,
|
||||
networks: allNetworks,
|
||||
secrets: allSecrets,
|
||||
configs: allConfigs,
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveStackVersion124DoesNotRemoveConfigsOrSecrets(t *testing.T) {
|
||||
client := fakeClientForRemoveStackTest("1.24")
|
||||
cmd := newRemoveCommand(test.NewFakeCli(client))
|
||||
cmd.SetArgs([]string{"foo", "bar"})
|
||||
|
||||
assert.NoError(t, cmd.Execute())
|
||||
assert.Equal(t, buildObjectIDs(client.services), client.removedServices)
|
||||
assert.Equal(t, buildObjectIDs(client.networks), client.removedNetworks)
|
||||
assert.Nil(t, client.removedSecrets)
|
||||
assert.Nil(t, client.removedConfigs)
|
||||
}
|
||||
|
||||
func TestRemoveStackVersion125DoesNotRemoveConfigs(t *testing.T) {
|
||||
client := fakeClientForRemoveStackTest("1.25")
|
||||
cmd := newRemoveCommand(test.NewFakeCli(client))
|
||||
cmd.SetArgs([]string{"foo", "bar"})
|
||||
|
||||
assert.NoError(t, cmd.Execute())
|
||||
assert.Equal(t, buildObjectIDs(client.services), client.removedServices)
|
||||
assert.Equal(t, buildObjectIDs(client.networks), client.removedNetworks)
|
||||
assert.Equal(t, buildObjectIDs(client.secrets), client.removedSecrets)
|
||||
assert.Nil(t, client.removedConfigs)
|
||||
}
|
||||
|
||||
func TestRemoveStackVersion130RemovesEverything(t *testing.T) {
|
||||
client := fakeClientForRemoveStackTest("1.30")
|
||||
cmd := newRemoveCommand(test.NewFakeCli(client))
|
||||
cmd.SetArgs([]string{"foo", "bar"})
|
||||
|
||||
assert.NoError(t, cmd.Execute())
|
||||
assert.Equal(t, buildObjectIDs(client.services), client.removedServices)
|
||||
assert.Equal(t, buildObjectIDs(client.networks), client.removedNetworks)
|
||||
assert.Equal(t, buildObjectIDs(client.secrets), client.removedSecrets)
|
||||
assert.Equal(t, buildObjectIDs(client.configs), client.removedConfigs)
|
||||
}
|
||||
|
||||
func TestRemoveStackSkipEmpty(t *testing.T) {
|
||||
allServices := []string{objectName("bar", "service1"), objectName("bar", "service2")}
|
||||
allServiceIDs := buildObjectIDs(allServices)
|
||||
|
||||
allNetworks := []string{objectName("bar", "network1")}
|
||||
allNetworkIDs := buildObjectIDs(allNetworks)
|
||||
|
||||
allSecrets := []string{objectName("bar", "secret1")}
|
||||
allSecretIDs := buildObjectIDs(allSecrets)
|
||||
|
||||
allConfigs := []string{objectName("bar", "config1")}
|
||||
allConfigIDs := buildObjectIDs(allConfigs)
|
||||
|
||||
fakeClient := &fakeClient{
|
||||
version: "1.30",
|
||||
services: allServices,
|
||||
networks: allNetworks,
|
||||
secrets: allSecrets,
|
||||
configs: allConfigs,
|
||||
}
|
||||
fakeCli := test.NewFakeCli(fakeClient)
|
||||
cmd := newRemoveCommand(fakeCli)
|
||||
cmd.SetArgs([]string{"foo", "bar"})
|
||||
|
||||
assert.NoError(t, cmd.Execute())
|
||||
expectedList := []string{"Removing service bar_service1",
|
||||
"Removing service bar_service2",
|
||||
"Removing secret bar_secret1",
|
||||
"Removing config bar_config1",
|
||||
"Removing network bar_network1\n",
|
||||
}
|
||||
assert.Equal(t, strings.Join(expectedList, "\n"), fakeCli.OutBuffer().String())
|
||||
assert.Contains(t, fakeCli.ErrBuffer().String(), "Nothing found in stack: foo\n")
|
||||
assert.Equal(t, allServiceIDs, fakeClient.removedServices)
|
||||
assert.Equal(t, allNetworkIDs, fakeClient.removedNetworks)
|
||||
assert.Equal(t, allSecretIDs, fakeClient.removedSecrets)
|
||||
assert.Equal(t, allConfigIDs, fakeClient.removedConfigs)
|
||||
}
|
||||
|
||||
func TestRemoveContinueAfterError(t *testing.T) {
|
||||
allServices := []string{objectName("foo", "service1"), objectName("bar", "service1")}
|
||||
allServiceIDs := buildObjectIDs(allServices)
|
||||
|
||||
allNetworks := []string{objectName("foo", "network1"), objectName("bar", "network1")}
|
||||
allNetworkIDs := buildObjectIDs(allNetworks)
|
||||
|
||||
allSecrets := []string{objectName("foo", "secret1"), objectName("bar", "secret1")}
|
||||
allSecretIDs := buildObjectIDs(allSecrets)
|
||||
|
||||
allConfigs := []string{objectName("foo", "config1"), objectName("bar", "config1")}
|
||||
allConfigIDs := buildObjectIDs(allConfigs)
|
||||
|
||||
removedServices := []string{}
|
||||
cli := &fakeClient{
|
||||
version: "1.30",
|
||||
services: allServices,
|
||||
networks: allNetworks,
|
||||
secrets: allSecrets,
|
||||
configs: allConfigs,
|
||||
|
||||
serviceRemoveFunc: func(serviceID string) error {
|
||||
removedServices = append(removedServices, serviceID)
|
||||
|
||||
if strings.Contains(serviceID, "foo") {
|
||||
return errors.New("")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd := newRemoveCommand(test.NewFakeCli(cli))
|
||||
cmd.SetOutput(ioutil.Discard)
|
||||
cmd.SetArgs([]string{"foo", "bar"})
|
||||
|
||||
assert.EqualError(t, cmd.Execute(), "Failed to remove some resources from stack: foo")
|
||||
assert.Equal(t, allServiceIDs, removedServices)
|
||||
assert.Equal(t, allNetworkIDs, cli.removedNetworks)
|
||||
assert.Equal(t, allSecretIDs, cli.removedSecrets)
|
||||
assert.Equal(t, allConfigIDs, cli.removedConfigs)
|
||||
}
|
||||
94
cli/command/stack/swarm/services.go
Normal file
94
cli/command/stack/swarm/services.go
Normal file
@ -0,0 +1,94 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/command/formatter"
|
||||
"github.com/docker/cli/cli/command/service"
|
||||
"github.com/docker/cli/opts"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
type servicesOptions struct {
|
||||
quiet bool
|
||||
format string
|
||||
filter opts.FilterOpt
|
||||
namespace string
|
||||
}
|
||||
|
||||
func newServicesCommand(dockerCli command.Cli) *cobra.Command {
|
||||
options := servicesOptions{filter: opts.NewFilterOpt()}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "services [OPTIONS] STACK",
|
||||
Short: "List the services in the stack",
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
options.namespace = args[0]
|
||||
return runServices(dockerCli, options)
|
||||
},
|
||||
}
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVarP(&options.quiet, "quiet", "q", false, "Only display IDs")
|
||||
flags.StringVar(&options.format, "format", "", "Pretty-print services using a Go template")
|
||||
flags.VarP(&options.filter, "filter", "f", "Filter output based on conditions provided")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runServices(dockerCli command.Cli, options servicesOptions) error {
|
||||
ctx := context.Background()
|
||||
client := dockerCli.Client()
|
||||
|
||||
filter := getStackFilterFromOpt(options.namespace, options.filter)
|
||||
services, err := client.ServiceList(ctx, types.ServiceListOptions{Filters: filter})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if no services in this stack, print message and exit 0
|
||||
if len(services) == 0 {
|
||||
fmt.Fprintf(dockerCli.Err(), "Nothing found in stack: %s\n", options.namespace)
|
||||
return nil
|
||||
}
|
||||
|
||||
info := map[string]formatter.ServiceListInfo{}
|
||||
if !options.quiet {
|
||||
taskFilter := filters.NewArgs()
|
||||
for _, service := range services {
|
||||
taskFilter.Add("service", service.ID)
|
||||
}
|
||||
|
||||
tasks, err := client.TaskList(ctx, types.TaskListOptions{Filters: taskFilter})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nodes, err := client.NodeList(ctx, types.NodeListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info = service.GetServicesStatus(services, nodes, tasks)
|
||||
}
|
||||
|
||||
format := options.format
|
||||
if len(format) == 0 {
|
||||
if len(dockerCli.ConfigFile().ServicesFormat) > 0 && !options.quiet {
|
||||
format = dockerCli.ConfigFile().ServicesFormat
|
||||
} else {
|
||||
format = formatter.TableFormatKey
|
||||
}
|
||||
}
|
||||
|
||||
servicesCtx := formatter.Context{
|
||||
Output: dockerCli.Out(),
|
||||
Format: formatter.NewServiceListFormat(format, options.quiet),
|
||||
}
|
||||
return formatter.ServiceListWrite(servicesCtx, services, info)
|
||||
}
|
||||
162
cli/command/stack/swarm/services_test.go
Normal file
162
cli/command/stack/swarm/services_test.go
Normal file
@ -0,0 +1,162 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/cli/config/configfile"
|
||||
"github.com/docker/cli/internal/test"
|
||||
// Import builders to get the builder function as package function
|
||||
. "github.com/docker/cli/internal/test/builders"
|
||||
"github.com/docker/cli/internal/test/testutil"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/gotestyourself/gotestyourself/golden"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStackServicesErrors(t *testing.T) {
|
||||
testCases := []struct {
|
||||
args []string
|
||||
flags map[string]string
|
||||
serviceListFunc func(options types.ServiceListOptions) ([]swarm.Service, error)
|
||||
nodeListFunc func(options types.NodeListOptions) ([]swarm.Node, error)
|
||||
taskListFunc func(options types.TaskListOptions) ([]swarm.Task, error)
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
args: []string{"foo"},
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return nil, errors.Errorf("error getting services")
|
||||
},
|
||||
expectedError: "error getting services",
|
||||
},
|
||||
{
|
||||
args: []string{"foo"},
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return []swarm.Service{*Service()}, nil
|
||||
},
|
||||
nodeListFunc: func(options types.NodeListOptions) ([]swarm.Node, error) {
|
||||
return nil, errors.Errorf("error getting nodes")
|
||||
},
|
||||
expectedError: "error getting nodes",
|
||||
},
|
||||
{
|
||||
args: []string{"foo"},
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return []swarm.Service{*Service()}, nil
|
||||
},
|
||||
taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
|
||||
return nil, errors.Errorf("error getting tasks")
|
||||
},
|
||||
expectedError: "error getting tasks",
|
||||
},
|
||||
{
|
||||
args: []string{"foo"},
|
||||
flags: map[string]string{
|
||||
"format": "{{invalid format}}",
|
||||
},
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return []swarm.Service{*Service()}, nil
|
||||
},
|
||||
expectedError: "Template parsing error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
serviceListFunc: tc.serviceListFunc,
|
||||
nodeListFunc: tc.nodeListFunc,
|
||||
taskListFunc: tc.taskListFunc,
|
||||
})
|
||||
cmd := newServicesCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
for key, value := range tc.flags {
|
||||
cmd.Flags().Set(key, value)
|
||||
}
|
||||
cmd.SetOutput(ioutil.Discard)
|
||||
testutil.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStackServicesEmptyServiceList(t *testing.T) {
|
||||
fakeCli := test.NewFakeCli(&fakeClient{
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return []swarm.Service{}, nil
|
||||
},
|
||||
})
|
||||
cmd := newServicesCommand(fakeCli)
|
||||
cmd.SetArgs([]string{"foo"})
|
||||
assert.NoError(t, cmd.Execute())
|
||||
assert.Equal(t, "", fakeCli.OutBuffer().String())
|
||||
assert.Equal(t, "Nothing found in stack: foo\n", fakeCli.ErrBuffer().String())
|
||||
}
|
||||
|
||||
func TestStackServicesWithQuietOption(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return []swarm.Service{*Service(ServiceID("id-foo"))}, nil
|
||||
},
|
||||
})
|
||||
cmd := newServicesCommand(cli)
|
||||
cmd.Flags().Set("quiet", "true")
|
||||
cmd.SetArgs([]string{"foo"})
|
||||
assert.NoError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), "stack-services-with-quiet-option.golden")
|
||||
}
|
||||
|
||||
func TestStackServicesWithFormat(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return []swarm.Service{
|
||||
*Service(ServiceName("service-name-foo")),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
cmd := newServicesCommand(cli)
|
||||
cmd.SetArgs([]string{"foo"})
|
||||
cmd.Flags().Set("format", "{{ .Name }}")
|
||||
assert.NoError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), "stack-services-with-format.golden")
|
||||
}
|
||||
|
||||
func TestStackServicesWithConfigFormat(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return []swarm.Service{
|
||||
*Service(ServiceName("service-name-foo")),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
cli.SetConfigFile(&configfile.ConfigFile{
|
||||
ServicesFormat: "{{ .Name }}",
|
||||
})
|
||||
cmd := newServicesCommand(cli)
|
||||
cmd.SetArgs([]string{"foo"})
|
||||
assert.NoError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), "stack-services-with-config-format.golden")
|
||||
}
|
||||
|
||||
func TestStackServicesWithoutFormat(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return []swarm.Service{*Service(
|
||||
ServiceName("name-foo"),
|
||||
ServiceID("id-foo"),
|
||||
ReplicatedService(2),
|
||||
ServiceImage("busybox:latest"),
|
||||
ServicePort(swarm.PortConfig{
|
||||
PublishMode: swarm.PortConfigPublishModeIngress,
|
||||
PublishedPort: 0,
|
||||
TargetPort: 3232,
|
||||
Protocol: swarm.PortConfigProtocolTCP,
|
||||
}),
|
||||
)}, nil
|
||||
},
|
||||
})
|
||||
cmd := newServicesCommand(cli)
|
||||
cmd.SetArgs([]string{"foo"})
|
||||
assert.NoError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), "stack-services-without-format.golden")
|
||||
}
|
||||
4
cli/command/stack/swarm/testdata/stack-list-sort-natural.golden
vendored
Normal file
4
cli/command/stack/swarm/testdata/stack-list-sort-natural.golden
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
NAME SERVICES
|
||||
service-name-1-foo 1
|
||||
service-name-2-foo 1
|
||||
service-name-10-foo 1
|
||||
3
cli/command/stack/swarm/testdata/stack-list-sort.golden
vendored
Normal file
3
cli/command/stack/swarm/testdata/stack-list-sort.golden
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
NAME SERVICES
|
||||
service-name-bar 1
|
||||
service-name-foo 1
|
||||
1
cli/command/stack/swarm/testdata/stack-list-with-format.golden
vendored
Normal file
1
cli/command/stack/swarm/testdata/stack-list-with-format.golden
vendored
Normal file
@ -0,0 +1 @@
|
||||
service-name-foo
|
||||
2
cli/command/stack/swarm/testdata/stack-list-without-format.golden
vendored
Normal file
2
cli/command/stack/swarm/testdata/stack-list-without-format.golden
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
NAME SERVICES
|
||||
service-name-foo 1
|
||||
1
cli/command/stack/swarm/testdata/stack-ps-with-config-format.golden
vendored
Normal file
1
cli/command/stack/swarm/testdata/stack-ps-with-config-format.golden
vendored
Normal file
@ -0,0 +1 @@
|
||||
service-id-foo.1
|
||||
1
cli/command/stack/swarm/testdata/stack-ps-with-format.golden
vendored
Normal file
1
cli/command/stack/swarm/testdata/stack-ps-with-format.golden
vendored
Normal file
@ -0,0 +1 @@
|
||||
service-id-foo.1
|
||||
1
cli/command/stack/swarm/testdata/stack-ps-with-no-resolve-option.golden
vendored
Normal file
1
cli/command/stack/swarm/testdata/stack-ps-with-no-resolve-option.golden
vendored
Normal file
@ -0,0 +1 @@
|
||||
id-node-foo
|
||||
1
cli/command/stack/swarm/testdata/stack-ps-with-no-trunc-option.golden
vendored
Normal file
1
cli/command/stack/swarm/testdata/stack-ps-with-no-trunc-option.golden
vendored
Normal file
@ -0,0 +1 @@
|
||||
xn4cypcov06f2w8gsbaf2lst3
|
||||
1
cli/command/stack/swarm/testdata/stack-ps-with-quiet-option.golden
vendored
Normal file
1
cli/command/stack/swarm/testdata/stack-ps-with-quiet-option.golden
vendored
Normal file
@ -0,0 +1 @@
|
||||
id-foo
|
||||
2
cli/command/stack/swarm/testdata/stack-ps-without-format.golden
vendored
Normal file
2
cli/command/stack/swarm/testdata/stack-ps-without-format.golden
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS
|
||||
id-foo service-id-foo.1 myimage:mytag node-name-bar Ready Failed 2 hours ago
|
||||
1
cli/command/stack/swarm/testdata/stack-services-with-config-format.golden
vendored
Normal file
1
cli/command/stack/swarm/testdata/stack-services-with-config-format.golden
vendored
Normal file
@ -0,0 +1 @@
|
||||
service-name-foo
|
||||
1
cli/command/stack/swarm/testdata/stack-services-with-format.golden
vendored
Normal file
1
cli/command/stack/swarm/testdata/stack-services-with-format.golden
vendored
Normal file
@ -0,0 +1 @@
|
||||
service-name-foo
|
||||
1
cli/command/stack/swarm/testdata/stack-services-with-quiet-option.golden
vendored
Normal file
1
cli/command/stack/swarm/testdata/stack-services-with-quiet-option.golden
vendored
Normal file
@ -0,0 +1 @@
|
||||
id-foo
|
||||
2
cli/command/stack/swarm/testdata/stack-services-without-format.golden
vendored
Normal file
2
cli/command/stack/swarm/testdata/stack-services-without-format.golden
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
ID NAME MODE REPLICAS IMAGE PORTS
|
||||
id-foo name-foo replicated 0/2 busybox:latest *:30000->3232/tcp
|
||||
Reference in New Issue
Block a user