Contribution
Secret managers (planned)
Status: planned, not yet implemented. Deferred from 1.6.0 and not part of 1.7.0. Decisions are locked in (see Context); see also specs/task-secrets.
Context
Hammerkit currently resolves task/service environment variables in one of two ways
(src/environment/replace-env-variables.ts):
- literal values (
KEY: value), and $VARreferences resolved at parse time fromprocess.envor.envfiles.
There is no way to source an env var from an external secret manager (Vault, AWS/GCP, 1Password, etc.), and resolved values are printed verbatim in logs and container options — no redaction.
This change adds a pluggable secret-provider mechanism, modeled on the existing
cache-backend system (src/cache/cache-backend*.ts, src/cache/resolve-cache.ts),
plus log redaction. Per the decisions taken:
- Reference style: inline in
envs:via asecret://<provider>/<name>value. - First provider:
command(runs a shell command, uses stdout as the secret) — zero new dependencies, works with any secret CLI. The abstraction leaves room for Vault/AWS/GCP providers later. - Masking: resolved secret values are redacted (
***) everywhere hammerkit writes.
Example
secrets: # top-level provider catalog (like caches:)
vault:
type: command
command: "vault kv get -field={{name}} secret/app"
tasks:
deploy:
image: deployer
envs:
DB_PASSWORD: secret://vault/db-password # -> runs the command, {{name}}=db-password
LOG_LEVEL: infoDesign overview
- A named provider catalog is declared at the top level (
secrets:), mirroringcaches:. Each entry is a discriminated-union spec keyed bytype. - Env values starting with
secret://become a new secret binding onWorkEnvironmentVariables, carrying the env key, the resolved provider spec, and the secret name. The spec travels with the binding (as cache backends do via theWeakMapinresolve-cache.ts), so executors never need the catalog threaded to them. - Resolution is lazy/async at execution time. This is mandatory: provider calls do
I/O and may need credentials, so they must not run for
ls/validate/clean, for tasks that never execute, or during cache-key computation. - Two resolution paths over the same bindings:
- sync
getEnvironmentVariables(envs)— unchanged callers (notably the cache description) get the literalsecret://provider/namereference string for a secret key. The cache key thus invalidates when the wiring changes but never embeds or fetches the secret value. - async
resolveEnvironmentVariables(envs, environment)— execution path; fetches each secret via its provider, registers the value for redaction, returns real values.
- sync
Files to add
src/schema/secret-schema.ts— Zod specs (mirrorsrc/schema/cache-schema.ts):secretProviderCommandSchema = object({ type: literal('command'), command: string() }).strict()(commandis a template;{{name}}is replaced with the secret name).secretProviderSchema = union([secretProviderCommandSchema])(single-member now, extend forvault/aws-secrets-manager/gcp-secret-managerlater).
src/secret/secret-provider.ts—SecretProvider { type: string; resolve(name, environment): Promise<string> }SecretProviderFactory(mirrorsrc/cache/cache-backend.ts).
src/secret/secret-provider-registry.ts—registerSecretProvider(type, factory)/createSecretProvider(spec); registers the builtincommandfactory (mirrorsrc/cache/cache-backend-registry.ts).src/secret/providers/command-secret-provider.ts— runscommandwith{{name}}substituted viachild_process.exec(followsrc/executer/execute-command.ts:PATHfromenvironment.processEnvs, honorenvironment.abortCtrl.signal); returns trimmed stdout; throws on non-zero exit.src/secret/resolve-secret-provider.ts—SecretCatalogtype +getProviderSpec(catalog, name)lookup (throws on unknown provider, likeresolveByNameinresolve-cache.ts), and aWeakMap<spec, SecretProvider>instance cache (mirrorgetBackendinresolve-cache.ts).src/secret/secret-registry.ts—createSecretRegistry(): SecretRegistrywithregister(value: string)andredact(text: string): string(replace each registered non-empty value with***). Ignore empty/very-short values to avoid over-masking.- Tests/fixtures:
src/secret/providers/command-secret-provider.spec.ts- extend
src/environment/replace-env-variables.spec.tsforsecret://parsing + sync placeholder behavior src/testing/integration/secret.spec.ts(end-to-end with acommandprovider, e.g.printenv/echo-style command) andexamples/secret/.hammerkit.yaml
Files to modify
src/schema/build-file-schema.ts— addsecrets: record(secretProviderSchema).optional().src/schema/reference-parser.ts— addsecrets: SecretCatalogtoReferencedContextand populate it from the build file's top-levelsecrets:(mirror howcachesis threaded into the context).src/environment/replace-env-variables.ts:- Extend
WorkEnvironmentVariableswithsecrets: EnvironmentVariableSecret[]({ key; name; ref; spec }). - In
buildEnvironmentVariables, detectvalue.startsWith('secret://'), parse<provider>/<name>(split on the first/, so names may contain/), look the provider up incontext.secrets(parse-time validation), and push a secret binding carrying the spec. getEnvironmentVariables(sync): for secret keys, setresult[key] = ref(the literalsecret://...string) — no I/O, stable for cache keys.- Add
async resolveEnvironmentVariables(envs, environment): variables + replacements as today, plus for each secretcreateSecretProvider(spec).resolve(name, environment),environment.secrets.register(value), then setresult[key] = value.
- Extend
- Executors — switch the execution path from sync to async resolve:
src/executer/docker-task.ts(move env resolution out of syncbuildCreateOptionsinto the asyncdockerTask, or makebuildCreateOptionsasync),src/executer/local-task.ts:19,src/executer/docker-service.ts,src/planner/work-runtime-kubernetes.ts+src/kubernetes/ensure-kubernetes-deployment-exists.ts(resolve to plainvalue, consistent with current K8s env injection).- Leave
src/optimizer/work-task-cache-description.tson the syncgetEnvironmentVariables— it must keep using the reference placeholder, not the value.
src/executer/environment.ts— addsecrets: SecretRegistryto theEnvironmentinterface. Instantiate it (createSecretRegistry()) at every construction site:src/index.ts,src/testing/test-case.ts,src/testing/example-test-suite.ts,src/executer/environment-mock.ts.src/log.ts— applyenv.secrets.redact(...)inside the central writers (writeWorkItemLogToConsoleand any direct stdout writer). This single chokepoint covers status messages,printContainerOptionsoutput (it writes viastatus.write), and captured command stdout/stderr (status.console('stdout', …)inexecute-command.ts), since all of these render through these writers. Verify the interactive/live loggers (src/logging/interactive-logger.ts,live-logger.ts) also funnel throughsrc/log.ts; if a logger writes to stdout directly, redact there too.package.json— no new runtime dependency for thecommandprovider.- Regenerate
build.schema.json(run thejson-schemascript,src/json-schema.ts) so the newsecrets:field appears for IDE validation.
Resolution & execution flow
- Parse:
buildEnvironmentVariablesclassifies each env value as variable /$replacement /secret://binding, validating the provider name againstcontext.secrets. - Plan / cache: sync
getEnvironmentVariablesyieldssecret://…placeholders → cache description (work-task-cache-description.ts) is value-independent and offline. - Execute: executor calls
await resolveEnvironmentVariables(item.data.envs, environment)→ provider fetches value → registered for redaction → injected as a real env var. - Output: every log line passes through
env.secrets.redact()→ values shown as***.
Verification
npm run test— new unit specs (command provider,secret://parsing, sync placeholder) and thesrc/testing/integration/secret.spec.tsend-to-end run.- Manual: with
examples/secret/.hammerkit.yamlusing acommandprovider such astype: command, command: "echo my-secret-{{name}}", runhammerkit run <task>and confirm (a) the task receives the resolved value and (b) logs show***not the value. - Confirm cache behavior: changing a secret's value (provider output) does NOT
invalidate the task cache; changing the
secret://reference DOES — verify the cache id via the existingwork-cache-idtests/patterns. - Run the contribution build gates (prettier, eslint,
tsc -b, jest) before committing, and re-run thejson-schemageneration sobuild.schema.jsonis in sync.
Out of scope (future)
- Additional providers (Vault HTTP, AWS Secrets Manager, GCP Secret Manager) — add a new
spec to the
secretProviderSchemaunion + a factory in the registry; the rest is unchanged. Cloud SDKs would become new dependencies at that point. - Native Kubernetes
secretKeyRef(creating a K8s Secret and referencing it instead of inlining the resolved value).