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:
@@ -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