diff --git a/.env.example b/.env.example index a963dd72..dff49108 100644 --- a/.env.example +++ b/.env.example @@ -59,8 +59,13 @@ AF_STACK_AUTH_SECRET=change-me-to-a-real-secret # or azure and provide a base64 encrypted data key instead. AF_STACK_KMS_PROVIDER=env -# KMS key for env-provider secrets encryption (generate with: openssl rand -hex 32) -AF_STACK_KMS_KEY=change-me-to-a-real-key +# KMS key for env-provider secrets encryption. The value below is the +# runtime's well-known DEV SENTINEL (the same default docker-compose.yml +# uses): it boots with a deterministic dev key and logs a warning. Any +# other value that is not 32 hex-encoded bytes makes the runtime refuse to +# start, because a misconfigured key must never silently disable the vault. +# For anything beyond local dev, generate a real one: openssl rand -hex 32 +AF_STACK_KMS_KEY=dev-secret-change-me # Cloud BYOK providers unwrap this encrypted data key at runtime boot. # The plaintext must be 32 bytes after cloud KMS decrypt/unwrap. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a6873c0..34e02c58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: compose: ${{ steps.filter.outputs.compose }} deploy: ${{ steps.filter.outputs.deploy }} images: ${{ steps.filter.outputs.images }} + install_script: ${{ steps.filter.outputs.install_script }} steps: - uses: actions/checkout@v7 - uses: dorny/paths-filter@v4 @@ -50,6 +51,9 @@ jobs: - 'go.mod' - 'go.sum' - '.golangci.yml' + # secrets.TestEnvExampleBootsKMS pins the quickstart contract + # that the committed example env boots the runtime. + - '.env.example' python: - '**/*.py' - 'pyproject.toml' @@ -88,6 +92,9 @@ jobs: - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' + install_script: + - 'scripts/install.sh' + - '.github/workflows/ci.yml' lint-go: name: Lint (Go) @@ -257,6 +264,90 @@ jobs: - name: Validate Helm, Fly, Railway, Render, and prod compose run: scripts/validate-deploy-targets.py + install-script: + # The first line of the README quickstart is `curl … install.sh | bash`. + # Lint it and actually run it — against the real latest release in the + # shapes users hit (piped from stdin, from a file, pinned with and + # without the v prefix), and against a local fake release to prove the + # checksum gate fails closed. Version assertions are exact: a resolver + # that silently picks the wrong tag must go red here. + name: Install script + needs: changes + if: needs.changes.outputs.install_script == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + GH_TOKEN: ${{ github.token }} + MIRROR: http://127.0.0.1:8765 + steps: + - uses: actions/checkout@v7 + - name: Lint + run: | + bash -n scripts/install.sh + shellcheck scripts/install.sh + - name: Resolve the two newest releases + # Via gh (authenticated), deliberately NOT via the same redirect the + # script uses, so the assertions below are independent of it. + run: | + latest="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName)" + prev="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --exclude-pre-releases --limit 10 --json tagName -q '.[1].tagName')" + [ -n "$latest" ] || { echo "could not resolve the latest release" >&2; exit 1; } + echo "latest=$latest prev=${prev:-}" + { echo "LATEST=$latest"; echo "PREV=$prev"; } >> "$GITHUB_ENV" + - name: Install latest (piped, like the README one-liner) + run: | + AF_STACK_INSTALL_DIR="$RUNNER_TEMP/piped" bash -c 'cat scripts/install.sh | bash' + "$RUNNER_TEMP/piped/af-stack" version | grep -Fx "af-stack ${LATEST#v}" + - name: Install latest (from file) + run: | + AF_STACK_INSTALL_DIR="$RUNNER_TEMP/file" bash scripts/install.sh + "$RUNNER_TEMP/file/af-stack" version | grep -Fx "af-stack ${LATEST#v}" + - name: Install the previous release, pinned (v-prefixed and bare) + # Pinning the *previous* release means ignoring the pin can never + # pass, and the step never needs a hand-bumped version. + run: | + if [ -z "$PREV" ]; then echo "only one release exists; nothing to pin"; exit 0; fi + AF_STACK_VERSION="$PREV" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/pinned" bash scripts/install.sh + "$RUNNER_TEMP/pinned/af-stack" version | grep -Fx "af-stack ${PREV#v}" + AF_STACK_VERSION="${PREV#v}" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/pinned-bare" bash scripts/install.sh + "$RUNNER_TEMP/pinned-bare/af-stack" version | grep -Fx "af-stack ${PREV#v}" + - name: Serve a local fake release + run: | + mkdir -p "$RUNNER_TEMP/release" && cd "$RUNNER_TEMP/release" + gh release download "$LATEST" --repo "$GITHUB_REPOSITORY" --pattern '*_linux_amd64.tar.gz' --pattern 'checksums.txt' + cp checksums.txt checksums.good + nohup python3 -m http.server 8765 --bind 127.0.0.1 >/dev/null 2>&1 & + for _ in $(seq 1 40); do curl -fs "$MIRROR/checksums.txt" >/dev/null && break; sleep 0.25; done + curl -fs "$MIRROR/checksums.txt" >/dev/null + - name: Reject a tampered checksums.txt (real script, nothing installed) + run: | + cd "$RUNNER_TEMP/release" + sed -E 's/^[0-9a-f]{64}/0000000000000000000000000000000000000000000000000000000000000000/' checksums.good > checksums.txt + if AF_STACK_VERSION="$LATEST" AF_STACK_DOWNLOAD_BASE="$MIRROR" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/tampered" \ + bash "$GITHUB_WORKSPACE/scripts/install.sh" 2>err.txt; then + echo "installer accepted a tampered checksum" >&2; cat err.txt; exit 1 + fi + grep -q 'checksum verification failed' err.txt + [ ! -e "$RUNNER_TEMP/tampered/af-stack" ] + - name: Refuse to install when checksums.txt is unreachable, unless overridden + run: | + cd "$RUNNER_TEMP/release" && rm -f checksums.txt + if AF_STACK_VERSION="$LATEST" AF_STACK_DOWNLOAD_BASE="$MIRROR" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/nochecksums" \ + bash "$GITHUB_WORKSPACE/scripts/install.sh" 2>err.txt; then + echo "installer proceeded without checksums.txt" >&2; cat err.txt; exit 1 + fi + grep -q 'HTTP 404' err.txt + [ ! -e "$RUNNER_TEMP/nochecksums/af-stack" ] + AF_STACK_SKIP_CHECKSUM=1 AF_STACK_VERSION="$LATEST" AF_STACK_DOWNLOAD_BASE="$MIRROR" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/skipped" \ + bash "$GITHUB_WORKSPACE/scripts/install.sh" + "$RUNNER_TEMP/skipped/af-stack" version | grep -Fx "af-stack ${LATEST#v}" + - name: Accept the correct checksums.txt from the mirror + run: | + cd "$RUNNER_TEMP/release" && cp checksums.good checksums.txt + AF_STACK_VERSION="$LATEST" AF_STACK_DOWNLOAD_BASE="$MIRROR" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/mirror" \ + bash "$GITHUB_WORKSPACE/scripts/install.sh" + "$RUNNER_TEMP/mirror/af-stack" version | grep -Fx "af-stack ${LATEST#v}" + build-app-images: name: Build app images needs: changes @@ -405,6 +496,7 @@ jobs: test-typescript, validate-compose, validate-deploy-targets, + install-script, helm-kind-smoke, fly-staging-smoke, prod-compose-smoke, diff --git a/README.md b/README.md index 7594a155..667cd208 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ BackAI is currently in beta and under active development. Expect rapid improveme ## Quickstart -Prerequisite: Docker with Compose. +Prerequisite: Docker with Compose. Node 18+ is optional: `af-stack dev` +uses it to auto-allocate conflict-free ports and falls back to the +defaults without it. ```bash git clone https://github.com/Agent-Field/backai.git diff --git a/docs-site/src/content/docs/get-started/quickstart.md b/docs-site/src/content/docs/get-started/quickstart.md index c7fc508b..7d9f915e 100644 --- a/docs-site/src/content/docs/get-started/quickstart.md +++ b/docs-site/src/content/docs/get-started/quickstart.md @@ -21,8 +21,11 @@ Open `.env` and set one provider key: ```bash OPENROUTER_API_KEY=sk-or-v1-... -# Optional: a 64-char hex KMS key. dev-secret-change-me works for local. -AF_STACK_KMS_KEY=$(openssl rand -hex 32) +# Optional. The default `dev-secret-change-me` boots with a dev key. For a +# real key, paste the OUTPUT of `openssl rand -hex 32` — a .env file does +# not run shell commands, and anything that is not 64 hex characters makes +# the runtime refuse to start. +AF_STACK_KMS_KEY=dev-secret-change-me ``` ## 2. Boot the stack diff --git a/docs/branch-protection.md b/docs/branch-protection.md index eecf589b..9f8789f0 100644 --- a/docs/branch-protection.md +++ b/docs/branch-protection.md @@ -32,8 +32,8 @@ ruleset**, targeting `main`, with the same rules as below. | Required checks | `CI Success`, `Security Success` | `CI Success` (`.github/workflows/ci.yml`) aggregates lint, test, -compose/deploy validation, docs, and the DCO job. Path-filtered jobs -that skip still count as success. +compose/deploy validation, the install-script gate, docs, and the DCO +job. Path-filtered jobs that skip still count as success. `Security Success` (`.github/workflows/security.yml`) aggregates pnpm/npm audit, pip-audit, gosec, and trivy. CodeQL uploads results diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md index d19309cd..c091a26e 100644 --- a/docs/cli-distribution.md +++ b/docs/cli-distribution.md @@ -12,9 +12,15 @@ verifies its checksum, and puts it on your PATH: curl -fsSL https://raw.githubusercontent.com/Agent-Field/backai/main/scripts/install.sh | bash ``` -Pin a version or install dir with env: `AF_STACK_VERSION=v0.6.0`, -`AF_STACK_INSTALL_DIR="$HOME/.local/bin"`. Source: -[`scripts/install.sh`](../scripts/install.sh). +Pin a version or install dir with env: `AF_STACK_VERSION=v0.12.4` (bare +`0.12.4` works too), `AF_STACK_INSTALL_DIR="$HOME/.local/bin"`. The +script resolves the latest tag from the `releases/latest` redirect (no +GitHub API rate limit), verifies the archive against the release's +`checksums.txt` and refuses to install if that file cannot be fetched +(`AF_STACK_SKIP_CHECKSUM=1` overrides), and can pull both files from a +mirror instead of GitHub with `AF_STACK_DOWNLOAD_BASE=https://…`. When the +install dir is not on your PATH it prints the `export PATH=…` line to run. +Source: [`scripts/install.sh`](../scripts/install.sh). **2. `go install`** (any platform with Go ≥ 1.25): diff --git a/scripts/install.sh b/scripts/install.sh index e296fb39..6ee82b50 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,9 +7,12 @@ # curl -fsSL https://raw.githubusercontent.com/Agent-Field/backai/main/scripts/install.sh | bash # # Env overrides: -# AF_STACK_VERSION pin a version, e.g. v0.6.0 (default: latest release) -# AF_STACK_INSTALL_DIR install target (default: /usr/local/bin, else ~/.local/bin) -# AF_STACK_REPO owner/name (default: Agent-Field/backai) +# AF_STACK_VERSION pin a release, e.g. v0.12.4 or 0.12.4 (default: latest release) +# AF_STACK_INSTALL_DIR install target (default: /usr/local/bin if writable, else ~/.local/bin) +# AF_STACK_REPO owner/name (default: Agent-Field/backai) +# AF_STACK_DOWNLOAD_BASE fetch the archive and checksums.txt from this URL instead of +# GitHub Releases (air-gapped mirrors, tests) +# AF_STACK_SKIP_CHECKSUM set to 1 to install even when checksums.txt cannot be fetched set -euo pipefail REPO="${AF_STACK_REPO:-Agent-Field/backai}" @@ -38,62 +41,198 @@ case "$arch" in *) die "unsupported architecture '$arch'" ;; esac +# --- pick the install dir -------------------------------------------------- +# Done before any download so a bad target fails in under a second, not +# after fetching and verifying the archive. +on_path() { case ":$PATH:" in *":$1:"*) return 0 ;; esac; return 1; } + +# usable_dir: the directory is writable, or does not exist yet and its +# nearest existing ancestor is writable (so mkdir -p will succeed). +usable_dir() { + local d="$1" + while [ ! -d "$d" ]; do + case "$d" in + */*) d="${d%/*}"; [ -n "$d" ] || d="/" ;; + *) d="." ;; + esac + done + [ -w "$d" ] +} + +dir="${AF_STACK_INSTALL_DIR:-}" +# Trim trailing slashes (tab completion adds them) so the PATH comparison +# below matches and the printed path is clean. +while [ "$dir" != "/" ] && [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done +if [ -z "$dir" ]; then + # Prefer a usable candidate that is already on PATH, so `af-stack` works + # in this shell right away; otherwise fall back to the first usable one. + for candidate in /usr/local/bin "$HOME/.local/bin"; do + if on_path "$candidate" && usable_dir "$candidate"; then dir="$candidate"; break; fi + done + if [ -z "$dir" ]; then + if usable_dir /usr/local/bin; then dir=/usr/local/bin; else dir="$HOME/.local/bin"; fi + fi +fi +if [ ! -d "$dir" ]; then + mkdir -p "$dir" 2>/dev/null \ + || { command -v sudo >/dev/null 2>&1 && sudo mkdir -p "$dir"; } \ + || die "could not create $dir (set AF_STACK_INSTALL_DIR to a writable dir)" +fi + # --- resolve version ------------------------------------------------------- +# Two rules here, both learned the hard way: +# +# 1. Never pipe curl straight into an early-exiting reader (grep -m1, head). +# Under `set -o pipefail` the reader closes the pipe as soon as it has its +# match, curl fails its remaining write (exit 23, "Failed writing body"), +# and the whole resolution "fails" even though the tag parsed fine. Buffer +# the response into a variable first, then parse. +# +# 2. Prefer the github.com redirect over the REST API. The unauthenticated +# API allows 60 requests/hour per IP, which shared CI runners and office +# NATs exhaust routinely; /releases/latest -> 302 -> /releases/tag/ +# has no such limit. The API is only a fallback. +latest_tag_from_redirect() { + local final tag + final="$(curl -fsSIL -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest" 2>/dev/null)" || return 1 + tag="${final##*/releases/tag/}" + # No redirect to a tag (no releases yet, or an unexpected page) — signal + # the caller to fall back rather than returning junk. + [ -n "$tag" ] && [ "$tag" != "$final" ] || return 1 + printf '%s\n' "$tag" +} + +latest_tag_from_api() { + local body tag + body="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest")" || return 1 + # sed reads the whole buffer (no early exit), so no broken-pipe hazard. + tag="$(printf '%s\n' "$body" | sed -nE 's/.*"tag_name": *"([^"]+)".*/\1/p')" + tag="${tag%%$'\n'*}" + [ -n "$tag" ] || return 1 + printf '%s\n' "$tag" +} + version="${AF_STACK_VERSION:-}" if [ -z "$version" ]; then info "Resolving latest release of $REPO..." - version="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \ - | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')" \ - || die "could not query the GitHub API for the latest release" - [ -n "$version" ] || die "could not determine the latest release tag (is $REPO public and released?)" + version="$(latest_tag_from_redirect)" || version="$(latest_tag_from_api)" \ + || die "could not determine the latest release of $REPO (is it public and released? pin one with AF_STACK_VERSION=vX.Y.Z)" fi -# checksums/archives use the version WITHOUT the leading 'v'. -ver_noprefix="${version#v}" +case "$version" in + v[0-9]*|[0-9]*) ;; + *) die "unexpected release tag '$version' (expected something like v0.12.4)" ;; +esac -archive="${BINARY}_${ver_noprefix}_${os}_${arch}.tar.gz" -base="https://github.com/$REPO/releases/download/$version" +# --- download -------------------------------------------------------------- +# Archives and checksums use the version WITHOUT the leading 'v'. +archive_for() { printf '%s' "${BINARY}_${1#v}_${os}_${arch}.tar.gz"; } +download_base() { + if [ -n "${AF_STACK_DOWNLOAD_BASE:-}" ]; then + printf '%s' "${AF_STACK_DOWNLOAD_BASE%/}" + else + printf '%s' "https://github.com/$REPO/releases/download/$1" + fi +} -# --- download + verify ----------------------------------------------------- tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT -info "Downloading $archive ($version)..." -curl -fsSL "$base/$archive" -o "$tmp/$archive" \ - || die "download failed. If $REPO is private, this 404s until it's made public. Otherwise check that $archive exists on the release." -if curl -fsSL "$base/checksums.txt" -o "$tmp/checksums.txt" 2>/dev/null; then - info "Verifying checksum..." - ( cd "$tmp" - if command -v sha256sum >/dev/null 2>&1; then - grep " ${archive}\$" checksums.txt | sha256sum -c - >/dev/null \ - || die "checksum verification failed for $archive" - elif command -v shasum >/dev/null 2>&1; then - grep " ${archive}\$" checksums.txt | shasum -a 256 -c - >/dev/null \ - || die "checksum verification failed for $archive" - else - warn "no sha256sum/shasum available — skipping checksum verification" - fi ) +# fetch_archive TAG: sets archive/base for TAG and downloads the archive. +fetch_archive() { + archive="$(archive_for "$1")" + base="$(download_base "$1")" + # Errors are kept for the final message so a failed first spelling of the + # tag does not print a stray 404 when the retry succeeds. + curl -fsSL "$base/$archive" -o "$tmp/$archive" 2>"$tmp/curl.err" +} + +info "Downloading $(archive_for "$version") ($version)..." +if ! fetch_archive "$version"; then + # Tags are normally v-prefixed, but AF_STACK_VERSION is often given bare + # (release.yml and docker-compose.prod.yml use it that way). Try the other + # spelling once before giving up. + case "$version" in + v*) alt="${version#v}" ;; + *) alt="v$version" ;; + esac + if fetch_archive "$alt"; then + version="$alt" + else + die "download failed: $(download_base "$version")/$(archive_for "$version") (also tried tag $alt; $(tr -d '\n' < "$tmp/curl.err")). Check that the release exists and has a ${os}/${arch} asset; a private $REPO 404s until it is made public." + fi +fi + +# --- verify ---------------------------------------------------------------- +verify_checksum() { + # Runs in $tmp. Returns non-zero on any mismatch or missing entry; the + # caller turns that into a hard error (a `die` inside a subshell would + # only exit the subshell). + local expected actual + expected="$(grep -E "[[:space:]]\*?${archive}\$" checksums.txt | awk '{print $1}')" + expected="${expected%%$'\n'*}" + [ -n "$expected" ] || { warn "no entry for $archive in checksums.txt"; return 1; } + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$archive" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$archive" | awk '{print $1}')" + else + warn "no sha256sum/shasum available — skipping checksum verification" + return 0 + fi + [ "$actual" = "$expected" ] +} + +if [ "${AF_STACK_SKIP_CHECKSUM:-0}" = "1" ]; then + warn "AF_STACK_SKIP_CHECKSUM=1 — installing $archive without checksum verification" else - warn "checksums.txt not found on the release — skipping verification" + # Every release publishes checksums.txt, so failing to fetch it is a + # transport problem, not a missing file. Fail closed rather than quietly + # installing an unverified binary; branch on the HTTP status because + # `curl -f` exits 22 for every 4xx/5xx alike. + code="$(curl -sSL -o "$tmp/checksums.txt" -w '%{http_code}' "$base/checksums.txt" 2>"$tmp/curl.err")" || code=000 + if [ "$code" != "200" ]; then + detail="" + if [ -s "$tmp/curl.err" ]; then detail=" ($(tr -d '\n' < "$tmp/curl.err"))"; fi + die "could not fetch $base/checksums.txt (HTTP $code$detail) — refusing to install an unverified binary. Retry, or set AF_STACK_SKIP_CHECKSUM=1 to override." + fi + info "Verifying checksum..." + (cd "$tmp" && verify_checksum) || die "checksum verification failed for $archive" fi tar -xzf "$tmp/$archive" -C "$tmp" [ -f "$tmp/$BINARY" ] || die "archive did not contain the '$BINARY' binary" -chmod +x "$tmp/$BINARY" # --- install --------------------------------------------------------------- -dir="${AF_STACK_INSTALL_DIR:-}" -if [ -z "$dir" ]; then - if [ -w /usr/local/bin ] 2>/dev/null; then dir="/usr/local/bin"; else dir="$HOME/.local/bin"; fi -fi -mkdir -p "$dir" - -if mv "$tmp/$BINARY" "$dir/$BINARY" 2>/dev/null; then :; -elif command -v sudo >/dev/null 2>&1 && sudo mv "$tmp/$BINARY" "$dir/$BINARY"; then :; +# `install` (not mv) so a sudo install lands root-owned instead of leaving a +# user-writable executable in a system PATH directory. +if install -m 0755 "$tmp/$BINARY" "$dir/$BINARY" 2>/dev/null; then : +elif command -v sudo >/dev/null 2>&1 && sudo install -m 0755 "$tmp/$BINARY" "$dir/$BINARY"; then : else die "could not install to $dir (set AF_STACK_INSTALL_DIR to a writable dir)"; fi -info "Installed $BINARY $version to $dir/$BINARY" -case ":$PATH:" in - *":$dir:"*) ;; - *) warn "$dir is not on your PATH — add it, e.g.: export PATH=\"$dir:\$PATH\"" ;; -esac -"$dir/$BINARY" version 2>/dev/null || true +# Prove the installed binary runs before claiming success; a noexec mount or +# a truncated download would otherwise surface as the user's next command. +if ! probe="$("$dir/$BINARY" version 2>&1)"; then + die "installed $dir/$BINARY but it will not run: ${probe:-no output} (a 'Permission denied' here usually means $dir is on a noexec mount)" +fi +info "Installed $probe to $dir/$BINARY" + +# --- is it reachable as `af-stack`? ---------------------------------------- +resolved="$(command -v "$BINARY" 2>/dev/null || true)" +if on_path "$dir" && [ "$resolved" = "$dir/$BINARY" ]; then + : # `af-stack dev` works in this shell right now +elif on_path "$dir"; then + warn "$dir is on your PATH but '$BINARY' currently resolves to ${resolved:-nothing} — remove or update that older copy" +else + case "${SHELL##*/}" in + zsh) rc_hint="echo 'export PATH=\"$dir:\$PATH\"' >> ~/.zshrc" ;; + bash) rc_hint="echo 'export PATH=\"$dir:\$PATH\"' >> ~/.bashrc" ;; + fish) rc_hint="fish_add_path $dir" ;; + *) rc_hint="" ;; + esac + warn "$dir is not on your PATH, so '$BINARY dev' will not be found yet. In this shell run:" + # shellcheck disable=SC2016 # the literal $PATH is what the user should type + printf ' export PATH="%s:%s"\n' "$dir" '$PATH' >&2 + if [ -n "$rc_hint" ]; then + printf ' and to make it permanent:\n %s\n' "$rc_hint" >&2 + fi +fi diff --git a/services/runtime/internal/secrets/env_example_test.go b/services/runtime/internal/secrets/env_example_test.go new file mode 100644 index 00000000..0186086d --- /dev/null +++ b/services/runtime/internal/secrets/env_example_test.go @@ -0,0 +1,76 @@ +package secrets + +import ( + "bufio" + "context" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestEnvExampleBootsKMS pins the quickstart contract: the committed +// .env.example, copied verbatim to .env (which `af-stack dev`, `af-stack +// mode`, the README, and AGENTS.md all do), must yield a KMS cipher the +// runtime can boot with. +// +// The runtime refuses to start when AF_STACK_KMS_KEY is set to something it +// cannot load (kmsBootDecision in cmd/af-stack), so an example value that is +// neither the dev sentinel nor 32 hex-encoded bytes turns every fresh clone +// into a crash loop. That is exactly what shipped while the example said +// "change-me-to-a-real-key". +func TestEnvExampleBootsKMS(t *testing.T) { + values := readEnvExample(t) + for _, k := range []string{ + "AF_STACK_KMS_PROVIDER", + "AF_STACK_KMS_KEY", + "AF_STACK_KMS_ENCRYPTED_DATA_KEY", + "AF_STACK_KMS_ENCRYPTED_DATA_KEY_FILE", + } { + t.Setenv(k, values[k]) + } + + quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) + c, err := LoadCipher(context.Background(), quiet) + if err != nil { + t.Fatalf(".env.example AF_STACK_KMS_KEY=%q does not boot the runtime: %v\n"+ + "use the dev sentinel %q or 32 random bytes hex-encoded", + values["AF_STACK_KMS_KEY"], err, devKEKSentinel) + } + if err := c.Preflight(); err != nil { + t.Fatalf("cipher built from .env.example fails preflight: %v", err) + } +} + +// readEnvExample parses the repo's .env.example into KEY=VALUE pairs, +// skipping comments and blank lines — the same view docker compose and the +// CLI's .env seeding take of the file. +func readEnvExample(t *testing.T) map[string]string { + t.Helper() + path := filepath.Join("..", "..", "..", "..", ".env.example") + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + defer f.Close() + + values := map[string]string{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + values[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"'`) + } + if err := sc.Err(); err != nil { + t.Fatalf("read %s: %v", path, err) + } + return values +} diff --git a/skills/af-stack/rules/deploy.md b/skills/af-stack/rules/deploy.md index 98f96c8e..46bd7bbd 100644 --- a/skills/af-stack/rules/deploy.md +++ b/skills/af-stack/rules/deploy.md @@ -173,8 +173,8 @@ create tenant → issue API key → set budget → open customer app. - **Dev `docker.sock` mounting in production** — use gVisor or Firecracker for sandboxes. -- **`AF_STACK_KMS_KEY=dev-secret-change-me-in-prod`** — replace with a - real 32-byte hex value. +- **`AF_STACK_KMS_KEY=dev-secret-change-me`** (the dev sentinel) — replace + with a real 32-byte hex value. - **Default better-auth signing key** — `AF_STACK_AUTH_SECRET` must be a real random value.