diff --git a/e2e/commands_test.go b/e2e/commands_test.go index 77667a0cf..e37a1bdbf 100644 --- a/e2e/commands_test.go +++ b/e2e/commands_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/deislabs/duffle/pkg/credentials" "github.com/docker/app/internal" "github.com/docker/app/internal/yaml" "gotest.tools/assert" @@ -421,6 +422,88 @@ STATUS }) } +func TestCredentials(t *testing.T) { + cmd, cleanup := dockerCli.createTestCmd( + WithCredentialSet(t, "default", &credentials.CredentialSet{ + Name: "test-creds", + Credentials: []credentials.CredentialStrategy{ + { + Name: "secret1", + Source: credentials.Source{ + Value: "secret1value", + }, + }, + { + Name: "secret2", + Source: credentials.Source{ + Value: "secret2value", + }, + }, + }, + }), + ) + defer cleanup() + + bundleJson := golden.Get(t, "credential-install-bundle.json") + tmpDir := fs.NewDir(t, t.Name(), + fs.WithFile("bundle.json", "", fs.WithBytes(bundleJson)), + ) + defer tmpDir.Remove() + + bundle := tmpDir.Join("bundle.json") + + t.Run("missing", func(t *testing.T) { + cmd.Command = dockerCli.Command( + "app", "install", + "--credential", "secret1=foo", + "--name", "missing", bundle, + ) + result := icmd.RunCmd(cmd).Assert(t, icmd.Expected{ + ExitCode: 1, + Out: icmd.None, + }) + golden.Assert(t, result.Stderr(), "credential-install-missing.golden") + }) + + t.Run("full", func(t *testing.T) { + cmd.Command = dockerCli.Command( + "app", "install", + "--credential", "secret1=foo", + "--credential", "secret2=bar", + "--credential", "secret3=baz", + "--name", "full", bundle, + ) + result := icmd.RunCmd(cmd).Assert(t, icmd.Success) + golden.Assert(t, result.Stdout(), "credential-install-full.golden") + }) + + t.Run("mixed", func(t *testing.T) { + cmd.Command = dockerCli.Command( + "app", "install", + "--credential-set", "test-creds", + "--credential", "secret3=xyzzy", + "--name", "mixed", bundle, + ) + result := icmd.RunCmd(cmd).Assert(t, icmd.Success) + golden.Assert(t, result.Stdout(), "credential-install-mixed.golden") + }) + + t.Run("overload", func(t *testing.T) { + cmd.Command = dockerCli.Command( + "app", "install", + "--credential-set", "test-creds", + "--credential", "secret1=overload", + "--credential", "secret3=xyzzy", + "--name", "overload", bundle, + ) + result := icmd.RunCmd(cmd).Assert(t, icmd.Expected{ + ExitCode: 1, + Out: icmd.None, + }) + golden.Assert(t, result.Stderr(), "credential-install-overload.golden") + }) +} + func initializeDockerAppEnvironment(t *testing.T, cmd *icmd.Cmd, tmpDir *fs.Dir, swarm *Container, useBindMount bool) { cmd.Env = append(cmd.Env, "DOCKER_TARGET_CONTEXT=swarm-target-context") diff --git a/e2e/main_test.go b/e2e/main_test.go index eae3ea049..50a325a23 100644 --- a/e2e/main_test.go +++ b/e2e/main_test.go @@ -12,7 +12,10 @@ import ( "strings" "testing" + "github.com/deislabs/duffle/pkg/credentials" + "github.com/docker/app/internal/store" dockerConfigFile "github.com/docker/cli/cli/config/configfile" + "gotest.tools/assert" "gotest.tools/icmd" ) @@ -29,12 +32,17 @@ type dockerCliCommand struct { cliPluginDir string } -func (d dockerCliCommand) createTestCmd() (icmd.Cmd, func()) { +type testCmdOp func(configDir string, config *dockerConfigFile.ConfigFile) + +func (d dockerCliCommand) createTestCmd(ops ...testCmdOp) (icmd.Cmd, func()) { configDir, err := ioutil.TempDir("", "config") if err != nil { panic(err) } config := dockerConfigFile.ConfigFile{CLIPluginsExtraDirs: []string{d.cliPluginDir}} + for _, op := range ops { + op(configDir, &config) + } configFile, err := os.Create(filepath.Join(configDir, "config.json")) if err != nil { panic(err) @@ -54,6 +62,21 @@ func (d dockerCliCommand) Command(args ...string) []string { return append([]string{d.path}, args...) } +func WithCredentialSet(t *testing.T, context string, creds *credentials.CredentialSet) testCmdOp { + t.Helper() + return func(configDir string, _ *dockerConfigFile.ConfigFile) { + + appstore, err := store.NewApplicationStore(configDir) + assert.NilError(t, err) + + credstore, err := appstore.CredentialStore(context) + assert.NilError(t, err) + + err = credstore.Store(creds) + assert.NilError(t, err) + } +} + func TestMain(m *testing.M) { flag.Parse() if err := os.Chdir(*e2ePath); err != nil { diff --git a/e2e/testdata/credential-install-bundle.json b/e2e/testdata/credential-install-bundle.json new file mode 100644 index 000000000..c70670b4d --- /dev/null +++ b/e2e/testdata/credential-install-bundle.json @@ -0,0 +1,24 @@ +{ + "name": "example-credentials", + "version": "0.0.1", + "schemaVersion": "v1.0.0-WD", + "invocationImages": [ + { + "imageType": "docker", + "image": "cnab/example-credentials@sha256:b93f7279bdc9610d4ef275dab5d0a1d19cc613a784e2522977866747090059f4" + } + ], + "credentials": { + "secret1": { + "env" :"SECRET_ONE" + }, + "secret2": { + "path": "/var/secret_two/data.txt" + }, + "secret3": { + "env": "SECRET_THREE", + "path": "/var/secret_three/data.txt" + } + } +} + diff --git a/e2e/testdata/credential-install-full.golden b/e2e/testdata/credential-install-full.golden new file mode 100644 index 000000000..fad6a2956 --- /dev/null +++ b/e2e/testdata/credential-install-full.golden @@ -0,0 +1,7 @@ +SECRET_ONE: foo +/var/secret_two/data.txt +bar +SECRET_THREE: baz +/var/secret_three/data.txt +baz +Application "full" installed on context "default" diff --git a/e2e/testdata/credential-install-missing.golden b/e2e/testdata/credential-install-missing.golden new file mode 100644 index 000000000..fc6ae345e --- /dev/null +++ b/e2e/testdata/credential-install-missing.golden @@ -0,0 +1 @@ +bundle requires credential for secret2 diff --git a/e2e/testdata/credential-install-mixed.golden b/e2e/testdata/credential-install-mixed.golden new file mode 100644 index 000000000..33604a74d --- /dev/null +++ b/e2e/testdata/credential-install-mixed.golden @@ -0,0 +1,7 @@ +SECRET_ONE: secret1value +/var/secret_two/data.txt +secret2value +SECRET_THREE: xyzzy +/var/secret_three/data.txt +xyzzy +Application "mixed" installed on context "default" diff --git a/e2e/testdata/credential-install-overload.golden b/e2e/testdata/credential-install-overload.golden new file mode 100644 index 000000000..ef477b42f --- /dev/null +++ b/e2e/testdata/credential-install-overload.golden @@ -0,0 +1 @@ +ambiguous credential resolution: "secret1" is already present in base credential sets, cannot merge diff --git a/internal/commands/cnab.go b/internal/commands/cnab.go index a0b110b1d..3a9524148 100644 --- a/internal/commands/cnab.go +++ b/internal/commands/cnab.go @@ -60,6 +60,33 @@ func addNamedCredentialSets(credStore appstore.CredentialStore, namedCredentials } } +func parseCommandlineCredential(c string) (string, string, error) { + split := strings.SplitN(c, "=", 2) + if len(split) != 2 || split[0] == "" { + return "", "", errors.Errorf("failed to parse %q as a credential name=value", c) + } + name := split[0] + value := split[1] + return name, value, nil +} + +func addCredentials(strcreds []string) credentialSetOpt { + return func(_ *bundle.Bundle, creds credentials.Set) error { + for _, c := range strcreds { + name, value, err := parseCommandlineCredential(c) + if err != nil { + return err + } + if err := creds.Merge(credentials.Set{ + name: value, + }); err != nil { + return err + } + } + return nil + } +} + func addDockerCredentials(contextName string, store contextstore.Store) credentialSetOpt { // docker desktop contexts require some rewriting for being used within a container store = dockerDesktopAwareStore{Store: store} diff --git a/internal/commands/cnab_test.go b/internal/commands/cnab_test.go index 2c2cb9014..fc6fac34e 100644 --- a/internal/commands/cnab_test.go +++ b/internal/commands/cnab_test.go @@ -230,3 +230,34 @@ func TestShareRegistryCreds(t *testing.T) { }) } } + +func TestParseCommandlineCredential(t *testing.T) { + for _, tc := range []struct { + in string + n, v string + err string // either err or n+v are non-"" + }{ + {in: "", err: `failed to parse "" as a credential name=value`}, + {in: "A", err: `failed to parse "A" as a credential name=value`}, + {in: "=B", err: `failed to parse "=B" as a credential name=value`}, + {in: "A=", n: "A", v: ""}, + {in: "A=B", n: "A", v: "B"}, + {in: "A==", n: "A", v: "="}, + {in: "A=B=C", n: "A", v: "B=C"}, + } { + n := tc.in + if n == "" { + n = "«empty»" + } + t.Run(n, func(t *testing.T) { + n, v, err := parseCommandlineCredential(tc.in) + if tc.err != "" { + assert.Error(t, err, tc.err) + } else { + assert.NilError(t, err) + assert.Equal(t, tc.n, n) + assert.Equal(t, tc.v, v) + } + }) + } +} diff --git a/internal/commands/root.go b/internal/commands/root.go index c24a40e8a..24774e034 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -91,12 +91,14 @@ func (o *parametersOptions) addFlags(flags *pflag.FlagSet) { type credentialOptions struct { targetContext string credentialsets []string + credentials []string sendRegistryAuth bool } func (o *credentialOptions) addFlags(flags *pflag.FlagSet) { flags.StringVar(&o.targetContext, "target-context", "", "Context on which the application is installed (default: )") flags.StringArrayVar(&o.credentialsets, "credential-set", []string{}, "Use a YAML file containing a credential set or a credential set present in the credential store") + flags.StringArrayVar(&o.credentials, "credential", nil, "Add a single credential, additive ontop of any --credential-set used") flags.BoolVar(&o.sendRegistryAuth, "with-registry-auth", false, "Sends registry auth") } @@ -107,6 +109,7 @@ func (o *credentialOptions) SetDefaultTargetContext(dockerCli command.Cli) { func (o *credentialOptions) CredentialSetOpts(dockerCli command.Cli, credentialStore store.CredentialStore) []credentialSetOpt { return []credentialSetOpt{ addNamedCredentialSets(credentialStore, o.credentialsets), + addCredentials(o.credentials), addDockerCredentials(o.targetContext, dockerCli.ContextStore()), addRegistryCredentials(o.sendRegistryAuth, dockerCli), }