diff --git a/api/server/router/swarm/cluster_routes.go b/api/server/router/swarm/cluster_routes.go index 509c7acad5..ef4157bd8a 100644 --- a/api/server/router/swarm/cluster_routes.go +++ b/api/server/router/swarm/cluster_routes.go @@ -213,19 +213,7 @@ func (sr *swarmRouter) createService(ctx context.Context, w http.ResponseWriter, if versions.LessThan(cliVersion, "1.30") { queryRegistry = true } - if versions.LessThan(cliVersion, "1.40") { - if service.TaskTemplate.ContainerSpec != nil { - // Sysctls for docker swarm services weren't supported before - // API version 1.40 - service.TaskTemplate.ContainerSpec.Sysctls = nil - } - - if service.TaskTemplate.Placement != nil { - // MaxReplicas for docker swarm services weren't supported before - // API version 1.40 - service.TaskTemplate.Placement.MaxReplicas = 0 - } - } + adjustForAPIVersion(cliVersion, &service) } resp, err := sr.backend.CreateService(service, encodedAuth, queryRegistry) @@ -265,19 +253,7 @@ func (sr *swarmRouter) updateService(ctx context.Context, w http.ResponseWriter, if versions.LessThan(cliVersion, "1.30") { queryRegistry = true } - if versions.LessThan(cliVersion, "1.40") { - if service.TaskTemplate.ContainerSpec != nil { - // Sysctls for docker swarm services weren't supported before - // API version 1.40 - service.TaskTemplate.ContainerSpec.Sysctls = nil - } - - if service.TaskTemplate.Placement != nil { - // MaxReplicas for docker swarm services weren't supported before - // API version 1.40 - service.TaskTemplate.Placement.MaxReplicas = 0 - } - } + adjustForAPIVersion(cliVersion, &service) } resp, err := sr.backend.UpdateService(vars["id"], version, service, flags, queryRegistry) diff --git a/api/server/router/swarm/helpers.go b/api/server/router/swarm/helpers.go index 1f57074f92..3120e8f8f2 100644 --- a/api/server/router/swarm/helpers.go +++ b/api/server/router/swarm/helpers.go @@ -9,6 +9,8 @@ import ( "github.com/docker/docker/api/server/httputils" basictypes "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/backend" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/api/types/versions" ) // swarmLogs takes an http response, request, and selector, and writes the logs @@ -64,3 +66,33 @@ func (sr *swarmRouter) swarmLogs(ctx context.Context, w io.Writer, r *http.Reque httputils.WriteLogStream(ctx, w, msgs, logsConfig, !tty) return nil } + +// adjustForAPIVersion takes a version and service spec and removes fields to +// make the spec compatible with the specified version. +func adjustForAPIVersion(cliVersion string, service *swarm.ServiceSpec) { + if cliVersion == "" { + return + } + if versions.LessThan(cliVersion, "1.40") { + if service.TaskTemplate.ContainerSpec != nil { + // Sysctls for docker swarm services weren't supported before + // API version 1.40 + service.TaskTemplate.ContainerSpec.Sysctls = nil + + if service.TaskTemplate.ContainerSpec.Privileges != nil && service.TaskTemplate.ContainerSpec.Privileges.CredentialSpec != nil { + // Support for setting credential-spec through configs was added in API 1.40 + service.TaskTemplate.ContainerSpec.Privileges.CredentialSpec.Config = "" + } + for _, config := range service.TaskTemplate.ContainerSpec.Configs { + // support for the Runtime target was added in API 1.40 + config.Runtime = nil + } + } + + if service.TaskTemplate.Placement != nil { + // MaxReplicas for docker swarm services weren't supported before + // API version 1.40 + service.TaskTemplate.Placement.MaxReplicas = 0 + } + } +} diff --git a/api/server/router/swarm/helpers_test.go b/api/server/router/swarm/helpers_test.go new file mode 100644 index 0000000000..08764e5a17 --- /dev/null +++ b/api/server/router/swarm/helpers_test.go @@ -0,0 +1,87 @@ +package swarm // import "github.com/docker/docker/api/server/router/swarm" + +import ( + "reflect" + "testing" + + "github.com/docker/docker/api/types/swarm" +) + +func TestAdjustForAPIVersion(t *testing.T) { + var ( + expectedSysctls = map[string]string{"foo": "bar"} + ) + // testing the negative -- does this leave everything else alone? -- is + // prohibitively time-consuming to write, because it would need an object + // with literally every field filled in. + spec := &swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Sysctls: expectedSysctls, + Privileges: &swarm.Privileges{ + CredentialSpec: &swarm.CredentialSpec{ + Config: "someconfig", + }, + }, + Configs: []*swarm.ConfigReference{ + { + File: &swarm.ConfigReferenceFileTarget{ + Name: "foo", + UID: "bar", + GID: "baz", + }, + ConfigID: "configFile", + ConfigName: "configFile", + }, + { + Runtime: &swarm.ConfigReferenceRuntimeTarget{}, + ConfigID: "configRuntime", + ConfigName: "configRuntime", + }, + }, + }, + Placement: &swarm.Placement{ + MaxReplicas: 222, + }, + }, + } + + // first, does calling this with a later version correctly NOT strip + // fields? do the later version first, so we can reuse this spec in the + // next test. + adjustForAPIVersion("1.40", spec) + if !reflect.DeepEqual(spec.TaskTemplate.ContainerSpec.Sysctls, expectedSysctls) { + t.Error("Sysctls was stripped from spec") + } + + if spec.TaskTemplate.ContainerSpec.Privileges.CredentialSpec.Config != "someconfig" { + t.Error("CredentialSpec.Config field was stripped from spec") + } + + if spec.TaskTemplate.ContainerSpec.Configs[1].Runtime == nil { + t.Error("ConfigReferenceRuntimeTarget was stripped from spec") + } + + if spec.TaskTemplate.Placement.MaxReplicas != 222 { + t.Error("MaxReplicas was stripped from spec") + } + + // next, does calling this with an earlier version correctly strip fields? + adjustForAPIVersion("1.29", spec) + if spec.TaskTemplate.ContainerSpec.Sysctls != nil { + t.Error("Sysctls was not stripped from spec") + } + + if spec.TaskTemplate.ContainerSpec.Privileges.CredentialSpec.Config != "" { + t.Error("CredentialSpec.Config field was not stripped from spec") + } + + if spec.TaskTemplate.ContainerSpec.Configs[1].Runtime != nil { + t.Error("ConfigReferenceRuntimeTarget was not stripped from spec") + } + + if spec.TaskTemplate.Placement.MaxReplicas != 0 { + t.Error("MaxReplicas was not stripped from spec") + } + +} diff --git a/api/swagger.yaml b/api/swagger.yaml index a1f608fad2..514077ff7f 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -2623,8 +2623,20 @@ definitions: type: "object" description: "CredentialSpec for managed service account (Windows only)" properties: + Config: + type: "string" + example: "0bt9dmxjvjiqermk6xrop3ekq" + description: | + Load credential spec from a Swarm Config with the given ID. + The specified config must also be present in the Configs field with the Runtime property set. + +


+ + + > **Note**: `CredentialSpec.File`, `CredentialSpec.Registry`, and `CredentialSpec.Config` are mutually exclusive. File: type: "string" + example: "spec.json" description: | Load credential spec from this file. The file is read by the daemon, and must be present in the `CredentialSpecs` subdirectory in the docker data directory, which defaults to @@ -2634,7 +2646,7 @@ definitions:


- > **Note**: `CredentialSpec.File` and `CredentialSpec.Registry` are mutually exclusive. + > **Note**: `CredentialSpec.File`, `CredentialSpec.Registry`, and `CredentialSpec.Config` are mutually exclusive. Registry: type: "string" description: | @@ -2646,7 +2658,7 @@ definitions:


- > **Note**: `CredentialSpec.File` and `CredentialSpec.Registry` are mutually exclusive. + > **Note**: `CredentialSpec.File`, `CredentialSpec.Registry`, and `CredentialSpec.Config` are mutually exclusive. SELinuxContext: type: "object" description: "SELinux labels of the container" @@ -2757,7 +2769,12 @@ definitions: type: "object" properties: File: - description: "File represents a specific target that is backed by a file." + description: | + File represents a specific target that is backed by a file. + +


+ + > **Note**: `Configs.File` and `Configs.Runtime` are mutually exclusive type: "object" properties: Name: @@ -2773,6 +2790,14 @@ definitions: description: "Mode represents the FileMode of the file." type: "integer" format: "uint32" + Runtime: + description: | + Runtime represents a target that is not mounted into the container but is used by the task + +


+ + > **Note**: `Configs.File` and `Configs.Runtime` are mutually exclusive + type: "object" ConfigID: description: "ConfigID represents the ID of the specific config that we're referencing." type: "string" diff --git a/api/types/swarm/config.go b/api/types/swarm/config.go index a1555cf43e..16202ccce6 100644 --- a/api/types/swarm/config.go +++ b/api/types/swarm/config.go @@ -27,9 +27,14 @@ type ConfigReferenceFileTarget struct { Mode os.FileMode } +// ConfigReferenceRuntimeTarget is a target for a config specifying that it +// isn't mounted into the container but instead has some other purpose. +type ConfigReferenceRuntimeTarget struct{} + // ConfigReference is a reference to a config in swarm type ConfigReference struct { - File *ConfigReferenceFileTarget + File *ConfigReferenceFileTarget `json:",omitempty"` + Runtime *ConfigReferenceRuntimeTarget `json:",omitempty"` ConfigID string ConfigName string } diff --git a/api/types/swarm/container.go b/api/types/swarm/container.go index e12f09837f..48190c1762 100644 --- a/api/types/swarm/container.go +++ b/api/types/swarm/container.go @@ -33,6 +33,7 @@ type SELinuxContext struct { // CredentialSpec for managed service account (Windows only) type CredentialSpec struct { + Config string File string Registry string } diff --git a/daemon/cluster/convert/container.go b/daemon/cluster/convert/container.go index 37f562ad26..c3eb28df54 100644 --- a/daemon/cluster/convert/container.go +++ b/daemon/cluster/convert/container.go @@ -1,7 +1,6 @@ package convert // import "github.com/docker/docker/daemon/cluster/convert" import ( - "errors" "fmt" "strings" @@ -10,6 +9,7 @@ import ( types "github.com/docker/docker/api/types/swarm" swarmapi "github.com/docker/swarmkit/api" gogotypes "github.com/gogo/protobuf/types" + "github.com/pkg/errors" "github.com/sirupsen/logrus" ) @@ -52,13 +52,7 @@ func containerSpecFromGRPC(c *swarmapi.ContainerSpec) *types.ContainerSpec { containerSpec.Privileges = &types.Privileges{} if c.Privileges.CredentialSpec != nil { - containerSpec.Privileges.CredentialSpec = &types.CredentialSpec{} - switch c.Privileges.CredentialSpec.Source.(type) { - case *swarmapi.Privileges_CredentialSpec_File: - containerSpec.Privileges.CredentialSpec.File = c.Privileges.CredentialSpec.GetFile() - case *swarmapi.Privileges_CredentialSpec_Registry: - containerSpec.Privileges.CredentialSpec.Registry = c.Privileges.CredentialSpec.GetRegistry() - } + containerSpec.Privileges.CredentialSpec = credentialSpecFromGRPC(c.Privileges.CredentialSpec) } if c.Privileges.SELinuxContext != nil { @@ -184,14 +178,26 @@ func secretReferencesFromGRPC(sr []*swarmapi.SecretReference) []*types.SecretRef return refs } -func configReferencesToGRPC(sr []*types.ConfigReference) []*swarmapi.ConfigReference { +func configReferencesToGRPC(sr []*types.ConfigReference) ([]*swarmapi.ConfigReference, error) { refs := make([]*swarmapi.ConfigReference, 0, len(sr)) for _, s := range sr { ref := &swarmapi.ConfigReference{ ConfigID: s.ConfigID, ConfigName: s.ConfigName, } - if s.File != nil { + switch { + case s.Runtime == nil && s.File == nil: + return nil, errors.New("either File or Runtime should be set") + case s.Runtime != nil && s.File != nil: + return nil, errors.New("cannot specify both File and Runtime") + case s.Runtime != nil: + // Runtime target was added in API v1.40 and takes precedence over + // File target. However, File and Runtime targets are mutually exclusive, + // so we should never have both. + ref.Target = &swarmapi.ConfigReference_Runtime{ + Runtime: &swarmapi.RuntimeTarget{}, + } + case s.File != nil: ref.Target = &swarmapi.ConfigReference_File{ File: &swarmapi.FileTarget{ Name: s.File.Name, @@ -205,28 +211,32 @@ func configReferencesToGRPC(sr []*types.ConfigReference) []*swarmapi.ConfigRefer refs = append(refs, ref) } - return refs + return refs, nil } func configReferencesFromGRPC(sr []*swarmapi.ConfigReference) []*types.ConfigReference { refs := make([]*types.ConfigReference, 0, len(sr)) for _, s := range sr { - target := s.GetFile() - if target == nil { - // not a file target - logrus.Warnf("config target not a file: config=%s", s.ConfigID) - continue + + r := &types.ConfigReference{ + ConfigID: s.ConfigID, + ConfigName: s.ConfigName, } - refs = append(refs, &types.ConfigReference{ - File: &types.ConfigReferenceFileTarget{ + if target := s.GetRuntime(); target != nil { + r.Runtime = &types.ConfigReferenceRuntimeTarget{} + } else if target := s.GetFile(); target != nil { + r.File = &types.ConfigReferenceFileTarget{ Name: target.Name, UID: target.UID, GID: target.GID, Mode: target.Mode, - }, - ConfigID: s.ConfigID, - ConfigName: s.ConfigName, - }) + } + } else { + // not a file target + logrus.Warnf("config target not known: config=%s", s.ConfigID) + continue + } + refs = append(refs, r) } return refs @@ -249,7 +259,6 @@ func containerToGRPC(c *types.ContainerSpec) (*swarmapi.ContainerSpec, error) { ReadOnly: c.ReadOnly, Hosts: c.Hosts, Secrets: secretReferencesToGRPC(c.Secrets), - Configs: configReferencesToGRPC(c.Configs), Isolation: isolationToGRPC(c.Isolation), Init: initToGRPC(c.Init), Sysctls: c.Sysctls, @@ -272,22 +281,11 @@ func containerToGRPC(c *types.ContainerSpec) (*swarmapi.ContainerSpec, error) { containerSpec.Privileges = &swarmapi.Privileges{} if c.Privileges.CredentialSpec != nil { - containerSpec.Privileges.CredentialSpec = &swarmapi.Privileges_CredentialSpec{} - - if c.Privileges.CredentialSpec.File != "" && c.Privileges.CredentialSpec.Registry != "" { - return nil, errors.New("cannot specify both \"file\" and \"registry\" credential specs") - } - if c.Privileges.CredentialSpec.File != "" { - containerSpec.Privileges.CredentialSpec.Source = &swarmapi.Privileges_CredentialSpec_File{ - File: c.Privileges.CredentialSpec.File, - } - } else if c.Privileges.CredentialSpec.Registry != "" { - containerSpec.Privileges.CredentialSpec.Source = &swarmapi.Privileges_CredentialSpec_Registry{ - Registry: c.Privileges.CredentialSpec.Registry, - } - } else { - return nil, errors.New("must either provide \"file\" or \"registry\" for credential spec") + cs, err := credentialSpecToGRPC(c.Privileges.CredentialSpec) + if err != nil { + return nil, errors.Wrap(err, "invalid CredentialSpec") } + containerSpec.Privileges.CredentialSpec = cs } if c.Privileges.SELinuxContext != nil { @@ -301,6 +299,14 @@ func containerToGRPC(c *types.ContainerSpec) (*swarmapi.ContainerSpec, error) { } } + if c.Configs != nil { + configs, err := configReferencesToGRPC(c.Configs) + if err != nil { + return nil, errors.Wrap(err, "invalid Config") + } + containerSpec.Configs = configs + } + // Mounts for _, m := range c.Mounts { mount := swarmapi.Mount{ @@ -359,6 +365,60 @@ func containerToGRPC(c *types.ContainerSpec) (*swarmapi.ContainerSpec, error) { return containerSpec, nil } +func credentialSpecFromGRPC(c *swarmapi.Privileges_CredentialSpec) *types.CredentialSpec { + cs := &types.CredentialSpec{} + switch c.Source.(type) { + case *swarmapi.Privileges_CredentialSpec_Config: + cs.Config = c.GetConfig() + case *swarmapi.Privileges_CredentialSpec_File: + cs.File = c.GetFile() + case *swarmapi.Privileges_CredentialSpec_Registry: + cs.Registry = c.GetRegistry() + } + return cs +} + +func credentialSpecToGRPC(c *types.CredentialSpec) (*swarmapi.Privileges_CredentialSpec, error) { + var opts []string + + if c.Config != "" { + opts = append(opts, `"config"`) + } + if c.File != "" { + opts = append(opts, `"file"`) + } + if c.Registry != "" { + opts = append(opts, `"registry"`) + } + l := len(opts) + switch { + case l == 0: + return nil, errors.New(`must either provide "file", "registry", or "config" for credential spec`) + case l == 2: + return nil, fmt.Errorf("cannot specify both %s and %s credential specs", opts[0], opts[1]) + case l > 2: + return nil, fmt.Errorf("cannot specify both %s, and %s credential specs", strings.Join(opts[:l-1], ", "), opts[l-1]) + } + + spec := &swarmapi.Privileges_CredentialSpec{} + switch { + case c.Config != "": + spec.Source = &swarmapi.Privileges_CredentialSpec_Config{ + Config: c.Config, + } + case c.File != "": + spec.Source = &swarmapi.Privileges_CredentialSpec_File{ + File: c.File, + } + case c.Registry != "": + spec.Source = &swarmapi.Privileges_CredentialSpec_Registry{ + Registry: c.Registry, + } + } + + return spec, nil +} + func healthConfigFromGRPC(h *swarmapi.HealthConfig) *container.HealthConfig { interval, _ := gogotypes.DurationFromProto(h.Interval) timeout, _ := gogotypes.DurationFromProto(h.Timeout) diff --git a/daemon/cluster/convert/service_test.go b/daemon/cluster/convert/service_test.go index ad5f0d4494..9c4284bd71 100644 --- a/daemon/cluster/convert/service_test.go +++ b/daemon/cluster/convert/service_test.go @@ -233,6 +233,167 @@ func TestServiceConvertFromGRPCIsolation(t *testing.T) { } } +func TestServiceConvertToGRPCCredentialSpec(t *testing.T) { + cases := []struct { + name string + from swarmtypes.CredentialSpec + to swarmapi.Privileges_CredentialSpec + expectedErr string + }{ + { + name: "empty credential spec", + from: swarmtypes.CredentialSpec{}, + to: swarmapi.Privileges_CredentialSpec{}, + expectedErr: `invalid CredentialSpec: must either provide "file", "registry", or "config" for credential spec`, + }, + { + name: "config and file credential spec", + from: swarmtypes.CredentialSpec{ + Config: "0bt9dmxjvjiqermk6xrop3ekq", + File: "spec.json", + }, + to: swarmapi.Privileges_CredentialSpec{}, + expectedErr: `invalid CredentialSpec: cannot specify both "config" and "file" credential specs`, + }, + { + name: "config and registry credential spec", + from: swarmtypes.CredentialSpec{ + Config: "0bt9dmxjvjiqermk6xrop3ekq", + Registry: "testing", + }, + to: swarmapi.Privileges_CredentialSpec{}, + expectedErr: `invalid CredentialSpec: cannot specify both "config" and "registry" credential specs`, + }, + { + name: "file and registry credential spec", + from: swarmtypes.CredentialSpec{ + File: "spec.json", + Registry: "testing", + }, + to: swarmapi.Privileges_CredentialSpec{}, + expectedErr: `invalid CredentialSpec: cannot specify both "file" and "registry" credential specs`, + }, + { + name: "config and file and registry credential spec", + from: swarmtypes.CredentialSpec{ + Config: "0bt9dmxjvjiqermk6xrop3ekq", + File: "spec.json", + Registry: "testing", + }, + to: swarmapi.Privileges_CredentialSpec{}, + expectedErr: `invalid CredentialSpec: cannot specify both "config", "file", and "registry" credential specs`, + }, + { + name: "config credential spec", + from: swarmtypes.CredentialSpec{Config: "0bt9dmxjvjiqermk6xrop3ekq"}, + to: swarmapi.Privileges_CredentialSpec{ + Source: &swarmapi.Privileges_CredentialSpec_Config{Config: "0bt9dmxjvjiqermk6xrop3ekq"}, + }, + }, + { + name: "file credential spec", + from: swarmtypes.CredentialSpec{File: "foo.json"}, + to: swarmapi.Privileges_CredentialSpec{ + Source: &swarmapi.Privileges_CredentialSpec_File{File: "foo.json"}, + }, + }, + { + name: "registry credential spec", + from: swarmtypes.CredentialSpec{Registry: "testing"}, + to: swarmapi.Privileges_CredentialSpec{ + Source: &swarmapi.Privileges_CredentialSpec_Registry{Registry: "testing"}, + }, + }, + } + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + s := swarmtypes.ServiceSpec{ + TaskTemplate: swarmtypes.TaskSpec{ + ContainerSpec: &swarmtypes.ContainerSpec{ + Privileges: &swarmtypes.Privileges{ + CredentialSpec: &c.from, + }, + }, + }, + } + + res, err := ServiceSpecToGRPC(s) + if c.expectedErr != "" { + assert.Error(t, err, c.expectedErr) + return + } + + assert.NilError(t, err) + v, ok := res.Task.Runtime.(*swarmapi.TaskSpec_Container) + if !ok { + t.Fatal("expected type swarmapi.TaskSpec_Container") + } + assert.DeepEqual(t, c.to, *v.Container.Privileges.CredentialSpec) + }) + } +} + +func TestServiceConvertFromGRPCCredentialSpec(t *testing.T) { + cases := []struct { + name string + from swarmapi.Privileges_CredentialSpec + to *swarmtypes.CredentialSpec + }{ + { + name: "empty credential spec", + from: swarmapi.Privileges_CredentialSpec{}, + to: &swarmtypes.CredentialSpec{}, + }, + { + name: "config credential spec", + from: swarmapi.Privileges_CredentialSpec{ + Source: &swarmapi.Privileges_CredentialSpec_Config{Config: "0bt9dmxjvjiqermk6xrop3ekq"}, + }, + to: &swarmtypes.CredentialSpec{Config: "0bt9dmxjvjiqermk6xrop3ekq"}, + }, + { + name: "file credential spec", + from: swarmapi.Privileges_CredentialSpec{ + Source: &swarmapi.Privileges_CredentialSpec_File{File: "foo.json"}, + }, + to: &swarmtypes.CredentialSpec{File: "foo.json"}, + }, + { + name: "registry credential spec", + from: swarmapi.Privileges_CredentialSpec{ + Source: &swarmapi.Privileges_CredentialSpec_Registry{Registry: "testing"}, + }, + to: &swarmtypes.CredentialSpec{Registry: "testing"}, + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + gs := swarmapi.Service{ + Spec: swarmapi.ServiceSpec{ + Task: swarmapi.TaskSpec{ + Runtime: &swarmapi.TaskSpec_Container{ + Container: &swarmapi.ContainerSpec{ + Privileges: &swarmapi.Privileges{ + CredentialSpec: &tc.from, + }, + }, + }, + }, + }, + } + + svc, err := ServiceFromGRPC(gs) + assert.NilError(t, err) + assert.DeepEqual(t, svc.Spec.TaskTemplate.ContainerSpec.Privileges.CredentialSpec, tc.to) + }) + } +} + func TestServiceConvertToGRPCNetworkAtachmentRuntime(t *testing.T) { someid := "asfjkl" s := swarmtypes.ServiceSpec{ @@ -306,3 +467,147 @@ func TestTaskConvertFromGRPCNetworkAttachment(t *testing.T) { t.Fatalf("expected Runtime to be %v", swarmtypes.RuntimeNetworkAttachment) } } + +// TestServiceConvertFromGRPCConfigs tests that converting config references +// from GRPC is correct +func TestServiceConvertFromGRPCConfigs(t *testing.T) { + cases := []struct { + name string + from *swarmapi.ConfigReference + to *swarmtypes.ConfigReference + }{ + { + name: "file", + from: &swarmapi.ConfigReference{ + ConfigID: "configFile", + ConfigName: "configFile", + Target: &swarmapi.ConfigReference_File{ + // skip mode, if everything else here works mode will too. otherwise we'd need to import os. + File: &swarmapi.FileTarget{Name: "foo", UID: "bar", GID: "baz"}, + }, + }, + to: &swarmtypes.ConfigReference{ + ConfigID: "configFile", + ConfigName: "configFile", + File: &swarmtypes.ConfigReferenceFileTarget{Name: "foo", UID: "bar", GID: "baz"}, + }, + }, + { + name: "runtime", + from: &swarmapi.ConfigReference{ + ConfigID: "configRuntime", + ConfigName: "configRuntime", + Target: &swarmapi.ConfigReference_Runtime{Runtime: &swarmapi.RuntimeTarget{}}, + }, + to: &swarmtypes.ConfigReference{ + ConfigID: "configRuntime", + ConfigName: "configRuntime", + Runtime: &swarmtypes.ConfigReferenceRuntimeTarget{}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + grpcService := swarmapi.Service{ + Spec: swarmapi.ServiceSpec{ + Task: swarmapi.TaskSpec{ + Runtime: &swarmapi.TaskSpec_Container{ + Container: &swarmapi.ContainerSpec{ + Configs: []*swarmapi.ConfigReference{tc.from}, + }, + }, + }, + }, + } + + engineService, err := ServiceFromGRPC(grpcService) + assert.NilError(t, err) + assert.DeepEqual(t, + engineService.Spec.TaskTemplate.ContainerSpec.Configs[0], + tc.to, + ) + }) + } +} + +// TestServiceConvertToGRPCConfigs tests that converting config references to +// GRPC is correct +func TestServiceConvertToGRPCConfigs(t *testing.T) { + cases := []struct { + name string + from *swarmtypes.ConfigReference + to *swarmapi.ConfigReference + expectedErr string + }{ + { + name: "file", + from: &swarmtypes.ConfigReference{ + ConfigID: "configFile", + ConfigName: "configFile", + File: &swarmtypes.ConfigReferenceFileTarget{Name: "foo", UID: "bar", GID: "baz"}, + }, + to: &swarmapi.ConfigReference{ + ConfigID: "configFile", + ConfigName: "configFile", + Target: &swarmapi.ConfigReference_File{ + // skip mode, if everything else here works mode will too. otherwise we'd need to import os. + File: &swarmapi.FileTarget{Name: "foo", UID: "bar", GID: "baz"}, + }, + }, + }, + { + name: "runtime", + from: &swarmtypes.ConfigReference{ + ConfigID: "configRuntime", + ConfigName: "configRuntime", + Runtime: &swarmtypes.ConfigReferenceRuntimeTarget{}, + }, + to: &swarmapi.ConfigReference{ + ConfigID: "configRuntime", + ConfigName: "configRuntime", + Target: &swarmapi.ConfigReference_Runtime{Runtime: &swarmapi.RuntimeTarget{}}, + }, + }, + { + name: "file and runtime", + from: &swarmtypes.ConfigReference{ + ConfigID: "fileAndRuntime", + ConfigName: "fileAndRuntime", + File: &swarmtypes.ConfigReferenceFileTarget{}, + Runtime: &swarmtypes.ConfigReferenceRuntimeTarget{}, + }, + expectedErr: "invalid Config: cannot specify both File and Runtime", + }, + { + name: "none", + from: &swarmtypes.ConfigReference{ + ConfigID: "none", + ConfigName: "none", + }, + expectedErr: "invalid Config: either File or Runtime should be set", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + engineServiceSpec := swarmtypes.ServiceSpec{ + TaskTemplate: swarmtypes.TaskSpec{ + ContainerSpec: &swarmtypes.ContainerSpec{ + Configs: []*swarmtypes.ConfigReference{tc.from}, + }, + }, + } + + grpcServiceSpec, err := ServiceSpecToGRPC(engineServiceSpec) + if tc.expectedErr != "" { + assert.Error(t, err, tc.expectedErr) + return + } + + assert.NilError(t, err) + taskRuntime := grpcServiceSpec.Task.Runtime.(*swarmapi.TaskSpec_Container) + assert.DeepEqual(t, taskRuntime.Container.Configs[0], tc.to) + }) + } +} diff --git a/daemon/cluster/executor/container/container.go b/daemon/cluster/executor/container/container.go index b26076bcd8..abbd6bfb11 100644 --- a/daemon/cluster/executor/container/container.go +++ b/daemon/cluster/executor/container/container.go @@ -651,6 +651,8 @@ func (c *containerConfig) applyPrivileges(hc *enginecontainer.HostConfig) { hc.SecurityOpt = append(hc.SecurityOpt, "credentialspec=file://"+credentials.GetFile()) case *api.Privileges_CredentialSpec_Registry: hc.SecurityOpt = append(hc.SecurityOpt, "credentialspec=registry://"+credentials.GetRegistry()) + case *api.Privileges_CredentialSpec_Config: + hc.SecurityOpt = append(hc.SecurityOpt, "credentialspec=config://"+credentials.GetConfig()) } } diff --git a/daemon/cluster/executor/container/container_test.go b/daemon/cluster/executor/container/container_test.go index 5f967c2f77..6a9a72d39b 100644 --- a/daemon/cluster/executor/container/container_test.go +++ b/daemon/cluster/executor/container/container_test.go @@ -80,3 +80,56 @@ func TestContainerLabels(t *testing.T) { labels := c.labels() assert.DeepEqual(t, expected, labels) } + +func TestCredentialSpecConversion(t *testing.T) { + cases := []struct { + name string + from swarmapi.Privileges_CredentialSpec + to []string + }{ + { + name: "none", + from: swarmapi.Privileges_CredentialSpec{}, + to: nil, + }, + { + name: "config", + from: swarmapi.Privileges_CredentialSpec{ + Source: &swarmapi.Privileges_CredentialSpec_Config{Config: "0bt9dmxjvjiqermk6xrop3ekq"}, + }, + to: []string{"credentialspec=config://0bt9dmxjvjiqermk6xrop3ekq"}, + }, + { + name: "file", + from: swarmapi.Privileges_CredentialSpec{ + Source: &swarmapi.Privileges_CredentialSpec_File{File: "foo.json"}, + }, + to: []string{"credentialspec=file://foo.json"}, + }, + { + name: "registry", + from: swarmapi.Privileges_CredentialSpec{ + Source: &swarmapi.Privileges_CredentialSpec_Registry{Registry: "testing"}, + }, + to: []string{"credentialspec=registry://testing"}, + }, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + task := swarmapi.Task{ + Spec: swarmapi.TaskSpec{ + Runtime: &swarmapi.TaskSpec_Container{ + Container: &swarmapi.ContainerSpec{ + Privileges: &swarmapi.Privileges{ + CredentialSpec: &c.from, + }, + }, + }, + }, + } + config := containerConfig{task: &task} + assert.DeepEqual(t, c.to, config.hostConfig().SecurityOpt) + }) + } +} diff --git a/daemon/container_operations_unix.go b/daemon/container_operations_unix.go index 5552d09df3..c7984f7a07 100644 --- a/daemon/container_operations_unix.go +++ b/daemon/container_operations_unix.go @@ -230,7 +230,14 @@ func (daemon *Daemon) setupSecretDir(c *container.Container) (setupErr error) { for _, ref := range c.ConfigReferences { // TODO (ehazlett): use type switch when more are supported if ref.File == nil { - logrus.Error("config target type is not a file target") + // Runtime configs are not mounted into the container, but they're + // a valid type of config so we should not error when we encounter + // one. + if ref.Runtime == nil { + logrus.Error("config target type is not a file or runtime target") + } + // However, in any case, this isn't a file config, so we have no + // further work to do continue } diff --git a/daemon/container_operations_windows.go b/daemon/container_operations_windows.go index 10bfd53d6e..c2381e6431 100644 --- a/daemon/container_operations_windows.go +++ b/daemon/container_operations_windows.go @@ -44,7 +44,14 @@ func (daemon *Daemon) setupConfigDir(c *container.Container) (setupErr error) { for _, configRef := range c.ConfigReferences { // TODO (ehazlett): use type switch when more are supported if configRef.File == nil { - logrus.Error("config target type is not a file target") + // Runtime configs are not mounted into the container, but they're + // a valid type of config so we should not error when we encounter + // one. + if configRef.Runtime == nil { + logrus.Error("config target type is not a file or runtime target") + } + // However, in any case, this isn't a file config, so we have no + // further work to do continue } diff --git a/daemon/oci_windows.go b/daemon/oci_windows.go index da0c7667d4..215d5b6d3a 100644 --- a/daemon/oci_windows.go +++ b/daemon/oci_windows.go @@ -288,6 +288,28 @@ func (daemon *Daemon) createSpecWindowsFields(c *container.Container, s *specs.S if cs, err = readCredentialSpecRegistry(c.ID, csValue); err != nil { return err } + } else if match, csValue = getCredentialSpec("config://", splitsOpt[1]); match { + // if the container does not have a DependencyStore, then it + // isn't swarmkit managed. In order to avoid creating any + // impression that `config://` is a valid API, return the same + // error as if you'd passed any other random word. + if c.DependencyStore == nil { + return fmt.Errorf("invalid credential spec security option - value must be prefixed file:// or registry:// followed by a value") + } + + // after this point, we can return regular swarmkit-relevant + // errors, because we'll know this container is managed. + if csValue == "" { + return fmt.Errorf("no value supplied for config:// credential spec security option") + } + + csConfig, err := c.DependencyStore.Configs().Get(csValue) + if err != nil { + return errors.Wrap(err, "error getting value from config store") + } + // stuff the resulting secret data into a string to use as the + // CredentialSpec + cs = string(csConfig.Spec.Data) } else { return fmt.Errorf("invalid credential spec security option - value must be prefixed file:// or registry:// followed by a value") } diff --git a/docs/api/version-history.md b/docs/api/version-history.md index 87e64753c0..409b94c8c4 100644 --- a/docs/api/version-history.md +++ b/docs/api/version-history.md @@ -29,6 +29,10 @@ keywords: "API, Docker, rcli, REST, documentation" * `GET /services/{id}` now returns `Sysctls` as part of the `ContainerSpec`. * `POST /services/create` now accepts `Sysctls` as part of the `ContainerSpec`. * `POST /services/{id}/update` now accepts `Sysctls` as part of the `ContainerSpec`. +* `POST /services/create` now accepts `Config` as part of `ContainerSpec.Privileges.CredentialSpec`. +* `POST /services/{id}/update` now accepts `Config` as part of `ContainerSpec.Privileges.CredentialSpec`. +* `POST /services/create` now includes `Runtime` as an option in `ContainerSpec.Configs` +* `POST /services/{id}/update` now includes `Runtime` as an option in `ContainerSpec.Configs` * `GET /tasks` now returns `Sysctls` as part of the `ContainerSpec`. * `GET /tasks/{id}` now returns `Sysctls` as part of the `ContainerSpec`. * `GET /nodes` now supports a filter type `node.label` filter to filter nodes based