From 6511da877f00e8b4f583ca5f1473a8b59cb37523 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 c51d37c1de..ce9f39c842 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 9493a82303..9f26daa3bf 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 21f4850c87..0fe40bf3a7 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 != "" && spec.Registry != "" { - return nil, errors.New("Invalid credential spec - must provide one of `File` or `Registry`") + if spec.File != "" { + o = append(o, `"File"`) + } + 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 d281e0e452..f275fb874c 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)) + 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{ - File: "/foo", - }) - assert.NilError(t, err) - assert.Check(t, is.Equal(swarmSpec.File, "/foo")) - assert.Check(t, is.Equal(swarmSpec.Registry, "")) + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + swarmSpec, err := convertCredentialSpec(tc.in) - 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")) - - 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 f78eb2f1fe..a5933cc31c 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 ccecb08cbc..581e9d502d 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: 18204, + size: 18246, modtime: 1518458244, compressed: ` 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 -1H3i76mnHEf4q+r/p9V/AwAA//9tFzTmHEcAAA== +3qQDBDstFR/15FfFkn+skiT9WdM9FCT9mqR7Y9TXx8fftBT3zdMHibvHHMnW3H/59bF59lN6V41jeTWE +SrFlu6x5kx3+8vC3h2p4Q2KOCioiufkNqGmeIfxeMoRq8FN6ANRMinR9t6reKZQK0DDQ6dek2lyS9CTd +g8G02iATu7R+fKpnSJJUAx4YHczQb/Wnx9f5H3uyO3vWwWbr54oYAyj+Pd1b/frbE7n/4x/3//ly//eH +7H79y8+j15V8EbbN8jlsmWCGSdGvn/aUp/Zfp35hkuc1MeGjtbeEaxjzLMB8l/gc4rkneyee2/UdPI/Z +OUheFkENdlTvxEyz/DL600ARTNhkG6p3s9hq+WUYbqJGiOGO6p0Ybpa/juFVx7R7j+m3l/vqv6d6ztn5 +mlkG+6uZGMU8lzhdMccvz16gHknmoLg81jt3y6whKECYtBdTkqSbkvHclroU8K9qiqfBwyT5YYf3wTz1 ++9FffqPo33t46d9TKQy8mJqp+aUbEUj6DLhlHGJHEGws3SMyzrTJJGY5o8Y5npMN8KtmoITuIduiLIKz +bLOGE+2cqIvgkZwbgjuIlqzeF5lmf4zk+pQyYWAHmN71Y9cna+xksrBj2j5d/W+9ckyYUqIykucjJggi +OVY7YgYK7eYvSUvBfi/hny2JwRLseXOUavmJdyhLlSmClRfOyz6lsiiIWMo1z+EjQvKTQ2Lk7+0aw1f9 +aqNtebhJIqzSES4C4SYccCpLlyXS2Phxrh8lSVqyPJ54dw5xIfPxvkVZbADT04R44qSjv9cr1xtL+4Yw +AZgJUkDQjhFyEIYRnmkF1GczDqXNqas1wQjxpJEHQoqwY9rg0Um78sS0uHg2lEcOCkSusyZxOj/ipzn0 +WdSi0SkXcydZM011llV7S62BmQaCdH/heFkQJmJsCYTBo5KsiZ4fLiyCOGS9tZ0tBhAHhlIU3dkQhygG +41+U1HB9TO7P95bxuz6UrG3PkliQarPd2l4vmVreUIBDHiokTnjGmXhe3sThxSDJ9lKbS0BbugfCzZ7u +gT7PDB9SjUZLbWKMnBVkFyYSbHzqbKTkQMSYSNHgPFpyYtoqzhzhxVA3XVSVg2nlbleR+ux3kjpFJh05 +sgNgLDKW6jXjc8GDECQJpsgj0m8PTYY846P1vzifQnHXyW8/sY/E2MPtVSsFoRUmR9A6ZFFtxpJNgMsr +7YRYx8b9ixKp8xPYKNUFqxxBOOyDvPFWFgd/O7VzRjTo6zLSQRQ6/BppE66xf50d6xnqnTM+/wxMNcTZ +nDs3sg4j71umx2qcPYxjRR0hhg6mJJo3Sehe49QrfGgWn+Z4trqjBt0mMZyJUnFpYVctcQ9Q5YYzvYf8 +nDEojaSSxzmGs/4V7wwzSeJFSE8hOzAOO4tjF4xBIHkmBT9GUGpDMFha0UBLZOaYSWUWx5juWtmr1fel +svGGrFuGz3rK/089RR81NZdha21yJjKpQAR9Qxupsh0SCpkCZNIpilGAzUtsUoPJNJrtBOEhNzOF2l5Y +UjAm7OwlZwXzO42zoBTEaw1Wc0O0GXgWFbJnMoT5BCEiM9gTPOPoqB1z6zmfVpEYaNwvUM93125k7aQ/ +C3rZ21h70Y/bqUodTOJqGqGziKPdcfH954jQIx3V5OuL4ni7UmTsvHXUj0YE44KxZtqAoMf4hTZscgNz +bt4Vl3XVVGTnL8W4c5NoX217It6EFSGpVB7VXMlGf6TcnosOw/mTUztyzuSxBROsKIv0a/LFl7HGS+bG +0N6qAc0Ael/s/S7xuTrZc4Zztnya7xIZd2Cc2cZilWrnei+GpMF+lvk+kFCPBtNkY11GOeu2wgAe3AAr +jNAQDDLrfqjDrkOIBfpj3qIYVoAszaXwlKA5H+Da3W6DlpruPmbOhAaUtgU99SbUlV2CZhKDR0Dk9T1Y +FHhBUJxRokMA8YoiP0rON4Q+Z6/3skvc8iqChHPgTBcx6DbNgZPjRZbTXGgRxkuEjNCIK5FWV4IZiZcv +WZCXrFu2Jgn4beOnmINvTRD1OWPjy8Yz7rcMtWnKEFK1f43D/4JX3aXKiYFPk/g0iWGFrs4N9FLm4CwC +LNN9qMrY+4q0gEKGO0euLflPGlZ0BRN8F5AfRQAO6h0IQEazkTV4jpwp7Y1uUa637AZ7SM6aFHOhNqdm +HzGR58pQV8WdCogXyuio0PqdiVx+Px9mLSBtxQkFC5pdK2htkDBhzu5VsMWiELaAICjMuuW0ZjRTN1qu +IK8QSP4OV0Yua+uAaQXYM2EjWVdF8hKzueJrCGegmssEpgMmKeVY7w59+/Xs12+VW1IEA/3Krm7LkA3N +20/63FbDgiE+PRBeRtyeXNRv4qs6RAw+OT/OCum0I1sgtYvp/4pqQGqpMqmWvwEJNxmtw/V3pkixVGyO +bslKnanGR4i65UZ4Ctw3jrrLHbldb6ZHq099Keuul9U6WsVex1hu/3VVzb62dJXfiDGE7qMqdWcWTN6g +8Dkp9DtDWkv1GdHOiGh/dvv/eLbafrca/Daypgp/anqFhUZ8I/IB9L+EWv/n3LLKVzkxkM2w8wa2PEEe +TltuqT5teWlb/iBWYLU0DaxherU2p6DovuvV8Cat34ZN5viFDl8W6t2U7yLYWrTVzTznCwaRh19m0P7c +9xE3gskLNJO6dWoVqFZ966j9AwP+0NONn/zcQMWnOE6ufn+M24eanwpYj+RjkTTfLg2i9jqqeOH6EQK7 +ean7MQBPP+U4w19V/z+t/hsAAP//Fd/bF0ZHAAA= `, }, diff --git a/cli/compose/schema/data/config_schema_v3.8.json b/cli/compose/schema/data/config_schema_v3.8.json index 670780905b..059c0bcf76 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 9b29b138b3..10c40bba72 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 0895a4d04d..d77c1b63dc 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"` }