From 8bceb888209141c5bdb23f403287e21625fb0cf3 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Sat, 2 Feb 2019 16:35:26 +0100 Subject: [PATCH] Add support for using Configs as CredentialSpecs in services Signed-off-by: Sebastiaan van Stijn --- cli/command/service/opts.go | 4 +- cli/command/service/opts_test.go | 55 +++++++++++++ cli/compose/convert/service.go | 23 +++++- cli/compose/convert/service_test.go | 79 +++++++++++++------ cli/compose/loader/loader_test.go | 14 ++++ cli/compose/schema/bindata.go | 72 ++++++++--------- .../schema/data/config_schema_v3.8.json | 1 + cli/compose/schema/schema_test.go | 4 +- cli/compose/types/types.go | 3 +- 9 files changed, 188 insertions(+), 67 deletions(-) diff --git a/cli/command/service/opts.go b/cli/command/service/opts.go index c51d37c1de8f..ce9f39c8420a 100644 --- a/cli/command/service/opts.go +++ b/cli/command/service/opts.go @@ -331,12 +331,14 @@ func (c *credentialSpecOpt) Set(value string) error { c.source = value c.value = &swarm.CredentialSpec{} switch { + case strings.HasPrefix(value, "config://"): + c.value.Config = strings.TrimPrefix(value, "config://") case strings.HasPrefix(value, "file://"): c.value.File = strings.TrimPrefix(value, "file://") case strings.HasPrefix(value, "registry://"): c.value.Registry = strings.TrimPrefix(value, "registry://") default: - return errors.New("Invalid credential spec - value must be prefixed file:// or registry:// followed by a value") + return errors.New(`invalid credential spec: value must be prefixed with "config://", "file://", or "registry://"`) } return nil diff --git a/cli/command/service/opts_test.go b/cli/command/service/opts_test.go index 9493a8230349..9f26daa3bf33 100644 --- a/cli/command/service/opts_test.go +++ b/cli/command/service/opts_test.go @@ -14,6 +14,61 @@ import ( is "gotest.tools/assert/cmp" ) +func TestCredentialSpecOpt(t *testing.T) { + tests := []struct { + name string + in string + value swarm.CredentialSpec + expectedErr string + }{ + { + name: "empty", + in: "", + value: swarm.CredentialSpec{}, + expectedErr: `invalid credential spec: value must be prefixed with "config://", "file://", or "registry://"`, + }, + { + name: "no-prefix", + in: "noprefix", + value: swarm.CredentialSpec{}, + expectedErr: `invalid credential spec: value must be prefixed with "config://", "file://", or "registry://"`, + }, + { + name: "config", + in: "config://0bt9dmxjvjiqermk6xrop3ekq", + value: swarm.CredentialSpec{Config: "0bt9dmxjvjiqermk6xrop3ekq"}, + }, + { + name: "file", + in: "file://somefile.json", + value: swarm.CredentialSpec{File: "somefile.json"}, + }, + { + name: "registry", + in: "registry://testing", + value: swarm.CredentialSpec{Registry: "testing"}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + var cs credentialSpecOpt + + err := cs.Set(tc.in) + + if tc.expectedErr != "" { + assert.Error(t, err, tc.expectedErr) + } else { + assert.NilError(t, err) + } + + assert.Equal(t, cs.String(), tc.in) + assert.DeepEqual(t, cs.Value(), &tc.value) + }) + } +} + func TestMemBytesString(t *testing.T) { var mem opts.MemBytes = 1048576 assert.Check(t, is.Equal("1MiB", mem.String())) diff --git a/cli/compose/convert/service.go b/cli/compose/convert/service.go index 21f4850c8792..0fe40bf3a7bc 100644 --- a/cli/compose/convert/service.go +++ b/cli/compose/convert/service.go @@ -600,11 +600,26 @@ func convertDNSConfig(DNS []string, DNSSearch []string) (*swarm.DNSConfig, error } func convertCredentialSpec(spec composetypes.CredentialSpecConfig) (*swarm.CredentialSpec, error) { - if spec.File == "" && spec.Registry == "" { - return nil, nil + var o []string + + // Config was added in API v1.40 + if spec.Config != "" { + o = append(o, `"Config"`) + } + if spec.File != "" { + o = append(o, `"File"`) } - if spec.File != "" && spec.Registry != "" { - return nil, errors.New("Invalid credential spec - must provide one of `File` or `Registry`") + if spec.Registry != "" { + o = append(o, `"Registry"`) + } + l := len(o) + switch { + case l == 0: + return nil, nil + case l == 2: + return nil, errors.Errorf("invalid credential spec: cannot specify both %s and %s", o[0], o[1]) + case l > 2: + return nil, errors.Errorf("invalid credential spec: cannot specify both %s, and %s", strings.Join(o[:l-1], ", "), o[l-1]) } swarmCredSpec := swarm.CredentialSpec(spec) return &swarmCredSpec, nil diff --git a/cli/compose/convert/service_test.go b/cli/compose/convert/service_test.go index d281e0e4529b..f275fb874cad 100644 --- a/cli/compose/convert/service_test.go +++ b/cli/compose/convert/service_test.go @@ -314,30 +314,65 @@ func TestConvertDNSConfigSearch(t *testing.T) { } func TestConvertCredentialSpec(t *testing.T) { - swarmSpec, err := convertCredentialSpec(composetypes.CredentialSpecConfig{}) - assert.NilError(t, err) - assert.Check(t, is.Nil(swarmSpec)) - - swarmSpec, err = convertCredentialSpec(composetypes.CredentialSpecConfig{ - File: "/foo", - }) - assert.NilError(t, err) - assert.Check(t, is.Equal(swarmSpec.File, "/foo")) - assert.Check(t, is.Equal(swarmSpec.Registry, "")) + tests := []struct { + name string + in composetypes.CredentialSpecConfig + out *swarm.CredentialSpec + expectedErr string + }{ + { + name: "empty", + }, + { + name: "config-and-file", + in: composetypes.CredentialSpecConfig{Config: "0bt9dmxjvjiqermk6xrop3ekq", File: "somefile.json"}, + expectedErr: `invalid credential spec: cannot specify both "Config" and "File"`, + }, + { + name: "config-and-registry", + in: composetypes.CredentialSpecConfig{Config: "0bt9dmxjvjiqermk6xrop3ekq", Registry: "testing"}, + expectedErr: `invalid credential spec: cannot specify both "Config" and "Registry"`, + }, + { + name: "file-and-registry", + in: composetypes.CredentialSpecConfig{File: "somefile.json", Registry: "testing"}, + expectedErr: `invalid credential spec: cannot specify both "File" and "Registry"`, + }, + { + name: "config-and-file-and-registry", + in: composetypes.CredentialSpecConfig{Config: "0bt9dmxjvjiqermk6xrop3ekq", File: "somefile.json", Registry: "testing"}, + expectedErr: `invalid credential spec: cannot specify both "Config", "File", and "Registry"`, + }, + { + name: "config", + in: composetypes.CredentialSpecConfig{Config: "0bt9dmxjvjiqermk6xrop3ekq"}, + out: &swarm.CredentialSpec{Config: "0bt9dmxjvjiqermk6xrop3ekq"}, + }, + { + name: "file", + in: composetypes.CredentialSpecConfig{File: "somefile.json"}, + out: &swarm.CredentialSpec{File: "somefile.json"}, + }, + { + name: "registry", + in: composetypes.CredentialSpecConfig{Registry: "testing"}, + out: &swarm.CredentialSpec{Registry: "testing"}, + }, + } - swarmSpec, err = convertCredentialSpec(composetypes.CredentialSpecConfig{ - Registry: "foo", - }) - assert.NilError(t, err) - assert.Check(t, is.Equal(swarmSpec.File, "")) - assert.Check(t, is.Equal(swarmSpec.Registry, "foo")) + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + swarmSpec, err := convertCredentialSpec(tc.in) - swarmSpec, err = convertCredentialSpec(composetypes.CredentialSpecConfig{ - File: "/asdf", - Registry: "foo", - }) - assert.Check(t, is.ErrorContains(err, "")) - assert.Check(t, is.Nil(swarmSpec)) + if tc.expectedErr != "" { + assert.Error(t, err, tc.expectedErr) + } else { + assert.NilError(t, err) + } + assert.DeepEqual(t, swarmSpec, tc.out) + }) + } } func TestConvertUpdateConfigOrder(t *testing.T) { diff --git a/cli/compose/loader/loader_test.go b/cli/compose/loader/loader_test.go index 51eb4fb46147..d51bf621b318 100644 --- a/cli/compose/loader/loader_test.go +++ b/cli/compose/loader/loader_test.go @@ -295,6 +295,20 @@ configs: assert.Assert(t, is.Len(actual.Configs, 1)) } +func TestLoadV38(t *testing.T) { + actual, err := loadYAML(` +version: "3.8" +services: + foo: + image: busybox + credential_spec: + config: "0bt9dmxjvjiqermk6xrop3ekq" +`) + assert.NilError(t, err) + assert.Assert(t, is.Len(actual.Services, 1)) + assert.Check(t, is.Equal(actual.Services[0].CredentialSpec.Config, "0bt9dmxjvjiqermk6xrop3ekq")) +} + func TestParseAndLoad(t *testing.T) { actual, err := loadYAML(sampleYAML) assert.NilError(t, err) diff --git a/cli/compose/schema/bindata.go b/cli/compose/schema/bindata.go index a1447aa245e4..6651be946a46 100644 --- a/cli/compose/schema/bindata.go +++ b/cli/compose/schema/bindata.go @@ -510,45 +510,45 @@ bnBpPlHfjORjkTRf1wyAwiYqMXd9/G6313QfoXs6/sbZ66r6e179PwAA//8ZL3SpvkUAAA== "/data/config_schema_v3.8.json": { local: "data/config_schema_v3.8.json", - size: 18006, + size: 18048, modtime: 1518458244, compressed: ` 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 -jmPjVfX3tPpvAAAA//+mJNa5VkYAAA== +3qQHGEy3VHxUsar41UPzY5Uk6c+a7qEg6dck3Rujvj4+/qaluG+ePkjcPeZItub+y6+PzbOf0rtqHMur +IVSKLdtlzZvs8JeHvz1UwxsSc1RQEcnNb0BN8wzh95IhVIOf0gOgZlKk67tV9U6hVICGgU6/JtXmkqQn +6R4MptUGmdil9eNTPUOSpBrwwOhghn6rPz2+zv/Yk93Zsw42Wz9XxBhA8e/p3urX357I/R//uP/Pl/u/ +P2T3619+Hr2u5IuwbZbPYcsEM0yKfv20pzy1P536hUme18SEj9beEq5hzLMA813ic4jnnuydeG7Xd/A8 +ZucgeVkET7CjeidmmuWXOT8NFMGEVbahejeNrZZfhuHGa4QY7qjeieFm+esYXnVMu/eYfnu5r/491XPO +ztfMMthfzcTI57nE6fI5fnn2AvVIMgfF5bHeuVtmDUEBwqS9mJIk3ZSM57bUpYB/VVM8DR4myQ/bvQ/m +qd+PfvMrRf/ew0v/nkph4MXUTM0v3YhA0mfALeMQO4Jgo+kekXGmTSYxyxk1zvGcbIBfNQMldA/ZFmUR +nGWbNZxo50SdB4/k3BDcQbRk9b7INPtjJNenlAkDO8D0rh+7PlljJ5OFDdO26erPeuWYMKVEZSTPR0wQ +RHKsdsQMFNrNX5KWgv1ewj9bEoMl2PPmKNXyE+9QlipTBCsrnJd9SmVRELGUaZ7DR4TkJ5fEyN7bNYav ++tVG2/Jwk0RopcNdBNxN2OFUmi5LpLH+41w7SpK0ZHk88e4c4kLm432LstgApqcJ8cRIR7+vV6431ukb +wgRgJkgBQT1GyEEYRnimFVCfzjgObe64WhWMEE8aeSGkCDumDR6dtCuPT4vzZ0N55KBA5DprAqfzPX6a +Qx9FLeqdcjF3kzXTVHdZtbfUGphpIEj3F46XBWEiRpdAGDwqyRrv+eHcIohD1mvb2WIAcWAoRdHdDXGI +YjD+RUkN1/vk/n5vGb/rXcnatiyJBak2263ttZKp5g0FOOShQuKEZ5yJ5+VVHF4MkmwvtbkEtKV7INzs +6R7o88zwIdVotNQmRslZQXZhIsHGt85GSg5EjIkUDc6jJSemzeLMEV4MddNFj3IwrdztKlKf/k5Cp8ig +I0d2AIxFxlK9RnwueBCCJMEQeUT67aGJkGdstP6J8ykUd9389hP7Soy93F5PpSC0wuQIWoc0qo1Ysglw +eaWdEOtYv39RIHV+ABt1dMEsRxAO+yBvvJbFwd/u2DkjGvR1EenACx1+jdQJ19i/zo71DPXOGR9/BqYa +4mzOnRtZh5H3LcNjNY4exr6i9hBDA1MSzZsEdK9+6hU+NItPYzz7uKMG3SYwnPFScWFhly1xD1DlhjO9 +h/ycMSiNpJLHGYYz/xVvDDNB4kVITyE7MA47i2MXjEEgeSYFP0ZQakMwmFrRQEtk5phJZRbHmO5c2avW +96my8YasKsNnPuX/J5+ij5qay7C1NjkTmVQggrahjVTZDgmFTAEy6RTFyMHmJTahwWQazXaC8JCZmUJt +L0wpGBM29pKzgvmNxplQCuK1Bqu5IdoMPIty2TMRwnyAEBEZ7AmecXXUhrn13E+rSAw07heo57trN7J2 +0p8FvextrL3ox21UpQ4GcTWN0FnE1e4ofP85PPTojGry9UV+vF0p0nfe2utHI4JxwlgzbUDQY/xCGzap +wJwbd8VFXTUV2flTMe7YJNpW256IN2FFSCqV52iuZKO/Um7PRYfh/MGp7Tln4tiCCVaURfo1+eKLWOMl +c2Nob+WAZgC9z/d+l/hc3ew5wzldPs13iYw7MM5sY7FStXO9F0PSYD/LfB9IqEeDabKxilHOvK0wgAc3 +wAojNASDzKoPddh1CLFAf8wqimEFyNJcCk8JmvMBrt3tNmip6eoxcyo0oLQ16KlXoS7tElSTGDwCIq/r +YFHgBUFxRokOAcQrkvwoOd8Q+py91mWXqPIqgoRz4EwXMeg2zYGT40Wa0xS0COMlQkZoREmkPSvBjMTL +lyzIS9YtW5ME7LaxU8zBtyaI+p6x8WVjGfdbhto0aQip2t/G7n/BUnepcmLgUyU+VWKYoatjA72UOjiT +AMt0H6oytl6RFlDIcOfItSn/ScOKrmCCrwD5UQTgoN6BAGQ0G2mD58qZ0t6oinK9ZjfYQ3LWhJgLtTk1 ++4jxPFe6usrvVEC8UEZHudbvTOTy+/kwawFpK04oWNDsWkFrg4QJc3avgi0WhbAFBEFh1iynOaOZvNFy +CXmFQPJ3KBm5tK0DphVgz4SNZF0ZyUvU5oqvIZyOai4SmA6YhJTjc3ect/+c/edbxZYUwUC/sqvbMqRD +8/qTPrfZsKCLTw+ElxHVk4v6TXxZh4jBJ+fHWaEz7cgWCO1i+r+iGpBaqkyq5Ssg4SajdTj/zhQplvLN +0S1ZqTPU+Ahet9wIT4L7xl53uSu36830nOpTn8q662W1jj5ir2Est/86q2aXLV3pN2IMofuoTN2ZCZM3 +SHxOEv1Ol9ZSfXq0Mzzan13/P56utt+tBr+NrKnCn5peoaER34h8gPNf4lhHFYBCcWIgm7HPN9CCyZ3t +1IKW6lML/ke1wGoGGmjDtCg1d0DRHcurYQ2q34ZN5vi/LXzxm3dTvhKqtWh7NvOcL3grPvwyg5Pnviy4 +EcBcoA3TfaZWamfVN13an+b7XU83fvKhfsWnOE6Kpj/GjTfNR/brkXwskuarnwEMWUeF/a7P9+22n+4z +ek8n4jg2XlV/T6v/BgAA//8BShmMgEYAAA== `, }, diff --git a/cli/compose/schema/data/config_schema_v3.8.json b/cli/compose/schema/data/config_schema_v3.8.json index 469544cc030c..08a9719c785d 100644 --- a/cli/compose/schema/data/config_schema_v3.8.json +++ b/cli/compose/schema/data/config_schema_v3.8.json @@ -125,6 +125,7 @@ "credential_spec": { "type": "object", "properties": { + "config": {"type": "string"}, "file": {"type": "string"}, "registry": {"type": "string"} }, diff --git a/cli/compose/schema/schema_test.go b/cli/compose/schema/schema_test.go index 9b29b138b3a9..10c40bba72c6 100644 --- a/cli/compose/schema/schema_test.go +++ b/cli/compose/schema/schema_test.go @@ -92,7 +92,7 @@ func TestValidateCredentialSpecs(t *testing.T) { {version: "3.5", expectedErr: "config"}, {version: "3.6", expectedErr: "config"}, {version: "3.7", expectedErr: "config"}, - {version: "3.8", expectedErr: "something"}, + {version: "3.8"}, } for _, tc := range tests { @@ -104,7 +104,7 @@ func TestValidateCredentialSpecs(t *testing.T) { "foo": dict{ "image": "busybox", "credential_spec": dict{ - tc.expectedErr: "foobar", + "config": "foobar", }, }, }, diff --git a/cli/compose/types/types.go b/cli/compose/types/types.go index 8185f836c840..c053d2ba21a7 100644 --- a/cli/compose/types/types.go +++ b/cli/compose/types/types.go @@ -500,8 +500,7 @@ func (e External) MarshalJSON() ([]byte, error) { // CredentialSpecConfig for credential spec on Windows type CredentialSpecConfig struct { - // @TODO Config is not yet in use - Config string `yaml:"-" json:"-"` // Config was added in API v1.40 + Config string `yaml:",omitempty" json:"config,omitempty"` // Config was added in API v1.40 File string `yaml:",omitempty" json:"file,omitempty"` Registry string `yaml:",omitempty" json:"registry,omitempty"` }