Secret providers
A secret can be read from a secret manager such as Google Secret Manager or 1Password. Hammerkit does not know any manager: a provider is a command that prints the value, and an account says which service account it runs as. A company declares both once and every project reuses them.
tasks:
test:
image: node:22-alpine
secrets:
- from: gsm:test-db-password # provider gsm, reference test-db-password
env: DB_PASSWORD
cmds:
- npm testfrom: <provider>:<ref> names the provider and what to fetch. The text after the first
: is the reference, passed to the provider as it is.
Providers
A provider is a command as a list of arguments. It is never run through a shell, and
{{ref}} is replaced by the secret's reference.
envs:
GCP_PROJECT: company-secrets-prod # an input, replaced with `with:` when included
secretProviders:
gsm:
command: [gcloud, secrets, versions, access, latest, '--secret={{ref}}', '--project=${GCP_PROJECT}']
op:
command: [op, read, --no-newline, '{{ref}}']| Field | Description |
|---|---|
command | The command and its arguments. ${NAME} is a variable of the file's envs, else of the environment hammerkit runs in. |
env | Static environment variables of the command. |
timeout | How long the command may run, 30s when omitted. |
The recipes put the reference in --secret={{ref}}, so a reference can never become an
option of the CLI, and a reference that starts with - is rejected.
The output is the value as it is, without trimming: a key file keeps its trailing
newline, which is why 1Password is read with --no-newline. A command that fails, is
not found, runs too long or prints nothing fails the task before it starts, naming the
secret, the provider and the account.
Accounts
An account is a service account: the environment that makes a provider's command act as it. Each value points at a credential the machine running hammerkit holds, so no credential is ever written to a build file.
secretAccounts:
test: # used when nothing else is named
default: true
providers:
gsm: { env: { CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: '${GSM_TEST_CREDENTIALS}' } }
op: { env: { OP_SERVICE_ACCOUNT_TOKEN: '${OP_TEST_TOKEN}' } }
deploy: # production: only machines that deploy hold this credential
providers:
gsm: { env: { CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: '${GSM_DEPLOY_CREDENTIALS}' } }A secret is read as its own account, else the account of its task or service, else
the account marked default: true:
tasks:
deploy:
account: deploy # every provider secret of this task
image: deployer:1
secrets:
- from: gsm:prod-db-password
env: DB_PASSWORD
- from: op:op://Prod/api/token
path: /run/secrets/api-token
account: test # this one is read as `test`An unknown account, an account without credentials for the secret's provider, or no account where one is needed fails when the build file is read, naming the secret.
Only a service account can authenticate
The command runs with PATH, the provider's env and the account's env, and nothing
else of your shell. A personal gcloud auth login or the 1Password app is never picked
up, and when a ${VARIABLE} of the account is not set, the secret fails with
account test, provider op: OP_TEST_TOKEN is not set and the command does not run.
Several accounts can coexist on one machine under different variable names, so a
deploy task and a test task use different identities in one run.
| Manager | Credential the account passes |
|---|---|
| 1Password | OP_SERVICE_ACCOUNT_TOKEN of a service account. Do not pass OP_CONNECT_*, it takes precedence. |
| Google Secret Manager | CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE, a service-account key file or a workload identity federation config file. google-github-actions/auth exports it. |
Prefer short-lived credentials (workload identity federation, a token your CI mints per
job) over long-lived key files. If gcloud or op need a configuration directory,
point CLOUDSDK_CONFIG or OP_CONFIG_DIR at one in the provider's env.
Sharing providers and accounts
Providers and accounts are names shared by all build files of a build, like caches. Keep them in one file the platform team owns and include it:
includes:
company:
git: https://github.com/acme/company-secrets.git
ref: 3f9c2e1a8b7d4c5e6f0a1b2c3d4e5f60718293a4 # a full commit SHA
with:
GCP_PROJECT: company-secrets-prod- A provider or account declared in a git include requires
refto be a full commit SHA. A tag or branch can be moved, and a moved file would run with your service accounts' credentials. - A name declared in two files is an error naming both files.
envandfileare built in and cannot be provider names.
Values
Each value is fetched once per run for a provider, account and reference, however many
tasks use it, which matters for rate-limited services (a 1Password service account is
limited per token). It is kept in memory, never written anywhere, and fetched again by
the next run. ls and validate never fetch.
The value is masked in hammerkit's logs like any other secret (see
Logs). The secret manager's own log shows the service
account; with --verbose hammerkit logs secret db-password via gsm/test for each
fetch, so a read can be traced to the run.
Caching
As for any secret, a provider secret does not affect the cache by default. With
cache: true the task id holds a salted digest of the fetched value, so a rotated
value, or a new latest version in Google Secret Manager, gives a new id.
tasks:
build:
image: alpine:3.21
secrets:
- from: gsm:license-key
env: LICENSE_KEY
cache: true
cmds:
- ./build.shCommands that need task ids (exec, up, explain, cache push/pull/ls, clean)
fetch these secrets first, once, for the tasks in scope, so they need the account's
credential too; a machine without it fails with the unset-variable error instead of
computing another id. The digest depends on the value, not on who read it: two accounts
that see the same value give the same id on every machine.
The digest is stored in the cache entry's description, and remote caches carry it. Use
cache: true for high-entropy or non-sensitive values (a license key, a toggle), never
for a short password: a digest of a short value can be guessed.
What a secret can still do
A task that receives a secret can print or send it, and so can the code it runs, such as the postinstall script of a dependency. Give secrets only to the tasks that need them, never to install or build steps. Where hammerkit itself runs in a container (a CI image), the provider's CLI has to be in that image.