hammerkit

Overview

Run tasks on a hammerkit server, with the credentials of service accounts and the build files that were published to it.

Experimental. The config and the API can still change between releases, see the remote compute spec.

A hammerkit server runs tasks for other people and agents on a machine that has what the work needs: a Mac mini for iOS builds, a Linux box with big CPUs, the credentials to deploy. Callers never get those credentials, and they can not change what a task does: they name a published build file, the tasks to run and where the code comes from, and the server decides with its config whether they may.

How it fits together

  1. Who calls. Every call carries an OAuth token of an identity provider the server trusts. The server knows people, CI jobs and agents by the claims of that token only, see Authentication.
  2. What they may do. Entitlements map callers to service accounts (use or only request), to the repositories they may run from and to what they may do with build files (run, propose or publish).
  3. Which credentials. Accounts are the service accounts of the operator, with the secret provider commands that read secrets as them. The secrets of a published build file say which account each is read as, so the account of a run follows from its tasks, never from the request.
  4. What runs. A run executes tasks of a published build file, pinned to a version, so whoever may run can not change what a task does.
  5. Which code. The code comes from a git ref of a repository the server reads itself, see Sources, or from files the caller uploads.
  6. Where it runs. Runs are queued, limited per caller and executed in a process of their own on the backend.

Starting a server

hammerkit server reads its own config file, so it needs no build file.

hammerkit server --config /etc/hammerkit/server.yaml

The server runs until it is interrupted. HAMMERKIT_SERVER_CONFIG can stand in for --config.

Setting up a server

The pieces, in the order an operator sets them up.

  1. Identity provider. Register an API audience for the server and list the issuer under issuers (Authentication). Nothing else identifies a caller.
  2. Accounts. Define one service account per set of credentials a build needs, and the secret provider commands that read them (Accounts). A production account is limited to source: [ref] and the branches it may run from.
  3. Repositories. List the repositories the server may read under sources, with a token that a provider command mints, for example from a GitHub App (Sources).
  4. Entitlements. Say who may use or request which account, which repositories they may name and whether they may run, propose or publish build files (Entitlements).
  5. Start it. hammerkit server --config server.yaml, behind a TLS proxy or with tls. Send SIGHUP to read the entitlements again without a restart (Changing entitlements).

A small config for one team on a Linux box, with a GitHub App token and a production account that needs a second person:

