fix(compose): strip and resolve pre-deploy hook secrets like service env (#424)

* fix(compose): strip and resolve pre-deploy hook secrets like service env (fixes #422)

normaliseContainerForStore only cleared Config.Env and
ServiceSpec.Container.Env before writing a container record to the
replicated Corrosion store, leaving ServiceSpec.PreDeploy.Env in place.
A secret set via x-pre_deploy.environment therefore replicated to
every machine in the cluster, unlike the same value set via
environment.

ResolveSecrets had the same asymmetry in the other direction: it only
resolved secret://name references in service.Environment, so a
reference placed in the pre-deploy hook's environment passed through
unresolved as a literal string. HasCommandSecretRefs is extended the
same way for consistency.

Add tests pinning both behaviours.

* update docs that secret references are available for pre-deploy hooks

---------

Co-authored-by: Pasha Sviderski <me@psviderski.name>
This commit is contained in:
Vaibhav Yadav
2026-10-12 15:48:47 +10:00
committed by GitHub
co-authored by Pasha Sviderski
parent ce1c6bd05a
commit 8ddbe3c606
6 changed files with 217 additions and 32 deletions
+54 -23
View File
@@ -89,40 +89,53 @@ func ResolveSecrets(ctx context.Context, project *types.Project) error {
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 {
// Resolve secret references set as values for environment variables in the service.
if err := resolveEnvSecrets(service.Environment, resolve); err != nil {
return err
}
// Resolve secret references set as values for the pre-deploy hook's environment variables, if any.
if hook, ok := service.Extensions[PreDeployHookExtensionKey].(PreDeployHook); ok {
if err := resolveEnvSecrets(hook.Environment, resolve); 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.
// resolveEnvSecrets resolves 'secret://name' references found as values in env to actual secret values using
// resolve, setting each referenced variable to the secret's value in place. Variables that are not secret
// references are left untouched.
func resolveEnvSecrets(env types.MappingWithEquals, resolve func(string) (string, error)) error {
for k, v := range env {
if v == nil {
continue
}
secretName, ok := secretRefName(*v)
if !ok {
continue
}
value, err := resolve(secretName)
if err != nil {
return err
}
env[k] = &value
}
return nil
}
// HasCommandSecretRefs reports whether any service environment, including a pre-deploy hook's 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 {
if envHasCommandSecretRef(service.Environment, project) {
return true
}
if hook, ok := service.Extensions[PreDeployHookExtensionKey].(PreDeployHook); ok {
if envHasCommandSecretRef(hook.Environment, project) {
return true
}
}
@@ -130,6 +143,24 @@ func HasCommandSecretRefs(project *types.Project) bool {
return false
}
// envHasCommandSecretRef reports whether env contains a 'secret://name' reference to a secret resolved
// by running a command.
func envHasCommandSecretRef(env types.MappingWithEquals, project *types.Project) bool {
for _, v := range env {
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
+84
View File
@@ -328,6 +328,74 @@ secrets:
assert.Equal(t, "run\n", string(runs), "command should run exactly once across services and repeated resolutions")
}
// TestResolveSecrets_PreDeployHook pins the behaviour that 'secret://name' references in the pre-deploy hook's
// environment are resolved just like references in the service's own environment.
// See https://github.com/psviderski/uncloud/issues/422.
func TestResolveSecrets_PreDeployHook(t *testing.T) {
t.Parallel()
content := `
services:
foo:
image: foo
environment:
CONTROL_TOKEN: secret://control_token
x-pre_deploy:
command: ["./migrate.sh"]
environment:
HOOK_TOKEN: secret://hook_token
PLAIN: hello
secrets:
control_token:
x-command: printf control-value
hook_token:
x-command: printf hook-value
`
project := loadProject(t, content)
require.NoError(t, ResolveSecrets(context.Background(), project))
assert.Equal(t, env(map[string]string{"CONTROL_TOKEN": "control-value"}), project.Services["foo"].Environment)
hook, ok := project.Services["foo"].Extensions[PreDeployHookExtensionKey].(PreDeployHook)
require.True(t, ok, "x-pre_deploy extension not found")
assert.Equal(t, env(map[string]string{"HOOK_TOKEN": "hook-value", "PLAIN": "hello"}), hook.Environment)
}
// TestResolveSecrets_PreDeployHook_ResolvedOnce verifies that a secret referenced from both the service's
// environment and its pre-deploy hook's environment is only resolved once.
func TestResolveSecrets_PreDeployHook_ResolvedOnce(t *testing.T) {
t.Parallel()
counter := filepath.Join(t.TempDir(), "runs")
content := fmt.Sprintf(`
services:
foo:
image: foo
environment:
TOKEN: secret://token
x-pre_deploy:
command: ["./migrate.sh"]
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"])
hook, ok := project.Services["foo"].Extensions[PreDeployHookExtensionKey].(PreDeployHook)
require.True(t, ok, "x-pre_deploy extension not found")
assert.Equal(t, "abc", *hook.Environment["TOKEN"])
runs, err := os.ReadFile(counter)
require.NoError(t, err)
assert.Equal(t, "run\n", string(runs),
"command should run exactly once across the service and hook environments")
}
func TestHasCommandSecretRefs(t *testing.T) {
t.Parallel()
@@ -387,6 +455,22 @@ services:
`,
want: false,
},
{
name: "command secret referenced from pre-deploy hook",
content: `
services:
foo:
image: foo
x-pre_deploy:
command: ["./migrate.sh"]
environment:
TOKEN: secret://token
secrets:
token:
x-command: printf abc
`,
want: true,
},
}
for _, tt := range tests {