From 925695a44a858a3a5d6b4898fab234ed8e56e3f3 Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Fri, 26 Jun 2026 12:00:16 +1000 Subject: [PATCH] feat(secrets): initial support for 'x-command' and 'driver: exec' secrets, referenced as secret://name in environment (#403) --- cmd/uncloud/deploy.go | 8 + go.mod | 2 +- pkg/client/compose/deploy.go | 6 + pkg/client/compose/project.go | 49 ++++ pkg/client/compose/project_test.go | 138 ++++++++++ pkg/client/compose/secret.go | 209 +++++++++++++++ pkg/client/compose/secret_test.go | 398 +++++++++++++++++++++++++++++ 7 files changed, 809 insertions(+), 1 deletion(-) create mode 100644 pkg/client/compose/secret.go create mode 100644 pkg/client/compose/secret_test.go diff --git a/cmd/uncloud/deploy.go b/cmd/uncloud/deploy.go index 02595240..4974c59c 100644 --- a/cmd/uncloud/deploy.go +++ b/cmd/uncloud/deploy.go @@ -163,6 +163,14 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { fmt.Println() } + // Resolve 'secret://name' references to actual secret values before creating a deployment. + if compose.HasCommandSecretRefs(project) { + fmt.Fprintln(os.Stderr, "Resolving secrets...") + } + if err = compose.ResolveSecrets(ctx, project); err != nil { + return fmt.Errorf("resolve secrets: %w", err) + } + strategy := &deploy.RollingStrategy{ ForceRecreate: opts.recreate, SkipHealthMonitor: opts.skipHealth, diff --git a/go.mod b/go.mod index bb35fdf4..e759140d 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/google/go-containerregistry v0.20.2 github.com/google/uuid v1.6.0 github.com/jmoiron/sqlx v1.4.0 + github.com/mattn/go-shellwords v1.0.12 github.com/miekg/dns v1.1.65 github.com/mitchellh/mapstructure v1.5.0 github.com/moby/term v0.5.2 @@ -207,7 +208,6 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect - github.com/mattn/go-shellwords v1.0.12 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.5.1 // indirect diff --git a/pkg/client/compose/deploy.go b/pkg/client/compose/deploy.go index 7d9e8b46..02bb6549 100644 --- a/pkg/client/compose/deploy.go +++ b/pkg/client/compose/deploy.go @@ -65,6 +65,12 @@ func (d *Deployment) Plan(ctx context.Context) (Plan, error) { } var plan Plan + // Resolve 'secret://name' references to actual secret values (if there are some and they haven't been resolved yet) + // before building service specs from the project. + if err := ResolveSecrets(ctx, d.Project); err != nil { + return plan, fmt.Errorf("resolve secrets: %w", err) + } + // Generate service specs for all services in the project. var serviceSpecs []api.ServiceSpec var mu sync.Mutex diff --git a/pkg/client/compose/project.go b/pkg/client/compose/project.go index 3d29d653..a45b71cb 100644 --- a/pkg/client/compose/project.go +++ b/pkg/client/compose/project.go @@ -23,6 +23,7 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project registerComposeOverrides.Do(func() { transform.RegisterDefaultValue("services.*.deploy.update_config", setUpdateConfigDefaults) transform.RegisterDefaultValue("services.*.volumes.*.source", checkRelativeVolumeMount) + transform.RegisterDefaultValue("secrets.*", expandSecretCommandExtension) }) defaultOpts := []composecli.ProjectOptionsFn{ @@ -69,6 +70,11 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project return nil, err } + // Validate secrets and clear the transient 'external' marker set on command secrets during loading. + if err = validateSecrets(project); err != nil { + return nil, err + } + for _, err = range validateServicesFeatures(project) { tui.PrintWarning(err.Error()) } @@ -137,3 +143,46 @@ func checkRelativeVolumeMount(data any, _ tree.Path, _ bool) (any, error) { return data, nil } + +// expandSecretCommandExtension expands the 'x-command' secret shorthand to the long form 'driver: exec'. It also marks +// driver-based secrets external so they pass compose-go's consistency check, which requires 'file' or 'environment' +// for non-external secrets. User-defined external secrets are not allowed. validateSecrets clears the 'external' marker +// after loading. +func expandSecretCommandExtension(data any, p tree.Path, _ bool) (any, error) { + secret, ok := data.(map[string]any) + if !ok { + return data, nil + } + + name := p.Last() + // We don't have a standalone secret entity in a cluster so external secrets don't make sense. Fail on any + // user-defined external secrets here so that we can be sure only driver-based secrets will be marked as external. + if ext, ok := secret["external"].(bool); ok && ext { + return nil, fmt.Errorf("secret '%s': external secrets are not supported", name) + } + + if command, ok := secret[SecretCommandExtensionKey]; ok { + cmd, ok := command.(string) + if !ok || cmd == "" { + return nil, fmt.Errorf("secret '%s': '%s' must be a non-empty string", name, SecretCommandExtensionKey) + } + if secret["driver"] != nil || secret["driver_opts"] != nil { + return nil, fmt.Errorf("secret '%s': '%s' cannot be combined with 'driver' or 'driver_opts'", + name, SecretCommandExtensionKey) + } + if secret["file"] != nil || secret["environment"] != nil { + return nil, fmt.Errorf("secret '%s': '%s' cannot be combined with 'file' or 'environment'", + name, SecretCommandExtensionKey) + } + + delete(secret, SecretCommandExtensionKey) + secret["driver"] = secretExecDriver + secret["driver_opts"] = map[string]string{"command": cmd} + } + + if secret["driver"] != nil && secret["file"] == nil && secret["environment"] == nil { + secret["external"] = true + } + + return secret, nil +} diff --git a/pkg/client/compose/project_test.go b/pkg/client/compose/project_test.go index 73f3d2ca..2f97cbb7 100644 --- a/pkg/client/compose/project_test.go +++ b/pkg/client/compose/project_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/compose-spec/compose-go/v2/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -346,3 +347,140 @@ volumes: }) } } + +// TestLoadProject_Secrets covers loading and validation of all top-level secret source combinations. +func TestLoadProject_Secrets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + secret string // YAML body under 'secrets.token' + want types.SecretConfig + errContains string + }{ + // Valid sources. + { + name: "file source", + secret: " file: /tmp/token", + want: types.SecretConfig{File: "/tmp/token"}, + }, + { + name: "environment source", + secret: " environment: UNSET_SECRET_VAR", + want: types.SecretConfig{Environment: "UNSET_SECRET_VAR"}, + }, + { + name: "x-command short form expands to exec driver", + secret: " x-command: printf abc", + want: types.SecretConfig{Driver: "exec", DriverOpts: map[string]string{"command": "printf abc"}}, + }, + { + name: "exec driver long form", + secret: ` driver: exec + driver_opts: + command: printf abc`, + want: types.SecretConfig{Driver: "exec", DriverOpts: map[string]string{"command": "printf abc"}}, + }, + // Invalid combinations. + { + name: "x-command with driver", + secret: ` x-command: printf abc + driver: exec`, + errContains: "cannot be combined with 'driver'", + }, + { + name: "x-command with driver_opts", + secret: ` x-command: printf abc + driver_opts: + command: printf abc`, + errContains: "cannot be combined with 'driver'", + }, + { + name: "x-command with file", + secret: ` x-command: printf abc + file: /tmp/token`, + errContains: "cannot be combined with 'file' or 'environment'", + }, + { + name: "x-command with environment", + secret: ` x-command: printf abc + environment: SOME_VAR`, + errContains: "cannot be combined with 'file' or 'environment'", + }, + { + name: "x-command empty", + secret: ` x-command: ""`, + errContains: "must be a non-empty string", + }, + { + name: "unsupported driver", + secret: ` driver: vault + driver_opts: + key: token`, + errContains: "unsupported driver 'vault'", + }, + { + name: "exec driver without command", + secret: " driver: exec", + errContains: "requires 'driver_opts.command'", + }, + { + name: "exec driver with file", + secret: ` driver: exec + driver_opts: + command: printf abc + file: /tmp/token`, + errContains: "cannot also define 'file' or 'environment'", + }, + { + name: "external not supported", + secret: " external: true", + errContains: "external secrets are not supported", + }, + { + name: "external with exec driver not supported", + secret: ` driver: exec + driver_opts: + command: printf abc + external: true`, + errContains: "external secrets are not supported", + }, + { + name: "file and environment mutually exclusive", + secret: ` file: /tmp/token + environment: SOME_VAR`, + errContains: "mutually exclusive", + }, + { + name: "no source", + secret: " name: token", + errContains: "must be set", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + content := ` +services: + foo: + image: foo +secrets: + token: +` + tt.secret + "\n" + project, err := LoadProjectFromContent(context.Background(), content) + + if tt.errContains != "" { + require.Error(t, err) + assert.ErrorContains(t, err, tt.errContains) + return + } + + require.NoError(t, err) + got := project.Secrets["token"] + got.Name = "" + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/client/compose/secret.go b/pkg/client/compose/secret.go new file mode 100644 index 00000000..c0005a0b --- /dev/null +++ b/pkg/client/compose/secret.go @@ -0,0 +1,209 @@ +package compose + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/mattn/go-shellwords" +) + +const ( + // SecretRefPrefix prefixes a reference to a secret defined in the top-level 'secrets' section, + // e.g. 'secret://my_secret'. + SecretRefPrefix = "secret://" + // SecretCommandExtensionKey is the short-form secret extension providing the command to run, + // equivalent to 'driver: exec' with 'driver_opts.command'. + SecretCommandExtensionKey = "x-command" + // secretExecDriver is a secret driver that resolves a secret by running an arbitrary command and using its output. + secretExecDriver = "exec" + // secretCommandTimeout bounds how long an 'exec' secret command may run before it is terminated. + secretCommandTimeout = 1 * time.Minute +) + +// secretRefName returns the secret name from the secret reference, for example, 'name' from 'secret://name'. +func secretRefName(ref string) (string, bool) { + name, ok := strings.CutPrefix(ref, SecretRefPrefix) + if !ok || name == "" { + return "", false + } + return name, true +} + +// validateSecrets validates the project's secrets and clears the transient 'external' marker that +// expandSecretCommandExtension sets on driver-based secrets during loading. A secret defines exactly one +// source: 'file', 'environment', or the 'exec' driver. +func validateSecrets(project *types.Project) error { + for name, secret := range project.Secrets { + if secret.Driver != "" { + // Clear the transient external marker set during loading to pass compose-go's consistency check. + secret.External = false + project.Secrets[name] = secret + + if secret.Driver != secretExecDriver { + return fmt.Errorf("secret '%s': unsupported driver '%s', only '%s' is supported", + name, secret.Driver, secretExecDriver) + } + if secret.DriverOpts["command"] == "" { + return fmt.Errorf("secret '%s': '%s' driver requires 'driver_opts.command'", name, secretExecDriver) + } + if secret.File != "" || secret.Environment != "" { + return fmt.Errorf("secret '%s': a secret using a driver cannot also define 'file' or 'environment'", + name) + } + continue + } + // Assume that compose-go already validated that a non-driver, non-external secret defines exactly one of + // 'file' or 'environment'. + } + + return nil +} + +// ResolveSecrets resolves 'secret://name' references in the services' environment to actual secret values, setting each +// referenced variable to the secret's value in place. Each referenced secret is resolved at most once, even if +// referenced by multiple services. Secrets that are not referenced are never resolved. +func ResolveSecrets(ctx context.Context, project *types.Project) error { + values := make(map[string]string) + resolve := func(name string) (string, error) { + if v, ok := values[name]; ok { + return v, nil + } + secret, ok := project.Secrets[name] + if !ok { + return "", fmt.Errorf("secret '%s' referenced via '%s%s' is not defined in the top-level "+ + "'secrets' section", name, SecretRefPrefix, name) + } + v, err := secretValue(ctx, secret, project) + if err != nil { + return "", fmt.Errorf("get the value of secret '%s': %w", name, err) + } + values[name] = v + return v, nil + } + + // Resolve only secret references set as values for environment variables in enabled services. + for _, service := range project.Services { + for k, v := range service.Environment { + if v == nil { + continue + } + secretName, ok := secretRefName(*v) + if !ok { + continue + } + value, err := resolve(secretName) + if err != nil { + return err + } + service.Environment[k] = &value + } + } + + return nil +} + +// HasCommandSecretRefs reports whether any service environment references a secret that is resolved by running +// a command. +func HasCommandSecretRefs(project *types.Project) bool { + for _, service := range project.Services { + for _, v := range service.Environment { + if v == nil { + continue + } + name, ok := secretRefName(*v) + if !ok { + continue + } + if secret, ok := project.Secrets[name]; ok && secret.Driver == secretExecDriver { + return true + } + } + } + return false +} + +// secretValue resolves a secret to its value depending on its source: an 'exec' driver command, +// an environment variable, or a file. A single trailing newline ('\n' or '\r\n') is stripped from the command +// output as command-line tools commonly append one. All other whitespace is kept. Environment and file values are +// returned verbatim. +func secretValue(ctx context.Context, secret types.SecretConfig, project *types.Project) (string, error) { + switch { + case secret.Environment != "": + value, ok := project.Environment[secret.Environment] + if !ok { + return "", fmt.Errorf("environment variable '%s' is not set", secret.Environment) + } + return value, nil + case secret.File != "": + content, err := os.ReadFile(secret.File) + if err != nil { + return "", fmt.Errorf("read secret file: %w", err) + } + return string(content), nil + case secret.Driver == secretExecDriver: + // Run with the resolved project environment (process env merged with .env files) so the command + // can use the same variables available elsewhere in the Compose file. + out, err := runSecretCommand(ctx, secret.DriverOpts["command"], project.WorkingDir, + project.Environment.Values()) + if err != nil { + return "", err + } + // Strip a single trailing newline, treating it as '\r\n' on Windows. + if trimmed, ok := strings.CutSuffix(out, "\n"); ok { + out = strings.TrimSuffix(trimmed, "\r") + } + return out, nil + default: + return "", fmt.Errorf("secret has no source: define one of 'file', 'environment', '%s', or 'driver'", + SecretCommandExtensionKey) + } +} + +// runSecretCommand runs the command in workingDir with the given environment and returns its stdout. +// The command runs directly without a shell, so shell features need an explicit shell, e.g. 'sh -c "cmd1 | cmd2"'. +// Its stdin and stderr are connected to the current process so it can prompt for authentication interactively. +// The command is terminated if it runs longer than secretCommandTimeout. +func runSecretCommand(ctx context.Context, command, workingDir string, env []string) (string, error) { + args, err := shellwords.Parse(command) + if err != nil { + return "", fmt.Errorf("parse command '%s': %w", command, err) + } + if len(args) == 0 { + return "", fmt.Errorf("command '%s' is empty", command) + } + + ctx, cancel := context.WithTimeout(ctx, secretCommandTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = workingDir + cmd.Env = env + + // Forward stdin and stderr to the user's terminal so prompts and progress are visible. + var stdout, stderr bytes.Buffer + cmd.Stdin = os.Stdin + cmd.Stdout = &stdout + cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) + + if err := cmd.Run(); err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return "", fmt.Errorf("command '%s' timed out after %s", command, secretCommandTimeout) + } + // Include stderr but never stdout in the error as stdout may contain secret data. + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + return "", fmt.Errorf("run command '%s': %w", command, err) + } + return "", fmt.Errorf("run command '%s': %w: %s", command, err, msg) + } + + return stdout.String(), nil +} diff --git a/pkg/client/compose/secret_test.go b/pkg/client/compose/secret_test.go new file mode 100644 index 00000000..1b719dc9 --- /dev/null +++ b/pkg/client/compose/secret_test.go @@ -0,0 +1,398 @@ +package compose + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// loadProject loads a compose project from content with a working directory that exists for the duration +// of the test. LoadProjectFromContent removes its own temporary directory before returning, so secret +// commands (which run in the project working directory) need a valid one. +func loadProject(t *testing.T, content string) *types.Project { + t.Helper() + + project, err := LoadProjectFromContent(context.Background(), content) + require.NoError(t, err) + project.WorkingDir = t.TempDir() + + return project +} + +// resolvedEnv loads a compose project from content, resolves its secrets, and returns the resolved +// environment of the given service. +func resolvedEnv(t *testing.T, content, service string) types.MappingWithEquals { + t.Helper() + + project := loadProject(t, content) + require.NoError(t, ResolveSecrets(context.Background(), project)) + + return project.Services[service].Environment +} + +// env builds a MappingWithEquals from a plain map for comparing against resolved service environments. +func env(vars map[string]string) types.MappingWithEquals { + return types.Mapping(vars).ToMappingWithEquals() +} + +func TestResolveSecrets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want types.MappingWithEquals + }{ + { + name: "short form x-command", + content: ` +services: + foo: + image: foo + environment: + TOKEN: secret://token +secrets: + token: + x-command: printf 'topsecret' +`, + want: env(map[string]string{"TOKEN": "topsecret"}), + }, + { + name: "long form driver exec", + content: ` +services: + foo: + image: foo + environment: + TOKEN: secret://token +secrets: + token: + driver: exec + driver_opts: + command: printf 'topsecret' +`, + want: env(map[string]string{"TOKEN": "topsecret"}), + }, + { + name: "command output trailing newline trimmed but surrounding space kept", + content: ` +services: + foo: + image: foo + environment: + TOKEN: secret://token +secrets: + token: + x-command: printf ' spaced value \n' +`, + want: env(map[string]string{"TOKEN": " spaced value "}), + }, + { + name: "command output trailing CRLF newline trimmed", + content: ` +services: + foo: + image: foo + environment: + TOKEN: secret://token +secrets: + token: + x-command: printf 'value\r\r\n' +`, + want: env(map[string]string{"TOKEN": "value\r"}), + }, + { + name: "empty command output is allowed", + content: ` +services: + foo: + image: foo + environment: + TOKEN: secret://token +secrets: + token: + x-command: printf '' +`, + want: env(map[string]string{"TOKEN": ""}), + }, + { + name: "non-secret env value left untouched", + content: ` +services: + foo: + image: foo + environment: + PLAIN: hello + TOKEN: secret://token +secrets: + token: + x-command: printf 'abc' +`, + want: env(map[string]string{"PLAIN": "hello", "TOKEN": "abc"}), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, resolvedEnv(t, tt.content, "foo")) + }) + } +} + +func TestResolveSecrets_FileSource(t *testing.T) { + t.Parallel() + + secretFile := filepath.Join(t.TempDir(), "token.txt") + // File content is used verbatim, including the trailing newline. + require.NoError(t, os.WriteFile(secretFile, []byte("file-secret\n"), 0o600)) + + // Use an absolute path so it survives LoadProjectFromContent removing its working directory. + content := fmt.Sprintf(` +services: + foo: + image: foo + environment: + TOKEN: secret://token +secrets: + token: + file: %s +`, secretFile) + + assert.Equal(t, env(map[string]string{"TOKEN": "file-secret\n"}), resolvedEnv(t, content, "foo")) +} + +func TestResolveSecrets_CommandUsesProjectEnvironment(t *testing.T) { + t.Parallel() + + // A variable defined only in a .env file (not in the process environment) must be available to the + // secret command, proving the resolved project environment is passed to it, not just os.Environ(). + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("DOTENV_ONLY=from-dotenv\n"), 0o600)) + // '$$' escapes the '$' so Compose doesn't interpolate it; the explicit shell expands it at run time. + composeYAML := ` +services: + foo: + image: foo + environment: + TOKEN: secret://token +secrets: + token: + x-command: sh -c 'printf %s "$$DOTENV_ONLY"' +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte(composeYAML), 0o644)) + + project, err := LoadProject(context.Background(), []string{filepath.Join(dir, "compose.yaml")}) + require.NoError(t, err) + require.NoError(t, ResolveSecrets(context.Background(), project)) + + assert.Equal(t, env(map[string]string{"TOKEN": "from-dotenv"}), project.Services["foo"].Environment) +} + +func TestResolveSecrets_EnvironmentSource(t *testing.T) { + // Surrounding whitespace verifies the environment value is used verbatim, not trimmed. + t.Setenv("MY_SECRET_VAR", " from-env ") + + content := ` +services: + foo: + image: foo + environment: + TOKEN: secret://token +secrets: + token: + environment: MY_SECRET_VAR +` + assert.Equal(t, env(map[string]string{"TOKEN": " from-env "}), resolvedEnv(t, content, "foo")) +} + +func TestResolveSecrets_EnvironmentSourceEmpty(t *testing.T) { + // A variable that is set but empty is a valid value, distinct from an unset variable. + t.Setenv("MY_EMPTY_VAR", "") + + content := ` +services: + foo: + image: foo + environment: + TOKEN: secret://token +secrets: + token: + environment: MY_EMPTY_VAR +` + assert.Equal(t, env(map[string]string{"TOKEN": ""}), resolvedEnv(t, content, "foo")) +} + +func TestResolveSecrets_Errors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + errContains string + }{ + { + name: "undefined secret", + content: ` +services: + foo: + image: foo + environment: + TOKEN: secret://missing +`, + errContains: "is not defined in the top-level 'secrets' section", + }, + { + name: "command fails with stderr", + content: ` +services: + foo: + image: foo + environment: + TOKEN: secret://token +secrets: + token: + x-command: sh -c 'echo boom >&2; exit 3' +`, + errContains: "boom", + }, + { + name: "environment variable not set", + content: ` +services: + foo: + image: foo + environment: + TOKEN: secret://token +secrets: + token: + environment: DEFINITELY_UNSET_VAR_XYZ +`, + errContains: "environment variable 'DEFINITELY_UNSET_VAR_XYZ' is not set", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + project := loadProject(t, tt.content) + err := ResolveSecrets(context.Background(), project) + require.Error(t, err) + assert.ErrorContains(t, err, tt.errContains) + }) + } +} + +// TestResolveSecrets_Once verifies a secret's command runs only once, both when referenced by +// multiple services and across repeated resolutions (ResolveSecrets is idempotent). +func TestResolveSecrets_Once(t *testing.T) { + t.Parallel() + + counter := filepath.Join(t.TempDir(), "runs") + content := fmt.Sprintf(` +services: + foo: + image: foo + environment: + TOKEN: secret://token + bar: + image: bar + environment: + TOKEN: secret://token +secrets: + token: + x-command: "sh -c 'echo run >> %s; printf abc'" +`, counter) + + project := loadProject(t, content) + require.NoError(t, ResolveSecrets(context.Background(), project)) + + assert.Equal(t, "abc", *project.Services["foo"].Environment["TOKEN"]) + assert.Equal(t, "abc", *project.Services["bar"].Environment["TOKEN"]) + + // A second resolution is a no-op: references are already replaced with their values. + require.NoError(t, ResolveSecrets(context.Background(), project)) + + assert.Equal(t, "abc", *project.Services["foo"].Environment["TOKEN"]) + assert.Equal(t, "abc", *project.Services["bar"].Environment["TOKEN"]) + + runs, err := os.ReadFile(counter) + require.NoError(t, err) + assert.Equal(t, "run\n", string(runs), "command should run exactly once across services and repeated resolutions") +} + +func TestHasCommandSecretRefs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want bool + }{ + { + name: "command secret referenced", + content: ` +services: + foo: + image: foo + environment: + TOKEN: secret://token +secrets: + token: + x-command: printf abc +`, + want: true, + }, + { + name: "environment secret referenced", + content: ` +services: + foo: + image: foo + environment: + TOKEN: secret://token +secrets: + token: + environment: SOME_VAR +`, + want: false, + }, + { + name: "command secret defined but not referenced", + content: ` +services: + foo: + image: foo + environment: + PLAIN: hello +secrets: + token: + x-command: printf abc +`, + want: false, + }, + { + name: "no secrets", + content: ` +services: + foo: + image: foo +`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, HasCommandSecretRefs(loadProject(t, tt.content))) + }) + } +}