server.yaml
listen: { host: 0.0.0.0, port: 8480 }
behindTlsProxy: true
issuers:
  - { issuer: https://login.example.com, audience: hammerkit, clientId: hammerkit-cli }
secretProviders:
  gsm: { command: [gcloud, secrets, versions, access, latest, '--secret={{ref}}'] }
  github-app: { command: [/usr/local/bin/github-app-token, '{{ref}}'] }
accounts:
  scm-read:         { providers: { github-app: { env: { APP_KEY_FILE: /etc/hammerkit/app.pem } } } }
  op-payments-dev:  { providers: { gsm: { env: { CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: /etc/hammerkit/dev.json } } } }
  op-payments-prod:
    providers: { gsm: { env: { CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: /etc/hammerkit/prod.json } } }
    source: [ref]
    refs: [main]
sources:
  - match: github.com/corp/*
    secret: { from: 'github-app:payments', account: scm-read }
runs:
  env: { allow: [PATH, DOCKER_HOST] }
entitlements:
  - group: { iss: https://login.example.com, name: platform }
    accounts: { op-payments-dev: use, op-payments-prod: use }
    repos: [github.com/corp/*]
    builds: publish
  - group: { iss: https://login.example.com, name: eng-payments }
    accounts: { op-payments-dev: use, op-payments-prod: request }
    repos: [github.com/corp/payments]
    builds: run

A developer then registers and signs in with remote add and remote login, someone in platform publishes the build file with remote publish, and a run is a POST /v1/runs (remote run does not exist yet). A run that reads the production secret waits in remote approvals until a member of platform approves it.

Config

The file belongs to whoever operates the server and is reviewed like code. Unknown keys are an error, so a typo is not silently ignored.

server.yaml
listen:
  host: 127.0.0.1   # default
  port: 8480        # default
tls:
  cert: /etc/hammerkit/tls.crt
  key: /etc/hammerkit/tls.key
issuers:
  - issuer: https://login.example.com
    audience: hammerkit
    groupsClaim: groups   # default
    clientId: hammerkit-cli
    scope: openid api://hammerkit/.default
  - issuer: https://token.actions.githubusercontent.com   # CI jobs, matched by claims
    audience: hammerkit
secretProviders:                    # the commands that read secrets, see Secret providers
  gsm:
    command: [gcloud, secrets, versions, access, latest, '--secret={{ref}}']
accounts:                           # the service accounts a secret can be read as
  op-payments-dev:
    description: Payments, test secrets
    providers: { gsm: { env: { CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: /etc/hammerkit/creds/payments-dev.json } } }
  op-payments-prod:
    description: Payments, production
    providers: { gsm: { env: { CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: /etc/hammerkit/creds/payments-prod.json } } }
    source: [ref]                   # only from reviewed code
    refs: [main, 'release/*']       # and only from these branches
  scm-read:                         # reads the repositories, no entitlement may name it
    description: Read-only access to source repositories
    providers: { gsm: { env: { CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: /etc/hammerkit/creds/scm-read.json } } }
entitlements:
  - subject: { iss: https://login.example.com, sub: 4f1c2b }
    accounts: { op-payments-dev: use, op-payments-prod: request }
    builds: run
  - group: { iss: https://login.example.com, name: release-managers }
    accounts: { op-payments-prod: use }
    builds: publish
  - claims:
      iss: https://token.actions.githubusercontent.com
      match: { repository: corp/payments, ref: refs/heads/main }
    accounts: { op-payments-dev: use }
    repos: [github.com/corp/payments]
    builds: publish
sources:                            # the repositories a run may get its code from
  - match: github.com/corp/*        # an organisation
    secret: { from: 'gsm:github-readonly-token', account: scm-read }
  - match: gitlab.corp.com/platform/**   # a group with its subgroups
    secret: { from: 'gsm:gitlab-group-token', account: scm-read, username: oauth2 }
runtimes: [docker]   # what tasks may run on, docker by default
approvals:
  timeout: 1h        # default
  maxWaiting: 100    # default
  cooldown: 10m      # default, a rejected version can not be proposed again before
storage:
  directory: /var/lib/hammerkit   # default ~/.hammerkit/server
runs:
  maxConcurrent: 4   # default
  keep: 200          # default
  limits:            # per caller (issuer and subject), 429 over it
    waiting: 10      # default, runs that wait for an approval
    queued: 20       # default, runs that wait for a free place
    running: 2       # default, runs that execute at once, the others wait in the queue
  maxDuration: 1h    # default, a longer run is stopped and fails
  maxLogBytes: 1048576   # default, log output kept per run
  env:
    allow: [PATH, DOCKER_HOST]   # the only variables of the server a run sees, none by default
    set: { CI: 'true' }
backend:
  type: host
  platform: { os: macos, arch: arm64 }   # detected when left out
KeyWhat it does
listenInterface and port. Port 0 picks a free one.
tlsCertificate and key to serve https.
behindTlsProxySet to true when a proxy in front terminates TLS.
issuersThe OAuth issuers whose tokens are accepted, see Authentication.
secretProvidersThe commands that read secrets, see Accounts.
accountsThe service accounts a secret can be read as, see Accounts.
entitlementsWho may use or request which account, which repositories and what they may do with build files, see Entitlements.
approvalsHow long requested runs and proposals wait, see Approvals.
sourcesThe repositories a run may get its code from, see Sources.
runsConcurrency, limits per caller, durations and the environment of runs, see Runs.
runtimesWhat tasks may run on, see Backend.
backendWhere tasks run and what the machine offers, see Backend.
storageWhere the server keeps what must survive a restart: the published build files, the uploaded source files, the task cache and the records of the runs (records/). Runs work in runs/ below it while they run.

Plain http carries OAuth tokens, so it is accepted on the loopback interface only. Anywhere else the config needs tls, or behindTlsProxy: true.

The server checks the references within the file when it starts: an entitlement has to name a configured issuer and defined accounts, an account only names declared secret providers, and a source reads its token with a provider the account has credentials for.

Discovery

GET /.well-known/hammerkit needs no token and returns what the server is, so a client can check it before signing in:

{
  "name": "hammerkit",
  "version": "1.25.0",
  "protocol": 1,
  "issuers": [
    {
      "issuer": "https://login.example.com",
      "audience": "hammerkit",
      "clientId": "hammerkit-cli",
      "scope": "openid api://hammerkit/.default"
    }
  ],
  "backend": { "type": "host", "platform": { "os": "macos", "arch": "arm64" } }
}

GET /healthz answers {"status":"ok"} for a load balancer or a process manager. Every other path needs a token.

On this page