diff --git a/cli/command/bundlefile/bundlefile.go b/cli/command/bundlefile/bundlefile.go new file mode 100644 index 0000000000..7fd1e4f6c4 --- /dev/null +++ b/cli/command/bundlefile/bundlefile.go @@ -0,0 +1,69 @@ +package bundlefile + +import ( + "encoding/json" + "fmt" + "io" +) + +// Bundlefile stores the contents of a bundlefile +type Bundlefile struct { + Version string + Services map[string]Service +} + +// Service is a service from a bundlefile +type Service struct { + Image string + Command []string `json:",omitempty"` + Args []string `json:",omitempty"` + Env []string `json:",omitempty"` + Labels map[string]string `json:",omitempty"` + Ports []Port `json:",omitempty"` + WorkingDir *string `json:",omitempty"` + User *string `json:",omitempty"` + Networks []string `json:",omitempty"` +} + +// Port is a port as defined in a bundlefile +type Port struct { + Protocol string + Port uint32 +} + +// LoadFile loads a bundlefile from a path to the file +func LoadFile(reader io.Reader) (*Bundlefile, error) { + bundlefile := &Bundlefile{} + + decoder := json.NewDecoder(reader) + if err := decoder.Decode(bundlefile); err != nil { + switch jsonErr := err.(type) { + case *json.SyntaxError: + return nil, fmt.Errorf( + "JSON syntax error at byte %v: %s", + jsonErr.Offset, + jsonErr.Error()) + case *json.UnmarshalTypeError: + return nil, fmt.Errorf( + "Unexpected type at byte %v. Expected %s but received %s.", + jsonErr.Offset, + jsonErr.Type, + jsonErr.Value) + } + return nil, err + } + + return bundlefile, nil +} + +// Print writes the contents of the bundlefile to the output writer +// as human readable json +func Print(out io.Writer, bundle *Bundlefile) error { + bytes, err := json.MarshalIndent(*bundle, "", " ") + if err != nil { + return err + } + + _, err = out.Write(bytes) + return err +} diff --git a/cli/command/bundlefile/bundlefile_test.go b/cli/command/bundlefile/bundlefile_test.go new file mode 100644 index 0000000000..c343410df3 --- /dev/null +++ b/cli/command/bundlefile/bundlefile_test.go @@ -0,0 +1,77 @@ +package bundlefile + +import ( + "bytes" + "strings" + "testing" + + "github.com/docker/docker/pkg/testutil/assert" +) + +func TestLoadFileV01Success(t *testing.T) { + reader := strings.NewReader(`{ + "Version": "0.1", + "Services": { + "redis": { + "Image": "redis@sha256:4b24131101fa0117bcaa18ac37055fffd9176aa1a240392bb8ea85e0be50f2ce", + "Networks": ["default"] + }, + "web": { + "Image": "dockercloud/hello-world@sha256:fe79a2cfbd17eefc344fb8419420808df95a1e22d93b7f621a7399fd1e9dca1d", + "Networks": ["default"], + "User": "web" + } + } + }`) + + bundle, err := LoadFile(reader) + assert.NilError(t, err) + assert.Equal(t, bundle.Version, "0.1") + assert.Equal(t, len(bundle.Services), 2) +} + +func TestLoadFileSyntaxError(t *testing.T) { + reader := strings.NewReader(`{ + "Version": "0.1", + "Services": unquoted string + }`) + + _, err := LoadFile(reader) + assert.Error(t, err, "syntax error at byte 37: invalid character 'u'") +} + +func TestLoadFileTypeError(t *testing.T) { + reader := strings.NewReader(`{ + "Version": "0.1", + "Services": { + "web": { + "Image": "redis", + "Networks": "none" + } + } + }`) + + _, err := LoadFile(reader) + assert.Error(t, err, "Unexpected type at byte 94. Expected []string but received string") +} + +func TestPrint(t *testing.T) { + var buffer bytes.Buffer + bundle := &Bundlefile{ + Version: "0.1", + Services: map[string]Service{ + "web": { + Image: "image", + Command: []string{"echo", "something"}, + }, + }, + } + assert.NilError(t, Print(&buffer, bundle)) + output := buffer.String() + assert.Contains(t, output, "\"Image\": \"image\"") + assert.Contains(t, output, + `"Command": [ + "echo", + "something" + ]`) +} diff --git a/cli/command/stack/deploy.go b/cli/command/stack/deploy.go index 6201c2bd2e..895442a04d 100644 --- a/cli/command/stack/deploy.go +++ b/cli/command/stack/deploy.go @@ -29,6 +29,7 @@ const ( ) type deployOptions struct { + bundlefile string composefile string namespace string sendRegistryAuth bool @@ -50,12 +51,108 @@ func newDeployCommand(dockerCli *command.DockerCli) *cobra.Command { } flags := cmd.Flags() + addBundlefileFlag(&opts.bundlefile, flags) addComposefileFlag(&opts.composefile, flags) addRegistryAuthFlag(&opts.sendRegistryAuth, flags) return cmd } func runDeploy(dockerCli *command.DockerCli, opts deployOptions) error { + if opts.bundlefile == "" && opts.composefile == "" { + return fmt.Errorf("Please specify either a bundle file (with --bundle-file) or a Compose file (with --compose-file).") + } + + if opts.bundlefile != "" && opts.composefile != "" { + return fmt.Errorf("You cannot specify both a bundle file and a Compose file.") + } + + info, err := dockerCli.Client().Info(context.Background()) + if err != nil { + return err + } + if !info.Swarm.ControlAvailable { + return fmt.Errorf("This node is not a swarm manager. Use \"docker swarm init\" or \"docker swarm join\" to connect this node to swarm and try again.") + } + + if opts.bundlefile != "" { + return deployBundle(dockerCli, opts) + } else { + return deployCompose(dockerCli, opts) + } +} + +func deployBundle(dockerCli *command.DockerCli, opts deployOptions) error { + bundle, err := loadBundlefile(dockerCli.Err(), opts.namespace, opts.bundlefile) + if err != nil { + return err + } + + namespace := namespace{name: opts.namespace} + + networks := make(map[string]types.NetworkCreate) + for _, service := range bundle.Services { + for _, networkName := range service.Networks { + networks[networkName] = types.NetworkCreate{ + Labels: getStackLabels(namespace.name, 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{networkName}, + }) + } + + serviceSpec := swarm.ServiceSpec{ + Annotations: swarm.Annotations{ + Name: name, + Labels: getStackLabels(namespace.name, 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: getStackLabels(namespace.name, nil), + }, + }, + EndpointSpec: &swarm.EndpointSpec{ + Ports: ports, + }, + Networks: nets, + } + + services[internalName] = serviceSpec + } + + ctx := context.Background() + + if err := createNetworks(ctx, dockerCli, namespace, networks); err != nil { + return err + } + return deployServices(ctx, dockerCli, services, namespace, opts.sendRegistryAuth) +} + +func deployCompose(dockerCli *command.DockerCli, opts deployOptions) error { configDetails, err := getConfigDetails(opts) if err != nil { return err @@ -86,14 +183,15 @@ func runDeploy(dockerCli *command.DockerCli, opts deployOptions) error { ctx := context.Background() namespace := namespace{name: opts.namespace} - networks := config.Networks - if networks == nil { - networks = make(map[string]composetypes.NetworkConfig) - } - if err := createNetworks(ctx, dockerCli, networks, namespace); err != nil { + networks := convertNetworks(namespace, config.Networks) + if err := createNetworks(ctx, dockerCli, namespace, networks); err != nil { return err } - return deployServices(ctx, dockerCli, config, namespace, opts.sendRegistryAuth) + services, err := convertServices(namespace, config) + if err != nil { + return err + } + return deployServices(ctx, dockerCli, services, namespace, opts.sendRegistryAuth) } func propertyWarnings(properties map[string]string) string { @@ -138,37 +236,24 @@ func getConfigFile(filename string) (*composetypes.ConfigFile, error) { }, nil } -func createNetworks( - ctx context.Context, - dockerCli *command.DockerCli, - networks map[string]composetypes.NetworkConfig, +func convertNetworks( namespace namespace, -) error { - client := dockerCli.Client() - - existingNetworks, err := getNetworks(ctx, client, namespace.name) - if err != nil { - return err - } - - existingNetworkMap := make(map[string]types.NetworkResource) - for _, network := range existingNetworks { - existingNetworkMap[network.Name] = network + networks map[string]composetypes.NetworkConfig, +) map[string]types.NetworkCreate { + if networks == nil { + networks = make(map[string]composetypes.NetworkConfig) } // TODO: only add default network if it's used networks["default"] = composetypes.NetworkConfig{} + result := make(map[string]types.NetworkCreate) + for internalName, network := range networks { if network.External.Name != "" { continue } - name := namespace.scope(internalName) - if _, exists := existingNetworkMap[name]; exists { - continue - } - createOpts := types.NetworkCreate{ Labels: getStackLabels(namespace.name, network.Labels), Driver: network.Driver, @@ -182,6 +267,36 @@ func createNetworks( } // TODO: IPAMConfig.Config + result[internalName] = createOpts + } + + return result +} + +func createNetworks( + ctx context.Context, + dockerCli *command.DockerCli, + namespace namespace, + networks map[string]types.NetworkCreate, +) error { + client := dockerCli.Client() + + existingNetworks, err := getNetworks(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 } @@ -191,10 +306,11 @@ func createNetworks( return err } } + return nil } -func convertNetworks( +func convertServiceNetworks( networks map[string]*composetypes.ServiceNetworkConfig, namespace namespace, name string, @@ -294,14 +410,12 @@ func convertVolumes( func deployServices( ctx context.Context, dockerCli *command.DockerCli, - config *composetypes.Config, + services map[string]swarm.ServiceSpec, namespace namespace, sendAuth bool, ) error { apiClient := dockerCli.Client() out := dockerCli.Out() - services := config.Services - volumes := config.Volumes existingServices, err := getServices(ctx, apiClient, namespace.name) if err != nil { @@ -313,13 +427,8 @@ func deployServices( existingServiceMap[service.Spec.Name] = service } - for _, service := range services { - name := namespace.scope(service.Name) - - serviceSpec, err := convertService(namespace, service, volumes) - if err != nil { - return err - } + for internalName, serviceSpec := range services { + name := namespace.scope(internalName) encodedAuth := "" if sendAuth { @@ -363,6 +472,26 @@ func deployServices( return nil } +func convertServices( + namespace namespace, + config *composetypes.Config, +) (map[string]swarm.ServiceSpec, error) { + result := make(map[string]swarm.ServiceSpec) + + services := config.Services + volumes := config.Volumes + + for _, service := range services { + serviceSpec, err := convertService(namespace, service, volumes) + if err != nil { + return nil, err + } + result[service.Name] = serviceSpec + } + + return result, nil +} + func convertService( namespace namespace, service composetypes.ServiceConfig, @@ -422,7 +551,7 @@ func convertService( }, EndpointSpec: endpoint, Mode: mode, - Networks: convertNetworks(service.Networks, namespace, service.Name), + Networks: convertServiceNetworks(service.Networks, namespace, service.Name), UpdateConfig: convertUpdateConfig(service.Deploy.UpdateConfig), } diff --git a/cli/command/stack/opts.go b/cli/command/stack/opts.go index a33e7707e8..c2cc0d1e70 100644 --- a/cli/command/stack/opts.go +++ b/cli/command/stack/opts.go @@ -1,11 +1,48 @@ package stack -import "github.com/spf13/pflag" +import ( + "fmt" + "io" + "os" + + "github.com/docker/docker/cli/command/bundlefile" + "github.com/spf13/pflag" +) func addComposefileFlag(opt *string, flags *pflag.FlagSet) { flags.StringVar(opt, "compose-file", "", "Path to a Compose file") } +func addBundlefileFlag(opt *string, flags *pflag.FlagSet) { + flags.StringVar(opt, "bundle-file", "", "Path to a Distributed Application Bundle file") +} + func addRegistryAuthFlag(opt *bool, flags *pflag.FlagSet) { flags.BoolVar(opt, "with-registry-auth", false, "Send registry authentication details to Swarm agents") } + +func loadBundlefile(stderr io.Writer, namespace string, path string) (*bundlefile.Bundlefile, error) { + defaultPath := fmt.Sprintf("%s.dab", namespace) + + if path == "" { + path = defaultPath + } + if _, err := os.Stat(path); err != nil { + return nil, fmt.Errorf( + "Bundle %s not found. Specify the path with --file", + path) + } + + fmt.Fprintf(stderr, "Loading bundle from %s\n", path) + reader, err := os.Open(path) + if err != nil { + return nil, err + } + defer reader.Close() + + bundle, err := bundlefile.LoadFile(reader) + if err != nil { + return nil, fmt.Errorf("Error reading %s: %v\n", path, err) + } + return bundle, err +}