mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
feat(secrets): initial support for 'x-command' and 'driver: exec' secrets, referenced as secret://name in environment (#403)
This commit is contained in:
@@ -163,6 +163,14 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
fmt.Println()
|
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{
|
strategy := &deploy.RollingStrategy{
|
||||||
ForceRecreate: opts.recreate,
|
ForceRecreate: opts.recreate,
|
||||||
SkipHealthMonitor: opts.skipHealth,
|
SkipHealthMonitor: opts.skipHealth,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ require (
|
|||||||
github.com/google/go-containerregistry v0.20.2
|
github.com/google/go-containerregistry v0.20.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jmoiron/sqlx v1.4.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/miekg/dns v1.1.65
|
||||||
github.com/mitchellh/mapstructure v1.5.0
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
github.com/moby/term v0.5.2
|
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-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.23 // 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/genetlink v1.3.2 // indirect
|
||||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||||
github.com/mdlayher/socket v0.5.1 // indirect
|
github.com/mdlayher/socket v0.5.1 // indirect
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ func (d *Deployment) Plan(ctx context.Context) (Plan, error) {
|
|||||||
}
|
}
|
||||||
var plan Plan
|
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.
|
// Generate service specs for all services in the project.
|
||||||
var serviceSpecs []api.ServiceSpec
|
var serviceSpecs []api.ServiceSpec
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
|
|||||||
registerComposeOverrides.Do(func() {
|
registerComposeOverrides.Do(func() {
|
||||||
transform.RegisterDefaultValue("services.*.deploy.update_config", setUpdateConfigDefaults)
|
transform.RegisterDefaultValue("services.*.deploy.update_config", setUpdateConfigDefaults)
|
||||||
transform.RegisterDefaultValue("services.*.volumes.*.source", checkRelativeVolumeMount)
|
transform.RegisterDefaultValue("services.*.volumes.*.source", checkRelativeVolumeMount)
|
||||||
|
transform.RegisterDefaultValue("secrets.*", expandSecretCommandExtension)
|
||||||
})
|
})
|
||||||
|
|
||||||
defaultOpts := []composecli.ProjectOptionsFn{
|
defaultOpts := []composecli.ProjectOptionsFn{
|
||||||
@@ -69,6 +70,11 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
|
|||||||
return nil, err
|
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) {
|
for _, err = range validateServicesFeatures(project) {
|
||||||
tui.PrintWarning(err.Error())
|
tui.PrintWarning(err.Error())
|
||||||
}
|
}
|
||||||
@@ -137,3 +143,46 @@ func checkRelativeVolumeMount(data any, _ tree.Path, _ bool) (any, error) {
|
|||||||
|
|
||||||
return data, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user