hammerkit

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 test

from: <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}}']
FieldDescription
commandThe command and its arguments. ${NAME} is a variable of the file's envs, else of the environment hammerkit runs in.
envStatic environment variables of the command.
timeoutHow 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.

ManagerCredential the account passes
1PasswordOP_SERVICE_ACCOUNT_TOKEN of a service account. Do not pass OP_CONNECT_*, it takes precedence.
Google Secret ManagerCLOUDSDK_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 ref to 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.
  • env and file are 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.sh

Commands 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.

On this page