205 lines
7.4 KiB
Markdown
205 lines
7.4 KiB
Markdown
# Docker Build Service
|
||
|
||
Small HTTP service for CI (e.g. Forgejo Actions) that builds images with **BuildKit** and pushes them to a registry. Callers authorize builds with a JWT signed by a shared secret.
|
||
|
||
The API container does **not** mount the host Docker socket. It talks to BuildKit only over a **Unix socket** on a shared Docker volume (`buildkit-socket`). Nothing else on the host or compose project mounts that volume, so other containers cannot reach BuildKit. Build steps use BuildKit’s default isolated network.
|
||
|
||
Flow:
|
||
|
||
1. Prepare the build context
|
||
2. Verify `sha256(Dockerfile)` matches the JWT claim
|
||
3. `buildctl build` (BuildKit) with `push=true` using registry credentials from the request
|
||
|
||
## Quick start
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
# set JWT_SECRET to a long random value
|
||
|
||
docker compose up --build
|
||
```
|
||
|
||
Health check: `GET http://localhost:8080/health`
|
||
|
||
## Configuration
|
||
|
||
| Variable | Default | Description |
|
||
|---|---|---|
|
||
| `JWT_SECRET` | *(required)* | HS256 secret used to verify build tokens |
|
||
| `GIT_HOST` | *(required)* | Only host used for clones |
|
||
| `BUILDKIT_DNS` | *(optional)* | Real upstream DNS IP for `RUN` steps (replaces Docker’s `127.0.0.11`, which BuildKit otherwise turns into `8.8.8.8`) |
|
||
| `BUILDKIT_HOST` | `unix:///run/buildkit/buildkitd.sock` | BuildKit socket address |
|
||
| `BUILDKIT_PRUNE_KEEP_DURATION` | `168h` | Keep cache used within this window; older unused cache is pruned by `buildkit-helper` |
|
||
| `BUILDKIT_PRUNE_INTERVAL_SECONDS` | `86400` | How often `buildkit-helper` runs prune (`86400` = daily) |
|
||
| `BUILD_SERVICE_PORT` | `8080` | Host port mapped to the API |
|
||
| `GIT_CLONE_TIMEOUT_SECONDS` | `300` | Max clone duration |
|
||
| `BUILD_TIMEOUT_SECONDS` | `1800` | Max build/push duration |
|
||
| `JWT_ALGORITHM` | `HS256` | JWT algorithm |
|
||
| `MAX_UPLOAD_BYTES` | `209715200` | Max total uploaded file bytes (200 MB) |
|
||
| `MAX_FILE_BYTES` | `52428800` | Max single uploaded file bytes (50 MB) |
|
||
| `MAX_MANIFEST_FILES` | `512` | Max paths in JWT `files` claim |
|
||
|
||
## JWT
|
||
|
||
Tokens must be signed with `JWT_SECRET` and include:
|
||
|
||
| Claim | Description |
|
||
|---|---|
|
||
| `repo` | `owner/name` (cloned from `https://<git_host>/<owner>/<name>.git` when a git commit is used) |
|
||
| `dockerfile` | Path to the Dockerfile inside the build context (no `..`) |
|
||
| `dockerfile_sha256` | Hex SHA-256 of the Dockerfile bytes that will be built |
|
||
| `exp` | Expiry (standard JWT claim) |
|
||
|
||
For requests that include file uploads, the JWT must also include:
|
||
|
||
| Claim | Description |
|
||
|---|---|
|
||
| `files` | Non-empty list of relative file paths. Uploaded paths must match this list exactly. |
|
||
|
||
File parts sent without a JWT `files` claim are ignored.
|
||
|
||
Arbitrary git URLs are rejected. Optional but recommended: keep TTL short (e.g. 15 minutes). Mint tokens from a trusted issuer that has the secret — not from untrusted CI jobs if you can avoid it.
|
||
|
||
### Mint a token
|
||
|
||
```bash
|
||
pip install PyJWT
|
||
|
||
JWT_SECRET='your-secret' python scripts/mint_token.py \
|
||
--repo 'org/app' \
|
||
--dockerfile Dockerfile \
|
||
--hash-from ./Dockerfile \
|
||
--ttl-minutes 15
|
||
```
|
||
|
||
For file uploads, add `--files` with the paths you will send:
|
||
|
||
```bash
|
||
JWT_SECRET='your-secret' python scripts/mint_token.py \
|
||
--repo 'org/app' \
|
||
--dockerfile Dockerfile \
|
||
--hash-from ./Dockerfile \
|
||
--files Dockerfile src/app.py \
|
||
--ttl-minutes 15
|
||
```
|
||
|
||
`--hash-from` can also be an http(s) URL to the Dockerfile. Or pass the digest directly with `--dockerfile-sha256` instead of `--hash-from`:
|
||
|
||
```bash
|
||
JWT_SECRET='your-secret' python scripts/mint_token.py \
|
||
--repo 'org/app' \
|
||
--dockerfile Dockerfile \
|
||
--dockerfile-sha256 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456' \
|
||
--ttl-minutes 15
|
||
```
|
||
|
||
With docker:
|
||
|
||
```bash
|
||
docker exec -it build-service python scripts/mint_token.py \
|
||
--repo 'org/app' \
|
||
--dockerfile Dockerfile \
|
||
--hash-from https://git.lschaefer.xyz/lukasdotcom/actions-tester/raw/branch/main/Dockerfile \
|
||
--ttl-minutes 600000
|
||
```
|
||
|
||
## CI action (other repos)
|
||
|
||
Composite action for Forgejo / GitHub Actions — call the build API with `uses:` instead of hand-rolled `curl`. Repo: `https://git.lschaefer.xyz/lukasdotcom/docker-build-container`.
|
||
|
||
```yaml
|
||
- name: Trigger buildkit build
|
||
uses: https://git.lschaefer.xyz/lukasdotcom/docker-build-container@<sha-or-tag>
|
||
with:
|
||
url: http://build-kit.local:8080/build
|
||
token: ${{ secrets.BUILD }}
|
||
commit: ${{ needs.build-caddy.outputs.dist_commit }}
|
||
tags: |
|
||
lukasdotcom/image:latest
|
||
lukasdotcom/image:${{ needs.build-caddy.outputs.dist_commit }}
|
||
username: lukasdotcom
|
||
password: ${{ secrets.DOCKER }}
|
||
# optional — private repo clone
|
||
repo_username: oauth2
|
||
repo_password: ${{ secrets.GIT_TOKEN }}
|
||
```
|
||
|
||
`tags` is a newline- or comma-separated list of full image refs. For private repos, pass `repo_username` and `repo_password` together (git credentials for the clone; not the same as registry `username` / `password`).
|
||
|
||
To overlay files (multipart), pass `files` — newline-separated paths used as both the local file and the context path (JWT must include a matching `files` claim):
|
||
|
||
```yaml
|
||
files: |
|
||
Dockerfile
|
||
dist/app
|
||
commit: ${{ github.sha }} # optional with uploads
|
||
```
|
||
|
||
The action only needs `bash` and `curl` (no Python).
|
||
|
||
## API
|
||
|
||
### `POST /build`
|
||
|
||
#### Git commit
|
||
|
||
Shallow-fetches `commit` from `https://<git_host>/<owner>/<name>.git`.
|
||
|
||
```json
|
||
{
|
||
"token": "<jwt>",
|
||
"commit": "a1b2c3d4e5f6789012345678901234567890abcd",
|
||
"tags": [
|
||
"lukasdotcom/app:1.0.0",
|
||
"lukasdotcom/app:latest"
|
||
],
|
||
"username": "registry-user",
|
||
"password": "registry-password",
|
||
"repo_username": "oauth2",
|
||
"repo_password": "<git-token>"
|
||
}
|
||
```
|
||
|
||
```bash
|
||
curl -N -sS http://localhost:8080/build \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{
|
||
"token": "'"$TOKEN"'",
|
||
"commit": "'"$COMMIT"'",
|
||
"tags": ["lukasdotcom/app:latest"],
|
||
"username": "user",
|
||
"password": "pass",
|
||
"repo_username": "oauth2",
|
||
"repo_password": "'"$GIT_TOKEN"'"
|
||
}'
|
||
```
|
||
|
||
| Field | Description |
|
||
|---|---|
|
||
| `token` | JWT with `repo`, `dockerfile`, and `dockerfile_sha256` |
|
||
| `commit` | Git commit SHA to build (7–40 hex chars) |
|
||
| `tags` | One or more image refs to build and push (`registry/name:tag`; safe charset only; max 16) |
|
||
| `remote` | Optional. Registry host for auth. Defaults to `https://index.docker.io/v1/` (Docker Hub) |
|
||
| `username` / `password` | Registry credentials |
|
||
| `repo_username` / `repo_password` | Optional. Git credentials for private clones (both required together) |
|
||
|
||
#### File upload
|
||
|
||
`Content-Type: multipart/form-data`. Clones `commit`, then overlays the uploaded files. The JWT `files` claim must list exactly the uploaded paths. Optional `repo_username` / `repo_password` form fields work the same as in JSON.
|
||
|
||
```bash
|
||
curl -N -sS http://localhost:8080/build \
|
||
-F "token=$TOKEN" \
|
||
-F "commit=$COMMIT" \
|
||
-F 'tags=["lukasdotcom/app:latest"]' \
|
||
-F "username=$USER" \
|
||
-F "password=$PASS" \
|
||
-F "files=@./Dockerfile;filename=Dockerfile" \
|
||
-F "files=@./src/app.py;filename=src/app.py"
|
||
```
|
||
|
||
Each `files` part uses its multipart `filename` as the relative path.
|
||
|
||
On success, the response is a chunked `text/plain` stream of `buildctl` progress (`--progress=plain`) as the build and push run. Use `curl -N` (or similar) so the client does not buffer the body.
|
||
|
||
JWT / clone / upload / Dockerfile path or hash errors return normal JSON error responses before streaming starts. If the BuildKit build fails after the stream has begun, the HTTP status stays `200` and the stream ends with a line like `[build-service] ERROR: ...`.
|