diff --git a/.dockerignore b/.dockerignore index 755dc76..d9781fe 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,8 @@ -# Secrets — prevent .env files and encrypted secrets from being baked into the image +# Secrets — prevent .env files from being baked into the image .env .env.enc -secrets/ +.env.vault +.env.vault.enc # Version control — .git is huge and not needed in image .git diff --git a/.env.example b/.env.example index a1f24d5..d5d801e 100644 --- a/.env.example +++ b/.env.example @@ -25,17 +25,12 @@ FORGE_URL=http://localhost:3000 # [CONFIG] local Forgejo instance # - FORGE_TOKEN_ = API token for REST calls (user identity via /api/v1/user) # - FORGE_PASS_ = password for git HTTP push (#361, Forgejo 11.x limitation) # -# Local-model agents hired with `disinto hire-an-agent` are keyed by *agent -# name* (not role), so multiple local-model dev agents can coexist without -# colliding on credentials (#834). For an agent named `dev-qwen2` the vars are: -# - FORGE_TOKEN_DEV_QWEN2 -# - FORGE_PASS_DEV_QWEN2 -# Name conversion: tr 'a-z-' 'A-Z_' (lowercase→UPPER, hyphens→underscores). -# The compose generator looks these up via the agent's `forge_user` field in -# the project TOML. Configure local-model agents via [agents.X] sections in -# projects/*.toml — this is the canonical activation path. +# Local-model agents (agents-llama) use FORGE_TOKEN_LLAMA / FORGE_PASS_LLAMA +# with FORGE_BOT_USER_LLAMA=dev-qwen to ensure correct attribution (#563). FORGE_TOKEN= # [SECRET] dev-bot API token (default for all agents) FORGE_PASS= # [SECRET] dev-bot password for git HTTP push (#361) +FORGE_TOKEN_LLAMA= # [SECRET] dev-qwen API token (for agents-llama) +FORGE_PASS_LLAMA= # [SECRET] dev-qwen password for git HTTP push FORGE_REVIEW_TOKEN= # [SECRET] review-bot API token FORGE_REVIEW_PASS= # [SECRET] review-bot password for git HTTP push FORGE_PLANNER_TOKEN= # [SECRET] planner-bot API token @@ -68,10 +63,6 @@ FORGE_BOT_USERNAMES=dev-bot,review-bot,planner-bot,gardener-bot,vault-bot,superv WOODPECKER_TOKEN= # [SECRET] Woodpecker API token WOODPECKER_SERVER=http://localhost:8000 # [CONFIG] Woodpecker server URL WOODPECKER_AGENT_SECRET= # [SECRET] shared secret for server↔agent auth (auto-generated) -# Woodpecker privileged-plugin allowlist — comma-separated image names -# Add plugins/docker (and others) here to allow privileged execution -WOODPECKER_PLUGINS_PRIVILEGED=plugins/docker - # WOODPECKER_REPO_ID — now per-project, set in projects/*.toml [ci] section # Woodpecker Postgres (for direct DB queries) @@ -88,17 +79,16 @@ FORWARD_AUTH_SECRET= # [SECRET] Shared secret for Caddy ↔ # ── Vault-only secrets (DO NOT put these in .env) ──────────────────────── # These tokens grant access to external systems (GitHub, ClawHub, deploy targets). -# They live ONLY in secrets/.enc (age-encrypted, one file per key) and are -# decrypted into the ephemeral runner container at fire time (#745, #777). -# lib/env.sh explicitly unsets them so agents can never hold them directly — -# all external actions go through vault dispatch. +# They live ONLY in .env.vault.enc and are injected into the ephemeral runner +# container at fire time (#745). lib/env.sh explicitly unsets them so agents +# can never hold them directly — all external actions go through vault dispatch. # # GITHUB_TOKEN — GitHub API access (publish, deploy, post) # CLAWHUB_TOKEN — ClawHub registry credentials (publish) -# CADDY_SSH_KEY — SSH key for Caddy log collection # (deploy keys) — SSH keys for deployment targets # -# To manage secrets: disinto secrets add/show/remove/list +# To manage vault secrets: disinto secrets edit-vault +# (vault redesign in progress: PR-based approval, see #73-#77) # ── Project-specific secrets ────────────────────────────────────────────── # Store all project secrets here so formulas reference env vars, never hardcode. @@ -107,16 +97,6 @@ BASE_RPC_URL= # [SECRET] on-chain RPC endpoint # ── Tuning ──────────────────────────────────────────────────────────────── CLAUDE_TIMEOUT=7200 # [CONFIG] max seconds per Claude invocation -# ── Host paths (Nomad-portable) ──────────────────────────────────────────── -# These env vars externalize host-side bind-mount paths from docker-compose.yml. -# At cutover, Nomad jobspecs reference the same vars — no path translation. -# Defaults point at current paths so an empty .env override still works. -CLAUDE_BIN_DIR=/usr/local/bin/claude # [CONFIG] host path to claude CLI binary (resolved by `disinto init`) -CLAUDE_CONFIG_FILE=${HOME}/.claude.json # [CONFIG] host path to claude config JSON file -CLAUDE_DIR=${HOME}/.claude # [CONFIG] host path to .claude directory (reproduce/edge) -AGENT_SSH_DIR=${HOME}/.ssh # [CONFIG] host path to SSH keys directory -SOPS_AGE_DIR=${HOME}/.config/sops/age # [CONFIG] host path to SOPS age key directory - # ── Claude Code shared OAuth state ───────────────────────────────────────── # Shared directory used by every factory container so Claude Code's internal # proper-lockfile-based OAuth refresh lock works across containers. Both diff --git a/.gitignore b/.gitignore index a29450c..2fd9aed 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # Encrypted secrets — safe to commit (SOPS-encrypted with age) !.env.enc +!.env.vault.enc !.sops.yaml # Per-box project config (generated by disinto init) @@ -20,6 +21,7 @@ metrics/supervisor-metrics.jsonl # OS .DS_Store dev/ci-fixes-*.json +gardener/dust.jsonl # Individual encrypted secrets (managed by disinto secrets add) secrets/ @@ -31,9 +33,6 @@ docker/agents/bin/ # Note: This file is now committed to track volume mount configuration # docker-compose.yml -# Generated Caddyfile — single source of truth is generate_caddyfile in lib/generators.sh -docker/Caddyfile - # Python bytecode __pycache__/ *.pyc diff --git a/.woodpecker/detect-duplicates.py b/.woodpecker/detect-duplicates.py index 9b108bf..0485833 100644 --- a/.woodpecker/detect-duplicates.py +++ b/.woodpecker/detect-duplicates.py @@ -294,20 +294,6 @@ def main() -> int: "9f6ae8e7811575b964279d8820494eb0": "Verification helper: for loop done pattern", # Standard lib source block shared across formula-driven agent run scripts "330e5809a00b95ade1a5fce2d749b94b": "Standard lib source block (env.sh, formula-session.sh, worktree.sh, guard.sh, agent-sdk.sh)", - # Common vault-seed script patterns: logging helpers + flag parsing - # Used in tools/vault-seed-woodpecker.sh + lib/init/nomad/wp-oauth-register.sh - "843a1cbf987952697d4e05e96ed2b2d5": "Logging helpers + DRY_RUN init (vault-seed-woodpecker + wp-oauth-register)", - "ee51df9642f2ef37af73b0c15f4d8406": "Logging helpers + DRY_RUN loop start (vault-seed-woodpecker + wp-oauth-register)", - "9a57368f3c1dfd29ec328596b86962a0": "Flag parsing loop + case start (vault-seed-woodpecker + wp-oauth-register)", - "9d72d40ff303cbed0b7e628fc15381c3": "Case loop + dry-run handler (vault-seed-woodpecker + wp-oauth-register)", - "5b52ddbbf47948e3cbc1b383f0909588": "Help + invalid arg handler end (vault-seed-woodpecker + wp-oauth-register)", - # Common vault-seed script preamble + precondition patterns - # Shared across tools/vault-seed-{forgejo,agents,woodpecker}.sh - "dff3675c151fcdbd2fef798826ae919b": "Vault-seed preamble: set -euo + path setup + source hvault.sh + KV_MOUNT", - "1cd9f0d083e24e6e6b2071db9b6dae09": "Vault-seed preconditions: binary check loop + VAULT_ADDR guard", - "63bfa88d71764c95c65a9a248f3e40ab": "Vault-seed preconditions: binary check end + VAULT_ADDR die", - "34873ad3570b211ce1d90468ab6ac94c": "Vault-seed preconditions: VAULT_ADDR die + hvault_token_lookup", - "71a52270f249e843cda48ad896d9f781": "Vault-seed preconditions: VAULT_ADDR + hvault_token_lookup + die", } if not sh_files: diff --git a/.woodpecker/nomad-validate.yml b/.woodpecker/nomad-validate.yml deleted file mode 100644 index 5a1cc7c..0000000 --- a/.woodpecker/nomad-validate.yml +++ /dev/null @@ -1,334 +0,0 @@ -# ============================================================================= -# .woodpecker/nomad-validate.yml — Static validation for Nomad+Vault artifacts -# -# Part of the Nomad+Vault migration (S0.5, issue #825; extended in S2.6, -# issue #884). Locks in the "no-ad-hoc-steps" principle: every HCL/shell -# artifact under nomad/, lib/init/nomad/, vault/policies/, plus the -# `disinto init` dispatcher and vault/roles.yaml, gets checked before it -# can land. -# -# Triggers on PRs (and pushes) that touch any of: -# nomad/** — HCL configs (server, client, vault) -# lib/init/nomad/** — cluster-up / install / systemd / vault-init / -# vault-nomad-auth (S2.6 trigger: vault-*.sh -# is a subset of this glob) -# bin/disinto — `disinto init --backend=nomad` dispatcher -# tests/disinto-init-nomad.bats — the bats suite itself -# vault/policies/** — Vault ACL policy HCL files (S2.1, S2.6) -# vault/roles.yaml — JWT-auth role bindings (S2.3, S2.6) -# .woodpecker/nomad-validate.yml — the pipeline definition -# -# Steps (all fail-closed — any error blocks merge): -# 1. nomad-config-validate — `nomad config validate` on server + client HCL -# 2. nomad-job-validate — `nomad job validate` looped over every -# nomad/jobs/*.hcl (new jobspecs get -# CI coverage automatically) -# 3. vault-operator-diagnose — `vault operator diagnose` syntax check on vault.hcl -# 4. vault-policy-fmt — `vault policy fmt` idempotence check on -# every vault/policies/*.hcl (format drift = -# CI fail; non-destructive via cp+diff) -# 5. vault-policy-validate — HCL syntax + capability validation for every -# vault/policies/*.hcl via `vault policy write` -# against an inline dev-mode Vault server -# 6. vault-roles-validate — yamllint + role→policy reference check on -# vault/roles.yaml (every referenced policy -# must exist as vault/policies/.hcl) -# 7. shellcheck-nomad — shellcheck the cluster-up + install scripts + disinto -# 8. bats-init-nomad — `disinto init --backend=nomad --dry-run` smoke tests -# -# Secret-scan coverage: vault/policies/*.hcl is already scanned by the -# P11 gate (.woodpecker/secret-scan.yml, issue #798) — its trigger path -# `vault/**/*` covers everything under this directory. We intentionally -# do NOT duplicate that gate here; one scanner, one source of truth. -# -# Pinned image versions match lib/init/nomad/install.sh (nomad 1.9.5 / -# vault 1.18.5). Bump there AND here together — drift = CI passing on -# syntax the runtime would reject. -# ============================================================================= - -when: - - event: [push, pull_request] - path: - - "nomad/**" - - "lib/init/nomad/**" - - "bin/disinto" - - "tests/disinto-init-nomad.bats" - - "vault/policies/**" - - "vault/roles.yaml" - - ".woodpecker/nomad-validate.yml" - -# Authenticated clone — same pattern as .woodpecker/ci.yml. Forgejo is -# configured with REQUIRE_SIGN_IN, so anonymous git clones fail (exit 128). -# FORGE_TOKEN is injected globally via WOODPECKER_ENVIRONMENT. -clone: - git: - image: alpine/git - commands: - - AUTH_URL=$(printf '%s' "$CI_REPO_CLONE_URL" | sed "s|://|://token:$FORGE_TOKEN@|") - - git clone --depth 1 "$AUTH_URL" . - - git fetch --depth 1 origin "$CI_COMMIT_REF" - - git checkout FETCH_HEAD - -steps: - # ── 1. Nomad HCL syntax check ──────────────────────────────────────────── - # `nomad config validate` parses server.hcl + client.hcl and fails on any - # HCL/semantic error (unknown block, invalid port range, bad driver cfg). - # vault.hcl is excluded — it's a Vault config, not Nomad, so it goes - # through the vault-operator-diagnose step instead. - - name: nomad-config-validate - image: hashicorp/nomad:1.9.5 - commands: - - nomad version - - nomad config validate nomad/server.hcl nomad/client.hcl - - # ── 2. Nomad jobspec HCL syntax check ──────────────────────────────────── - # `nomad job validate` is a *different* tool from `nomad config validate` — - # the former parses jobspec HCL (job/group/task blocks, driver config, - # volume refs, network ports), the latter parses agent config HCL - # (server/client blocks). Running step 1 on a jobspec would reject it - # with "unknown block 'job'", and vice versa. Hence two separate steps. - # - # Validation is offline: no running Nomad server is required (exit 0 on - # valid HCL, 1 on syntax/semantic error). The CLI takes a single path - # argument so we loop over every `*.hcl` file under nomad/jobs/ — - # that way a new jobspec PR gets CI coverage automatically (no separate - # "edit the pipeline" step to forget). The `.hcl` suffix is the naming - # convention: anything else in nomad/jobs/ is deliberately not validated - # by this step. - # - # `[ -f "$f" ]` guards against the no-match case: POSIX sh does not - # nullglob, so an empty jobs/ directory would leave the literal glob in - # "$f" and fail. Today forgejo.hcl exists, but the guard keeps the - # step safe during any future transient empty state. - # - # Scope note: offline validate catches jobspec-level errors (unknown - # stanzas, missing required fields, wrong value types, invalid driver - # config). It does NOT resolve cross-file references like host_volume - # source names against nomad/client.hcl — that mismatch surfaces at - # scheduling time on the live cluster, not here. The paired-write rule - # in nomad/AGENTS.md ("add to both client.hcl and cluster-up.sh") is the - # primary guardrail for that class of drift. - - name: nomad-job-validate - image: hashicorp/nomad:1.9.5 - commands: - - | - set -e - for f in nomad/jobs/*.hcl; do - [ -f "$f" ] || continue - echo "validating jobspec: $f" - nomad job validate "$f" - done - - # ── 3. Vault HCL syntax check ──────────────────────────────────────────── - # `vault operator diagnose` loads the config and runs a suite of checks. - # Exit codes: - # 0 — all checks green - # 1 — at least one hard failure (bad HCL, bad schema, unreachable storage) - # 2 — advisory warnings only (no hard failure) - # Our factory dev-box vault.hcl deliberately runs TLS-disabled on a - # localhost-only listener (documented in nomad/vault.hcl), which triggers - # an advisory "Check Listener TLS" warning → exit 2. The config still - # parses, so we tolerate exit 2 and fail only on exit 1 or crashes. - # -skip=storage/-skip=listener disables the runtime-only checks (vault's - # container has /vault/file so storage is fine, but explicit skip is cheap - # insurance against future container-image drift). - - name: vault-operator-diagnose - image: hashicorp/vault:1.18.5 - commands: - - | - rc=0 - vault operator diagnose -config=nomad/vault.hcl -skip=storage -skip=listener || rc=$? - case "$rc" in - 0) echo "vault config: all checks green" ;; - 2) echo "vault config: parse OK (rc=2 — advisory warnings only; TLS-disabled on localhost listener is by design)" ;; - *) echo "vault config: hard failure (rc=$rc)" >&2; exit "$rc" ;; - esac - - # ── 4. Vault policy fmt idempotence check ──────────────────────────────── - # `vault policy fmt ` formats a local HCL policy file in place. - # There's no `-check`/dry-run flag (vault 1.18.5), so we implement a - # non-destructive check as cp → fmt-on-copy → diff against original. - # Any diff means the committed file would be rewritten by `vault policy - # fmt` — failure steers the author to run `vault policy fmt ` - # locally before pushing. - # - # Scope: vault/policies/*.hcl only. The `[ -f "$f" ]` guard handles the - # no-match case (POSIX sh does not nullglob) so an empty policies/ - # directory does not fail this step. - # - # Note: `vault policy fmt` is purely local (HCL text transform) and does - # not require a running Vault server, which is why this step can run - # without starting one. - - name: vault-policy-fmt - image: hashicorp/vault:1.18.5 - commands: - - | - set -e - failed=0 - for f in vault/policies/*.hcl; do - [ -f "$f" ] || continue - tmp="/tmp/$(basename "$f").fmt" - cp "$f" "$tmp" - vault policy fmt "$tmp" >/dev/null 2>&1 - if ! diff -u "$f" "$tmp"; then - echo "ERROR: $f is not formatted — run 'vault policy fmt $f' locally" >&2 - failed=1 - fi - done - if [ "$failed" -gt 0 ]; then - echo "vault-policy-fmt: formatting drift detected" >&2 - exit 1 - fi - echo "vault-policy-fmt: all policies formatted correctly" - - # ── 5. Vault policy HCL syntax + capability validation ─────────────────── - # Vault has no offline `vault policy validate` subcommand — the closest - # in-CLI validator is `vault policy write`, which sends the HCL to a - # running server which parses it, checks capability names against the - # known set (read, list, create, update, delete, patch, sudo, deny), - # and rejects unknown stanzas / malformed path blocks. We start an - # inline dev-mode Vault (in-memory, no persistence, root token = "root") - # for the duration of this step and loop `vault policy write` over every - # vault/policies/*.hcl; the policies never leave the ephemeral dev - # server, so this is strictly a validator — not a deploy. - # - # Exit-code handling: - # - `vault policy write` exits 0 on success, non-zero on any parse / - # semantic error. We aggregate failures across all files so a single - # CI run surfaces every broken policy (not just the first). - # - The dev server is killed on any step exit via EXIT trap so the - # step tears down cleanly even on failure. - # - # Why dev-mode is sufficient: we're not persisting secrets, only asking - # Vault to parse policy text. The factory's production Vault is NOT - # contacted. - - name: vault-policy-validate - image: hashicorp/vault:1.18.5 - commands: - - | - set -e - vault server -dev -dev-root-token-id=root -dev-listen-address=127.0.0.1:8200 >/tmp/vault-dev.log 2>&1 & - VAULT_PID=$! - trap 'kill "$VAULT_PID" 2>/dev/null || true' EXIT INT TERM - export VAULT_ADDR=http://127.0.0.1:8200 - export VAULT_TOKEN=root - ready=0 - i=0 - while [ "$i" -lt 30 ]; do - if vault status >/dev/null 2>&1; then - ready=1 - break - fi - i=$((i + 1)) - sleep 0.5 - done - if [ "$ready" -ne 1 ]; then - echo "vault-policy-validate: dev server failed to start after 15s" >&2 - cat /tmp/vault-dev.log >&2 || true - exit 1 - fi - failed=0 - for f in vault/policies/*.hcl; do - [ -f "$f" ] || continue - name=$(basename "$f" .hcl) - echo "validate: $f" - if ! vault policy write "$name" "$f"; then - echo " ERROR: $f failed validation" >&2 - failed=1 - fi - done - if [ "$failed" -gt 0 ]; then - echo "vault-policy-validate: validation errors found" >&2 - exit 1 - fi - echo "vault-policy-validate: all policies valid" - - # ── 6. vault/roles.yaml validator ──────────────────────────────────────── - # Validates the JWT-auth role bindings file (S2.3). Two checks: - # - # a. `yamllint` — catches YAML syntax errors and indentation drift. - # Uses a relaxed config (line length bumped to 200) because - # roles.yaml's comments are wide by design. - # b. role → policy reference check — every role's `policy:` field - # must match a basename in vault/policies/*.hcl. A role pointing - # at a non-existent policy = runtime "permission denied" at job - # placement; catching the drift here turns it into a CI failure. - # Also verifies each role entry has the four required fields - # (name, policy, namespace, job_id) per the file's documented - # format. - # - # Parsing is done with PyYAML (the roles.yaml format is a strict - # subset that awk-level parsing in tools/vault-apply-roles.sh handles - # too, but PyYAML in CI gives us structural validation for free). If - # roles.yaml is ever absent (e.g. reverted), the step skips rather - # than fails — presence is enforced by S2.3's own tooling, not here. - - name: vault-roles-validate - image: python:3.12-alpine - commands: - - pip install --quiet --disable-pip-version-check pyyaml yamllint - - | - set -e - if [ ! -f vault/roles.yaml ]; then - echo "vault-roles-validate: vault/roles.yaml not present, skipping" - exit 0 - fi - yamllint -d '{extends: relaxed, rules: {line-length: {max: 200}}}' vault/roles.yaml - echo "vault-roles-validate: yamllint OK" - python3 - <<'PY' - import os - import sys - import yaml - - with open('vault/roles.yaml') as f: - data = yaml.safe_load(f) or {} - roles = data.get('roles') or [] - if not roles: - print("vault-roles-validate: no roles defined in vault/roles.yaml", file=sys.stderr) - sys.exit(1) - existing = { - os.path.splitext(e)[0] - for e in os.listdir('vault/policies') - if e.endswith('.hcl') - } - required = ('name', 'policy', 'namespace', 'job_id') - failed = 0 - for r in roles: - if not isinstance(r, dict): - print(f"ERROR: role entry is not a mapping: {r!r}", file=sys.stderr) - failed = 1 - continue - for field in required: - if r.get(field) in (None, ''): - print(f"ERROR: role entry missing required field '{field}': {r}", file=sys.stderr) - failed = 1 - policy = r.get('policy') - if policy and policy not in existing: - print( - f"ERROR: role '{r.get('name')}' references policy '{policy}' " - f"but vault/policies/{policy}.hcl does not exist", - file=sys.stderr, - ) - failed = 1 - sys.exit(failed) - PY - echo "vault-roles-validate: all role→policy references valid" - - # ── 7. Shellcheck ──────────────────────────────────────────────────────── - # Covers the new lib/init/nomad/*.sh scripts plus bin/disinto (which owns - # the backend dispatcher). bin/disinto has no .sh extension so the - # repo-wide shellcheck in .woodpecker/ci.yml skips it — this step is the - # one place it gets checked. - - name: shellcheck-nomad - image: koalaman/shellcheck-alpine:stable - commands: - - shellcheck --severity=warning lib/init/nomad/*.sh bin/disinto - - # ── 8. bats: `disinto init --backend=nomad --dry-run` ──────────────────── - # Smoke-tests the CLI dispatcher: both --backend=nomad variants exit 0 - # with the expected step list, and --backend=docker stays on the docker - # path (regression guard). Pure dry-run — no sudo, no network. - - name: bats-init-nomad - image: alpine:3.19 - commands: - - apk add --no-cache bash bats - - bats tests/disinto-init-nomad.bats diff --git a/.woodpecker/ops-filer.yml b/.woodpecker/ops-filer.yml new file mode 100644 index 0000000..98c5bb2 --- /dev/null +++ b/.woodpecker/ops-filer.yml @@ -0,0 +1,36 @@ +# .woodpecker/ops-filer.yml — Sub-issue filer pipeline (#764) +# +# Triggered on push to main of the ops repo after a sprint PR merges. +# Parses sprints/*.md for ## Sub-issues blocks and files them on the +# project repo via filer-bot (FORGE_FILER_TOKEN). +# +# NOTE: This pipeline runs on the ops repo. It must be registered in the +# ops repo's Woodpecker project. The filer script (lib/sprint-filer.sh) +# lives in the code repo and is cloned into the workspace. +# +# Idempotency: safe to re-run — each sub-issue carries a decomposed-from +# marker that the filer checks before creating. + +when: + branch: main + event: push + +steps: + - name: file-subissues + image: alpine:3 + commands: + - apk add --no-cache bash curl jq + # Clone the code repo to get the filer script + - AUTH_URL=$(printf '%s' "${FORGE_URL}/disinto-admin/disinto.git" | sed "s|://|://token:${FORGE_FILER_TOKEN}@|") + - git clone --depth 1 "$AUTH_URL" /tmp/code-repo + # Run filer against all sprint files in the ops repo workspace + - bash /tmp/code-repo/lib/sprint-filer.sh --all sprints/ + environment: + FORGE_FILER_TOKEN: + from_secret: forge_filer_token + FORGE_URL: + from_secret: forge_url + FORGE_API: + from_secret: forge_api + FORGE_API_BASE: + from_secret: forge_api_base diff --git a/.woodpecker/publish-images.yml b/.woodpecker/publish-images.yml deleted file mode 100644 index 15f373d..0000000 --- a/.woodpecker/publish-images.yml +++ /dev/null @@ -1,64 +0,0 @@ -# .woodpecker/publish-images.yml — Build and push versioned container images -# Triggered on tag pushes (e.g. v1.2.3). Builds and pushes: -# - ghcr.io/disinto/agents: -# - ghcr.io/disinto/reproduce: -# - ghcr.io/disinto/edge: -# -# Requires GHCR_TOKEN secret configured in Woodpecker with push access -# to ghcr.io/disinto. - -when: - event: tag - ref: refs/tags/v* - -clone: - git: - image: alpine/git - commands: - - AUTH_URL=$(printf '%s' "$CI_REPO_CLONE_URL" | sed "s|://|://token:$FORGE_TOKEN@|") - - git clone --depth 1 "$AUTH_URL" . - - git fetch --depth 1 origin "$CI_COMMIT_REF" - - git checkout FETCH_HEAD - -steps: - - name: build-and-push-agents - image: plugins/docker - settings: - repo: ghcr.io/disinto/agents - registry: ghcr.io - dockerfile: docker/agents/Dockerfile - context: . - tags: - - ${CI_COMMIT_TAG} - - latest - username: disinto - password: - from_secret: GHCR_TOKEN - - - name: build-and-push-reproduce - image: plugins/docker - settings: - repo: ghcr.io/disinto/reproduce - registry: ghcr.io - dockerfile: docker/reproduce/Dockerfile - context: . - tags: - - ${CI_COMMIT_TAG} - - latest - username: disinto - password: - from_secret: GHCR_TOKEN - - - name: build-and-push-edge - image: plugins/docker - settings: - repo: ghcr.io/disinto/edge - registry: ghcr.io - dockerfile: docker/edge/Dockerfile - context: docker/edge - tags: - - ${CI_COMMIT_TAG} - - latest - username: disinto - password: - from_secret: GHCR_TOKEN diff --git a/.woodpecker/run-secret-scan.sh b/.woodpecker/run-secret-scan.sh deleted file mode 100644 index e8d7d5d..0000000 --- a/.woodpecker/run-secret-scan.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -# run-secret-scan.sh — CI wrapper for lib/secret-scan.sh -# -# Scans files changed in this PR for plaintext secrets. -# Exits non-zero if any secret is detected. - -# shellcheck source=../lib/secret-scan.sh -source lib/secret-scan.sh - -# Path patterns considered secret-adjacent -SECRET_PATH_PATTERNS=( - '\.env' - 'tools/vault-.*\.sh' - 'nomad/' - 'vault/' - 'action-vault/' - 'lib/hvault\.sh' - 'lib/action-vault\.sh' -) - -# Build a single regex from patterns -path_regex=$(printf '%s|' "${SECRET_PATH_PATTERNS[@]}") -path_regex="${path_regex%|}" - -# Get files changed in this PR vs target branch. -# Note: shallow clone (depth 50) may lack the merge base for very large PRs, -# causing git diff to fail — || true means the gate skips rather than blocks. -changed_files=$(git diff --name-only --diff-filter=ACMR "origin/${CI_COMMIT_TARGET_BRANCH}...HEAD" || true) - -if [ -z "$changed_files" ]; then - echo "secret-scan: no changed files found, skipping" - exit 0 -fi - -# Filter to secret-adjacent paths only -target_files=$(printf '%s\n' "$changed_files" | grep -E "$path_regex" || true) - -if [ -z "$target_files" ]; then - echo "secret-scan: no secret-adjacent files changed, skipping" - exit 0 -fi - -echo "secret-scan: scanning $(printf '%s\n' "$target_files" | wc -l) file(s):" -printf ' %s\n' "$target_files" - -failures=0 -while IFS= read -r file; do - # Skip deleted files / non-existent - [ -f "$file" ] || continue - # Skip binary files - file -b --mime-encoding "$file" 2>/dev/null | grep -q binary && continue - - content=$(cat "$file") - if ! scan_for_secrets "$content"; then - echo "FAIL: secret detected in $file" - failures=$((failures + 1)) - fi -done <<< "$target_files" - -if [ "$failures" -gt 0 ]; then - echo "" - echo "secret-scan: $failures file(s) contain potential secrets — merge blocked" - echo "If these are false positives, verify patterns in lib/secret-scan.sh" - exit 1 -fi - -echo "secret-scan: all files clean" diff --git a/.woodpecker/secret-scan.yml b/.woodpecker/secret-scan.yml deleted file mode 100644 index 7db9c50..0000000 --- a/.woodpecker/secret-scan.yml +++ /dev/null @@ -1,32 +0,0 @@ -# .woodpecker/secret-scan.yml — Block PRs that leak plaintext secrets -# -# Triggers on pull requests touching secret-adjacent paths. -# Sources lib/secret-scan.sh and scans each changed file's content. -# Exits non-zero if any potential secret is detected. - -when: - - event: pull_request - path: - - ".env*" - - "tools/vault-*.sh" - - "nomad/**/*" - - "vault/**/*" - - "action-vault/**/*" - - "lib/hvault.sh" - - "lib/action-vault.sh" - -clone: - git: - image: alpine/git - commands: - - AUTH_URL=$(printf '%s' "$CI_REPO_CLONE_URL" | sed "s|://|://token:$FORGE_TOKEN@|") - - git clone --depth 50 "$AUTH_URL" . - - git fetch --depth 50 origin "$CI_COMMIT_REF" "$CI_COMMIT_TARGET_BRANCH" - - git checkout FETCH_HEAD - -steps: - - name: secret-scan - image: alpine:3 - commands: - - apk add --no-cache bash git grep file - - bash .woodpecker/run-secret-scan.sh diff --git a/AGENTS.md b/AGENTS.md index 42f7253..3a7fc48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ - + # Disinto — Agent Instructions ## What this repo is @@ -31,26 +31,21 @@ disinto/ (code repo) ├── supervisor/ supervisor-run.sh — formula-driven health monitoring (polling-loop executor) │ preflight.sh — pre-flight data collection for supervisor formula ├── architect/ architect-run.sh — strategic decomposition of vision into sprints -├── action-vault/ vault-env.sh — shared env setup (vault redesign in progress, see #73-#77) +├── vault/ vault-env.sh — shared env setup (vault redesign in progress, see #73-#77) │ SCHEMA.md — vault item schema documentation │ validate.sh — vault item validator │ examples/ — example vault action TOMLs (promote, publish, release, webhook-call) -├── lib/ env.sh, agent-sdk.sh, ci-helpers.sh, ci-debug.sh, load-project.sh, parse-deps.sh, guard.sh, mirrors.sh, pr-lifecycle.sh, issue-lifecycle.sh, worktree.sh, formula-session.sh, stack-lock.sh, forge-setup.sh, forge-push.sh, ops-setup.sh, ci-setup.sh, generators.sh, hire-agent.sh, release.sh, build-graph.py, branch-protection.sh, secret-scan.sh, tea-helpers.sh, action-vault.sh, ci-log-reader.py, git-creds.sh, sprint-filer.sh, hvault.sh +├── lib/ env.sh, agent-sdk.sh, ci-helpers.sh, ci-debug.sh, load-project.sh, parse-deps.sh, guard.sh, mirrors.sh, pr-lifecycle.sh, issue-lifecycle.sh, worktree.sh, formula-session.sh, stack-lock.sh, forge-setup.sh, forge-push.sh, ops-setup.sh, ci-setup.sh, generators.sh, hire-agent.sh, release.sh, build-graph.py, branch-protection.sh, secret-scan.sh, tea-helpers.sh, vault.sh, ci-log-reader.py, git-creds.sh, sprint-filer.sh │ hooks/ — Claude Code session hooks (on-compact-reinject, on-idle-stop, on-phase-change, on-pretooluse-guard, on-session-end, on-stop-failure) -│ init/nomad/ — cluster-up.sh, install.sh, vault-init.sh, lib-systemd.sh (Nomad+Vault Step 0 installers, #821-#825); wp-oauth-register.sh (Forgejo OAuth2 app + Vault KV seeder for Woodpecker, S3.3); deploy.sh (dependency-ordered Nomad job deploy + health-wait, S4) -├── nomad/ server.hcl, client.hcl (allow_privileged for woodpecker-agent, S3-fix-5), vault.hcl — HCL configs deployed to /etc/nomad.d/ and /etc/vault.d/ by lib/init/nomad/cluster-up.sh -│ jobs/ — Nomad jobspecs: forgejo.hcl (Vault secrets via template, S2.4); woodpecker-server.hcl + woodpecker-agent.hcl (host-net, docker.sock, Vault KV, S3.1-S3.2); agents.hcl (7 roles, llama, Vault-templated bot tokens, S4.1); vault-runner.hcl (parameterized batch dispatch, S5.3); staging.hcl (Caddy file-server, S5.2); chat.hcl (Claude chat UI, Vault OAuth secrets, S5.2); edge.hcl (Caddy proxy + dispatcher sidecar, S5.1) ├── projects/ *.toml.example — templates; *.toml — local per-box config (gitignored) ├── formulas/ Issue templates (TOML specs for multi-step agent tasks) ├── docker/ Dockerfiles and entrypoints: reproduce, triage, edge dispatcher, chat (server.py, entrypoint-chat.sh, Dockerfile, ui/) ├── tools/ Operational tools: edge-control/ (register.sh, install.sh, verify-chat-sandbox.sh) -│ vault-apply-policies.sh, vault-apply-roles.sh, vault-import.sh — Vault provisioning (S2.1/S2.2) -│ vault-seed-.sh — per-service Vault secret seeders; auto-invoked by `bin/disinto --with ` (add a new file to support a new service) ├── docs/ Protocol docs (PHASE-PROTOCOL.md, EVIDENCE-ARCHITECTURE.md) ├── site/ disinto.ai website content -├── tests/ Test files (mock-forgejo.py, smoke-init.sh, lib-hvault.bats, lib-generators.bats, vault-import.bats, disinto-init-nomad.bats) +├── tests/ Test files (mock-forgejo.py, smoke-init.sh) ├── templates/ Issue templates -├── bin/ The `disinto` CLI script (`--with ` deploys services + runs their Vault seeders) +├── bin/ The `disinto` CLI script ├── disinto-factory/ Setup documentation and skill ├── state/ Runtime state ├── .woodpecker/ Woodpecker CI pipeline configs @@ -91,7 +86,7 @@ Each agent has a `.profile` repository on Forgejo storing `knowledge/lessons-lea - All scripts start with `#!/usr/bin/env bash` and `set -euo pipefail` - Source shared environment: `source "$(dirname "$0")/../lib/env.sh"` - Log to `$LOGFILE` using the `log()` function from env.sh or defined locally -- Never hardcode secrets — agent secrets come from `.env.enc`, vault secrets from `secrets/.enc` (age-encrypted, one file per key) +- Never hardcode secrets — agent secrets come from `.env.enc`, vault secrets from `.env.vault.enc` (or `.env`/`.env.vault` fallback) - Never embed secrets in issue bodies, PR descriptions, or comments — use env var references (e.g. `$BASE_RPC_URL`) - ShellCheck must pass (CI runs `shellcheck` on all `.sh` files) - Avoid duplicate code — shared helpers go in `lib/` @@ -119,11 +114,10 @@ bash dev/phase-test.sh | Planner | `planner/` | Strategic planning | [planner/AGENTS.md](planner/AGENTS.md) | | Predictor | `predictor/` | Infrastructure pattern detection | [predictor/AGENTS.md](predictor/AGENTS.md) | | Architect | `architect/` | Strategic decomposition (read-only on project repo) | [architect/AGENTS.md](architect/AGENTS.md) | -| Filer | `lib/sprint-filer.sh` | Sub-issue filing from merged sprint PRs | ops repo pipeline (deferred, see #779) | +| Filer | `lib/sprint-filer.sh` | Sub-issue filing from merged sprint PRs | `.woodpecker/ops-filer.yml` | | Reproduce | `docker/reproduce/` | Bug reproduction using Playwright MCP | `formulas/reproduce.toml` | | Triage | `docker/reproduce/` | Deep root cause analysis | `formulas/triage.toml` | | Edge dispatcher | `docker/edge/` | Polls ops repo for vault actions, executes via Claude sessions | `docker/edge/dispatcher.sh` | -| Local-model agents | `docker/agents/` (same image) | Local llama-server agents configured via `[agents.X]` sections in project TOML | [docs/agents-llama.md](docs/agents-llama.md) | > **Vault:** Being redesigned as a PR-based approval workflow (issues #73-#77). > See [docs/VAULT.md](docs/VAULT.md) for the vault PR workflow details. @@ -184,17 +178,24 @@ Humans write these. Agents read and enforce them. | AD-002 | **Concurrency is bounded per LLM backend, not per project.** One concurrent Claude session per OAuth credential pool; one concurrent session per llama-server instance. Containers with disjoint backends may run in parallel. | The single-thread invariant is about *backends*, not pipelines. **(a) Anthropic OAuth credentials race on token refresh** — each container uses a per-session `CLAUDE_CONFIG_DIR`, so Claude Code's native lockfile-based OAuth refresh handles contention automatically without external serialization. (Legacy: set `CLAUDE_EXTERNAL_LOCK=1` to re-enable the old `flock session.lock` wrapper for rollback.) **(b) llama-server has finite VRAM and one KV cache** — parallel inference thrashes the cache and risks OOM. All llama-backed agents serialize on the same lock. **(c) Disjoint backends are free to parallelize.** Today `disinto-agents` (Anthropic OAuth, runs `review,gardener`) runs concurrently with `disinto-agents-llama` (llama, runs `dev`) on the same project — they share neither OAuth state nor llama VRAM. **(d) Per-project work-conflict safety** (no duplicate dev work, no merge conflicts on the same branch) is enforced by `issue_claim` (assignee + `in-progress` label) and per-issue worktrees — that's a separate guard that does NOT depend on this AD. | | AD-003 | The runtime creates and destroys, the formula preserves. | Runtime manages worktrees/sessions/temp. Formulas commit knowledge to git before signaling done. | | AD-004 | Event-driven > polling > fixed delays. | Never `waitForTimeout` or hardcoded sleep. Use phase files, webhooks, or poll loops with backoff. | -| AD-005 | Secrets via env var indirection, never in issue bodies. | Issue bodies become code. Agent secrets go in `.env.enc` (SOPS-encrypted), vault secrets in `secrets/.enc` (age-encrypted, one file per key). Referenced as `$VAR_NAME`. Runner gets only vault secrets; agents get only agent secrets. | -| AD-006 | External actions go through vault dispatch, never direct. | Agents build addressables; only the vault exercises them (publishes, deploys, posts). Tokens for external systems (`GITHUB_TOKEN`, `CLAWHUB_TOKEN`, deploy keys) live only in `secrets/.enc` and are decrypted into the ephemeral runner container. `lib/env.sh` unsets them so agents never hold them. PRs with direct external actions without vault dispatch get REQUEST_CHANGES. (Vault redesign in progress: PR-based approval on ops repo, see #73-#77) | +| AD-005 | Secrets via env var indirection, never in issue bodies. | Issue bodies become code. Agent secrets go in `.env.enc`, vault secrets in `.env.vault.enc` (SOPS-encrypted when available; plaintext `.env`/`.env.vault` fallback supported). Referenced as `$VAR_NAME`. Runner gets only vault secrets; agents get only agent secrets. | +| AD-006 | External actions go through vault dispatch, never direct. | Agents build addressables; only the vault exercises them (publishes, deploys, posts). Tokens for external systems (`GITHUB_TOKEN`, `CLAWHUB_TOKEN`, deploy keys) live only in `.env.vault.enc` and are injected into the ephemeral runner container. `lib/env.sh` unsets them so agents never hold them. PRs with direct external actions without vault dispatch get REQUEST_CHANGES. (Vault redesign in progress: PR-based approval on ops repo, see #73-#77) | **Who enforces what:** -- **Gardener** checks open backlog issues against ADs during grooming; closes violations with a comment. **Planner** plans within the architecture; does not create issues that violate ADs. +- **Gardener** checks open backlog issues against ADs during grooming; closes violations with a comment referencing the AD number. +- **Planner** plans within the architecture; does not create issues that violate ADs. - **Dev-agent** reads AGENTS.md before implementing; refuses work that violates ADs. - **AD-002 is a runtime invariant; nothing for the gardener to check at issue-groom time.** OAuth concurrency is handled by per-session `CLAUDE_CONFIG_DIR` isolation (with `CLAUDE_EXTERNAL_LOCK` as a rollback flag). Per-issue work is enforced by `issue_claim`. A violation manifests as a 401 or VRAM OOM in agent logs, not as a malformed issue. +--- + ## Phase-Signaling Protocol -When running as a persistent tmux session, Claude must signal the orchestrator at each phase boundary by writing to a phase file (e.g. `/tmp/dev-session-{project}-{issue}.phase`). +When running as a persistent tmux session, Claude must signal the orchestrator +at each phase boundary by writing to a phase file (e.g. +`/tmp/dev-session-{project}-{issue}.phase`). + +Key phases: `PHASE:awaiting_ci` → `PHASE:awaiting_review` → `PHASE:done`. +Also: `PHASE:escalate` (needs human input), `PHASE:failed`. -Key phases: `PHASE:awaiting_ci` → `PHASE:awaiting_review` → `PHASE:done`. Also: `PHASE:escalate` (needs human input), `PHASE:failed`. See [docs/PHASE-PROTOCOL.md](docs/PHASE-PROTOCOL.md) for the complete spec, orchestrator reaction matrix, sequence diagram, and crash recovery. diff --git a/architect/AGENTS.md b/architect/AGENTS.md index b2bd57a..e705f23 100644 --- a/architect/AGENTS.md +++ b/architect/AGENTS.md @@ -1,4 +1,4 @@ - + # Architect — Agent Instructions ## What this agent is @@ -96,7 +96,6 @@ Run via `architect/architect-run.sh`, which: - Acquires a poll-loop lock (via `acquire_lock`) and checks available memory - Cleans up per-issue scratch files from previous runs (`/tmp/architect-{project}-scratch-*.md`) - Sources shared libraries (env.sh, formula-session.sh) -- Exports `FORGE_TOKEN_OVERRIDE="${FORGE_ARCHITECT_TOKEN}"` BEFORE sourcing env.sh, ensuring architect-bot identity survives re-sourcing (#762) - Uses FORGE_ARCHITECT_TOKEN for authentication - Processes existing architect PRs via bash-driven design phase - Loads the formula and builds context from VISION.md, AGENTS.md, and ops repo diff --git a/bin/disinto b/bin/disinto index c18ef0c..bbb11ec 100755 --- a/bin/disinto +++ b/bin/disinto @@ -60,7 +60,7 @@ Usage: Read CI logs from Woodpecker SQLite disinto release Create vault PR for release (e.g., v1.2.0) disinto hire-an-agent [--formula ] [--local-model ] [--model ] - Hire a new agent (create user + .profile repo; re-run to rotate credentials) + Hire a new agent (create user + .profile repo) disinto agent Manage agent state (enable/disable) disinto edge [options] Manage edge tunnel registrations @@ -81,17 +81,9 @@ Init options: --repo-root Local clone path (default: ~/name) --ci-id Woodpecker CI repo ID (default: 0 = no CI) --forge-url Forge base URL (default: http://localhost:3000) - --backend Orchestration backend: docker (default) | nomad - --with (nomad) Deploy services: forgejo,woodpecker,agents,staging,chat,edge[,...] (S1.3, S3.4, S4.2, S5.2, S5.5) - --empty (nomad) Bring up cluster only, no jobs (S0.4) --bare Skip compose generation (bare-metal setup) - --build Use local docker build instead of registry images (dev mode) --yes Skip confirmation prompts --rotate-tokens Force regeneration of all bot tokens/passwords (idempotent by default) - --dry-run Print every intended action without executing - --import-env (nomad) Path to .env file for import into Vault KV (S2.5) - --import-sops (nomad) Path to sops-encrypted .env.vault.enc for import (S2.5) - --age-key (nomad) Path to age keyfile (required with --import-sops) (S2.5) Hire an agent options: --formula Path to role formula TOML (default: formulas/.toml) @@ -211,21 +203,18 @@ generate_compose() { # Generate docker/agents/ files if they don't already exist. # (Implementation in lib/generators.sh) -# shellcheck disable=SC2120 # passthrough wrapper; forwards any future args to impl generate_agent_docker() { _generate_agent_docker_impl "$@" } # Generate docker/Caddyfile template for edge proxy. # (Implementation in lib/generators.sh) -# shellcheck disable=SC2120 # passthrough wrapper; forwards any future args to impl generate_caddyfile() { _generate_caddyfile_impl "$@" } # Generate docker/index.html default page. # (Implementation in lib/generators.sh) -# shellcheck disable=SC2120 # passthrough wrapper; forwards any future args to impl generate_staging_index() { _generate_staging_index_impl "$@" } @@ -653,632 +642,30 @@ prompt_admin_password() { # ── init command ───────────────────────────────────────────────────────────── -# Nomad backend init — dispatcher (Nomad+Vault migration, S0.4, issue #824). -# -# Today `--empty` and the default (no flag) both bring up an empty -# single-node Nomad+Vault cluster via lib/init/nomad/cluster-up.sh. Step 1 -# will extend the default path to also deploy jobs; `--empty` will remain -# the "cluster only, no workloads" escape hatch. -# -# Uses `sudo -n` when not already root — cluster-up.sh mutates /etc/, -# /srv/, and systemd state, so it has to run as root. The `-n` keeps the -# failure mode legible (no hanging TTY-prompted sudo inside a factory -# init run); operators running without sudo-NOPASSWD should invoke -# `sudo disinto init ...` directly. -_disinto_init_nomad() { - local dry_run="${1:-false}" empty="${2:-false}" with_services="${3:-}" - local import_env="${4:-}" import_sops="${5:-}" age_key="${6:-}" - local cluster_up="${FACTORY_ROOT}/lib/init/nomad/cluster-up.sh" - local deploy_sh="${FACTORY_ROOT}/lib/init/nomad/deploy.sh" - local vault_engines_sh="${FACTORY_ROOT}/lib/init/nomad/vault-engines.sh" - local vault_policies_sh="${FACTORY_ROOT}/tools/vault-apply-policies.sh" - local vault_auth_sh="${FACTORY_ROOT}/lib/init/nomad/vault-nomad-auth.sh" - local vault_import_sh="${FACTORY_ROOT}/tools/vault-import.sh" - - if [ ! -x "$cluster_up" ]; then - echo "Error: ${cluster_up} not found or not executable" >&2 - exit 1 - fi - - if [ -n "$with_services" ] && [ ! -x "$deploy_sh" ]; then - echo "Error: ${deploy_sh} not found or not executable" >&2 - exit 1 - fi - - # --empty short-circuits after cluster-up: no policies, no auth, no - # import, no deploy. It's the "cluster-only escape hatch" for debugging - # (docs/nomad-migration.md). Caller-side validation already rejects - # --empty combined with --with or any --import-* flag, so reaching - # this branch with those set is a bug in the caller. - # - # On the default (non-empty) path, vault-engines.sh (enables the kv/ - # mount), vault-apply-policies.sh, and vault-nomad-auth.sh are invoked - # unconditionally — they are idempotent and cheap to re-run, and - # subsequent --with deployments depend on them. vault-import.sh is - # invoked only when an --import-* flag is set. vault-engines.sh runs - # first because every policy and role below references kv/disinto/* - # paths, which 403 if the engine is not yet mounted (issue #912). - local import_any=false - if [ -n "$import_env" ] || [ -n "$import_sops" ]; then - import_any=true - fi - if [ "$empty" != "true" ]; then - if [ ! -x "$vault_engines_sh" ]; then - echo "Error: ${vault_engines_sh} not found or not executable" >&2 - exit 1 - fi - if [ ! -x "$vault_policies_sh" ]; then - echo "Error: ${vault_policies_sh} not found or not executable" >&2 - exit 1 - fi - if [ ! -x "$vault_auth_sh" ]; then - echo "Error: ${vault_auth_sh} not found or not executable" >&2 - exit 1 - fi - if [ "$import_any" = true ] && [ ! -x "$vault_import_sh" ]; then - echo "Error: ${vault_import_sh} not found or not executable" >&2 - exit 1 - fi - fi - - # --empty and default both invoke cluster-up today. Log the requested - # mode so the dispatch is visible in factory bootstrap logs — Step 1 - # will branch on $empty to gate the job-deployment path. - if [ "$empty" = "true" ]; then - echo "nomad backend: --empty (cluster-up only, no jobs)" - else - echo "nomad backend: default (cluster-up; jobs deferred to Step 1)" - fi - - # Dry-run: print cluster-up plan + policies/auth/import plan + deploy.sh plan - if [ "$dry_run" = "true" ]; then - echo "" - echo "── Cluster-up dry-run ─────────────────────────────────" - local -a cmd=("$cluster_up" "--dry-run") - "${cmd[@]}" || true - echo "" - - # --empty skips policies/auth/import/deploy — cluster-up only, no - # workloads. The operator-visible dry-run plan must match the real - # run, so short-circuit here too. - if [ "$empty" = "true" ]; then - exit 0 - fi - - # Vault engines + policies + auth are invoked on every nomad real-run - # path regardless of --import-* flags (they're idempotent; S2.1 + S2.3). - # Engines runs first because policies/roles/templates all reference the - # kv/ mount it enables (issue #912). Mirror that ordering in the - # dry-run plan so the operator sees the full sequence Step 2 will - # execute. - echo "── Vault engines dry-run ──────────────────────────────" - echo "[engines] [dry-run] ${vault_engines_sh} --dry-run" - echo "" - echo "── Vault policies dry-run ─────────────────────────────" - echo "[policies] [dry-run] ${vault_policies_sh} --dry-run" - echo "" - echo "── Vault auth dry-run ─────────────────────────────────" - echo "[auth] [dry-run] ${vault_auth_sh}" - echo "" - - # Import plan: one line per --import-* flag that is actually set. - # Printing independently (not in an if/elif chain) means that all - # three flags appearing together each echo their own path — the - # regression that bit prior implementations of this issue (#883). - if [ "$import_any" = true ]; then - echo "── Vault import dry-run ───────────────────────────────" - [ -n "$import_env" ] && echo "[import] --import-env env file: ${import_env}" - [ -n "$import_sops" ] && echo "[import] --import-sops sops file: ${import_sops}" - [ -n "$age_key" ] && echo "[import] --age-key age key: ${age_key}" - local -a import_dry_cmd=("$vault_import_sh") - [ -n "$import_env" ] && import_dry_cmd+=("--env" "$import_env") - [ -n "$import_sops" ] && import_dry_cmd+=("--sops" "$import_sops") - [ -n "$age_key" ] && import_dry_cmd+=("--age-key" "$age_key") - import_dry_cmd+=("--dry-run") - echo "[import] [dry-run] ${import_dry_cmd[*]}" - echo "" - else - echo "[import] no --import-env/--import-sops — skipping; set them or seed kv/disinto/* manually before deploying secret-dependent services" - echo "" - fi - - if [ -n "$with_services" ]; then - # Interleaved seed/deploy per service (S2.6, #928, #948): match the - # real-run path so dry-run output accurately represents execution order. - # Build ordered deploy list: only include services present in with_services - local DEPLOY_ORDER="" - for ordered_svc in forgejo woodpecker-server woodpecker-agent agents staging chat edge; do - if echo ",$with_services," | grep -q ",$ordered_svc,"; then - DEPLOY_ORDER="${DEPLOY_ORDER:+${DEPLOY_ORDER} }${ordered_svc}" - fi - done - - local IFS=' ' - echo "[deploy] deployment order: ${DEPLOY_ORDER}" - for svc in $DEPLOY_ORDER; do - # Seed this service (if seed script exists) - local seed_name="$svc" - case "$svc" in - woodpecker-server|woodpecker-agent) seed_name="woodpecker" ;; - agents) seed_name="agents" ;; - chat) seed_name="chat" ;; - esac - local seed_script="${FACTORY_ROOT}/tools/vault-seed-${seed_name}.sh" - if [ -x "$seed_script" ]; then - echo "── Vault seed dry-run ─────────────────────────────────" - echo "[seed] [dry-run] ${seed_script} --dry-run" - echo "" - fi - - # Deploy this service - echo "── Deploy services dry-run ────────────────────────────" - echo "[deploy] services to deploy: ${with_services}" - local jobspec_path="${FACTORY_ROOT}/nomad/jobs/${svc}.hcl" - if [ ! -f "$jobspec_path" ]; then - echo "Error: jobspec not found: ${jobspec_path}" >&2 - exit 1 - fi - echo "[deploy] [dry-run] nomad job validate ${jobspec_path}" - echo "[deploy] [dry-run] nomad job run -detach ${jobspec_path}" - done - echo "[deploy] dry-run complete" - fi - - # Dry-run vault-runner (unconditionally, not gated by --with) - echo "" - echo "── Vault-runner dry-run ───────────────────────────────────" - local vault_runner_path="${FACTORY_ROOT}/nomad/jobs/vault-runner.hcl" - if [ -f "$vault_runner_path" ]; then - echo "[deploy] vault-runner: [dry-run] nomad job validate ${vault_runner_path}" - echo "[deploy] vault-runner: [dry-run] nomad job run -detach ${vault_runner_path}" - else - echo "[deploy] vault-runner: jobspec not found, skipping" - fi - - # Build custom images dry-run (if agents, chat, or edge services are included) - if echo ",$with_services," | grep -qE ",(agents|chat|edge),"; then - echo "" - echo "── Build images dry-run ──────────────────────────────" - if echo ",$with_services," | grep -q ",agents,"; then - echo "[build] [dry-run] docker build -t disinto/agents:local -f ${FACTORY_ROOT}/docker/agents/Dockerfile ${FACTORY_ROOT}" - fi - if echo ",$with_services," | grep -q ",chat,"; then - echo "[build] [dry-run] docker build -t disinto/chat:local -f ${FACTORY_ROOT}/docker/chat/Dockerfile ${FACTORY_ROOT}/docker/chat" - fi - if echo ",$with_services," | grep -q ",edge,"; then - echo "[build] [dry-run] docker build -t disinto/edge:local -f ${FACTORY_ROOT}/docker/edge/Dockerfile ${FACTORY_ROOT}/docker/edge" - fi - fi - exit 0 - fi - - # Real run: cluster-up + policies + auth + (optional) import + deploy - local -a cluster_cmd=("$cluster_up") - if [ "$(id -u)" -eq 0 ]; then - "${cluster_cmd[@]}" || exit $? - else - if ! command -v sudo >/dev/null 2>&1; then - echo "Error: cluster-up.sh must run as root and sudo is not installed" >&2 - exit 1 - fi - sudo -n -- "${cluster_cmd[@]}" || exit $? - fi - - # --empty short-circuits here: cluster-up only, no policies/auth/import - # and no deploy. Matches the dry-run plan above and the docs/runbook. - if [ "$empty" = "true" ]; then - exit 0 - fi - - # Enable Vault secret engines (S2.1 / issue #912) — must precede - # policies/auth/import because every policy and every import target - # addresses paths under kv/. Idempotent, safe to re-run. - echo "" - echo "── Enabling Vault secret engines ──────────────────────" - local -a engines_cmd=("$vault_engines_sh") - if [ "$(id -u)" -eq 0 ]; then - "${engines_cmd[@]}" || exit $? - else - if ! command -v sudo >/dev/null 2>&1; then - echo "Error: vault-engines.sh must run as root and sudo is not installed" >&2 - exit 1 - fi - sudo -n -- "${engines_cmd[@]}" || exit $? - fi - - # Apply Vault policies (S2.1) — idempotent, safe to re-run. - echo "" - echo "── Applying Vault policies ────────────────────────────" - local -a policies_cmd=("$vault_policies_sh") - if [ "$(id -u)" -eq 0 ]; then - "${policies_cmd[@]}" || exit $? - else - if ! command -v sudo >/dev/null 2>&1; then - echo "Error: vault-apply-policies.sh must run as root and sudo is not installed" >&2 - exit 1 - fi - sudo -n -- "${policies_cmd[@]}" || exit $? - fi - - # Configure Vault JWT auth + Nomad workload identity (S2.3) — idempotent. - echo "" - echo "── Configuring Vault JWT auth ─────────────────────────" - local -a auth_cmd=("$vault_auth_sh") - if [ "$(id -u)" -eq 0 ]; then - "${auth_cmd[@]}" || exit $? - else - if ! command -v sudo >/dev/null 2>&1; then - echo "Error: vault-nomad-auth.sh must run as root and sudo is not installed" >&2 - exit 1 - fi - sudo -n -- "${auth_cmd[@]}" || exit $? - fi - - # Import secrets if any --import-* flag is set (S2.2). - if [ "$import_any" = true ]; then - echo "" - echo "── Importing secrets into Vault ───────────────────────" - local -a import_cmd=("$vault_import_sh") - [ -n "$import_env" ] && import_cmd+=("--env" "$import_env") - [ -n "$import_sops" ] && import_cmd+=("--sops" "$import_sops") - [ -n "$age_key" ] && import_cmd+=("--age-key" "$age_key") - if [ "$(id -u)" -eq 0 ]; then - "${import_cmd[@]}" || exit $? - else - if ! command -v sudo >/dev/null 2>&1; then - echo "Error: vault-import.sh must run as root and sudo is not installed" >&2 - exit 1 - fi - sudo -n -- "${import_cmd[@]}" || exit $? - fi - else - echo "" - echo "[import] no --import-env/--import-sops — skipping; set them or seed kv/disinto/* manually before deploying secret-dependent services" - fi - - # Build custom images required by Nomad jobs (S4.2, S5.2, S5.5) — before deploy. - # Single-node factory dev box: no multi-node pull needed, no registry auth. - # Can upgrade to approach B (registry push/pull) later if multi-node. - if echo ",$with_services," | grep -qE ",(agents|chat|edge),"; then - echo "" - echo "── Building custom images ─────────────────────────────" - if echo ",$with_services," | grep -q ",agents,"; then - local tag="disinto/agents:local" - echo "── Building $tag ─────────────────────────────" - docker build -t "$tag" -f "${FACTORY_ROOT}/docker/agents/Dockerfile" "${FACTORY_ROOT}" 2>&1 | tail -5 - fi - if echo ",$with_services," | grep -q ",chat,"; then - local tag="disinto/chat:local" - echo "── Building $tag ─────────────────────────────" - docker build -t "$tag" -f "${FACTORY_ROOT}/docker/chat/Dockerfile" "${FACTORY_ROOT}/docker/chat" 2>&1 | tail -5 - fi - if echo ",$with_services," | grep -q ",edge,"; then - local tag="disinto/edge:local" - echo "── Building $tag ─────────────────────────────" - docker build -t "$tag" -f "${FACTORY_ROOT}/docker/edge/Dockerfile" "${FACTORY_ROOT}/docker/edge" 2>&1 | tail -5 - fi - fi - - # Interleaved seed/deploy per service (S2.6, #928, #948). - # We interleave seed + deploy per service (not batch all seeds then all deploys) - # so that OAuth-dependent services can reach their dependencies during seeding. - # E.g., seed-forgejo → deploy-forgejo → seed-woodpecker (OAuth can now reach - # running forgejo) → deploy-woodpecker. - if [ -n "$with_services" ]; then - local vault_addr="${VAULT_ADDR:-http://127.0.0.1:8200}" - - # Build ordered deploy list (S3.4, S4.2, S5.2, S5.5): forgejo → woodpecker-server → woodpecker-agent → agents → staging → chat → edge - local DEPLOY_ORDER="" - for ordered_svc in forgejo woodpecker-server woodpecker-agent agents staging chat edge; do - if echo ",$with_services," | grep -q ",$ordered_svc,"; then - DEPLOY_ORDER="${DEPLOY_ORDER:+${DEPLOY_ORDER} }${ordered_svc}" - fi - done - - local IFS=' ' - for svc in $DEPLOY_ORDER; do - # Seed this service (if seed script exists) - local seed_name="$svc" - case "$svc" in - woodpecker-server|woodpecker-agent) seed_name="woodpecker" ;; - agents) seed_name="agents" ;; - chat) seed_name="chat" ;; - esac - local seed_script="${FACTORY_ROOT}/tools/vault-seed-${seed_name}.sh" - if [ -x "$seed_script" ]; then - echo "" - echo "── Seeding Vault for ${seed_name} ───────────────────────────" - if [ "$(id -u)" -eq 0 ]; then - VAULT_ADDR="$vault_addr" "$seed_script" || exit $? - else - if ! command -v sudo >/dev/null 2>&1; then - echo "Error: vault-seed-${seed_name}.sh must run as root and sudo is not installed" >&2 - exit 1 - fi - sudo -n -- env "VAULT_ADDR=$vault_addr" "$seed_script" || exit $? - fi - fi - - # Deploy this service - echo "" - echo "── Deploying ${svc} ───────────────────────────────────────" - - # Seed host volumes before deployment (if needed) - case "$svc" in - staging) - # Seed site-content host volume (/srv/disinto/docker) with static content - # The staging jobspec mounts this volume read-only to /srv/site - local site_content_src="${FACTORY_ROOT}/docker/index.html" - local site_content_dst="/srv/disinto/docker" - if [ -f "$site_content_src" ] && [ -d "$site_content_dst" ]; then - if ! cmp -s "$site_content_src" "${site_content_dst}/index.html" 2>/dev/null; then - echo "[staging] seeding site-content volume..." - cp "$site_content_src" "${site_content_dst}/index.html" - fi - fi - ;; - esac - - local jobspec_path="${FACTORY_ROOT}/nomad/jobs/${svc}.hcl" - if [ ! -f "$jobspec_path" ]; then - echo "Error: jobspec not found: ${jobspec_path}" >&2 - exit 1 - fi - - local -a deploy_cmd=("$deploy_sh" "$svc") - if [ "$(id -u)" -eq 0 ]; then - "${deploy_cmd[@]}" || exit $? - else - if ! command -v sudo >/dev/null 2>&1; then - echo "Error: deploy.sh must run as root and sudo is not installed" >&2 - exit 1 - fi - sudo -n -- "${deploy_cmd[@]}" || exit $? - fi - done - - # Run vault-runner (unconditionally, not gated by --with) — infrastructure job - # vault-runner is always present since it's needed for vault action dispatch - echo "" - echo "── Running vault-runner ────────────────────────────────────" - local vault_runner_path="${FACTORY_ROOT}/nomad/jobs/vault-runner.hcl" - if [ -f "$vault_runner_path" ]; then - echo "[deploy] vault-runner: running Nomad job (infrastructure)" - local -a vault_runner_cmd=("$deploy_sh" "vault-runner") - if [ "$(id -u)" -eq 0 ]; then - "${vault_runner_cmd[@]}" || exit $? - else - if ! command -v sudo >/dev/null 2>&1; then - echo "Error: deploy.sh must run as root and sudo is not installed" >&2 - exit 1 - fi - sudo -n -- "${vault_runner_cmd[@]}" || exit $? - fi - else - echo "[deploy] vault-runner: jobspec not found, skipping" - fi - - # Print final summary - echo "" - echo "── Summary ────────────────────────────────────────────" - echo "Cluster: Nomad+Vault cluster is up" - echo "Policies: applied (Vault ACL)" - echo "Auth: Vault JWT auth + Nomad workload identity configured" - if [ "$import_any" = true ]; then - local import_desc="" - [ -n "$import_env" ] && import_desc+="${import_env} " - [ -n "$import_sops" ] && import_desc+="${import_sops} " - echo "Imported: ${import_desc% }" - else - echo "Imported: (none — seed kv/disinto/* manually before deploying secret-dependent services)" - fi - echo "Deployed: ${with_services}" - if echo ",$with_services," | grep -q ",forgejo,"; then - echo "Ports: forgejo: 3000" - fi - if echo ",$with_services," | grep -q ",woodpecker-server,"; then - echo " woodpecker-server: 8000" - fi - if echo ",$with_services," | grep -q ",woodpecker-agent,"; then - echo " woodpecker-agent: (agent connected)" - fi - if echo ",$with_services," | grep -q ",agents,"; then - echo " agents: (polling loop running)" - fi - if echo ",$with_services," | grep -q ",staging,"; then - echo " staging: (internal, no external port)" - fi - if echo ",$with_services," | grep -q ",chat,"; then - echo " chat: 8080" - fi - echo "────────────────────────────────────────────────────────" - fi - - exit 0 -} - disinto_init() { - # Only consume $1 as repo_url if it looks like a positional arg (not a - # flag). The nomad backend (#835) takes no positional — the LXC already - # has the repo cloned by the operator, and repo_url is a docker-backend - # concept. Eagerly consuming `--backend=nomad` as repo_url produced the - # nonsense "--empty is only valid with --backend=nomad" error seen in - # S0.1 end-to-end testing on a fresh LXC. Defer the "repo URL required" - # check to after argparse, where we know the backend. - local repo_url="" - if [ $# -gt 0 ] && [[ "$1" != --* ]]; then - repo_url="$1" - shift + local repo_url="${1:-}" + if [ -z "$repo_url" ]; then + echo "Error: repo URL required" >&2 + echo "Usage: disinto init " >&2 + exit 1 fi + shift # Parse flags - local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag="" bare=false rotate_tokens=false use_build=false dry_run=false backend="docker" empty=false with_services="" - local import_env="" import_sops="" age_key="" + local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag="" bare=false rotate_tokens=false while [ $# -gt 0 ]; do case "$1" in --branch) branch="$2"; shift 2 ;; --repo-root) repo_root="$2"; shift 2 ;; --ci-id) ci_id="$2"; shift 2 ;; --forge-url) forge_url_flag="$2"; shift 2 ;; - --backend) backend="$2"; shift 2 ;; - --backend=*) backend="${1#--backend=}"; shift ;; - --with) with_services="$2"; shift 2 ;; - --with=*) with_services="${1#--with=}"; shift ;; --bare) bare=true; shift ;; - --build) use_build=true; shift ;; - --empty) empty=true; shift ;; --yes) auto_yes=true; shift ;; --rotate-tokens) rotate_tokens=true; shift ;; - --dry-run) dry_run=true; shift ;; - --import-env) import_env="$2"; shift 2 ;; - --import-env=*) import_env="${1#--import-env=}"; shift ;; - --import-sops) import_sops="$2"; shift 2 ;; - --import-sops=*) import_sops="${1#--import-sops=}"; shift ;; - --age-key) age_key="$2"; shift 2 ;; - --age-key=*) age_key="${1#--age-key=}"; shift ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac done - # Validate backend - case "$backend" in - docker|nomad) ;; - *) echo "Error: invalid --backend value '${backend}' (expected: docker|nomad)" >&2; exit 1 ;; - esac - - # Docker backend requires a repo_url positional; nomad doesn't use one. - # This check must run *after* argparse so `--backend=docker` (with no - # positional) errors with a helpful message instead of the misleading - # "Unknown option: --backend=docker". - if [ "$backend" = "docker" ] && [ -z "$repo_url" ]; then - echo "Error: repo URL required" >&2 - echo "Usage: disinto init [options]" >&2 - exit 1 - fi - - # --empty is nomad-only today (the docker path has no concept of an - # "empty cluster"). Reject explicitly rather than letting it silently - # do nothing on --backend=docker. - if [ "$empty" = true ] && [ "$backend" != "nomad" ]; then - echo "Error: --empty is only valid with --backend=nomad" >&2 - exit 1 - fi - - # --with requires --backend=nomad - if [ -n "$with_services" ] && [ "$backend" != "nomad" ]; then - echo "Error: --with requires --backend=nomad" >&2 - exit 1 - fi - - # --empty and --with are mutually exclusive - if [ "$empty" = true ] && [ -n "$with_services" ]; then - echo "Error: --empty and --with are mutually exclusive" >&2 - exit 1 - fi - - # Normalize --with services (S3.4): expand 'woodpecker' shorthand to - # 'woodpecker-server,woodpecker-agent', auto-include forgejo when - # woodpecker is requested (OAuth dependency), and validate all names. - if [ -n "$with_services" ]; then - # Expand 'woodpecker' (bare) → 'woodpecker-server,woodpecker-agent'. - # Must not match already-expanded 'woodpecker-server'/'woodpecker-agent'. - local expanded="" - local IFS=',' - for _svc in $with_services; do - _svc=$(echo "$_svc" | xargs) - case "$_svc" in - woodpecker) _svc="woodpecker-server,woodpecker-agent" ;; - agents) _svc="agents" ;; - esac - expanded="${expanded:+${expanded},}${_svc}" - done - with_services="$expanded" - unset IFS - - # Auto-include forgejo when woodpecker is requested - if echo ",$with_services," | grep -q ",woodpecker-server,\|,woodpecker-agent," \ - && ! echo ",$with_services," | grep -q ",forgejo,"; then - echo "Note: --with woodpecker implies --with forgejo (OAuth dependency)" - with_services="forgejo,${with_services}" - fi - - # Auto-include forgejo and woodpecker when agents is requested - if echo ",$with_services," | grep -q ",agents,"; then - if ! echo ",$with_services," | grep -q ",forgejo,"; then - echo "Note: --with agents implies --with forgejo (agents need forge)" - with_services="forgejo,${with_services}" - fi - if ! echo ",$with_services," | grep -q ",woodpecker-server,\|,woodpecker-agent,"; then - echo "Note: --with agents implies --with woodpecker (agents need CI)" - with_services="${with_services},woodpecker-server,woodpecker-agent" - fi - fi - - # Auto-include all dependencies when edge is requested (S5.5) - if echo ",$with_services," | grep -q ",edge,"; then - # Edge depends on all backend services - for dep in forgejo woodpecker-server woodpecker-agent agents staging chat; do - if ! echo ",$with_services," | grep -q ",${dep},"; then - echo "Note: --with edge implies --with ${dep} (edge depends on all backend services)" - with_services="${with_services},${dep}" - fi - done - fi - - # Validate all service names are known - local IFS=',' - for _svc in $with_services; do - _svc=$(echo "$_svc" | xargs) - case "$_svc" in - forgejo|woodpecker-server|woodpecker-agent|agents|staging|chat|edge) ;; - *) - echo "Error: unknown service '${_svc}' — known: forgejo, woodpecker-server, woodpecker-agent, agents, staging, chat, edge" >&2 - exit 1 - ;; - esac - done - unset IFS - fi - - # --import-* flag validation (S2.5). These three flags form an import - # triple and must be consistent before dispatch: sops encryption is - # useless without the age key to decrypt it, so either both --import-sops - # and --age-key are present or neither is. --import-env alone is fine - # (it just imports the plaintext dotenv). All three flags are nomad-only. - if [ -n "$import_sops" ] && [ -z "$age_key" ]; then - echo "Error: --import-sops requires --age-key" >&2 - exit 1 - fi - if [ -n "$age_key" ] && [ -z "$import_sops" ]; then - echo "Error: --age-key requires --import-sops" >&2 - exit 1 - fi - if { [ -n "$import_env" ] || [ -n "$import_sops" ] || [ -n "$age_key" ]; } \ - && [ "$backend" != "nomad" ]; then - echo "Error: --import-env, --import-sops, and --age-key require --backend=nomad" >&2 - exit 1 - fi - - # --empty is the cluster-only escape hatch — it skips policies, auth, - # import, and deploy. Pairing it with --import-* silently does nothing, - # which is a worse failure mode than a clear error. Reject explicitly. - if [ "$empty" = true ] \ - && { [ -n "$import_env" ] || [ -n "$import_sops" ] || [ -n "$age_key" ]; }; then - echo "Error: --empty and --import-env/--import-sops/--age-key are mutually exclusive" >&2 - exit 1 - fi - - # Dispatch on backend — the nomad path runs lib/init/nomad/cluster-up.sh - # (S0.4). The default and --empty variants are identical today; Step 1 - # will branch on $empty to add job deployment to the default path. - if [ "$backend" = "nomad" ]; then - _disinto_init_nomad "$dry_run" "$empty" "$with_services" \ - "$import_env" "$import_sops" "$age_key" - # shellcheck disable=SC2317 # _disinto_init_nomad always exits today; - # `return` is defensive against future refactors. - return - fi - # Export bare-metal flag for setup_forge export DISINTO_BARE="$bare" @@ -1351,91 +738,12 @@ p.write_text(text) fi fi - # ── Dry-run mode: report intended actions and exit ───────────────────────── - if [ "$dry_run" = true ]; then - echo "" - echo "── Dry-run: intended actions ────────────────────────────" - local env_file="${FACTORY_ROOT}/.env" - local rr="${repo_root:-/home/${USER}/${project_name}}" - - if [ "$bare" = false ]; then - [ -f "${FACTORY_ROOT}/docker-compose.yml" ] \ - && echo "[skip] docker-compose.yml (exists)" \ - || echo "[create] docker-compose.yml" - fi - - [ -f "$env_file" ] \ - && echo "[exists] .env" \ - || echo "[create] .env" - - # Report token state from .env - if [ -f "$env_file" ]; then - local _var - for _var in FORGE_ADMIN_TOKEN HUMAN_TOKEN FORGE_TOKEN FORGE_REVIEW_TOKEN \ - FORGE_PLANNER_TOKEN FORGE_GARDENER_TOKEN FORGE_VAULT_TOKEN \ - FORGE_SUPERVISOR_TOKEN FORGE_PREDICTOR_TOKEN FORGE_ARCHITECT_TOKEN; do - if grep -q "^${_var}=" "$env_file" 2>/dev/null; then - echo "[keep] ${_var} (preserved)" - else - echo "[create] ${_var}" - fi - done - else - echo "[create] all tokens and passwords" - fi - - echo "" - echo "[ensure] Forgejo admin user 'disinto-admin'" - echo "[ensure] 8 bot users: dev-bot, review-bot, planner-bot, gardener-bot, vault-bot, supervisor-bot, predictor-bot, architect-bot" - echo "[ensure] .profile repos for all bots" - echo "[ensure] repo ${forge_repo} on Forgejo with collaborators" - echo "[run] preflight checks" - - [ -d "${rr}/.git" ] \ - && echo "[skip] clone ${rr} (exists)" \ - || echo "[clone] ${repo_url} -> ${rr}" - - echo "[push] to local Forgejo" - echo "[ensure] ops repo disinto-admin/${project_name}-ops" - echo "[ensure] branch protection on ${forge_repo}" - - [ "$toml_exists" = true ] \ - && echo "[skip] ${toml_path} (exists)" \ - || echo "[create] ${toml_path}" - - if [ "$bare" = false ]; then - echo "[ensure] Woodpecker OAuth2 app" - echo "[ensure] Chat OAuth2 app" - echo "[ensure] WOODPECKER_AGENT_SECRET in .env" - fi - - echo "[ensure] labels on ${forge_repo}" - - [ -f "${rr}/VISION.md" ] \ - && echo "[skip] VISION.md (exists)" \ - || echo "[create] VISION.md" - - echo "[copy] issue templates" - echo "[ensure] scheduling (cron or compose polling)" - - if [ "$bare" = false ]; then - echo "[start] docker compose stack" - echo "[ensure] Woodpecker token + repo activation" - fi - - echo "[ensure] CLAUDE_CONFIG_DIR" - echo "[ensure] state files (.dev-active, .reviewer-active, .gardener-active)" - echo "" - echo "Dry run complete — no changes made." - exit 0 - fi - # Generate compose files (unless --bare) if [ "$bare" = false ]; then local forge_port forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|') forge_port="${forge_port:-3000}" - generate_compose "$forge_port" "$use_build" + generate_compose "$forge_port" generate_agent_docker generate_caddyfile generate_staging_index @@ -1810,6 +1118,8 @@ disinto_secrets() { local subcmd="${1:-}" local enc_file="${FACTORY_ROOT}/.env.enc" local env_file="${FACTORY_ROOT}/.env" + local vault_enc_file="${FACTORY_ROOT}/.env.vault.enc" + local vault_env_file="${FACTORY_ROOT}/.env.vault" # Shared helper: ensure sops+age and .sops.yaml exist _secrets_ensure_sops() { @@ -1855,51 +1165,30 @@ disinto_secrets() { case "$subcmd" in add) - # Parse flags - local force=false - shift # consume 'add' - while [ $# -gt 0 ]; do - case "$1" in - -f|--force) force=true; shift ;; - -*) echo "Unknown flag: $1" >&2; exit 1 ;; - *) break ;; - esac - done - local name="${1:-}" + local name="${2:-}" if [ -z "$name" ]; then - echo "Usage: disinto secrets add [-f|--force] " >&2 + echo "Usage: disinto secrets add " >&2 exit 1 fi _secrets_ensure_age_key mkdir -p "$secrets_dir" + printf 'Enter value for %s: ' "$name" >&2 local value - if [ -t 0 ]; then - # Interactive TTY — prompt with hidden input (original behavior) - printf 'Enter value for %s: ' "$name" >&2 - IFS= read -rs value - echo >&2 - else - # Piped/redirected stdin — read raw bytes verbatim - IFS= read -r -d '' value || true - fi + IFS= read -rs value + echo >&2 if [ -z "$value" ]; then echo "Error: empty value" >&2 exit 1 fi local enc_path="${secrets_dir}/${name}.enc" - if [ -f "$enc_path" ] && [ "$force" = false ]; then - if [ -t 0 ]; then - printf 'Secret %s already exists. Overwrite? [y/N] ' "$name" >&2 - local confirm - read -r confirm - if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then - echo "Aborted." >&2 - exit 1 - fi - else - echo "Error: secret ${name} already exists (use -f to overwrite)" >&2 + if [ -f "$enc_path" ]; then + printf 'Secret %s already exists. Overwrite? [y/N] ' "$name" >&2 + local confirm + read -r confirm + if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then + echo "Aborted." >&2 exit 1 fi fi @@ -1932,37 +1221,6 @@ disinto_secrets() { sops -d "$enc_file" fi ;; - remove) - local name="${2:-}" - if [ -z "$name" ]; then - echo "Usage: disinto secrets remove " >&2 - exit 1 - fi - local enc_path="${secrets_dir}/${name}.enc" - if [ ! -f "$enc_path" ]; then - echo "Error: ${enc_path} not found" >&2 - exit 1 - fi - rm -f "$enc_path" - echo "Removed: ${enc_path}" - ;; - list) - if [ ! -d "$secrets_dir" ]; then - echo "No secrets directory found." >&2 - exit 0 - fi - local found=false - for enc_file_path in "${secrets_dir}"/*.enc; do - [ -f "$enc_file_path" ] || continue - found=true - local secret_name - secret_name=$(basename "$enc_file_path" .enc) - echo "$secret_name" - done - if [ "$found" = false ]; then - echo "No secrets stored." >&2 - fi - ;; edit) if [ ! -f "$enc_file" ]; then echo "Error: ${enc_file} not found. Run 'disinto secrets migrate' first." >&2 @@ -1986,100 +1244,54 @@ disinto_secrets() { rm -f "$env_file" echo "Migrated: .env -> .env.enc (plaintext removed)" ;; - migrate-from-vault) - # One-shot migration: split .env.vault.enc into secrets/.enc files (#777) - local vault_enc_file="${FACTORY_ROOT}/.env.vault.enc" - local vault_env_file="${FACTORY_ROOT}/.env.vault" - local source_file="" - - if [ -f "$vault_enc_file" ] && command -v sops &>/dev/null; then - source_file="$vault_enc_file" - elif [ -f "$vault_env_file" ]; then - source_file="$vault_env_file" - else - echo "Error: neither .env.vault.enc nor .env.vault found — nothing to migrate." >&2 + edit-vault) + if [ ! -f "$vault_enc_file" ]; then + echo "Error: ${vault_enc_file} not found. Run 'disinto secrets migrate-vault' first." >&2 exit 1 fi - - _secrets_ensure_age_key - mkdir -p "$secrets_dir" - - # Decrypt vault to temp dotenv - local tmp_dotenv - tmp_dotenv=$(mktemp /tmp/disinto-vault-migrate-XXXXXX) - trap 'rm -f "$tmp_dotenv"' RETURN - - if [ "$source_file" = "$vault_enc_file" ]; then - if ! sops -d --output-type dotenv "$vault_enc_file" > "$tmp_dotenv" 2>/dev/null; then - rm -f "$tmp_dotenv" - echo "Error: failed to decrypt .env.vault.enc" >&2 - exit 1 - fi - else - cp "$vault_env_file" "$tmp_dotenv" - fi - - # Parse each KEY=VALUE and encrypt into secrets/.enc - local count=0 - local failed=0 - while IFS='=' read -r key value; do - # Skip empty lines and comments - [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue - # Trim whitespace from key - key=$(echo "$key" | xargs) - [ -z "$key" ] && continue - - local enc_path="${secrets_dir}/${key}.enc" - if printf '%s' "$value" | age -r "$AGE_PUBLIC_KEY" -o "$enc_path" 2>/dev/null; then - # Verify round-trip - local check - check=$(age -d -i "$age_key_file" "$enc_path" 2>/dev/null) || { failed=$((failed + 1)); echo " FAIL (verify): ${key}" >&2; continue; } - if [ "$check" = "$value" ]; then - echo " OK: ${key} -> secrets/${key}.enc" - count=$((count + 1)) - else - echo " FAIL (mismatch): ${key}" >&2 - failed=$((failed + 1)) - fi - else - echo " FAIL (encrypt): ${key}" >&2 - failed=$((failed + 1)) - fi - done < "$tmp_dotenv" - - rm -f "$tmp_dotenv" - - if [ "$failed" -gt 0 ]; then - echo "Error: ${failed} secret(s) failed migration. Vault files NOT removed." >&2 + sops "$vault_enc_file" + ;; + show-vault) + if [ ! -f "$vault_enc_file" ]; then + echo "Error: ${vault_enc_file} not found." >&2 exit 1 fi - - if [ "$count" -eq 0 ]; then - echo "Warning: no secrets found in vault file." >&2 - else - echo "Migrated ${count} secret(s) to secrets/*.enc" - # Remove old vault files on success - rm -f "$vault_enc_file" "$vault_env_file" - echo "Removed: .env.vault.enc / .env.vault" + sops -d "$vault_enc_file" + ;; + migrate-vault) + if [ ! -f "$vault_env_file" ]; then + echo "Error: ${vault_env_file} not found — nothing to migrate." >&2 + echo " Create .env.vault with vault secrets (GITHUB_TOKEN, deploy keys, etc.)" >&2 + exit 1 fi + _secrets_ensure_sops + encrypt_env_file "$vault_env_file" "$vault_enc_file" + # Verify decryption works before removing plaintext + if ! sops -d "$vault_enc_file" >/dev/null 2>&1; then + echo "Error: failed to verify .env.vault.enc decryption" >&2 + rm -f "$vault_enc_file" + exit 1 + fi + rm -f "$vault_env_file" + echo "Migrated: .env.vault -> .env.vault.enc (plaintext removed)" ;; *) cat <&2 Usage: disinto secrets -Secrets (secrets/.enc — age-encrypted, one file per key): - add Prompt for value, encrypt, store in secrets/.enc - show Decrypt and print a secret - remove Remove a secret - list List all stored secrets +Individual secrets (secrets/.enc): + add Prompt for value, encrypt, store in secrets/.enc + show Decrypt and print an individual secret -Agent secrets (.env.enc — sops-encrypted dotenv): - edit Edit agent secrets (FORGE_TOKEN, CLAUDE_API_KEY, etc.) - show Show decrypted agent secrets (no argument) - migrate Encrypt .env -> .env.enc +Agent secrets (.env.enc): + edit Edit agent secrets (FORGE_TOKEN, CLAUDE_API_KEY, etc.) + show Show decrypted agent secrets (no argument) + migrate Encrypt .env -> .env.enc -Migration: - migrate-from-vault Split .env.vault.enc into secrets/.enc (one-shot) +Vault secrets (.env.vault.enc): + edit-vault Edit vault secrets (GITHUB_TOKEN, deploy keys, etc.) + show-vault Show decrypted vault secrets + migrate-vault Encrypt .env.vault -> .env.vault.enc EOF exit 1 ;; @@ -2091,8 +1303,7 @@ EOF disinto_run() { local action_id="${1:?Usage: disinto run }" local compose_file="${FACTORY_ROOT}/docker-compose.yml" - local secrets_dir="${FACTORY_ROOT}/secrets" - local age_key_file="${HOME}/.config/sops/age/keys.txt" + local vault_enc="${FACTORY_ROOT}/.env.vault.enc" if [ ! -f "$compose_file" ]; then echo "Error: docker-compose.yml not found" >&2 @@ -2100,42 +1311,29 @@ disinto_run() { exit 1 fi - if [ ! -d "$secrets_dir" ]; then - echo "Error: secrets/ directory not found — create secrets first" >&2 - echo " Run 'disinto secrets add ' to add secrets" >&2 + if [ ! -f "$vault_enc" ]; then + echo "Error: .env.vault.enc not found — create vault secrets first" >&2 + echo " Run 'disinto secrets migrate-vault' after creating .env.vault" >&2 exit 1 fi - if ! command -v age &>/dev/null; then - echo "Error: age not found — required to decrypt secrets" >&2 + if ! command -v sops &>/dev/null; then + echo "Error: sops not found — required to decrypt vault secrets" >&2 exit 1 fi - if [ ! -f "$age_key_file" ]; then - echo "Error: age key not found at ${age_key_file}" >&2 - exit 1 - fi - - # Decrypt all secrets/*.enc into a temp env file for the runner + # Decrypt vault secrets to temp file local tmp_env - tmp_env=$(mktemp /tmp/disinto-secrets-XXXXXX) + tmp_env=$(mktemp /tmp/disinto-vault-XXXXXX) trap 'rm -f "$tmp_env"' EXIT - local count=0 - for enc_path in "${secrets_dir}"/*.enc; do - [ -f "$enc_path" ] || continue - local key - key=$(basename "$enc_path" .enc) - local val - val=$(age -d -i "$age_key_file" "$enc_path" 2>/dev/null) || { - echo "Warning: failed to decrypt ${enc_path}" >&2 - continue - } - printf '%s=%s\n' "$key" "$val" >> "$tmp_env" - count=$((count + 1)) - done + if ! sops -d --output-type dotenv "$vault_enc" > "$tmp_env" 2>/dev/null; then + rm -f "$tmp_env" + echo "Error: failed to decrypt .env.vault.enc" >&2 + exit 1 + fi - echo "Decrypted ${count} secret(s) to tmpfile" + echo "Vault secrets decrypted to tmpfile" # Run action in ephemeral runner container local rc=0 @@ -2206,211 +1404,21 @@ download_agent_binaries() { # ── up command ──────────────────────────────────────────────────────────────── -# Regenerate a file idempotently: run the generator, compare output, backup if changed. -# Usage: _regen_file [args...] -_regen_file() { - local target="$1"; shift - local generator="$1"; shift - local basename - basename=$(basename "$target") - - # Move existing file aside so the generator (which skips if file exists) - # produces a fresh copy. - local stashed="" - if [ -f "$target" ]; then - stashed=$(mktemp "${target}.stash.XXXXXX") - mv "$target" "$stashed" - fi - - # Run the generator — it writes $target from scratch. - # If the generator fails, restore the stashed original so it is not stranded. - if ! "$generator" "$@"; then - if [ -n "$stashed" ]; then - mv "$stashed" "$target" - fi - return 1 - fi - - if [ -z "$stashed" ]; then - # No previous file — first generation - echo "regenerated: ${basename} (new)" - return - fi - - if cmp -s "$stashed" "$target"; then - # Content unchanged — restore original to preserve mtime - mv "$stashed" "$target" - echo "unchanged: ${basename}" - else - # Content changed — keep new, save old as .prev - mv "$stashed" "${target}.prev" - echo "regenerated: ${basename} (previous saved as ${basename}.prev)" - fi -} - -# Validate that required environment variables are present for all services -# that reference them in docker-compose.yml -_validate_env_vars() { - local env_file="${FACTORY_ROOT}/.env" - local errors=0 - local -a missing_vars=() - - # Load env vars from .env file into associative array - declare -A env_vars - if [ -f "$env_file" ]; then - while IFS='=' read -r key value; do - # Skip empty lines and comments - [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue - env_vars["$key"]="$value" - done < "$env_file" - fi - - # Check for local-model agent services - # Each [agents.*] section in projects/*.toml requires: - # - FORGE_TOKEN_ - # - FORGE_PASS_ - # - ANTHROPIC_BASE_URL (local model) OR ANTHROPIC_API_KEY (Anthropic backend) - - # Parse projects/*.toml for [agents.*] sections - local projects_dir="${FACTORY_ROOT}/projects" - for toml in "${projects_dir}"/*.toml; do - [ -f "$toml" ] || continue - - # Extract agent config using Python - while IFS='|' read -r service_name forge_user base_url _api_key; do - [ -n "$service_name" ] || continue - [ -n "$forge_user" ] || continue - - # Derive variable names (user -> USER_UPPER) - local user_upper - user_upper=$(echo "$forge_user" | tr 'a-z-' 'A-Z_') - local token_var="FORGE_TOKEN_${user_upper}" - local pass_var="FORGE_PASS_${user_upper}" - - # Check token - if [ -z "${env_vars[$token_var]:-}" ]; then - missing_vars+=("$token_var (for agent ${service_name}/${forge_user})") - errors=$((errors + 1)) - fi - - # Check password - if [ -z "${env_vars[$pass_var]:-}" ]; then - missing_vars+=("$pass_var (for agent ${service_name}/${forge_user})") - errors=$((errors + 1)) - fi - - # Check backend URL or API key (conditional based on base_url presence) - if [ -n "$base_url" ]; then - # Local model: needs ANTHROPIC_BASE_URL - if [ -z "${env_vars[ANTHROPIC_BASE_URL]:-}" ]; then - missing_vars+=("ANTHROPIC_BASE_URL (for agent ${service_name})") - errors=$((errors + 1)) - fi - else - # Anthropic backend: needs ANTHROPIC_API_KEY - if [ -z "${env_vars[ANTHROPIC_API_KEY]:-}" ]; then - missing_vars+=("ANTHROPIC_API_KEY (for agent ${service_name})") - errors=$((errors + 1)) - fi - fi - - done < <(python3 -c ' -import sys, tomllib, re - -with open(sys.argv[1], "rb") as f: - cfg = tomllib.load(f) - -agents = cfg.get("agents", {}) -for name, config in agents.items(): - if not isinstance(config, dict): - continue - - base_url = config.get("base_url", "") - model = config.get("model", "") - api_key = config.get("api_key", "") - forge_user = config.get("forge_user", f"{name}-bot") - - safe_name = name.lower() - safe_name = re.sub(r"[^a-z0-9]", "-", safe_name) - - print(f"{safe_name}|{forge_user}|{base_url}|{api_key}") -' "$toml" 2>/dev/null) - done - - # Check for legacy ENABLE_LLAMA_AGENT services - if [ "${env_vars[ENABLE_LLAMA_AGENT]:-0}" = "1" ]; then - if [ -z "${env_vars[FORGE_TOKEN_LLAMA]:-}" ]; then - missing_vars+=("FORGE_TOKEN_LLAMA (ENABLE_LLAMA_AGENT=1)") - errors=$((errors + 1)) - fi - if [ -z "${env_vars[FORGE_PASS_LLAMA]:-}" ]; then - missing_vars+=("FORGE_PASS_LLAMA (ENABLE_LLAMA_AGENT=1)") - errors=$((errors + 1)) - fi - fi - - if [ "$errors" -gt 0 ]; then - echo "Error: missing required environment variables:" >&2 - for var in "${missing_vars[@]}"; do - echo " - $var" >&2 - done - echo "" >&2 - echo "Run 'disinto hire-an-agent ' to create the agent and write credentials to .env" >&2 - exit 1 - fi -} - disinto_up() { local compose_file="${FACTORY_ROOT}/docker-compose.yml" - local caddyfile="${FACTORY_ROOT}/docker/Caddyfile" if [ ! -f "$compose_file" ]; then echo "Error: docker-compose.yml not found" >&2 echo " Run 'disinto init ' first (without --bare)" >&2 exit 1 fi - # Validate environment variables before proceeding - _validate_env_vars - - # Parse --no-regen flag; remaining args pass through to docker compose - local no_regen=false - local -a compose_args=() - for arg in "$@"; do - case "$arg" in - --no-regen) no_regen=true ;; - *) compose_args+=("$arg") ;; - esac - done - - # ── Regenerate compose & Caddyfile from generators ────────────────────── - if [ "$no_regen" = true ]; then - echo "Warning: running with unmanaged compose — hand-edits will drift" >&2 - else - # Determine forge_port from FORGE_URL (same logic as init) - local forge_url="${FORGE_URL:-http://localhost:3000}" - local forge_port - forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|') - forge_port="${forge_port:-3000}" - - # Detect build mode from existing compose - local use_build=false - if grep -q '^\s*build:' "$compose_file"; then - use_build=true - fi - - _regen_file "$compose_file" generate_compose "$forge_port" "$use_build" - _regen_file "$caddyfile" generate_caddyfile - fi - - # Pre-build: download binaries only when compose uses local build - if grep -q '^\s*build:' "$compose_file"; then - echo "── Pre-build: downloading agent binaries ────────────────────────" - if ! download_agent_binaries; then - echo "Error: failed to download agent binaries" >&2 - exit 1 - fi - echo "" + # Pre-build: download binaries to docker/agents/bin/ to avoid network calls during docker build + echo "── Pre-build: downloading agent binaries ────────────────────────" + if ! download_agent_binaries; then + echo "Error: failed to download agent binaries" >&2 + exit 1 fi + echo "" # Decrypt secrets to temp .env if SOPS available and .env.enc exists local tmp_env="" @@ -2423,7 +1431,7 @@ disinto_up() { echo "Decrypted secrets for compose" fi - docker compose -f "$compose_file" up -d --build --remove-orphans ${compose_args[@]+"${compose_args[@]}"} + docker compose -f "$compose_file" up -d "$@" echo "Stack is up" # Clean up temp .env (also handled by EXIT trap if compose fails) diff --git a/dev/AGENTS.md b/dev/AGENTS.md index ff529af..abeb619 100644 --- a/dev/AGENTS.md +++ b/dev/AGENTS.md @@ -1,4 +1,4 @@ - + # Dev Agent **Role**: Implement issues autonomously — write code, push branches, address diff --git a/dev/dev-agent.sh b/dev/dev-agent.sh index 913a2a7..cd8d390 100755 --- a/dev/dev-agent.sh +++ b/dev/dev-agent.sh @@ -254,11 +254,7 @@ agent_recover_session # WORKTREE SETUP # ============================================================================= status "setting up worktree" -if ! cd "$REPO_ROOT"; then - log "ERROR: REPO_ROOT=${REPO_ROOT} does not exist — cannot cd" - log "Check PROJECT_REPO_ROOT vs compose PROJECT_NAME vs TOML name mismatch" - exit 1 -fi +cd "$REPO_ROOT" # Determine forge remote by matching FORGE_URL host against git remotes _forge_host=$(printf '%s' "$FORGE_URL" | sed 's|https\?://||; s|/.*||') diff --git a/docker-compose.yml b/docker-compose.yml index c4676f2..65a7f58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,9 +14,10 @@ services: - agent-data:/home/agent/data - project-repos:/home/agent/repos - ${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}:${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared} - - ${CLAUDE_CONFIG_FILE:-${HOME}/.claude.json}:/home/agent/.claude.json:ro - - ${AGENT_SSH_DIR:-${HOME}/.ssh}:/home/agent/.ssh:ro - - ${SOPS_AGE_DIR:-${HOME}/.config/sops/age}:/home/agent/.config/sops/age:ro + - ${HOME}/.claude.json:/home/agent/.claude.json:ro + - CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro + - ${HOME}/.ssh:/home/agent/.ssh:ro + - ${HOME}/.config/sops/age:/home/agent/.config/sops/age:ro - woodpecker-data:/woodpecker-data:ro environment: - FORGE_URL=http://forgejo:3000 @@ -48,13 +49,6 @@ services: - GARDENER_INTERVAL=${GARDENER_INTERVAL:-21600} - ARCHITECT_INTERVAL=${ARCHITECT_INTERVAL:-21600} - PLANNER_INTERVAL=${PLANNER_INTERVAL:-43200} - - SUPERVISOR_INTERVAL=${SUPERVISOR_INTERVAL:-1200} - healthcheck: - test: ["CMD", "pgrep", "-f", "entrypoint.sh"] - interval: 60s - timeout: 5s - retries: 3 - start_period: 30s depends_on: forgejo: condition: service_healthy @@ -76,9 +70,10 @@ services: - agent-data:/home/agent/data - project-repos:/home/agent/repos - ${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}:${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared} - - ${CLAUDE_CONFIG_FILE:-${HOME}/.claude.json}:/home/agent/.claude.json:ro - - ${AGENT_SSH_DIR:-${HOME}/.ssh}:/home/agent/.ssh:ro - - ${SOPS_AGE_DIR:-${HOME}/.config/sops/age}:/home/agent/.config/sops/age:ro + - ${HOME}/.claude.json:/home/agent/.claude.json:ro + - CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro + - ${HOME}/.ssh:/home/agent/.ssh:ro + - ${HOME}/.config/sops/age:/home/agent/.config/sops/age:ro - woodpecker-data:/woodpecker-data:ro environment: - FORGE_URL=http://forgejo:3000 @@ -108,79 +103,6 @@ services: - CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR:-/var/lib/disinto/claude-shared/config} - POLL_INTERVAL=${POLL_INTERVAL:-300} - AGENT_ROLES=dev - healthcheck: - test: ["CMD", "pgrep", "-f", "entrypoint.sh"] - interval: 60s - timeout: 5s - retries: 3 - start_period: 30s - depends_on: - forgejo: - condition: service_healthy - woodpecker: - condition: service_started - networks: - - disinto-net - - agents-llama-all: - build: - context: . - dockerfile: docker/agents/Dockerfile - image: disinto/agents-llama:latest - container_name: disinto-agents-llama-all - restart: unless-stopped - profiles: ["agents-llama-all"] - security_opt: - - apparmor=unconfined - volumes: - - agent-data:/home/agent/data - - project-repos:/home/agent/repos - - ${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}:${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared} - - ${CLAUDE_CONFIG_FILE:-${HOME}/.claude.json}:/home/agent/.claude.json:ro - - ${AGENT_SSH_DIR:-${HOME}/.ssh}:/home/agent/.ssh:ro - - ${SOPS_AGE_DIR:-${HOME}/.config/sops/age}:/home/agent/.config/sops/age:ro - - woodpecker-data:/woodpecker-data:ro - environment: - - FORGE_URL=http://forgejo:3000 - - FORGE_REPO=${FORGE_REPO:-disinto-admin/disinto} - - FORGE_TOKEN=${FORGE_TOKEN_LLAMA:-} - - FORGE_PASS=${FORGE_PASS_LLAMA:-} - - FORGE_REVIEW_TOKEN=${FORGE_REVIEW_TOKEN:-} - - FORGE_PLANNER_TOKEN=${FORGE_PLANNER_TOKEN:-} - - FORGE_GARDENER_TOKEN=${FORGE_GARDENER_TOKEN:-} - - FORGE_VAULT_TOKEN=${FORGE_VAULT_TOKEN:-} - - FORGE_SUPERVISOR_TOKEN=${FORGE_SUPERVISOR_TOKEN:-} - - FORGE_PREDICTOR_TOKEN=${FORGE_PREDICTOR_TOKEN:-} - - FORGE_ARCHITECT_TOKEN=${FORGE_ARCHITECT_TOKEN:-} - - FORGE_FILER_TOKEN=${FORGE_FILER_TOKEN:-} - - FORGE_BOT_USERNAMES=${FORGE_BOT_USERNAMES:-} - - WOODPECKER_TOKEN=${WOODPECKER_TOKEN:-} - - CLAUDE_TIMEOUT=${CLAUDE_TIMEOUT:-7200} - - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=${CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:-1} - - CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=60 - - CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - - ANTHROPIC_BASE_URL=${ANTHROPIC_BASE_URL:-} - - FORGE_ADMIN_PASS=${FORGE_ADMIN_PASS:-} - - DISINTO_CONTAINER=1 - - PROJECT_TOML=projects/disinto.toml - - PROJECT_NAME=${PROJECT_NAME:-project} - - PROJECT_REPO_ROOT=/home/agent/repos/${PROJECT_NAME:-project} - - WOODPECKER_DATA_DIR=/woodpecker-data - - WOODPECKER_REPO_ID=${WOODPECKER_REPO_ID:-} - - CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR:-/var/lib/disinto/claude-shared/config} - - POLL_INTERVAL=${POLL_INTERVAL:-300} - - GARDENER_INTERVAL=${GARDENER_INTERVAL:-21600} - - ARCHITECT_INTERVAL=${ARCHITECT_INTERVAL:-21600} - - PLANNER_INTERVAL=${PLANNER_INTERVAL:-43200} - - SUPERVISOR_INTERVAL=${SUPERVISOR_INTERVAL:-1200} - - AGENT_ROLES=review,dev,gardener,architect,planner,predictor,supervisor - healthcheck: - test: ["CMD", "pgrep", "-f", "entrypoint.sh"] - interval: 60s - timeout: 5s - retries: 3 - start_period: 30s depends_on: forgejo: condition: service_healthy @@ -200,9 +122,9 @@ services: - /var/run/docker.sock:/var/run/docker.sock - agent-data:/home/agent/data - project-repos:/home/agent/repos - - ${CLAUDE_DIR:-${HOME}/.claude}:/home/agent/.claude - - ${CLAUDE_BIN_DIR:-/usr/local/bin/claude}:/usr/local/bin/claude:ro - - ${AGENT_SSH_DIR:-${HOME}/.ssh}:/home/agent/.ssh:ro + - ${HOME}/.claude:/home/agent/.claude + - /usr/local/bin/claude:/usr/local/bin/claude:ro + - ${HOME}/.ssh:/home/agent/.ssh:ro env_file: - .env @@ -216,9 +138,9 @@ services: - apparmor=unconfined volumes: - /var/run/docker.sock:/var/run/docker.sock - - ${CLAUDE_BIN_DIR:-/usr/local/bin/claude}:/usr/local/bin/claude:ro - - ${CLAUDE_CONFIG_FILE:-${HOME}/.claude.json}:/root/.claude.json:ro - - ${CLAUDE_DIR:-${HOME}/.claude}:/root/.claude:ro + - /usr/local/bin/claude:/usr/local/bin/claude:ro + - ${HOME}/.claude.json:/root/.claude.json:ro + - ${HOME}/.claude:/root/.claude:ro - disinto-logs:/opt/disinto-logs environment: - FORGE_SUPERVISOR_TOKEN=${FORGE_SUPERVISOR_TOKEN:-} @@ -234,12 +156,6 @@ services: ports: - "80:80" - "443:443" - healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:2019/config/"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 15s depends_on: - forgejo networks: diff --git a/docker/agents/Dockerfile b/docker/agents/Dockerfile index fa3b2d8..78fbbf6 100644 --- a/docker/agents/Dockerfile +++ b/docker/agents/Dockerfile @@ -1,26 +1,21 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ - bash curl git jq tmux nodejs npm python3 python3-pip openssh-client ca-certificates age shellcheck procps gosu \ - && pip3 install --break-system-packages networkx tomlkit \ + bash curl git jq tmux python3 python3-pip openssh-client ca-certificates age shellcheck procps gosu \ + && pip3 install --break-system-packages networkx \ && rm -rf /var/lib/apt/lists/* # Pre-built binaries (copied from docker/agents/bin/) # SOPS — encrypted data decryption tool -# Download sops binary (replaces manual COPY of vendored binary) -ARG SOPS_VERSION=3.9.4 -RUN curl -fsSL "https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" \ - -o /usr/local/bin/sops && chmod +x /usr/local/bin/sops +COPY docker/agents/bin/sops /usr/local/bin/sops +RUN chmod +x /usr/local/bin/sops # tea CLI — official Gitea/Forgejo CLI for issue/label/comment operations -# Download tea binary (replaces manual COPY of vendored binary) -ARG TEA_VERSION=0.9.2 -RUN curl -fsSL "https://dl.gitea.com/tea/${TEA_VERSION}/tea-${TEA_VERSION}-linux-amd64" \ - -o /usr/local/bin/tea && chmod +x /usr/local/bin/tea +COPY docker/agents/bin/tea /usr/local/bin/tea +RUN chmod +x /usr/local/bin/tea -# Install Claude Code CLI — agent runtime for all LLM backends (llama, Claude API). -# The CLI is the execution environment; ANTHROPIC_BASE_URL selects the model provider. -RUN npm install -g @anthropic-ai/claude-code@2.1.84 +# Claude CLI is mounted from the host via docker-compose volume. +# No internet access to cli.anthropic.com required at build time. # Non-root user RUN useradd -m -u 1000 -s /bin/bash agent @@ -33,9 +28,6 @@ RUN chmod +x /entrypoint.sh # Entrypoint runs polling loop directly, dropping to agent user via gosu. # All scripts execute as the agent user (UID 1000) while preserving env vars. -VOLUME /home/agent/data -VOLUME /home/agent/repos - WORKDIR /home/agent/disinto ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/agents/entrypoint.sh b/docker/agents/entrypoint.sh index 7c58674..9df6d01 100644 --- a/docker/agents/entrypoint.sh +++ b/docker/agents/entrypoint.sh @@ -7,47 +7,14 @@ set -euo pipefail # poll scripts. All Docker Compose env vars are inherited (PATH, FORGE_TOKEN, # ANTHROPIC_API_KEY, etc.). # -# AGENT_ROLES env var controls which scripts run: "review,dev,gardener,architect,planner,predictor,supervisor" -# (default: all seven). Uses while-true loop with staggered intervals: +# AGENT_ROLES env var controls which scripts run: "review,dev,gardener,architect,planner,predictor" +# (default: all six). Uses while-true loop with staggered intervals: # - review-poll: every 5 minutes (offset by 0s) # - dev-poll: every 5 minutes (offset by 2 minutes) # - gardener: every GARDENER_INTERVAL seconds (default: 21600 = 6 hours) # - architect: every ARCHITECT_INTERVAL seconds (default: 21600 = 6 hours) # - planner: every PLANNER_INTERVAL seconds (default: 43200 = 12 hours) # - predictor: every 24 hours (288 iterations * 5 min) -# - supervisor: every SUPERVISOR_INTERVAL seconds (default: 1200 = 20 min) - -# ── Migration check: reject ENABLE_LLAMA_AGENT ─────────────────────────────── -# #846: The legacy ENABLE_LLAMA_AGENT env flag is no longer supported. -# Activation is now done exclusively via [agents.X] sections in project TOML. -# If this legacy flag is detected, fail immediately with a migration message. -if [ "${ENABLE_LLAMA_AGENT:-}" = "1" ]; then - cat <<'MIGRATION_ERR' -FATAL: ENABLE_LLAMA_AGENT is no longer supported. - -The legacy ENABLE_LLAMA_AGENT=1 flag has been removed (#846). -Activation is now done exclusively via [agents.X] sections in projects/*.toml. - -To migrate: - 1. Remove ENABLE_LLAMA_AGENT from your .env or .env.enc file - 2. Add an [agents.] section to your project TOML: - - [agents.dev-qwen] - base_url = "http://your-llama-server:8081" - model = "unsloth/Qwen3.5-35B-A3B" - api_key = "sk-no-key-required" - roles = ["dev"] - forge_user = "dev-qwen" - compact_pct = 60 - poll_interval = 60 - - 3. Run: disinto init - 4. Start the agent: docker compose up -d agents-dev-qwen - -See docs/agents-llama.md for full details. -MIGRATION_ERR - exit 1 -fi DISINTO_BAKED="/home/agent/disinto" DISINTO_LIVE="/home/agent/repos/_factory" @@ -347,24 +314,6 @@ _setup_git_creds configure_git_identity configure_tea_login -# Parse first available project TOML to get the project name for cloning. -# This ensures PROJECT_NAME matches the TOML 'name' field, not the compose -# default of 'project'. The clone will land at /home/agent/repos/ -# and subsequent env exports in the main loop will be consistent. -if compgen -G "${DISINTO_DIR}/projects/*.toml" >/dev/null 2>&1; then - _first_toml=$(compgen -G "${DISINTO_DIR}/projects/*.toml" | head -1) - _pname=$(python3 -c " -import sys, tomllib -with open(sys.argv[1], 'rb') as f: - print(tomllib.load(f).get('name', '')) -" "$_first_toml" 2>/dev/null) || _pname="" - if [ -n "$_pname" ]; then - export PROJECT_NAME="$_pname" - export PROJECT_REPO_ROOT="/home/agent/repos/${_pname}" - log "Parsed PROJECT_NAME=${PROJECT_NAME} from ${_first_toml}" - fi -fi - # Clone project repo on first run (makes agents self-healing, #589) ensure_project_clone @@ -374,35 +323,12 @@ bootstrap_ops_repos # Bootstrap factory repo — switch DISINTO_DIR to live checkout (#593) bootstrap_factory_repo -# Validate that projects directory has at least one real .toml file (not .example) -# This prevents the silent-zombie mode where the polling loop matches zero files -# and does nothing forever. -validate_projects_dir() { - # NOTE: compgen -G exits non-zero when no matches exist, so piping it through - # `wc -l` under `set -eo pipefail` aborts the script before the FATAL branch - # can log a diagnostic (#877). Use the conditional form already adopted at - # lines above (see bootstrap_factory_repo, PROJECT_NAME parsing). - if ! compgen -G "${DISINTO_DIR}/projects/*.toml" >/dev/null 2>&1; then - log "FATAL: No real .toml files found in ${DISINTO_DIR}/projects/" - log "Expected at least one project config file (e.g., disinto.toml)" - log "The directory only contains *.toml.example template files." - log "Mount the host ./projects volume or copy real .toml files into the container." - exit 1 - fi - local toml_count - toml_count=$(compgen -G "${DISINTO_DIR}/projects/*.toml" | wc -l) - log "Projects directory validated: ${toml_count} real .toml file(s) found" -} - # Initialize state directory for check_active guards init_state_dir -# Validate projects directory before entering polling loop -validate_projects_dir - # Parse AGENT_ROLES env var (default: all agents) # Expected format: comma-separated list like "review,dev,gardener" -AGENT_ROLES="${AGENT_ROLES:-review,dev,gardener,architect,planner,predictor,supervisor}" +AGENT_ROLES="${AGENT_ROLES:-review,dev,gardener,architect,planner,predictor}" log "Agent roles configured: ${AGENT_ROLES}" # Poll interval in seconds (5 minutes default) @@ -412,10 +338,9 @@ POLL_INTERVAL="${POLL_INTERVAL:-300}" GARDENER_INTERVAL="${GARDENER_INTERVAL:-21600}" ARCHITECT_INTERVAL="${ARCHITECT_INTERVAL:-21600}" PLANNER_INTERVAL="${PLANNER_INTERVAL:-43200}" -SUPERVISOR_INTERVAL="${SUPERVISOR_INTERVAL:-1200}" log "Entering polling loop (interval: ${POLL_INTERVAL}s, roles: ${AGENT_ROLES})" -log "Gardener interval: ${GARDENER_INTERVAL}s, Architect interval: ${ARCHITECT_INTERVAL}s, Planner interval: ${PLANNER_INTERVAL}s, Supervisor interval: ${SUPERVISOR_INTERVAL}s" +log "Gardener interval: ${GARDENER_INTERVAL}s, Architect interval: ${ARCHITECT_INTERVAL}s, Planner interval: ${PLANNER_INTERVAL}s" # Main polling loop using iteration counter for gardener scheduling iteration=0 @@ -538,19 +463,6 @@ print(cfg.get('primary_branch', 'main')) fi fi fi - - # Supervisor (interval configurable via SUPERVISOR_INTERVAL env var, default 20 min) - if [[ ",${AGENT_ROLES}," == *",supervisor,"* ]]; then - supervisor_iteration=$((iteration * POLL_INTERVAL)) - if [ $((supervisor_iteration % SUPERVISOR_INTERVAL)) -eq 0 ] && [ "$now" -ge "$supervisor_iteration" ]; then - if ! pgrep -f "supervisor-run.sh" >/dev/null; then - log "Running supervisor (iteration ${iteration}, ${SUPERVISOR_INTERVAL}s interval) for ${toml}" - gosu agent bash -c "cd ${DISINTO_DIR} && bash supervisor/supervisor-run.sh \"${toml}\"" >> "${DISINTO_LOG_DIR}/supervisor.log" 2>&1 & - else - log "Skipping supervisor — already running" - fi - fi - fi done sleep "${POLL_INTERVAL}" diff --git a/docker/chat/Dockerfile b/docker/chat/Dockerfile index 3d89863..81aebbe 100644 --- a/docker/chat/Dockerfile +++ b/docker/chat/Dockerfile @@ -30,6 +30,6 @@ WORKDIR /var/chat EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" || exit 1 + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/')" || exit 1 ENTRYPOINT ["/entrypoint-chat.sh"] diff --git a/docker/chat/server.py b/docker/chat/server.py index 6748354..ad8897d 100644 --- a/docker/chat/server.py +++ b/docker/chat/server.py @@ -481,14 +481,6 @@ class ChatHandler(BaseHTTPRequestHandler): parsed = urlparse(self.path) path = parsed.path - # Health endpoint (no auth required) — used by Docker healthcheck - if path == "/health": - self.send_response(200) - self.send_header("Content-Type", "text/plain") - self.end_headers() - self.wfile.write(b"ok\n") - return - # Verify endpoint for Caddy forward_auth (#709) if path == "/chat/auth/verify": self.handle_auth_verify() diff --git a/docker/edge/Dockerfile b/docker/edge/Dockerfile index eca7d7e..6706852 100644 --- a/docker/edge/Dockerfile +++ b/docker/edge/Dockerfile @@ -1,7 +1,4 @@ FROM caddy:latest RUN apk add --no-cache bash jq curl git docker-cli python3 openssh-client autossh COPY entrypoint-edge.sh /usr/local/bin/entrypoint-edge.sh - -VOLUME /data - ENTRYPOINT ["bash", "/usr/local/bin/entrypoint-edge.sh"] diff --git a/docker/edge/dispatcher.sh b/docker/edge/dispatcher.sh index 282342a..67a1ba9 100755 --- a/docker/edge/dispatcher.sh +++ b/docker/edge/dispatcher.sh @@ -8,8 +8,8 @@ # 2. Scan vault/actions/ for TOML files without .result.json # 3. Verify TOML arrived via merged PR with admin merger (Forgejo API) # 4. Validate TOML using vault-env.sh validator -# 5. Decrypt declared secrets via load_secret (lib/env.sh) -# 6. Launch: delegate to _launch_runner_{docker,nomad} backend +# 5. Decrypt .env.vault.enc and extract only declared secrets +# 6. Launch: docker run --rm disinto/agents:latest # 7. Write .result.json with exit code, timestamp, logs summary # # Part of #76. @@ -19,7 +19,7 @@ set -euo pipefail # Resolve script root (parent of lib/) SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -# Source shared environment (provides load_secret, log helpers, etc.) +# Source shared environment source "${SCRIPT_ROOT}/../lib/env.sh" # Project TOML location: prefer mounted path, fall back to cloned path @@ -27,18 +27,26 @@ source "${SCRIPT_ROOT}/../lib/env.sh" # the shallow clone only has .toml.example files. PROJECTS_DIR="${PROJECTS_DIR:-${FACTORY_ROOT:-/opt/disinto}-projects}" -# ----------------------------------------------------------------------------- -# Backend selection: DISPATCHER_BACKEND={docker,nomad} -# Default: docker. nomad lands as a pure addition during migration Step 5. -# ----------------------------------------------------------------------------- -DISPATCHER_BACKEND="${DISPATCHER_BACKEND:-docker}" +# Load vault secrets after env.sh (env.sh unsets them for agent security) +# Vault secrets must be available to the dispatcher +if [ -f "$FACTORY_ROOT/.env.vault.enc" ] && command -v sops &>/dev/null; then + set -a + eval "$(sops -d --output-type dotenv "$FACTORY_ROOT/.env.vault.enc" 2>/dev/null)" \ + || echo "Warning: failed to decrypt .env.vault.enc — vault secrets not loaded" >&2 + set +a +elif [ -f "$FACTORY_ROOT/.env.vault" ]; then + set -a + # shellcheck source=/dev/null + source "$FACTORY_ROOT/.env.vault" + set +a +fi # Ops repo location (vault/actions directory) OPS_REPO_ROOT="${OPS_REPO_ROOT:-/home/debian/disinto-ops}" VAULT_ACTIONS_DIR="${OPS_REPO_ROOT}/vault/actions" # Vault action validation -VAULT_ENV="${SCRIPT_ROOT}/../action-vault/vault-env.sh" +VAULT_ENV="${SCRIPT_ROOT}/../vault/vault-env.sh" # Admin users who can merge vault PRs (from issue #77) # Comma-separated list of Forgejo usernames with admin role @@ -342,113 +350,73 @@ get_dispatch_mode() { fi } -# Commit result.json to the ops repo via git push (portable, no bind-mount). -# -# Clones the ops repo into a scratch directory, writes the result file, -# commits as vault-bot, and pushes to the primary branch. -# Idempotent: skips if result.json already exists upstream. -# Retries on push conflict with rebase-and-push (handles concurrent merges). -# -# Usage: commit_result_via_git -commit_result_via_git() { - local action_id="$1" - local exit_code="$2" - local logs="$3" - - local result_relpath="vault/actions/${action_id}.result.json" - local ops_clone_url="${FORGE_URL}/${FORGE_OPS_REPO}.git" - local branch="${PRIMARY_BRANCH:-main}" - local scratch_dir - scratch_dir=$(mktemp -d /tmp/dispatcher-result-XXXXXX) - # shellcheck disable=SC2064 - trap "rm -rf '${scratch_dir}'" RETURN - - # Shallow clone of the ops repo — only the primary branch - if ! git clone --depth 1 --branch "$branch" \ - "$ops_clone_url" "$scratch_dir" 2>/dev/null; then - log "ERROR: Failed to clone ops repo for result commit (action ${action_id})" - return 1 - fi - - # Idempotency: skip if result.json already exists upstream - if [ -f "${scratch_dir}/${result_relpath}" ]; then - log "Result already exists upstream for ${action_id} — skipping commit" - return 0 - fi - - # Configure git identity as vault-bot - git -C "$scratch_dir" config user.name "vault-bot" - git -C "$scratch_dir" config user.email "vault-bot@disinto.local" - - # Truncate logs if too long (keep last 1000 chars) - if [ ${#logs} -gt 1000 ]; then - logs="${logs: -1000}" - fi - - # Write result JSON via jq (never string-interpolate into JSON) - mkdir -p "$(dirname "${scratch_dir}/${result_relpath}")" - jq -n \ - --arg id "$action_id" \ - --argjson exit_code "$exit_code" \ - --arg timestamp "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ - --arg logs "$logs" \ - '{id: $id, exit_code: $exit_code, timestamp: $timestamp, logs: $logs}' \ - > "${scratch_dir}/${result_relpath}" - - git -C "$scratch_dir" add "$result_relpath" - git -C "$scratch_dir" commit -q -m "vault: result for ${action_id}" - - # Push with retry on conflict (rebase-and-push pattern). - # Common case: admin merges another action PR between our clone and push. - local attempt - for attempt in 1 2 3; do - if git -C "$scratch_dir" push origin "$branch" 2>/dev/null; then - log "Result committed and pushed for ${action_id} (attempt ${attempt})" - return 0 - fi - - log "Push conflict for ${action_id} (attempt ${attempt}/3) — rebasing" - - if ! git -C "$scratch_dir" pull --rebase origin "$branch" 2>/dev/null; then - # Rebase conflict — check if result was pushed by another process - git -C "$scratch_dir" rebase --abort 2>/dev/null || true - if git -C "$scratch_dir" fetch origin "$branch" 2>/dev/null && \ - git -C "$scratch_dir" show "origin/${branch}:${result_relpath}" >/dev/null 2>&1; then - log "Result already exists upstream for ${action_id} (pushed by another process)" - return 0 - fi - fi - done - - log "ERROR: Failed to push result for ${action_id} after 3 attempts" - return 1 -} - -# Write result file for an action via git push to the ops repo. +# Write result file for an action # Usage: write_result write_result() { local action_id="$1" local exit_code="$2" local logs="$3" - commit_result_via_git "$action_id" "$exit_code" "$logs" + local result_file="${VAULT_ACTIONS_DIR}/${action_id}.result.json" + + # Truncate logs if too long (keep last 1000 chars) + if [ ${#logs} -gt 1000 ]; then + logs="${logs: -1000}" + fi + + # Write result JSON + jq -n \ + --arg id "$action_id" \ + --argjson exit_code "$exit_code" \ + --arg timestamp "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --arg logs "$logs" \ + '{id: $id, exit_code: $exit_code, timestamp: $timestamp, logs: $logs}' \ + > "$result_file" + + log "Result written: ${result_file}" } -# ----------------------------------------------------------------------------- -# Pluggable launcher backends -# ----------------------------------------------------------------------------- +# Launch runner for the given action +# Usage: launch_runner +launch_runner() { + local toml_file="$1" + local action_id + action_id=$(basename "$toml_file" .toml) -# _launch_runner_docker ACTION_ID SECRETS_CSV MOUNTS_CSV -# -# Builds and executes a `docker run` command for the vault runner. -# Secrets are resolved via load_secret (lib/env.sh). -# Returns: exit code of the docker run. Stdout/stderr are captured to a temp -# log file whose path is printed to stdout (caller reads it). -_launch_runner_docker() { - local action_id="$1" - local secrets_csv="$2" - local mounts_csv="$3" + log "Launching runner for action: ${action_id}" + # Validate TOML + if ! validate_action "$toml_file"; then + log "ERROR: Action validation failed for ${action_id}" + write_result "$action_id" 1 "Validation failed: see logs above" + return 1 + fi + + # Check dispatch mode to determine if admin verification is needed + local dispatch_mode + dispatch_mode=$(get_dispatch_mode "$toml_file") + + if [ "$dispatch_mode" = "direct" ]; then + log "Action ${action_id}: tier=${VAULT_TIER:-unknown}, dispatch_mode=${dispatch_mode} — skipping admin merge verification (direct commit)" + else + # Verify admin merge for PR-based actions + log "Action ${action_id}: tier=${VAULT_TIER:-unknown}, dispatch_mode=${dispatch_mode} — verifying admin merge" + if ! verify_admin_merged "$toml_file"; then + log "ERROR: Admin merge verification failed for ${action_id}" + write_result "$action_id" 1 "Admin merge verification failed: see logs above" + return 1 + fi + log "Action ${action_id}: admin merge verified" + fi + + # Extract secrets from validated action + local secrets_array + secrets_array="${VAULT_ACTION_SECRETS:-}" + + # Build docker run command (self-contained, no compose context needed). + # The edge container has the Docker socket but not the host's compose project, + # so docker compose run would fail with exit 125. docker run is self-contained: + # the dispatcher knows the image, network, env vars, and entrypoint. local -a cmd=(docker run --rm --name "vault-runner-${action_id}" --network host @@ -483,27 +451,29 @@ _launch_runner_docker() { cmd+=(-v "${runtime_home}/.claude.json:/home/agent/.claude.json:ro") fi - # Add environment variables for secrets (resolved via load_secret) - if [ -n "$secrets_csv" ]; then - local secret - for secret in $(echo "$secrets_csv" | tr ',' ' '); do + # Add environment variables for secrets (if any declared) + if [ -n "$secrets_array" ]; then + for secret in $secrets_array; do secret=$(echo "$secret" | xargs) - [ -n "$secret" ] || continue - local secret_val - secret_val=$(load_secret "$secret") || true - if [ -z "$secret_val" ]; then - log "ERROR: Secret '${secret}' could not be resolved for action ${action_id}" - write_result "$action_id" 1 "Secret not found: ${secret}" - return 1 + if [ -n "$secret" ]; then + # Verify secret exists in vault + if [ -z "${!secret:-}" ]; then + log "ERROR: Secret '${secret}' not found in vault for action ${action_id}" + write_result "$action_id" 1 "Secret not found in vault: ${secret}" + return 1 + fi + cmd+=(-e "${secret}=${!secret}") fi - cmd+=(-e "${secret}=${secret_val}") done + else + log "Action ${action_id} has no secrets declared — runner will execute without extra env vars" fi - # Add volume mounts for file-based credentials - if [ -n "$mounts_csv" ]; then - local mount_alias - for mount_alias in $(echo "$mounts_csv" | tr ',' ' '); do + # Add volume mounts for file-based credentials (if any declared) + local mounts_array + mounts_array="${VAULT_ACTION_MOUNTS:-}" + if [ -n "$mounts_array" ]; then + for mount_alias in $mounts_array; do mount_alias=$(echo "$mount_alias" | xargs) [ -n "$mount_alias" ] || continue case "$mount_alias" in @@ -531,7 +501,7 @@ _launch_runner_docker() { # Image and entrypoint arguments: runner entrypoint + action-id cmd+=(disinto/agents:latest /home/agent/disinto/docker/runner/entrypoint-runner.sh "$action_id") - log "Running: docker run --rm vault-runner-${action_id} (secrets: ${secrets_csv:-none}, mounts: ${mounts_csv:-none})" + log "Running: docker run --rm vault-runner-${action_id} (secrets: ${secrets_array:-none}, mounts: ${mounts_array:-none})" # Create temp file for logs local log_file @@ -539,6 +509,7 @@ _launch_runner_docker() { trap 'rm -f "$log_file"' RETURN # Execute with array expansion (safe from shell injection) + # Capture stdout and stderr to log file "${cmd[@]}" > "$log_file" 2>&1 local exit_code=$? @@ -558,295 +529,6 @@ _launch_runner_docker() { return $exit_code } -# _launch_runner_nomad ACTION_ID SECRETS_CSV MOUNTS_CSV -# -# Dispatches a vault-runner batch job via `nomad job dispatch`. -# Polls `nomad job status` until terminal state (completed/failed). -# Reads exit code from allocation and writes .result.json. -# -# Usage: _launch_runner_nomad -# Returns: exit code of the nomad job (0=success, non-zero=failure) -_launch_runner_nomad() { - local action_id="$1" - local secrets_csv="$2" - local mounts_csv="$3" - - log "Dispatching vault-runner batch job via Nomad for action: ${action_id}" - - # Dispatch the parameterized batch job - # The vault-runner job expects meta: action_id, secrets_csv - # Note: mounts_csv is not passed as meta (not declared in vault-runner.hcl) - local dispatch_output - dispatch_output=$(nomad job dispatch \ - -detach \ - -meta action_id="$action_id" \ - -meta secrets_csv="$secrets_csv" \ - vault-runner 2>&1) || { - log "ERROR: Failed to dispatch vault-runner job for ${action_id}" - log "Dispatch output: ${dispatch_output}" - write_result "$action_id" 1 "Nomad dispatch failed: ${dispatch_output}" - return 1 - } - - # Extract dispatched job ID from output (format: "vault-runner/dispatch--") - local dispatched_job_id - dispatched_job_id=$(echo "$dispatch_output" | grep -oP '(?<=Dispatched Job ID = ).+' || true) - - if [ -z "$dispatched_job_id" ]; then - log "ERROR: Could not extract dispatched job ID from nomad output" - log "Dispatch output: ${dispatch_output}" - write_result "$action_id" 1 "Could not extract dispatched job ID from nomad output" - return 1 - fi - - log "Dispatched vault-runner with job ID: ${dispatched_job_id}" - - # Poll job status until terminal state - # Batch jobs transition: running -> completed/failed - local max_wait=300 # 5 minutes max wait - local elapsed=0 - local poll_interval=5 - local alloc_id="" - - log "Polling nomad job status for ${dispatched_job_id}..." - - while [ "$elapsed" -lt "$max_wait" ]; do - # Get job status with JSON output for the dispatched child job - local job_status_json - job_status_json=$(nomad job status -json "$dispatched_job_id" 2>/dev/null) || { - log "ERROR: Failed to get job status for ${dispatched_job_id}" - write_result "$action_id" 1 "Failed to get job status for ${dispatched_job_id}" - return 1 - } - - # Check job status field (transitions to "dead" on completion) - local job_state - job_state=$(echo "$job_status_json" | jq -r '.Status // empty' 2>/dev/null) || job_state="" - - # Check allocation state directly - alloc_id=$(echo "$job_status_json" | jq -r '.Allocations[0]?.ID // empty' 2>/dev/null) || alloc_id="" - - if [ -n "$alloc_id" ]; then - local alloc_state - alloc_state=$(nomad alloc status -short "$alloc_id" 2>/dev/null || true) - - case "$alloc_state" in - *completed*|*success*|*dead*) - log "Allocation ${alloc_id} reached terminal state: ${alloc_state}" - break - ;; - *running*|*pending*|*starting*) - log "Allocation ${alloc_id} still running (state: ${alloc_state})..." - ;; - *failed*|*crashed*) - log "Allocation ${alloc_id} failed (state: ${alloc_state})" - break - ;; - esac - fi - - # Also check job-level state - case "$job_state" in - dead) - log "Job ${dispatched_job_id} reached terminal state: ${job_state}" - break - ;; - failed) - log "Job ${dispatched_job_id} failed" - break - ;; - esac - - sleep "$poll_interval" - elapsed=$((elapsed + poll_interval)) - done - - if [ "$elapsed" -ge "$max_wait" ]; then - log "ERROR: Timeout waiting for vault-runner job to complete" - write_result "$action_id" 1 "Timeout waiting for nomad job to complete" - return 1 - fi - - # Get final job status and exit code - local final_status_json - final_status_json=$(nomad job status -json "$dispatched_job_id" 2>/dev/null) || { - log "ERROR: Failed to get final job status" - write_result "$action_id" 1 "Failed to get final job status" - return 1 - } - - # Get allocation exit code - local exit_code=0 - local logs="" - - if [ -n "$alloc_id" ]; then - # Get allocation logs - logs=$(nomad alloc logs -short "$alloc_id" 2>/dev/null || true) - - # Try to get exit code from alloc status JSON - # Nomad alloc status -json has .TaskStates[""].Events[].ExitCode - local alloc_exit_code - alloc_exit_code=$(nomad alloc status -json "$alloc_id" 2>/dev/null | jq -r '.TaskStates["runner"].Events[-1].ExitCode // empty' 2>/dev/null) || alloc_exit_code="" - - if [ -n "$alloc_exit_code" ] && [ "$alloc_exit_code" != "null" ]; then - exit_code="$alloc_exit_code" - fi - fi - - # If we couldn't get exit code from alloc, check job state as fallback - # Note: "dead" = terminal state for batch jobs (includes successful completion) - # Only "failed" indicates actual failure - if [ "$exit_code" -eq 0 ]; then - local final_state - final_state=$(echo "$final_status_json" | jq -r '.Status // empty' 2>/dev/null) || final_state="" - - case "$final_state" in - failed) - exit_code=1 - ;; - esac - fi - - # Truncate logs if too long - if [ ${#logs} -gt 1000 ]; then - logs="${logs: -1000}" - fi - - # Write result file - write_result "$action_id" "$exit_code" "$logs" - - if [ "$exit_code" -eq 0 ]; then - log "Vault-runner job completed successfully for action: ${action_id}" - else - log "Vault-runner job failed for action: ${action_id} (exit code: ${exit_code})" - fi - - return "$exit_code" -} - -# Launch runner for the given action (backend-agnostic orchestrator) -# Usage: launch_runner -launch_runner() { - local toml_file="$1" - local action_id - action_id=$(basename "$toml_file" .toml) - - log "Launching runner for action: ${action_id}" - - # Validate TOML - if ! validate_action "$toml_file"; then - log "ERROR: Action validation failed for ${action_id}" - write_result "$action_id" 1 "Validation failed: see logs above" - return 1 - fi - - # Check dispatch mode to determine if admin verification is needed - local dispatch_mode - dispatch_mode=$(get_dispatch_mode "$toml_file") - - if [ "$dispatch_mode" = "direct" ]; then - log "Action ${action_id}: tier=${VAULT_TIER:-unknown}, dispatch_mode=${dispatch_mode} — skipping admin merge verification (direct commit)" - else - # Verify admin merge for PR-based actions - log "Action ${action_id}: tier=${VAULT_TIER:-unknown}, dispatch_mode=${dispatch_mode} — verifying admin merge" - if ! verify_admin_merged "$toml_file"; then - log "ERROR: Admin merge verification failed for ${action_id}" - write_result "$action_id" 1 "Admin merge verification failed: see logs above" - return 1 - fi - log "Action ${action_id}: admin merge verified" - fi - - # Build CSV lists from validated action metadata - local secrets_csv="" - if [ -n "${VAULT_ACTION_SECRETS:-}" ]; then - # Convert space-separated to comma-separated - secrets_csv=$(echo "${VAULT_ACTION_SECRETS}" | xargs | tr ' ' ',') - fi - - local mounts_csv="" - if [ -n "${VAULT_ACTION_MOUNTS:-}" ]; then - mounts_csv=$(echo "${VAULT_ACTION_MOUNTS}" | xargs | tr ' ' ',') - fi - - # Delegate to the selected backend - "_launch_runner_${DISPATCHER_BACKEND}" "$action_id" "$secrets_csv" "$mounts_csv" -} - -# ----------------------------------------------------------------------------- -# Pluggable sidecar launcher (reproduce / triage / verify) -# ----------------------------------------------------------------------------- - -# _dispatch_sidecar_docker CONTAINER_NAME ISSUE_NUM PROJECT_TOML IMAGE [FORMULA] -# -# Launches a sidecar container via docker run (background, pid-tracked). -# Prints the background PID to stdout. -_dispatch_sidecar_docker() { - local container_name="$1" - local issue_number="$2" - local project_toml="$3" - local image="$4" - local formula="${5:-}" - - local -a cmd=(docker run --rm - --name "${container_name}" - --network host - --security-opt apparmor=unconfined - -v /var/run/docker.sock:/var/run/docker.sock - -v agent-data:/home/agent/data - -v project-repos:/home/agent/repos - -e "FORGE_URL=${FORGE_URL}" - -e "FORGE_TOKEN=${FORGE_TOKEN}" - -e "FORGE_REPO=${FORGE_REPO}" - -e "PRIMARY_BRANCH=${PRIMARY_BRANCH:-main}" - -e DISINTO_CONTAINER=1 - ) - - # Set formula if provided - if [ -n "$formula" ]; then - cmd+=(-e "DISINTO_FORMULA=${formula}") - fi - - # Pass through ANTHROPIC_API_KEY if set - if [ -n "${ANTHROPIC_API_KEY:-}" ]; then - cmd+=(-e "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}") - fi - - # Mount shared Claude config dir and ~/.ssh from the runtime user's home - local runtime_home="${HOME:-/home/debian}" - if [ -d "${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}" ]; then - cmd+=(-v "${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}:${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}") - cmd+=(-e "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR:-/var/lib/disinto/claude-shared/config}") - fi - if [ -f "${runtime_home}/.claude.json" ]; then - cmd+=(-v "${runtime_home}/.claude.json:/home/agent/.claude.json:ro") - fi - if [ -d "${runtime_home}/.ssh" ]; then - cmd+=(-v "${runtime_home}/.ssh:/home/agent/.ssh:ro") - fi - if [ -f /usr/local/bin/claude ]; then - cmd+=(-v /usr/local/bin/claude:/usr/local/bin/claude:ro) - fi - - # Mount the project TOML into the container at a stable path - local container_toml="/home/agent/project.toml" - cmd+=(-v "${project_toml}:${container_toml}:ro") - - cmd+=("${image}" "$container_toml" "$issue_number") - - # Launch in background - "${cmd[@]}" & - echo $! -} - -# _dispatch_sidecar_nomad CONTAINER_NAME ISSUE_NUM PROJECT_TOML IMAGE [FORMULA] -# -# Nomad sidecar backend stub — will be implemented in migration Step 5. -_dispatch_sidecar_nomad() { - echo "nomad backend not yet implemented" >&2 - return 1 -} - # ----------------------------------------------------------------------------- # Reproduce dispatch — launch sidecar for bug-report issues # ----------------------------------------------------------------------------- @@ -925,13 +607,52 @@ dispatch_reproduce() { log "Dispatching reproduce-agent for issue #${issue_number} (project: ${project_toml})" - local bg_pid - bg_pid=$("_dispatch_sidecar_${DISPATCHER_BACKEND}" \ - "disinto-reproduce-${issue_number}" \ - "$issue_number" \ - "$project_toml" \ - "disinto-reproduce:latest") + # Build docker run command using array (safe from injection) + local -a cmd=(docker run --rm + --name "disinto-reproduce-${issue_number}" + --network host + --security-opt apparmor=unconfined + -v /var/run/docker.sock:/var/run/docker.sock + -v agent-data:/home/agent/data + -v project-repos:/home/agent/repos + -e "FORGE_URL=${FORGE_URL}" + -e "FORGE_TOKEN=${FORGE_TOKEN}" + -e "FORGE_REPO=${FORGE_REPO}" + -e "PRIMARY_BRANCH=${PRIMARY_BRANCH:-main}" + -e DISINTO_CONTAINER=1 + ) + # Pass through ANTHROPIC_API_KEY if set + if [ -n "${ANTHROPIC_API_KEY:-}" ]; then + cmd+=(-e "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}") + fi + + # Mount shared Claude config dir and ~/.ssh from the runtime user's home if available + local runtime_home="${HOME:-/home/debian}" + if [ -d "${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}" ]; then + cmd+=(-v "${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}:${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}") + cmd+=(-e "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR:-/var/lib/disinto/claude-shared/config}") + fi + if [ -f "${runtime_home}/.claude.json" ]; then + cmd+=(-v "${runtime_home}/.claude.json:/home/agent/.claude.json:ro") + fi + if [ -d "${runtime_home}/.ssh" ]; then + cmd+=(-v "${runtime_home}/.ssh:/home/agent/.ssh:ro") + fi + # Mount claude CLI binary if present on host + if [ -f /usr/local/bin/claude ]; then + cmd+=(-v /usr/local/bin/claude:/usr/local/bin/claude:ro) + fi + + # Mount the project TOML into the container at a stable path + local container_toml="/home/agent/project.toml" + cmd+=(-v "${project_toml}:${container_toml}:ro") + + cmd+=(disinto-reproduce:latest "$container_toml" "$issue_number") + + # Launch in background; write pid-file so we don't double-launch + "${cmd[@]}" & + local bg_pid=$! echo "$bg_pid" > "$(_reproduce_lockfile "$issue_number")" log "Reproduce container launched (pid ${bg_pid}) for issue #${issue_number}" } @@ -1011,14 +732,53 @@ dispatch_triage() { log "Dispatching triage-agent for issue #${issue_number} (project: ${project_toml})" - local bg_pid - bg_pid=$("_dispatch_sidecar_${DISPATCHER_BACKEND}" \ - "disinto-triage-${issue_number}" \ - "$issue_number" \ - "$project_toml" \ - "disinto-reproduce:latest" \ - "triage") + # Build docker run command using array (safe from injection) + local -a cmd=(docker run --rm + --name "disinto-triage-${issue_number}" + --network host + --security-opt apparmor=unconfined + -v /var/run/docker.sock:/var/run/docker.sock + -v agent-data:/home/agent/data + -v project-repos:/home/agent/repos + -e "FORGE_URL=${FORGE_URL}" + -e "FORGE_TOKEN=${FORGE_TOKEN}" + -e "FORGE_REPO=${FORGE_REPO}" + -e "PRIMARY_BRANCH=${PRIMARY_BRANCH:-main}" + -e DISINTO_CONTAINER=1 + -e DISINTO_FORMULA=triage + ) + # Pass through ANTHROPIC_API_KEY if set + if [ -n "${ANTHROPIC_API_KEY:-}" ]; then + cmd+=(-e "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}") + fi + + # Mount shared Claude config dir and ~/.ssh from the runtime user's home if available + local runtime_home="${HOME:-/home/debian}" + if [ -d "${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}" ]; then + cmd+=(-v "${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}:${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}") + cmd+=(-e "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR:-/var/lib/disinto/claude-shared/config}") + fi + if [ -f "${runtime_home}/.claude.json" ]; then + cmd+=(-v "${runtime_home}/.claude.json:/home/agent/.claude.json:ro") + fi + if [ -d "${runtime_home}/.ssh" ]; then + cmd+=(-v "${runtime_home}/.ssh:/home/agent/.ssh:ro") + fi + # Mount claude CLI binary if present on host + if [ -f /usr/local/bin/claude ]; then + cmd+=(-v /usr/local/bin/claude:/usr/local/bin/claude:ro) + fi + + # Mount the project TOML into the container at a stable path + local container_toml="/home/agent/project.toml" + cmd+=(-v "${project_toml}:${container_toml}:ro") + + cmd+=(disinto-reproduce:latest "$container_toml" "$issue_number") + + # Launch in background; write pid-file so we don't double-launch + "${cmd[@]}" & + local bg_pid=$! echo "$bg_pid" > "$(_triage_lockfile "$issue_number")" log "Triage container launched (pid ${bg_pid}) for issue #${issue_number}" } @@ -1174,14 +934,53 @@ dispatch_verify() { log "Dispatching verification-agent for issue #${issue_number} (project: ${project_toml})" - local bg_pid - bg_pid=$("_dispatch_sidecar_${DISPATCHER_BACKEND}" \ - "disinto-verify-${issue_number}" \ - "$issue_number" \ - "$project_toml" \ - "disinto-reproduce:latest" \ - "verify") + # Build docker run command using array (safe from injection) + local -a cmd=(docker run --rm + --name "disinto-verify-${issue_number}" + --network host + --security-opt apparmor=unconfined + -v /var/run/docker.sock:/var/run/docker.sock + -v agent-data:/home/agent/data + -v project-repos:/home/agent/repos + -e "FORGE_URL=${FORGE_URL}" + -e "FORGE_TOKEN=${FORGE_TOKEN}" + -e "FORGE_REPO=${FORGE_REPO}" + -e "PRIMARY_BRANCH=${PRIMARY_BRANCH:-main}" + -e DISINTO_CONTAINER=1 + -e DISINTO_FORMULA=verify + ) + # Pass through ANTHROPIC_API_KEY if set + if [ -n "${ANTHROPIC_API_KEY:-}" ]; then + cmd+=(-e "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}") + fi + + # Mount shared Claude config dir and ~/.ssh from the runtime user's home if available + local runtime_home="${HOME:-/home/debian}" + if [ -d "${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}" ]; then + cmd+=(-v "${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}:${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}") + cmd+=(-e "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR:-/var/lib/disinto/claude-shared/config}") + fi + if [ -f "${runtime_home}/.claude.json" ]; then + cmd+=(-v "${runtime_home}/.claude.json:/home/agent/.claude.json:ro") + fi + if [ -d "${runtime_home}/.ssh" ]; then + cmd+=(-v "${runtime_home}/.ssh:/home/agent/.ssh:ro") + fi + # Mount claude CLI binary if present on host + if [ -f /usr/local/bin/claude ]; then + cmd+=(-v /usr/local/bin/claude:/usr/local/bin/claude:ro) + fi + + # Mount the project TOML into the container at a stable path + local container_toml="/home/agent/project.toml" + cmd+=(-v "${project_toml}:${container_toml}:ro") + + cmd+=(disinto-reproduce:latest "$container_toml" "$issue_number") + + # Launch in background; write pid-file so we don't double-launch + "${cmd[@]}" & + local bg_pid=$! echo "$bg_pid" > "$(_verify_lockfile "$issue_number")" log "Verification container launched (pid ${bg_pid}) for issue #${issue_number}" } @@ -1203,22 +1002,10 @@ ensure_ops_repo() { # Main dispatcher loop main() { - log "Starting dispatcher (backend=${DISPATCHER_BACKEND})..." + log "Starting dispatcher..." log "Polling ops repo: ${VAULT_ACTIONS_DIR}" log "Admin users: ${ADMIN_USERS}" - # Validate backend selection at startup - case "$DISPATCHER_BACKEND" in - docker|nomad) - log "Using ${DISPATCHER_BACKEND} backend for vault-runner dispatch" - ;; - *) - log "ERROR: unknown DISPATCHER_BACKEND=${DISPATCHER_BACKEND}" - echo "unknown DISPATCHER_BACKEND=${DISPATCHER_BACKEND} (expected: docker, nomad)" >&2 - exit 1 - ;; - esac - while true; do # Refresh ops repo at the start of each poll cycle ensure_ops_repo diff --git a/docker/edge/entrypoint-edge.sh b/docker/edge/entrypoint-edge.sh index 1b5f94f..7fc4f4f 100755 --- a/docker/edge/entrypoint-edge.sh +++ b/docker/edge/entrypoint-edge.sh @@ -173,40 +173,9 @@ PROJECT_TOML="${PROJECT_TOML:-projects/disinto.toml}" sleep 1200 # 20 minutes done) & -# ── Load required secrets from secrets/*.enc (#777) ──────────────────── -# Edge container declares its required secrets; missing ones cause a hard fail. -_AGE_KEY_FILE="${HOME}/.config/sops/age/keys.txt" -_SECRETS_DIR="/opt/disinto/secrets" -EDGE_REQUIRED_SECRETS="CADDY_SSH_KEY CADDY_SSH_HOST CADDY_SSH_USER CADDY_ACCESS_LOG" - -_edge_decrypt_secret() { - local enc_path="${_SECRETS_DIR}/${1}.enc" - [ -f "$enc_path" ] || return 1 - age -d -i "$_AGE_KEY_FILE" "$enc_path" 2>/dev/null -} - -if [ -f "$_AGE_KEY_FILE" ] && [ -d "$_SECRETS_DIR" ]; then - _missing="" - for _secret_name in $EDGE_REQUIRED_SECRETS; do - _val=$(_edge_decrypt_secret "$_secret_name") || { _missing="${_missing} ${_secret_name}"; continue; } - export "$_secret_name=$_val" - done - if [ -n "$_missing" ]; then - echo "FATAL: required secrets missing from secrets/*.enc:${_missing}" >&2 - echo " Run 'disinto secrets add ' for each missing secret." >&2 - echo " If migrating from .env.vault.enc, run 'disinto secrets migrate-from-vault' first." >&2 - exit 1 - fi - echo "edge: loaded required secrets: ${EDGE_REQUIRED_SECRETS}" >&2 -else - echo "FATAL: age key (${_AGE_KEY_FILE}) or secrets dir (${_SECRETS_DIR}) not found — cannot load required secrets" >&2 - echo " Ensure age is installed and secrets/*.enc files are present." >&2 - exit 1 -fi - # Start daily engagement collection cron loop in background (#745) # Runs collect-engagement.sh daily at ~23:50 UTC via a sleep loop that -# calculates seconds until the next 23:50 window. SSH key from secrets/*.enc (#777). +# calculates seconds until the next 23:50 window. SSH key from .env.vault.enc. (while true; do # Calculate seconds until next 23:50 UTC _now=$(date -u +%s) @@ -217,21 +186,26 @@ fi _sleep_secs=$(( _target - _now )) echo "edge: collect-engagement scheduled in ${_sleep_secs}s (next 23:50 UTC)" >&2 sleep "$_sleep_secs" + # Set CADDY_ACCESS_LOG so the script reads from the fetched local copy _fetch_log="/tmp/caddy-access-log-fetch.log" - _ssh_key_file=$(mktemp) - printf '%s\n' "$CADDY_SSH_KEY" > "$_ssh_key_file" - chmod 0600 "$_ssh_key_file" - scp -i "$_ssh_key_file" -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o BatchMode=yes \ - "${CADDY_SSH_USER}@${CADDY_SSH_HOST}:${CADDY_ACCESS_LOG}" \ - "$_fetch_log" 2>&1 | tee -a /opt/disinto-logs/collect-engagement.log || true - rm -f "$_ssh_key_file" - if [ -s "$_fetch_log" ]; then - CADDY_ACCESS_LOG="$_fetch_log" bash /opt/disinto/site/collect-engagement.sh 2>&1 \ - | tee -a /opt/disinto-logs/collect-engagement.log || true + if [ -n "${CADDY_SSH_KEY:-}" ]; then + _ssh_key_file=$(mktemp) + printf '%s\n' "$CADDY_SSH_KEY" > "$_ssh_key_file" + chmod 0600 "$_ssh_key_file" + scp -i "$_ssh_key_file" -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o BatchMode=yes \ + "${CADDY_SSH_USER:-debian}@${CADDY_SSH_HOST:-disinto.ai}:${CADDY_ACCESS_LOG:-/var/log/caddy/access.log}" \ + "$_fetch_log" 2>&1 | tee -a /opt/disinto-logs/collect-engagement.log || true + rm -f "$_ssh_key_file" + if [ -s "$_fetch_log" ]; then + CADDY_ACCESS_LOG="$_fetch_log" bash /opt/disinto/site/collect-engagement.sh 2>&1 \ + | tee -a /opt/disinto-logs/collect-engagement.log || true + else + echo "edge: collect-engagement: fetched log is empty, skipping parse" >&2 + fi + rm -f "$_fetch_log" else - echo "edge: collect-engagement: fetched log is empty, skipping parse" >&2 + echo "edge: collect-engagement: CADDY_SSH_KEY not set, skipping" >&2 fi - rm -f "$_fetch_log" done) & # Caddy as main process — run in foreground via wait so background jobs survive diff --git a/docker/reproduce/Dockerfile b/docker/reproduce/Dockerfile index 30bc75f..3192744 100644 --- a/docker/reproduce/Dockerfile +++ b/docker/reproduce/Dockerfile @@ -7,8 +7,5 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN useradd -m -u 1000 -s /bin/bash agent COPY docker/reproduce/entrypoint-reproduce.sh /entrypoint-reproduce.sh RUN chmod +x /entrypoint-reproduce.sh -VOLUME /home/agent/data -VOLUME /home/agent/repos - WORKDIR /home/agent ENTRYPOINT ["/entrypoint-reproduce.sh"] diff --git a/docs/VAULT.md b/docs/VAULT.md index d927170..838c364 100644 --- a/docs/VAULT.md +++ b/docs/VAULT.md @@ -26,8 +26,8 @@ The `main` branch on the ops repo (`johba/disinto-ops`) is protected via Forgejo ## Vault PR Lifecycle -1. **Request** — Agent calls `lib/action-vault.sh:vault_request()` with action TOML content -2. **Validation** — TOML is validated against the schema in `action-vault/vault-env.sh` +1. **Request** — Agent calls `lib/vault.sh:vault_request()` with action TOML content +2. **Validation** — TOML is validated against the schema in `vault/vault-env.sh` 3. **PR Creation** — A PR is created on `disinto-ops` with: - Branch: `vault/` - Title: `vault: ` @@ -90,12 +90,12 @@ To verify the protection is working: - #73 — Vault redesign proposal - #74 — Vault action TOML schema -- #75 — Vault PR creation helper (`lib/action-vault.sh`) +- #75 — Vault PR creation helper (`lib/vault.sh`) - #76 — Dispatcher rewrite (poll for merged vault PRs) - #77 — Branch protection on ops repo (this issue) ## See Also -- [`lib/action-vault.sh`](../lib/action-vault.sh) — Vault PR creation helper -- [`action-vault/vault-env.sh`](../action-vault/vault-env.sh) — TOML validation +- [`lib/vault.sh`](../lib/vault.sh) — Vault PR creation helper +- [`vault/vault-env.sh`](../vault/vault-env.sh) — TOML validation - [`lib/branch-protection.sh`](../lib/branch-protection.sh) — Branch protection helper diff --git a/docs/agents-llama.md b/docs/agents-llama.md deleted file mode 100644 index b3a1334..0000000 --- a/docs/agents-llama.md +++ /dev/null @@ -1,194 +0,0 @@ -# Local-Model Agents - -Local-model agents run the same agent code as the Claude-backed agents, but -connect to a local llama-server (or compatible OpenAI-API endpoint) instead of -the Anthropic API. This document describes the canonical activation flow using -`disinto hire-an-agent` and `[agents.X]` TOML configuration. - -> **Note:** The legacy `ENABLE_LLAMA_AGENT=1` env flag has been removed (#846). -> Activation is now done exclusively via `[agents.X]` sections in project TOML. - -## Overview - -Local-model agents are configured via `[agents.]` sections in -`projects/.toml`. Each agent gets: -- Its own Forgejo bot user with dedicated API token and password -- A dedicated compose service `agents-` -- Isolated credentials stored as `FORGE_TOKEN_` and `FORGE_PASS_` in `.env` - -## Prerequisites - -- **llama-server** (or compatible OpenAI-API endpoint) running on the host, - reachable from inside Docker at the URL you will configure. -- A disinto factory already initialized (`disinto init` completed). - -## Hiring a local-model agent - -Use `disinto hire-an-agent` with `--local-model` to create a bot user and -configure the agent: - -```bash -# Hire a local-model agent for the dev role -disinto hire-an-agent dev-qwen dev \ - --local-model http://10.10.10.1:8081 \ - --model unsloth/Qwen3.5-35B-A3B -``` - -The command performs these steps: - -1. **Creates a Forgejo user** `dev-qwen` with a random password -2. **Generates an API token** for the user -3. **Writes credentials to `.env`**: - - `FORGE_TOKEN_DEV_QWEN` — the API token - - `FORGE_PASS_DEV_QWEN` — the password - - `ANTHROPIC_BASE_URL` — the llama endpoint (required by the agent) -4. **Writes `[agents.dev-qwen]` to `projects/.toml`** with: - - `base_url`, `model`, `api_key` - - `roles = ["dev"]` - - `forge_user = "dev-qwen"` - - `compact_pct = 60` - - `poll_interval = 60` -5. **Regenerates `docker-compose.yml`** to include the `agents-dev-qwen` service - -### Anthropic backend agents - -For agents that use Anthropic API instead of a local model, omit `--local-model`: - -```bash -# Anthropic backend agent (requires ANTHROPIC_API_KEY in environment) -export ANTHROPIC_API_KEY="sk-..." -disinto hire-an-agent dev-claude dev -``` - -This writes `ANTHROPIC_API_KEY` to `.env` instead of `ANTHROPIC_BASE_URL`. - -## Activation and running - -Once hired, the agent service is added to `docker-compose.yml`. Start the -service with `docker compose up -d`: - -```bash -# Start all agent services -docker compose up -d - -# Start a single named agent service -docker compose up -d agents-dev-qwen - -# Start multiple named agent services -docker compose up -d agents-dev-qwen agents-planner -``` - -### Stopping agents - -```bash -# Stop a specific agent service -docker compose down agents-dev-qwen - -# Stop all agent services -docker compose down -``` - -## Credential rotation - -Re-running `disinto hire-an-agent ` with the same parameters rotates -credentials idempotently: - -```bash -# Re-hire the same agent to rotate token and password -disinto hire-an-agent dev-qwen dev \ - --local-model http://10.10.10.1:8081 \ - --model unsloth/Qwen3.5-35B-A3B - -# The command will: -# 1. Detect the user already exists -# 2. Reset the password to a new random value -# 3. Create a new API token -# 4. Update .env with the new credentials -``` - -This is the recommended way to rotate agent credentials. The `.env` file is -updated in place, so no manual editing is required. - -If you need to manually rotate credentials: -1. Generate a new token in Forgejo admin UI -2. Edit `.env` and replace `FORGE_TOKEN_` and `FORGE_PASS_` -3. Restart the agent service: `docker compose restart agents-` - -## Configuration reference - -### Environment variables (`.env`) - -| Variable | Description | Example | -|----------|-------------|---------| -| `FORGE_TOKEN_` | Forgejo API token for the bot user | `FORGE_TOKEN_DEV_QWEN` | -| `FORGE_PASS_` | Forgejo password for the bot user | `FORGE_PASS_DEV_QWEN` | -| `ANTHROPIC_BASE_URL` | Local llama endpoint (local model agents) | `http://host.docker.internal:8081` | -| `ANTHROPIC_API_KEY` | Anthropic API key (Anthropic backend agents) | `sk-...` | - -### Project TOML (`[agents.]` section) - -```toml -[agents.dev-qwen] -base_url = "http://10.10.10.1:8081" -model = "unsloth/Qwen3.5-35B-A3B" -api_key = "sk-no-key-required" -roles = ["dev"] -forge_user = "dev-qwen" -compact_pct = 60 -poll_interval = 60 -``` - -| Field | Description | -|-------|-------------| -| `base_url` | llama-server endpoint | -| `model` | Model name (for logging/identification) | -| `api_key` | Required by API; set to placeholder for llama | -| `roles` | Agent roles this instance handles | -| `forge_user` | Forgejo bot username | -| `compact_pct` | Context compaction threshold (lower = more aggressive) | -| `poll_interval` | Seconds between polling cycles | - -## Behaviour - -- Each agent runs with `AGENT_ROLES` set to its configured roles -- `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=60` — more aggressive compaction for smaller - context windows -- Agents serialize on the llama-server's single KV cache (AD-002) - -## Troubleshooting - -### Agent service not starting - -Check that the service was created by `disinto hire-an-agent`: - -```bash -docker compose config | grep -A5 "agents-dev-qwen" -``` - -If the service is missing, re-run `disinto hire-an-agent dev-qwen dev` to -regenerate `docker-compose.yml`. - -### Model endpoint unreachable - -Verify llama-server is accessible from inside Docker: - -```bash -docker compose -f docker-compose.yml exec agents curl -sf http://host.docker.internal:8081/health -``` - -If using a custom host IP, update `ANTHROPIC_BASE_URL` in `.env`: - -```bash -# Update the base URL -sed -i 's|^ANTHROPIC_BASE_URL=.*|ANTHROPIC_BASE_URL=http://192.168.1.100:8081|' .env - -# Restart the agent -docker compose restart agents-dev-qwen -``` - -### Invalid agent name - -Agent names must match `^[a-z]([a-z0-9]|-[a-z0-9])*$` (lowercase letters, digits, -hyphens; starts with letter, ends with alphanumeric). Invalid names like -`dev-qwen2` (trailing digit is OK) or `dev--qwen` (consecutive hyphens) will -be rejected. diff --git a/docs/mirror-bootstrap.md b/docs/mirror-bootstrap.md deleted file mode 100644 index ca91d32..0000000 --- a/docs/mirror-bootstrap.md +++ /dev/null @@ -1,59 +0,0 @@ -# Mirror Bootstrap — Pull-Mirror Cutover Path - -How to populate an empty Forgejo repo from an external source using -`lib/mirrors.sh`'s `mirror_pull_register()`. - -## Prerequisites - -| Variable | Example | Purpose | -|---|---|---| -| `FORGE_URL` | `http://forgejo:3000` | Forgejo instance base URL | -| `FORGE_API_BASE` | `${FORGE_URL}/api/v1` | Global API base (set by `lib/env.sh`) | -| `FORGE_TOKEN` | (admin or org-owner token) | Must have `repo:create` scope | - -The target org/user must already exist on the Forgejo instance. - -## Command - -```bash -source lib/env.sh -source lib/mirrors.sh - -# Register a pull mirror — creates the repo and starts the first sync. -mirror_pull_register \ - "https://codeberg.org/johba/disinto.git" \ # source URL - "disinto-admin" \ # target owner - "disinto" \ # target repo name - "8h0m0s" # sync interval (optional, default 8h) -``` - -The function calls `POST /api/v1/repos/migrate` with `mirror: true`. -Forgejo creates the repo and immediately queues the first sync. - -## Verifying the sync - -```bash -# Check mirror status via API -forge_api GET "/repos/disinto-admin/disinto" | jq '.mirror, .mirror_interval' - -# Confirm content arrived — should list branches -forge_api GET "/repos/disinto-admin/disinto/branches" | jq '.[].name' -``` - -The first sync typically completes within a few seconds for small-to-medium -repos. For large repos, poll the branches endpoint until content appears. - -## Cutover scenario (Nomad migration) - -At cutover to the Nomad box: - -1. Stand up fresh Forgejo on the Nomad cluster (empty instance). -2. Create the `disinto-admin` org via `disinto init` or API. -3. Run `mirror_pull_register` pointing at the Codeberg source. -4. Wait for sync to complete (check branches endpoint). -5. Once content is confirmed, proceed with `disinto init` against the - now-populated repo — all subsequent `mirror_push` calls will push - to any additional mirrors configured in `projects/*.toml`. - -No manual `git clone` + `git push` step is needed. The Forgejo pull-mirror -handles the entire transfer. diff --git a/docs/nomad-migration.md b/docs/nomad-migration.md deleted file mode 100644 index 02ff023..0000000 --- a/docs/nomad-migration.md +++ /dev/null @@ -1,124 +0,0 @@ - -# Nomad+Vault migration — cutover-day runbook - -`disinto init --backend=nomad` is the single entry-point that turns a fresh -LXC (with the disinto repo cloned) into a running Nomad+Vault cluster with -policies applied, JWT workload-identity auth configured, secrets imported -from the old docker stack, and services deployed. - -## Cutover-day invocation - -On the new LXC, as root (or an operator with NOPASSWD sudo): - -```bash -# Copy the plaintext .env + sops-encrypted .env.vault.enc + age keyfile -# from the old box first (out of band — SSH, USB, whatever your ops -# procedure allows). Then: - -sudo ./bin/disinto init \ - --backend=nomad \ - --import-env /tmp/.env \ - --import-sops /tmp/.env.vault.enc \ - --age-key /tmp/keys.txt \ - --with forgejo -``` - -This runs, in order: - -1. **`lib/init/nomad/cluster-up.sh`** (S0) — installs Nomad + Vault - binaries, writes `/etc/nomad.d/*`, initializes Vault, starts both - services, waits for the Nomad node to become ready. -2. **`tools/vault-apply-policies.sh`** (S2.1) — syncs every - `vault/policies/*.hcl` into Vault as an ACL policy. Idempotent. -3. **`lib/init/nomad/vault-nomad-auth.sh`** (S2.3) — enables Vault's - JWT auth method at `jwt-nomad`, points it at Nomad's JWKS, writes - one role per policy, reloads Nomad so jobs can exchange - workload-identity tokens for Vault tokens. Idempotent. -4. **`tools/vault-import.sh`** (S2.2) — reads `/tmp/.env` and the - sops-decrypted `/tmp/.env.vault.enc`, writes them to the KV paths - matching the S2.1 policy layout (`kv/disinto/bots/*`, `kv/disinto/shared/*`, - `kv/disinto/runner/*`). Idempotent (overwrites KV v2 data in place). -5. **`lib/init/nomad/deploy.sh forgejo`** (S1) — validates + runs the - `nomad/jobs/forgejo.hcl` jobspec. Forgejo reads its admin creds from - Vault via the `template` stanza (S2.4). - -## Flag summary - -| Flag | Meaning | -|---|---| -| `--backend=nomad` | Switch the init dispatcher to the Nomad+Vault path (instead of docker compose). | -| `--empty` | Bring the cluster up, skip policies/auth/import/deploy. Escape hatch for debugging. | -| `--with forgejo[,…]` | Deploy these services after the cluster is up. | -| `--import-env PATH` | Plaintext `.env` from the old stack. Optional. | -| `--import-sops PATH` | Sops-encrypted `.env.vault.enc` from the old stack. Requires `--age-key`. | -| `--age-key PATH` | Age keyfile used to decrypt `--import-sops`. Requires `--import-sops`. | -| `--dry-run` | Print the full plan (cluster-up + policies + auth + import + deploy) and exit. Touches nothing. | - -### Flag validation - -- `--import-sops` without `--age-key` → error. -- `--age-key` without `--import-sops` → error. -- `--import-env` alone (no sops) → OK (imports just the plaintext `.env`). -- `--backend=docker` with any `--import-*` flag → error. -- `--empty` with any `--import-*` flag → error (mutually exclusive: `--empty` - skips the import step, so pairing them silently discards the import - intent). - -## Idempotency - -Every layer is idempotent by design. Re-running the same command on an -already-provisioned box is a no-op at every step: - -- **Cluster-up:** second run detects running `nomad`/`vault` systemd - units and state files, skips re-init. -- **Policies:** byte-for-byte compare against on-server policy text; - "unchanged" for every untouched file. -- **Auth:** skips auth-method create if `jwt-nomad/` already enabled, - skips config write if the JWKS + algs match, skips server.hcl write if - the file on disk is identical to the repo copy. -- **Import:** KV v2 writes overwrite in place (same path, same keys, - same values → no new version). -- **Deploy:** `nomad job run` is declarative; same jobspec → no new - allocation. - -## Dry-run - -```bash -./bin/disinto init --backend=nomad \ - --import-env /tmp/.env \ - --import-sops /tmp/.env.vault.enc \ - --age-key /tmp/keys.txt \ - --with forgejo \ - --dry-run -``` - -Prints the five-section plan — cluster-up, policies, auth, import, -deploy — with every path and every argv that would be executed. No -network, no sudo, no state mutation. See -`tests/disinto-init-nomad.bats` for the exact output shape. - -## No-import path - -If you already have `kv/disinto/*` seeded by other means (manual -`vault kv put`, a replica, etc.), omit all three `--import-*` flags. -`disinto init --backend=nomad --with forgejo` still applies policies, -configures auth, and deploys — but skips the import step with: - -``` -[import] no --import-env/--import-sops — skipping; set them or seed kv/disinto/* manually before deploying secret-dependent services -``` - -Forgejo's template stanza will fail to render (and thus the allocation -will stall) until those KV paths exist — so either import them or seed -them first. - -## Secret hygiene - -- Never log a secret value. The CLI only prints paths (`--import-env`, - `--age-key`) and KV *paths* (`kv/disinto/bots/review/token`), never - the values themselves. `tools/vault-import.sh` is the only thing that - reads the values, and it pipes them directly into Vault's HTTP API. -- The age keyfile must be mode 0400 — `vault-import.sh` refuses to - source a keyfile with looser permissions. -- `VAULT_ADDR` must be localhost during import — the import tool - refuses to run against a remote Vault, preventing accidental exposure. diff --git a/formulas/collect-engagement.toml b/formulas/collect-engagement.toml index 64ba54b..fdfa65e 100644 --- a/formulas/collect-engagement.toml +++ b/formulas/collect-engagement.toml @@ -50,7 +50,7 @@ description = """ Fetch today's Caddy access log segment from the remote host using SCP. The SSH key is read from the environment (CADDY_SSH_KEY), which is -decrypted from secrets/CADDY_SSH_KEY.enc by the edge entrypoint. It is NEVER hardcoded. +decrypted from .env.vault.enc by the dispatcher. It is NEVER hardcoded. 1. Write the SSH key to a temporary file with restricted permissions: _ssh_key_file=$(mktemp) diff --git a/formulas/release.sh b/formulas/release.sh index 6526d1a..b8c4eb6 100644 --- a/formulas/release.sh +++ b/formulas/release.sh @@ -178,8 +178,8 @@ log "Tagged disinto/agents:${RELEASE_VERSION}" log "Step 6/6: Restarting agent containers" -docker compose stop agents 2>/dev/null || true -docker compose up -d agents +docker compose stop agents agents-llama 2>/dev/null || true +docker compose up -d agents agents-llama log "Agent containers restarted" # ── Done ───────────────────────────────────────────────────────────────── diff --git a/formulas/release.toml b/formulas/release.toml index ccd7f95..f702f42 100644 --- a/formulas/release.toml +++ b/formulas/release.toml @@ -189,10 +189,10 @@ Restart agent containers to use the new image. - docker compose pull agents 2. Stop and remove existing agent containers: - - docker compose down agents + - docker compose down agents agents-llama 2>/dev/null || true 3. Start agents with new image: - - docker compose up -d agents + - docker compose up -d agents agents-llama 4. Wait for containers to be healthy: - for i in {1..30}; do @@ -203,7 +203,7 @@ Restart agent containers to use the new image. - done 5. Verify containers are running: - - docker compose ps agents + - docker compose ps agents agents-llama 6. Log restart: - echo "Restarted agents containers" diff --git a/formulas/rent-a-human-caddy-ssh.toml b/formulas/rent-a-human-caddy-ssh.toml index eb3aed1..57dfc77 100644 --- a/formulas/rent-a-human-caddy-ssh.toml +++ b/formulas/rent-a-human-caddy-ssh.toml @@ -79,23 +79,28 @@ AND set CADDY_ACCESS_LOG in the factory environment to match. [[steps]] id = "store-private-key" -title = "Add the private key as CADDY_SSH_KEY secret" +title = "Add the private key to .env.vault.enc as CADDY_SSH_KEY" needs = ["generate-keypair"] description = """ -Store the private key in the factory's encrypted secrets store. +Store the private key in the factory's encrypted vault secrets. -1. Add the private key using `disinto secrets add`: +1. Read the private key: + cat caddy-collect - cat caddy-collect | disinto secrets add CADDY_SSH_KEY +2. Add it to .env.vault.enc (or .env.vault for plaintext fallback) as + CADDY_SSH_KEY. The key is multi-line, so use the base64-encoded form: - This encrypts the key with age and stores it as secrets/CADDY_SSH_KEY.enc. + echo "CADDY_SSH_KEY=$(base64 -w0 caddy-collect)" >> .env.vault.enc -2. IMPORTANT: After storing, securely delete the local private key file: + Or, if using SOPS-encrypted vault, decrypt first, add the variable, + then re-encrypt. + +3. IMPORTANT: After storing, securely delete the local private key file: shred -u caddy-collect 2>/dev/null || rm -f caddy-collect rm -f caddy-collect.pub The public key is already installed on the Caddy host; the private key - now lives only in secrets/CADDY_SSH_KEY.enc. + now lives only in the vault. Never commit the private key to any git repository. """ @@ -104,19 +109,20 @@ Never commit the private key to any git repository. [[steps]] id = "store-caddy-host" -title = "Add the Caddy host details as secrets" +title = "Add the Caddy host address to .env.vault.enc as CADDY_HOST" needs = ["install-public-key"] description = """ -Store the Caddy connection details so collect-engagement.sh knows +Store the Caddy host connection string so collect-engagement.sh knows where to SSH. -1. Add each value using `disinto secrets add`: +1. Add to .env.vault.enc (or .env.vault for plaintext fallback): - echo 'disinto.ai' | disinto secrets add CADDY_SSH_HOST - echo 'debian' | disinto secrets add CADDY_SSH_USER - echo '/var/log/caddy/access.log' | disinto secrets add CADDY_ACCESS_LOG + echo "CADDY_HOST=user@caddy-host-ip-or-domain" >> .env.vault.enc - Replace values with the actual SSH host, user, and log path for your setup. + Replace user@caddy-host-ip-or-domain with the actual SSH user and host + (e.g. debian@203.0.113.42 or deploy@caddy.disinto.ai). + +2. If using SOPS, decrypt/add/re-encrypt as above. """ # ── Step 5: Test the connection ────────────────────────────────────────────── diff --git a/formulas/review-pr.toml b/formulas/review-pr.toml index ce6d2bf..fe62a89 100644 --- a/formulas/review-pr.toml +++ b/formulas/review-pr.toml @@ -213,7 +213,7 @@ should file a vault item instead of executing directly. **Exceptions** (do NOT flag these): - Code inside `vault/` — the vault system itself is allowed to handle secrets - References in comments or documentation explaining the architecture -- `bin/disinto` setup commands that manage `secrets/*.enc` and the `run` subcommand +- `bin/disinto` setup commands that manage `.env.vault.enc` and the `run` subcommand - Local operations (git push to forge, forge API calls with `FORGE_TOKEN`) ## 6. Re-review (if previous review is provided) diff --git a/formulas/run-gardener.toml b/formulas/run-gardener.toml index 427aeb3..7b0cdde 100644 --- a/formulas/run-gardener.toml +++ b/formulas/run-gardener.toml @@ -177,7 +177,7 @@ DUST (trivial — single-line edit, rename, comment, style, whitespace): VAULT (needs human decision or external resource): File a vault procurement item using vault_request(): - source "$(dirname "$0")/../lib/action-vault.sh" + source "$(dirname "$0")/../lib/vault.sh" TOML_CONTENT="# Vault action: context = \"\" unblocks = [\"#NNN\"] diff --git a/formulas/run-planner.toml b/formulas/run-planner.toml index aae72e8..ec6d6c8 100644 --- a/formulas/run-planner.toml +++ b/formulas/run-planner.toml @@ -243,7 +243,7 @@ needs = ["preflight"] [[steps]] id = "commit-ops-changes" -title = "Write tree, memory, and journal; commit and push branch" +title = "Write tree, memory, and journal; commit and push" description = """ ### 1. Write prerequisite tree Write to: $OPS_REPO_ROOT/prerequisites.md @@ -256,16 +256,14 @@ If (count - N) >= 5 or planner-memory.md missing, write to: Include: run counter marker, date, constraint focus, patterns, direction. Keep under 100 lines. Replace entire file. -### 3. Commit ops repo changes to the planner branch -Commit the ops repo changes (prerequisites, memory, vault items) and push the -branch. Do NOT push directly to $PRIMARY_BRANCH — planner-run.sh will create a -PR and walk it to merge via review-bot. +### 3. Commit ops repo changes +Commit the ops repo changes (prerequisites, memory, vault items): cd "$OPS_REPO_ROOT" git add prerequisites.md knowledge/planner-memory.md vault/pending/ git add -u if ! git diff --cached --quiet; then git commit -m "chore: planner run $(date -u +%Y-%m-%d)" - git push origin HEAD + git push origin "$PRIMARY_BRANCH" fi cd "$PROJECT_REPO_ROOT" diff --git a/formulas/run-predictor.toml b/formulas/run-predictor.toml index 14364aa..ddaa8a4 100644 --- a/formulas/run-predictor.toml +++ b/formulas/run-predictor.toml @@ -125,8 +125,8 @@ For each weakness you identify, choose one: The prediction explains the theory. The vault PR triggers the proof after human approval. When the planner runs next, evidence is already there. - Vault dispatch (requires lib/action-vault.sh): - source "$PROJECT_REPO_ROOT/lib/action-vault.sh" + Vault dispatch (requires lib/vault.sh): + source "$PROJECT_REPO_ROOT/lib/vault.sh" TOML_CONTENT="id = \"predict--\" context = \"Test prediction #: — focus: \" @@ -154,7 +154,7 @@ tea is pre-configured with login "$TEA_LOGIN" and repo "$FORGE_REPO". --title "" --body "<body>" --labels "prediction/unreviewed" 2. Dispatch formula via vault (if exploiting): - source "$PROJECT_REPO_ROOT/lib/action-vault.sh" + source "$PROJECT_REPO_ROOT/lib/vault.sh" PR_NUM=$(vault_request "predict-NNN-<formula>" "$TOML_CONTENT") # See EXPLOIT section above for TOML_CONTENT format diff --git a/formulas/run-supervisor.toml b/formulas/run-supervisor.toml index 4101252..f31e6bc 100644 --- a/formulas/run-supervisor.toml +++ b/formulas/run-supervisor.toml @@ -29,7 +29,7 @@ and injected into your prompt above. Review them now. 1. Read the injected metrics data carefully (System Resources, Docker, Active Sessions, Phase Files, Stale Phase Cleanup, Lock Files, Agent Logs, - CI Pipelines, Open PRs, Issue Status, Stale Worktrees, **Woodpecker Agent Health**). + CI Pipelines, Open PRs, Issue Status, Stale Worktrees). Note: preflight.sh auto-removes PHASE:escalate files for closed issues (24h grace period). Check the "Stale Phase Cleanup" section for any files cleaned or in grace period this run. @@ -75,10 +75,6 @@ Categorize every finding from the metrics into priority levels. - Dev/action sessions in PHASE:escalate for > 24h (session timeout) (Note: PHASE:escalate files for closed issues are auto-cleaned by preflight; this check covers sessions where the issue is still open) -- **Woodpecker agent unhealthy** — see "Woodpecker Agent Health" section in preflight: - - Container not running or in unhealthy state - - gRPC errors >= 3 in last 20 minutes - - Fast-failure pipelines (duration < 60s) >= 3 in last 15 minutes ### P3 — Factory degraded - PRs stale: CI finished >20min ago AND no git push to the PR branch since CI completed @@ -104,15 +100,6 @@ For each finding from the health assessment, decide and execute an action. ### Auto-fixable (execute these directly) -**P2 Woodpecker agent unhealthy:** -The supervisor-run.sh script automatically handles WP agent recovery: -- Detects unhealthy state via preflight.sh health checks -- Restarts container via `docker restart` -- Scans for `blocked: ci_exhausted` issues updated in last 30 minutes -- Unassigns and removes blocked label from affected issues -- Posts recovery comment with infra-flake context -- Avoids duplicate restarts via 5-minute cooldown in history file - **P0 Memory crisis:** # Kill stale one-shot claude processes (>3h old) pgrep -f "claude -p" --older 10800 2>/dev/null | xargs kill 2>/dev/null || true @@ -261,11 +248,6 @@ Format: - <what was fixed> (or "No actions needed") - ### WP Agent Recovery (if applicable) - - WP agent restart: <time of restart or "none"> - - Issues recovered: <count> - - Reason: <health check reason or "healthy"> - ### Vault items filed - vault/pending/<id>.md — <reason> (or "None") diff --git a/gardener/AGENTS.md b/gardener/AGENTS.md index fdfae86..8d4c3af 100644 --- a/gardener/AGENTS.md +++ b/gardener/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: 8fc3ba5b59cd6cb15bd01ca0658cfea2bcb12068 --> +<!-- last-reviewed: cdb0408466d875cdd893c712e3b3f0d0b909d4ef --> # Gardener Agent **Role**: Backlog grooming — detect duplicate issues, missing acceptance @@ -32,7 +32,7 @@ the gardener runs as part of the polling loop alongside the planner, predictor, PR, reviewed alongside AGENTS.md changes, executed by gardener-run.sh after merge. **Environment variables consumed**: -- `FORGE_TOKEN`, `FORGE_GARDENER_TOKEN` (falls back to FORGE_TOKEN), `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`. `FORGE_TOKEN_OVERRIDE` is exported to `$FORGE_GARDENER_TOKEN` before sourcing env.sh so the gardener-bot identity survives re-sourcing (#762). +- `FORGE_TOKEN`, `FORGE_GARDENER_TOKEN` (falls back to FORGE_TOKEN), `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT` - `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by gardener-run.sh) **Lifecycle**: gardener-run.sh (invoked by polling loop every 6h, `check_active gardener`) → diff --git a/gardener/dust.jsonl b/gardener/dust.jsonl deleted file mode 100644 index 14b0d5c..0000000 --- a/gardener/dust.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"issue":915,"group":"lib/generators.sh","title":"remove no-op sed in generate_compose --build mode","reason":"sed replaces agents: with itself — no behavior change; single-line removal","ts":"2026-04-17T01:04:05Z"} diff --git a/gardener/pending-actions.json b/gardener/pending-actions.json index 724b2ee..fe51488 100644 --- a/gardener/pending-actions.json +++ b/gardener/pending-actions.json @@ -1,12 +1 @@ -[ - { - "action": "edit_body", - "issue": 996, - "body": "Flagged by AI reviewer in PR #993.\n\n## Problem\n\nThe consul-template with/else/end pattern using aggressive whitespace trimming (e.g. `{{- with secret ... -}}` / `{{- else -}}` / `{{- end }}` then immediately `{{- with`) strips all newlines between consecutive single-variable env blocks at parse time. This would render the secrets env file as one concatenated line (`GITHUB_TOKEN=valCODEBERG_TOKEN=val...`), which Nomad's `env = true` cannot parse correctly.\n\n## Why not blocked\n\nagents.hcl has been runtime-tested (S4-fix-6 and S4-fix-7 made observable runtime fixes). If the env file were broken, all bot tokens would be absent — a loud, observable failure. This suggests consul-template may handle whitespace trimming differently from raw Go text/template. Needs runtime verification.\n\n## Verification\n\nDeploy either job and inspect the rendered secrets file:\n```\nnomad alloc exec <alloc-id> cat /secrets/bots.env\n```\nConfirm each KEY=VALUE pair is on its own line.\n\n---\n*Auto-created from AI review*\n\n## Affected files\n- `nomad/jobs/agents.hcl` — bots.env template (lines 147-189)\n- `nomad/jobs/vault-runner.hcl` — runner.env template (PR #993)\n\n## Acceptance criteria\n- [ ] Deploy `agents` or `vault-runner` job on factory host\n- [ ] Inspect rendered secrets file: `nomad alloc exec <alloc-id> cat /secrets/bots.env`\n- [ ] Confirm each KEY=VALUE pair is on its own line (not concatenated)\n- [ ] If broken: fix whitespace trimming to preserve newlines between blocks; if fine, close as not-a-bug" - }, - { - "action": "add_label", - "issue": 996, - "label": "backlog" - } -] +[] diff --git a/lib/AGENTS.md b/lib/AGENTS.md index 146648a..0f53ef8 100644 --- a/lib/AGENTS.md +++ b/lib/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: 8fc3ba5b59cd6cb15bd01ca0658cfea2bcb12068 --> +<!-- last-reviewed: cdb0408466d875cdd893c712e3b3f0d0b909d4ef --> # Shared Helpers (`lib/`) All agents source `lib/env.sh` as their first action. Additional helpers are @@ -6,7 +6,7 @@ sourced as needed. | File | What it provides | Sourced by | |---|---|---| -| `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (paginates all pages; accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`; handles invalid/empty JSON responses gracefully — returns empty on parse error instead of crashing), `woodpecker_api()`, `wpdb()`, `memory_guard()` (skips agent if RAM < threshold), `load_secret()` (secret-source abstraction — see below). Auto-loads project TOML if `PROJECT_TOML` is set. Exports per-agent tokens (`FORGE_PLANNER_TOKEN`, `FORGE_GARDENER_TOKEN`, `FORGE_VAULT_TOKEN`, `FORGE_SUPERVISOR_TOKEN`, `FORGE_PREDICTOR_TOKEN`) — each falls back to `$FORGE_TOKEN` if not set. **Vault-only token guard (AD-006)**: `unset GITHUB_TOKEN CLAWHUB_TOKEN` so agents never hold external-action tokens — only the runner container receives them. **Container note**: when `DISINTO_CONTAINER=1`, `.env` is NOT re-sourced — compose already injects env vars (including `FORGE_URL=http://forgejo:3000`) and re-sourcing would clobber them. **Save/restore scope (#364)**: only `FORGE_URL` is preserved across `.env` re-sourcing (compose injects `http://forgejo:3000`, `.env` has `http://localhost:3000`). `FORGE_TOKEN` is NOT preserved so refreshed tokens in `.env` take effect immediately. **Per-agent token override (#762)**: agent run scripts export `FORGE_TOKEN_OVERRIDE=<agent-specific-token>` BEFORE sourcing `env.sh`; `env.sh` applies this override at lines 98-100, ensuring the correct identity survives any re-sourcing of `env.sh` by nested shells or `claude -p` invocations. **Required env var**: `FORGE_PASS` — bot password for git HTTP push (Forgejo 11.x rejects API tokens for `git push`, #361). **Hard preconditions (#674)**: `USER` and `HOME` must be exported by the entrypoint before sourcing. When `PROJECT_TOML` is set, `PROJECT_REPO_ROOT`, `PRIMARY_BRANCH`, and `OPS_REPO_ROOT` must also be set (by entrypoint or TOML). **`load_secret NAME [DEFAULT]` (#793)**: backend-agnostic secret resolution. Precedence: (1) `/secrets/<NAME>.env` — Nomad-rendered template, (2) current environment — already set by `.env.enc` / compose, (3) `secrets/<NAME>.enc` — age-encrypted per-key file (decrypted on demand, cached in process env), (4) DEFAULT or empty. Consumers call `$(load_secret GITHUB_TOKEN)` instead of `${GITHUB_TOKEN}` — identical behavior whether secrets come from Docker compose injection or Nomad Vault templates. | Every agent | +| `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (paginates all pages; accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`; handles invalid/empty JSON responses gracefully — returns empty on parse error instead of crashing), `woodpecker_api()`, `wpdb()`, `memory_guard()` (skips agent if RAM < threshold). Auto-loads project TOML if `PROJECT_TOML` is set. Exports per-agent tokens (`FORGE_PLANNER_TOKEN`, `FORGE_GARDENER_TOKEN`, `FORGE_VAULT_TOKEN`, `FORGE_SUPERVISOR_TOKEN`, `FORGE_PREDICTOR_TOKEN`) — each falls back to `$FORGE_TOKEN` if not set. **Vault-only token guard (AD-006)**: `unset GITHUB_TOKEN CLAWHUB_TOKEN` so agents never hold external-action tokens — only the runner container receives them. **Container note**: when `DISINTO_CONTAINER=1`, `.env` is NOT re-sourced — compose already injects env vars (including `FORGE_URL=http://forgejo:3000`) and re-sourcing would clobber them. **Save/restore scope (#364)**: only `FORGE_URL` is preserved across `.env` re-sourcing (compose injects `http://forgejo:3000`, `.env` has `http://localhost:3000`). `FORGE_TOKEN` is NOT preserved so refreshed tokens in `.env` take effect immediately. **Required env var**: `FORGE_PASS` — bot password for git HTTP push (Forgejo 11.x rejects API tokens for `git push`, #361). **Hard preconditions (#674)**: `USER` and `HOME` must be exported by the entrypoint before sourcing. When `PROJECT_TOML` is set, `PROJECT_REPO_ROOT`, `PRIMARY_BRANCH`, and `OPS_REPO_ROOT` must also be set (by entrypoint or TOML). | Every agent | | `lib/ci-helpers.sh` | `ci_passed()` — returns 0 if CI state is "success" (or no CI configured). `ci_required_for_pr()` — returns 0 if PR has code files (CI required), 1 if non-code only (CI not required). `is_infra_step()` — returns 0 if a single CI step failure matches infra heuristics (clone/git exit 128, any exit 137, log timeout patterns). `classify_pipeline_failure()` — returns "infra \<reason>" if any failed Woodpecker step matches infra heuristics via `is_infra_step()`, else "code". `ensure_priority_label()` — looks up (or creates) the `priority` label and returns its ID; caches in `_PRIORITY_LABEL_ID`. `ci_commit_status <sha>` — queries Woodpecker directly for CI state, falls back to forge commit status API. `ci_pipeline_number <sha>` — returns the Woodpecker pipeline number for a commit, falls back to parsing forge status `target_url`. `ci_promote <repo_id> <pipeline_num> <environment>` — promotes a pipeline to a named Woodpecker environment (vault-gated deployment: vault approves, vault-fire calls this — vault redesign in progress, see #73-#77). `ci_get_logs <pipeline_number> [--step <name>]` — reads CI logs from Woodpecker SQLite database via `lib/ci-log-reader.py`; outputs last 200 lines to stdout. Requires mounted woodpecker-data volume at /woodpecker-data. | dev-poll, review-poll, review-pr | | `lib/ci-debug.sh` | CLI tool for Woodpecker CI: `list`, `status`, `logs`, `failures` subcommands. Not sourced — run directly. | Humans / dev-agent (tool access) | | `lib/ci-log-reader.py` | Python tool: reads CI logs from Woodpecker SQLite database. `<pipeline_number> [--step <name>]` — returns last 200 lines from failed steps (or specified step). Used by `ci_get_logs()` in ci-helpers.sh. Requires `WOODPECKER_DATA_DIR` (default: /woodpecker-data). | ci-helpers.sh | @@ -14,7 +14,7 @@ sourced as needed. | `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` / `blocked by #N` patterns. Inline scan skips fenced code blocks to prevent false positives from code examples in issue bodies. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll | | `lib/formula-session.sh` | `acquire_run_lock()`, `load_formula()`, `load_formula_or_profile()`, `build_context_block()`, `ensure_ops_repo()`, `ops_commit_and_push()`, `build_prompt_footer()`, `build_sdk_prompt_footer()`, `formula_worktree_setup()`, `formula_prepare_profile_context()`, `formula_lessons_block()`, `profile_write_journal()`, `profile_load_lessons()`, `ensure_profile_repo()`, `_profile_has_repo()`, `_count_undigested_journals()`, `_profile_digest_journals()`, `_profile_restore_lessons()`, `_profile_commit_and_push()`, `resolve_agent_identity()`, `build_graph_section()`, `build_scratch_instruction()`, `read_scratch_context()`, `cleanup_stale_crashed_worktrees()` — shared helpers for formula-driven polling-loop agents (lock, .profile repo management, prompt assembly, worktree setup). Memory guard is provided by `memory_guard()` in `lib/env.sh` (not duplicated here). `resolve_agent_identity()` — sets `FORGE_TOKEN`, `AGENT_IDENTITY`, `FORGE_REMOTE` from per-agent token env vars and FORGE_URL remote detection. `build_graph_section()` generates the structural-analysis section (runs `lib/build-graph.py`, formats JSON output) — previously duplicated in planner-run.sh and predictor-run.sh, now shared here. `cleanup_stale_crashed_worktrees()` — thin wrapper around `worktree_cleanup_stale()` from `lib/worktree.sh` (kept for backwards compatibility). **Journal digestion guards (#702)**: `_profile_digest_journals()` respects `PROFILE_DIGEST_TIMEOUT` (default 300s) and `PROFILE_DIGEST_MAX_BATCH` (default 5 journals per run); `_profile_restore_lessons()` restores the previous lessons-learned.md on digest failure. | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh, dev-agent.sh | | `lib/guard.sh` | `check_active(agent_name)` — reads `$FACTORY_ROOT/state/.{agent_name}-active`; exits 0 (skip) if the file is absent. Factory is off by default — state files must be created to enable each agent. **Logs a message to stderr** when skipping (`[check_active] SKIP: state file not found`), so agent dropout is visible in loop logs. Sourced by dev-poll.sh, review-poll.sh, predictor-run.sh, supervisor-run.sh. | polling-loop entry points | -| `lib/mirrors.sh` | `mirror_push()` — pushes `$PRIMARY_BRANCH` + tags to all configured mirror remotes (fire-and-forget background pushes). Reads `MIRROR_NAMES` and `MIRROR_*` vars exported by `load-project.sh` from the `[mirrors]` TOML section. Failures are logged but never block the pipeline. `mirror_pull_register(clone_url, owner, repo_name, [interval])` — registers a Forgejo pull mirror via `POST /repos/migrate` with `mirror: true`. Creates the target repo and queues the first sync automatically. Works against empty Forgejo instances — no pre-existing content required. Used for Nomad migration cutover: point at Codeberg source, wait for sync, then proceed with `disinto init`. See [docs/mirror-bootstrap.md](../docs/mirror-bootstrap.md) for the full cutover path. Sourced by dev-poll.sh — called after every successful merge. | dev-poll.sh | +| `lib/mirrors.sh` | `mirror_push()` — pushes `$PRIMARY_BRANCH` + tags to all configured mirror remotes (fire-and-forget background pushes). Reads `MIRROR_NAMES` and `MIRROR_*` vars exported by `load-project.sh` from the `[mirrors]` TOML section. Failures are logged but never block the pipeline. Sourced by dev-poll.sh — called after every successful merge. | dev-poll.sh | | `lib/build-graph.py` | Python tool: parses VISION.md, prerequisites.md (from ops repo), AGENTS.md, formulas/*.toml, evidence/ (from ops repo), and forge issues/labels into a NetworkX DiGraph. Runs structural analyses (orphaned objectives, stale prerequisites, thin evidence, circular deps) and outputs a JSON report. Used by `review-pr.sh` (per-PR changed-file analysis) and `predictor-run.sh` (full-project analysis) to provide structural context to Claude. | review-pr.sh, predictor-run.sh | | `lib/secret-scan.sh` | `scan_for_secrets()` — detects potential secrets (API keys, bearer tokens, private keys, URLs with embedded credentials) in text; returns 1 if secrets found. `redact_secrets()` — replaces detected secret patterns with `[REDACTED]`. | issue-lifecycle.sh | | `lib/stack-lock.sh` | File-based lock protocol for singleton project stack access. `stack_lock_acquire(holder, project)` — polls until free, breaks stale heartbeats (>10 min old), claims lock. `stack_lock_release(project)` — deletes lock file. `stack_lock_check(project)` — inspect current lock state. `stack_lock_heartbeat(project)` — update heartbeat timestamp (callers must call every 2 min while holding). Lock files at `~/data/locks/<project>-stack.lock`. | docker/edge/dispatcher.sh, reproduce formula | @@ -22,7 +22,7 @@ sourced as needed. | `lib/worktree.sh` | Reusable git worktree management: `worktree_create(path, branch, [base_ref])` — create worktree, checkout base, fetch submodules. `worktree_recover(path, branch, [remote])` — detect existing worktree, reuse if on correct branch (sets `_WORKTREE_REUSED`), otherwise clean and recreate. `worktree_cleanup(path)` — `git worktree remove --force`, clear Claude Code project cache (`~/.claude/projects/` matching path). `worktree_cleanup_stale([max_age_hours])` — scan `/tmp` for orphaned worktrees older than threshold, skip preserved and active tmux worktrees, prune. `worktree_preserve(path, reason)` — mark worktree as preserved for debugging (writes `.worktree-preserved` marker, skipped by stale cleanup). | dev-agent.sh, supervisor-run.sh, planner-run.sh, predictor-run.sh, gardener-run.sh | | `lib/pr-lifecycle.sh` | Reusable PR lifecycle library: `pr_create()`, `pr_find_by_branch()`, `pr_poll_ci()`, `pr_poll_review()`, `pr_merge()`, `pr_is_merged()`, `pr_walk_to_merge()`, `build_phase_protocol_prompt()`. Requires `lib/ci-helpers.sh`. | dev-agent.sh (future) | | `lib/issue-lifecycle.sh` | Reusable issue lifecycle library: `issue_claim()` (add in-progress, remove backlog), `issue_release()` (remove in-progress, add backlog), `issue_block()` (post diagnostic comment with secret redaction, add blocked label), `issue_close()`, `issue_check_deps()` (parse deps, check transitive closure; sets `_ISSUE_BLOCKED_BY`, `_ISSUE_SUGGESTION`), `issue_suggest_next()` (find next unblocked backlog issue; sets `_ISSUE_NEXT`), `issue_post_refusal()` (structured refusal comment with dedup). Label IDs cached in globals on first lookup. Sources `lib/secret-scan.sh`. | dev-agent.sh (future) | -| `lib/action-vault.sh` | **Vault PR helper** — create vault action PRs on ops repo via Forgejo API (works from containers without SSH). `vault_request <action_id> <toml_content>` validates TOML (using `validate_vault_action` from `action-vault/vault-env.sh`), creates branch `vault/<action-id>`, writes `vault/actions/<action-id>.toml`, creates PR targeting `main` with title `vault: <action-id>` and body from context field, returns PR number. Idempotent: if PR exists, returns existing number. **Low-tier bypass**: if the action's `blast_radius` classifies as `low` (via `action-vault/classify.sh`), `vault_request` calls `_vault_commit_direct()` which commits directly to ops `main` using `FORGE_ADMIN_TOKEN` — no PR, no approval wait. Returns `0` (not a PR number) for direct commits. Requires `FORGE_TOKEN`, `FORGE_ADMIN_TOKEN` (low-tier only), `FORGE_URL`, `FORGE_REPO`, `FORGE_OPS_REPO`. Uses the calling agent's own token (saves/restores `FORGE_TOKEN` around sourcing `vault-env.sh`), so approval workflow respects individual agent identities. | dev-agent (vault actions), future vault dispatcher | +| `lib/vault.sh` | **Vault PR helper** — create vault action PRs on ops repo via Forgejo API (works from containers without SSH). `vault_request <action_id> <toml_content>` validates TOML (using `validate_vault_action` from `vault/vault-env.sh`), creates branch `vault/<action-id>`, writes `vault/actions/<action-id>.toml`, creates PR targeting `main` with title `vault: <action-id>` and body from context field, returns PR number. Idempotent: if PR exists, returns existing number. **Low-tier bypass**: if the action's `blast_radius` classifies as `low` (via `vault/classify.sh`), `vault_request` calls `_vault_commit_direct()` which commits directly to ops `main` using `FORGE_ADMIN_TOKEN` — no PR, no approval wait. Returns `0` (not a PR number) for direct commits. Requires `FORGE_TOKEN`, `FORGE_ADMIN_TOKEN` (low-tier only), `FORGE_URL`, `FORGE_REPO`, `FORGE_OPS_REPO`. Uses the calling agent's own token (saves/restores `FORGE_TOKEN` around sourcing `vault-env.sh`), so approval workflow respects individual agent identities. | dev-agent (vault actions), future vault dispatcher | | `lib/branch-protection.sh` | Branch protection helpers for Forgejo repos. `setup_vault_branch_protection()` — configures admin-only merge protection on main (require 1 approval, restrict merge to admin role, block direct pushes). `setup_profile_branch_protection()` — same protection for `.profile` repos. `verify_branch_protection()` — checks protection is correctly configured. `remove_branch_protection()` — removes protection (cleanup/testing). Handles race condition after initial push: retries with backoff if Forgejo hasn't processed the branch yet. Requires `FORGE_TOKEN`, `FORGE_URL`, `FORGE_OPS_REPO`. | bin/disinto (hire-an-agent) | | `lib/agent-sdk.sh` | `agent_run([--resume SESSION_ID] [--worktree DIR] PROMPT)` — one-shot `claude -p` invocation with session persistence. Saves session ID to `SID_FILE`, reads it back on resume. `agent_recover_session()` — restore previous session ID from `SID_FILE` on startup. **Nudge guard**: skips nudge injection if the worktree is clean and no push is expected, preventing spurious re-invocations. Callers must define `SID_FILE`, `LOGFILE`, and `log()` before sourcing. **Concurrency**: external `flock` on `session.lock` is gated behind `CLAUDE_EXTERNAL_LOCK=1` (default off). When unset, each container's per-session `CLAUDE_CONFIG_DIR` isolation lets Claude Code's native lockfile handle OAuth refresh — no external serialization needed. Set `CLAUDE_EXTERNAL_LOCK=1` to re-enable the old flock wrapper as a rollback mechanism. See [`docs/CLAUDE-AUTH-CONCURRENCY.md`](../docs/CLAUDE-AUTH-CONCURRENCY.md) and AD-002 (#647). | formula-driven agents (dev-agent, planner-run, predictor-run, gardener-run) | | `lib/forge-setup.sh` | `setup_forge()` — Forgejo instance provisioning: creates admin user, bot accounts, org, repos (code + ops), configures webhooks, sets repo topics. Extracted from `bin/disinto`. Requires `FORGE_URL`, `FORGE_TOKEN`, `FACTORY_ROOT`. **Password storage (#361)**: after creating each bot account, stores its password in `.env` as `FORGE_<BOT>_PASS` (e.g. `FORGE_PASS`, `FORGE_REVIEW_PASS`, etc.) for use by `forge-push.sh`. | bin/disinto (init) | @@ -30,9 +30,6 @@ sourced as needed. | `lib/git-creds.sh` | Shared git credential helper configuration. `configure_git_creds([HOME_DIR] [RUN_AS_CMD])` — writes a static credential helper script and configures git globally to use password-based HTTP auth (Forgejo 11.x rejects API tokens for `git push`, #361). **Retry on cold boot (#741)**: resolves bot username from `FORGE_TOKEN` with 5 retries (exponential backoff 1-5s); fails loudly and returns 1 if Forgejo is unreachable — never falls back to a wrong hardcoded default (exports `BOT_USER` on success). `repair_baked_cred_urls([--as RUN_AS_CMD] DIR ...)` — rewrites any git remote URLs that have credentials baked in to use clean URLs instead; uses `safe.directory` bypass for root-owned repos (#671). Requires `FORGE_PASS`, `FORGE_URL`, `FORGE_TOKEN`. | entrypoints (agents, edge) | | `lib/ops-setup.sh` | `setup_ops_repo()` — creates ops repo on Forgejo if it doesn't exist, configures bot collaborators, clones/initializes ops repo locally, seeds directory structure (vault, knowledge, evidence, sprints). Evidence subdirectories seeded: engagement/, red-team/, holdout/, evolution/, user-test/. Also seeds sprints/ for architect output. Exports `_ACTUAL_OPS_SLUG`. `migrate_ops_repo(ops_root, [primary_branch])` — idempotent migration helper that seeds missing directories and .gitkeep files on existing ops repos (pre-#407 deployments). | bin/disinto (init) | | `lib/ci-setup.sh` | `_install_cron_impl()` — installs crontab entries for bare-metal deployments (compose mode uses polling loop instead). `_create_forgejo_oauth_app()` — generic helper to create an OAuth2 app on Forgejo (shared by Woodpecker and chat). `_create_woodpecker_oauth_impl()` — creates Woodpecker OAuth2 app (thin wrapper). `_create_chat_oauth_impl()` — creates disinto-chat OAuth2 app, writes `CHAT_OAUTH_CLIENT_ID`/`CHAT_OAUTH_CLIENT_SECRET` to `.env` (#708). `_generate_woodpecker_token_impl()` — auto-generates WOODPECKER_TOKEN via OAuth2 flow. `_activate_woodpecker_repo_impl()` — activates repo in Woodpecker. All gated by `_load_ci_context()` which validates required env vars. | bin/disinto (init) | -| `lib/generators.sh` | Template generation for `disinto init`: `generate_compose()` — docker-compose.yml (uses `codeberg.org/forgejo/forgejo:11.0` tag; `CLAUDE_BIN_DIR` volume mount removed from agents/llama services — only `reproduce` and `edge` still use the host-mounted CLI (#992); adds `security_opt: [apparmor:unconfined]` to all services for rootless container compatibility; Forgejo includes a healthcheck so dependent services use `condition: service_healthy` — fixes cold-start races, #665; adds `chat` service block with isolated `chat-config` named volume and `CHAT_HISTORY_DIR` bind-mount for per-user NDJSON history persistence (#710); injects `FORWARD_AUTH_SECRET` for Caddy↔chat defense-in-depth auth (#709); cost-cap env vars `CHAT_MAX_REQUESTS_PER_HOUR`, `CHAT_MAX_REQUESTS_PER_DAY`, `CHAT_MAX_TOKENS_PER_DAY` (#711); subdomain fallback comment for `EDGE_TUNNEL_FQDN_*` vars (#713); all `depends_on` now use `condition: service_healthy/started` instead of bare service names; all services now include `restart: unless-stopped` including the edge service — #768; agents service now uses `image: ghcr.io/disinto/agents:${DISINTO_IMAGE_TAG:-latest}` instead of `build:` (#429); `WOODPECKER_PLUGINS_PRIVILEGED` env var added to woodpecker service (#779); agents-llama conditional block gated on `ENABLE_LLAMA_AGENT=1` (#769); `agents-llama-all` compose service (profile `agents-llama-all`, all 7 roles: review,dev,gardener,architect,planner,predictor,supervisor) added by #801; agents service gains volume mounts for `./projects`, `./.env`, `./state`), `generate_caddyfile()` — Caddyfile (routes: `/forge/*` → forgejo:3000, `/woodpecker/*` → woodpecker:8000, `/staging/*` → staging:80; `/chat/login` and `/chat/oauth/callback` bypass `forward_auth` so unauthenticated users can reach the OAuth flow; `/chat/*` gated by `forward_auth` on `chat:8080/chat/auth/verify` which stamps `X-Forwarded-User` (#709); root `/` redirects to `/forge/`), `generate_staging_index()` — staging index, `generate_deploy_pipelines()` — Woodpecker deployment pipeline configs. Requires `FACTORY_ROOT`, `PROJECT_NAME`, `PRIMARY_BRANCH`. | bin/disinto (init) | -| `lib/sprint-filer.sh` | Post-merge sub-issue filer for sprint PRs. Invoked by the `.woodpecker/ops-filer.yml` pipeline after a sprint PR merges to ops repo `main`. Parses `<!-- filer:begin --> ... <!-- filer:end -->` blocks from sprint PR bodies to extract sub-issue definitions, creates them on the project repo using `FORGE_FILER_TOKEN` (narrow-scope `filer-bot` identity with `issues:write` only), adds `in-progress` label to the parent vision issue, and handles vision lifecycle closure when all sub-issues are closed. Uses `filer_api_all()` for paginated fetches. Idempotent: uses `<!-- decomposed-from: #<vision>, sprint: <slug>, id: <id> -->` markers to skip already-filed issues. Requires `FORGE_FILER_TOKEN`, `FORGE_API`, `FORGE_API_BASE`, `FORGE_OPS_REPO`. | `.woodpecker/ops-filer.yml` (CI pipeline on ops repo) | +| `lib/generators.sh` | Template generation for `disinto init`: `generate_compose()` — docker-compose.yml (uses `codeberg.org/forgejo/forgejo:11.0` tag; adds `security_opt: [apparmor:unconfined]` to all services for rootless container compatibility; Forgejo includes a healthcheck so dependent services use `condition: service_healthy` — fixes cold-start races, #665; adds `chat` service block with isolated `chat-config` named volume and `CHAT_HISTORY_DIR` bind-mount for per-user NDJSON history persistence (#710); injects `FORWARD_AUTH_SECRET` for Caddy↔chat defense-in-depth auth (#709); cost-cap env vars `CHAT_MAX_REQUESTS_PER_HOUR`, `CHAT_MAX_REQUESTS_PER_DAY`, `CHAT_MAX_TOKENS_PER_DAY` (#711); subdomain fallback comment for `EDGE_TUNNEL_FQDN_*` vars (#713); all `depends_on` now use `condition: service_healthy/started` instead of bare service names), `generate_caddyfile()` — Caddyfile (routes: `/forge/*` → forgejo:3000, `/woodpecker/*` → woodpecker:8000, `/staging/*` → staging:80; `/chat/login` and `/chat/oauth/callback` bypass `forward_auth` so unauthenticated users can reach the OAuth flow; `/chat/*` gated by `forward_auth` on `chat:8080/chat/auth/verify` which stamps `X-Forwarded-User` (#709); root `/` redirects to `/forge/`), `generate_staging_index()` — staging index, `generate_deploy_pipelines()` — Woodpecker deployment pipeline configs. Requires `FACTORY_ROOT`, `PROJECT_NAME`, `PRIMARY_BRANCH`. | bin/disinto (init) | | `lib/hire-agent.sh` | `disinto_hire_an_agent()` — user creation, `.profile` repo setup, formula copying, branch protection, and state marker creation for hiring a new agent. Requires `FORGE_URL`, `FORGE_TOKEN`, `FACTORY_ROOT`, `PROJECT_NAME`. Extracted from `bin/disinto`. | bin/disinto (hire) | | `lib/release.sh` | `disinto_release()` — vault TOML creation, branch setup on ops repo, PR creation, and auto-merge request for a versioned release. `_assert_release_globals()` validates required env vars. Requires `FORGE_URL`, `FORGE_TOKEN`, `FORGE_OPS_REPO`, `FACTORY_ROOT`, `PRIMARY_BRANCH`. Extracted from `bin/disinto`. | bin/disinto (release) | -| `lib/hvault.sh` | HashiCorp Vault helper module. `hvault_kv_get(PATH, [KEY])` — read KV v2 secret, optionally extract one key. `hvault_kv_put(PATH, KEY=VAL ...)` — write KV v2 secret. `hvault_kv_list(PATH)` — list keys at a KV path. `hvault_get_or_empty(PATH)` — GET /v1/PATH; 200→raw body, 404→empty, else structured error + return 1 (used by sync scripts to distinguish "absent, create" from hard failure without tripping errexit, #881). `hvault_ensure_kv_v2(MOUNT, [LOG_PREFIX])` — idempotent KV v2 mount assertion: enables mount if absent, fails loudly if present as wrong type/version. Extracted from all `vault-seed-*.sh` scripts to eliminate dup-detector violations. Respects `DRY_RUN=1`. `hvault_policy_apply(NAME, FILE)` — idempotent policy upsert. `hvault_jwt_login(ROLE, JWT)` — exchange JWT for short-lived token. `hvault_token_lookup()` — returns TTL/policies/accessor for current token. `_hvault_seed_key(PATH, KEY, [GENERATOR])` — seed one KV key if absent; reads existing data and merges to preserve sibling keys (KV v2 replaces atomically); returns 0=created, 1=unchanged, 2=API error (#992). All functions use `VAULT_ADDR` + `VAULT_TOKEN` from env (fallback: `/etc/vault.d/root.token`), emit structured JSON errors to stderr on failure. Tests: `tests/lib-hvault.bats` (requires `vault server -dev`). | `tools/vault-apply-policies.sh`, `tools/vault-apply-roles.sh`, `lib/init/nomad/vault-nomad-auth.sh`, `tools/vault-seed-*.sh` | -| `lib/init/nomad/` | Nomad+Vault installer scripts. `cluster-up.sh` — idempotent Step-0 orchestrator that runs all steps in order (installs packages, writes HCL, enables systemd units, unseals Vault); uses `poll_until_healthy()` helper for deduped readiness polling; `HOST_VOLUME_DIRS` array now includes `/srv/disinto/docker` (for staging file-server, S5.2, #989, #992). `install.sh` — installs pinned Nomad+Vault apt packages. `vault-init.sh` — initializes Vault (unseal keys → `/etc/vault.d/`), creates dev-persisted unseal unit. `lib-systemd.sh` — shared systemd unit helpers. `systemd-nomad.sh`, `systemd-vault.sh` — write and enable service units. `vault-nomad-auth.sh` — Step-2 script that enables Vault's JWT auth at path `jwt-nomad`, writes the JWKS/algs config pointing at Nomad's workload-identity signer, delegates role sync to `tools/vault-apply-roles.sh`, installs `/etc/nomad.d/server.hcl`, and SIGHUPs `nomad.service` if the file changed (#881). `wp-oauth-register.sh` — S3.3 script that creates the Woodpecker OAuth2 app in Forgejo and stores `forgejo_client`/`forgejo_secret` in Vault KV v2 at `kv/disinto/shared/woodpecker`; idempotent (skips if app or secrets already present); called by `bin/disinto --with woodpecker`. `deploy.sh` — S4 dependency-ordered Nomad job deploy + health-wait; takes a list of jobspec basenames, submits each to Nomad and polls until healthy before proceeding to the next; supports `--dry-run` and per-job timeout overrides via `JOB_READY_TIMEOUT_<JOBNAME>`; invoked by `bin/disinto --with <svc>` and `cluster-up.sh`; deploy order now covers staging, chat, edge (S5.5, #992). Idempotent: each step checks current state before acting. Sourced and called by `cluster-up.sh`; not sourced by agents. | `bin/disinto init --backend=nomad` | diff --git a/lib/env.sh b/lib/env.sh index 85acb34..e91412c 100755 --- a/lib/env.sh +++ b/lib/env.sh @@ -158,8 +158,8 @@ export WOODPECKER_SERVER="${WOODPECKER_SERVER:-http://localhost:8000}" export CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-7200}" # Vault-only token guard (#745): external-action tokens (GITHUB_TOKEN, CLAWHUB_TOKEN) -# must NEVER be available to agents. They live in secrets/*.enc and are decrypted -# only into the ephemeral runner container at fire time (#777). Unset them here so +# must NEVER be available to agents. They live in .env.vault.enc and are injected +# only into the ephemeral runner container at fire time. Unset them here so # even an accidental .env inclusion cannot leak them into agent sessions. unset GITHUB_TOKEN 2>/dev/null || true unset CLAWHUB_TOKEN 2>/dev/null || true @@ -313,68 +313,6 @@ memory_guard() { fi } -# ============================================================================= -# SECRET LOADING ABSTRACTION -# ============================================================================= -# load_secret NAME [DEFAULT] -# -# Resolves a secret value using the following precedence: -# 1. /secrets/<NAME>.env — Nomad-rendered template (future) -# 2. Current environment — already set by .env.enc, compose, etc. -# 3. secrets/<NAME>.enc — age-encrypted per-key file (decrypted on demand) -# 4. DEFAULT (or empty) -# -# Prints the resolved value to stdout. Caches age-decrypted values in the -# process environment so subsequent calls are free. -# ============================================================================= -load_secret() { - local name="$1" - local default="${2:-}" - - # 1. Nomad-rendered template (future: Nomad writes /secrets/<NAME>.env) - local nomad_path="/secrets/${name}.env" - if [ -f "$nomad_path" ]; then - # Source into a subshell to extract just the value - local _nomad_val - _nomad_val=$( - set -a - # shellcheck source=/dev/null - source "$nomad_path" - set +a - printf '%s' "${!name:-}" - ) - if [ -n "$_nomad_val" ]; then - export "$name=$_nomad_val" - printf '%s' "$_nomad_val" - return 0 - fi - fi - - # 2. Already in environment (set by .env.enc, compose injection, etc.) - if [ -n "${!name:-}" ]; then - printf '%s' "${!name}" - return 0 - fi - - # 3. Age-encrypted per-key file: secrets/<NAME>.enc (#777) - local _age_key="${HOME}/.config/sops/age/keys.txt" - local _enc_path="${FACTORY_ROOT}/secrets/${name}.enc" - if [ -f "$_enc_path" ] && [ -f "$_age_key" ] && command -v age &>/dev/null; then - local _dec_val - if _dec_val=$(age -d -i "$_age_key" "$_enc_path" 2>/dev/null) && [ -n "$_dec_val" ]; then - export "$name=$_dec_val" - printf '%s' "$_dec_val" - return 0 - fi - fi - - # 4. Default (or empty) - if [ -n "$default" ]; then - printf '%s' "$default" - fi - return 0 -} - # Source tea helpers (available when tea binary is installed) if command -v tea &>/dev/null; then # shellcheck source=tea-helpers.sh diff --git a/lib/forge-setup.sh b/lib/forge-setup.sh index 2f8b117..b925103 100644 --- a/lib/forge-setup.sh +++ b/lib/forge-setup.sh @@ -31,9 +31,8 @@ _load_init_context() { # Execute a command in the Forgejo container (for admin operations) _forgejo_exec() { local use_bare="${DISINTO_BARE:-false}" - local cname="${FORGEJO_CONTAINER_NAME:-disinto-forgejo}" if [ "$use_bare" = true ]; then - docker exec -u git "$cname" "$@" + docker exec -u git disinto-forgejo "$@" else docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T -u git forgejo "$@" fi @@ -95,12 +94,11 @@ setup_forge() { # Bare-metal mode: standalone docker run mkdir -p "${FORGEJO_DATA_DIR}" - local cname="${FORGEJO_CONTAINER_NAME:-disinto-forgejo}" - if docker ps -a --format '{{.Names}}' | grep -q "^${cname}$"; then - docker start "$cname" >/dev/null 2>&1 || true + if docker ps -a --format '{{.Names}}' | grep -q '^disinto-forgejo$'; then + docker start disinto-forgejo >/dev/null 2>&1 || true else docker run -d \ - --name "$cname" \ + --name disinto-forgejo \ --restart unless-stopped \ -p "${forge_port}:3000" \ -p 2222:22 \ @@ -212,8 +210,8 @@ setup_forge() { # Create human user (disinto-admin) as site admin if it doesn't exist local human_user="disinto-admin" - # human_user == admin_user; reuse admin_pass for basic-auth operations - local human_pass="$admin_pass" + local human_pass + human_pass="admin-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)" if ! curl -sf --max-time 5 -H "Authorization: token ${FORGE_TOKEN:-}" "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then echo "Creating human user: ${human_user}" @@ -245,89 +243,63 @@ setup_forge() { echo "Human user: ${human_user} (already exists)" fi - # Preserve admin token if already stored in .env (idempotent re-run) - local admin_token="" - if _token_exists_in_env "FORGE_ADMIN_TOKEN" "$env_file" && [ "$rotate_tokens" = false ]; then - admin_token=$(grep '^FORGE_ADMIN_TOKEN=' "$env_file" | head -1 | cut -d= -f2-) - [ -n "$admin_token" ] && echo "Admin token: preserved (use --rotate-tokens to force)" + # Delete existing admin token if present (token sha1 is only returned at creation time) + local existing_token_id + existing_token_id=$(curl -sf \ + -u "${admin_user}:${admin_pass}" \ + "${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \ + | jq -r '.[] | select(.name == "disinto-admin-token") | .id') || existing_token_id="" + if [ -n "$existing_token_id" ]; then + curl -sf -X DELETE \ + -u "${admin_user}:${admin_pass}" \ + "${forge_url}/api/v1/users/${admin_user}/tokens/${existing_token_id}" >/dev/null 2>&1 || true fi + # Create admin token (fresh, so sha1 is returned) + local admin_token + admin_token=$(curl -sf -X POST \ + -u "${admin_user}:${admin_pass}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/users/${admin_user}/tokens" \ + -d '{"name":"disinto-admin-token","scopes":["all"]}' 2>/dev/null \ + | jq -r '.sha1 // empty') || admin_token="" + if [ -z "$admin_token" ]; then - # Delete existing admin token if present (token sha1 is only returned at creation time) - local existing_token_id - existing_token_id=$(curl -sf \ - -u "${admin_user}:${admin_pass}" \ - "${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \ - | jq -r '.[] | select(.name == "disinto-admin-token") | .id') || existing_token_id="" - if [ -n "$existing_token_id" ]; then - curl -sf -X DELETE \ - -u "${admin_user}:${admin_pass}" \ - "${forge_url}/api/v1/users/${admin_user}/tokens/${existing_token_id}" >/dev/null 2>&1 || true - fi - - # Create admin token (fresh, so sha1 is returned) - admin_token=$(curl -sf -X POST \ - -u "${admin_user}:${admin_pass}" \ - -H "Content-Type: application/json" \ - "${forge_url}/api/v1/users/${admin_user}/tokens" \ - -d '{"name":"disinto-admin-token","scopes":["all"]}' 2>/dev/null \ - | jq -r '.sha1 // empty') || admin_token="" - - if [ -z "$admin_token" ]; then - echo "Error: failed to obtain admin API token" >&2 - exit 1 - fi - - # Store admin token for idempotent re-runs - if grep -q '^FORGE_ADMIN_TOKEN=' "$env_file" 2>/dev/null; then - sed -i "s|^FORGE_ADMIN_TOKEN=.*|FORGE_ADMIN_TOKEN=${admin_token}|" "$env_file" - else - printf 'FORGE_ADMIN_TOKEN=%s\n' "$admin_token" >> "$env_file" - fi - echo "Admin token: generated and saved (FORGE_ADMIN_TOKEN)" + echo "Error: failed to obtain admin API token" >&2 + exit 1 fi - # Get or create human user token (human_user == admin_user; use admin_pass) + # Get or create human user token local human_token="" - if _token_exists_in_env "HUMAN_TOKEN" "$env_file" && [ "$rotate_tokens" = false ]; then - human_token=$(grep '^HUMAN_TOKEN=' "$env_file" | head -1 | cut -d= -f2-) - if [ -n "$human_token" ]; then - export HUMAN_TOKEN="$human_token" - echo " Human token preserved (use --rotate-tokens to force)" - fi + # Delete existing human token if present (token sha1 is only returned at creation time) + local existing_human_token_id + existing_human_token_id=$(curl -sf \ + -u "${human_user}:${human_pass}" \ + "${forge_url}/api/v1/users/${human_user}/tokens" 2>/dev/null \ + | jq -r '.[] | select(.name == "disinto-human-token") | .id') || existing_human_token_id="" + if [ -n "$existing_human_token_id" ]; then + curl -sf -X DELETE \ + -u "${human_user}:${human_pass}" \ + "${forge_url}/api/v1/users/${human_user}/tokens/${existing_human_token_id}" >/dev/null 2>&1 || true fi - if [ -z "$human_token" ]; then - # Delete existing human token if present (token sha1 is only returned at creation time) - local existing_human_token_id - existing_human_token_id=$(curl -sf \ - -u "${admin_user}:${admin_pass}" \ - "${forge_url}/api/v1/users/${human_user}/tokens" 2>/dev/null \ - | jq -r '.[] | select(.name == "disinto-human-token") | .id') || existing_human_token_id="" - if [ -n "$existing_human_token_id" ]; then - curl -sf -X DELETE \ - -u "${admin_user}:${admin_pass}" \ - "${forge_url}/api/v1/users/${human_user}/tokens/${existing_human_token_id}" >/dev/null 2>&1 || true - fi + # Create human token (fresh, so sha1 is returned) + human_token=$(curl -sf -X POST \ + -u "${human_user}:${human_pass}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/users/${human_user}/tokens" \ + -d '{"name":"disinto-human-token","scopes":["all"]}' 2>/dev/null \ + | jq -r '.sha1 // empty') || human_token="" - # Create human token (use admin_pass since human_user == admin_user) - human_token=$(curl -sf -X POST \ - -u "${admin_user}:${admin_pass}" \ - -H "Content-Type: application/json" \ - "${forge_url}/api/v1/users/${human_user}/tokens" \ - -d '{"name":"disinto-human-token","scopes":["all"]}' 2>/dev/null \ - | jq -r '.sha1 // empty') || human_token="" - - if [ -n "$human_token" ]; then - # Store human token in .env - if grep -q '^HUMAN_TOKEN=' "$env_file" 2>/dev/null; then - sed -i "s|^HUMAN_TOKEN=.*|HUMAN_TOKEN=${human_token}|" "$env_file" - else - printf 'HUMAN_TOKEN=%s\n' "$human_token" >> "$env_file" - fi - export HUMAN_TOKEN="$human_token" - echo " Human token generated and saved (HUMAN_TOKEN)" + if [ -n "$human_token" ]; then + # Store human token in .env + if grep -q '^HUMAN_TOKEN=' "$env_file" 2>/dev/null; then + sed -i "s|^HUMAN_TOKEN=.*|HUMAN_TOKEN=${human_token}|" "$env_file" + else + printf 'HUMAN_TOKEN=%s\n' "$human_token" >> "$env_file" fi + export HUMAN_TOKEN="$human_token" + echo " Human token saved (HUMAN_TOKEN)" fi # Create bot users and tokens @@ -356,6 +328,16 @@ setup_forge() { [predictor-bot]="FORGE_PREDICTOR_PASS" [architect-bot]="FORGE_ARCHITECT_PASS" ) + # Llama bot users (local-model agents) — separate from main agents + # Each llama agent gets its own Forgejo user, token, and password + local -A llama_token_vars=( + [dev-qwen]="FORGE_TOKEN_LLAMA" + [dev-qwen-nightly]="FORGE_TOKEN_LLAMA_NIGHTLY" + ) + local -A llama_pass_vars=( + [dev-qwen]="FORGE_PASS_LLAMA" + [dev-qwen-nightly]="FORGE_PASS_LLAMA_NIGHTLY" + ) local bot_user bot_pass token token_var pass_var @@ -505,12 +487,159 @@ setup_forge() { fi done + # Create llama bot users and tokens (local-model agents) + # These are separate from the main agents and get their own credentials + echo "" + echo "── Setting up llama bot users ────────────────────────────" + + local llama_user llama_pass llama_token llama_token_var llama_pass_var + for llama_user in "${!llama_token_vars[@]}"; do + llama_token_var="${llama_token_vars[$llama_user]}" + llama_pass_var="${llama_pass_vars[$llama_user]}" + + # Check if token already exists in .env + local token_exists=false + if _token_exists_in_env "$llama_token_var" "$env_file"; then + token_exists=true + fi + + # Check if password already exists in .env + local pass_exists=false + if _pass_exists_in_env "$llama_pass_var" "$env_file"; then + pass_exists=true + fi + + # Check if llama bot user exists on Forgejo + local llama_user_exists=false + if curl -sf --max-time 5 \ + -H "Authorization: token ${admin_token}" \ + "${forge_url}/api/v1/users/${llama_user}" >/dev/null 2>&1; then + llama_user_exists=true + fi + + # Skip token/password regeneration if both exist in .env and not forcing rotation + if [ "$token_exists" = true ] && [ "$pass_exists" = true ] && [ "$rotate_tokens" = false ]; then + echo " ${llama_user} token and password preserved (use --rotate-tokens to force)" + # Still export the existing token for use within this run + local existing_token existing_pass + existing_token=$(grep "^${llama_token_var}=" "$env_file" | head -1 | cut -d= -f2-) + existing_pass=$(grep "^${llama_pass_var}=" "$env_file" | head -1 | cut -d= -f2-) + export "${llama_token_var}=${existing_token}" + export "${llama_pass_var}=${existing_pass}" + continue + fi + + # Generate new credentials if: + # - Token doesn't exist (first run) + # - Password doesn't exist (first run) + # - --rotate-tokens flag is set (explicit rotation) + if [ "$llama_user_exists" = false ]; then + # User doesn't exist - create it + llama_pass="llama-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)" + echo "Creating llama bot user: ${llama_user}" + local create_output + if ! create_output=$(_forgejo_exec forgejo admin user create \ + --username "${llama_user}" \ + --password "${llama_pass}" \ + --email "${llama_user}@disinto.local" \ + --must-change-password=false 2>&1); then + echo "Error: failed to create llama bot user '${llama_user}':" >&2 + echo " ${create_output}" >&2 + exit 1 + fi + # Forgejo 11.x ignores --must-change-password=false on create; + # explicitly clear the flag so basic-auth token creation works. + _forgejo_exec forgejo admin user change-password \ + --username "${llama_user}" \ + --password "${llama_pass}" \ + --must-change-password=false + + # Verify llama bot user was actually created + if ! curl -sf --max-time 5 \ + -H "Authorization: token ${admin_token}" \ + "${forge_url}/api/v1/users/${llama_user}" >/dev/null 2>&1; then + echo "Error: llama bot user '${llama_user}' not found after creation" >&2 + exit 1 + fi + echo " ${llama_user} user created" + else + # User exists - reset password if needed + echo " ${llama_user} user exists" + if [ "$rotate_tokens" = true ] || [ "$pass_exists" = false ]; then + llama_pass="llama-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)" + _forgejo_exec forgejo admin user change-password \ + --username "${llama_user}" \ + --password "${llama_pass}" \ + --must-change-password=false || { + echo "Error: failed to reset password for existing llama bot user '${llama_user}'" >&2 + exit 1 + } + echo " ${llama_user} password reset for token generation" + else + # Password exists, get it from .env + llama_pass=$(grep "^${llama_pass_var}=" "$env_file" | head -1 | cut -d= -f2-) + fi + fi + + # Generate token via API (basic auth as the llama user) + # First, delete any existing tokens to avoid name collision + local existing_llama_token_ids + existing_llama_token_ids=$(curl -sf \ + -u "${llama_user}:${llama_pass}" \ + "${forge_url}/api/v1/users/${llama_user}/tokens" 2>/dev/null \ + | jq -r '.[].id // empty' 2>/dev/null) || existing_llama_token_ids="" + + # Delete any existing tokens for this user + if [ -n "$existing_llama_token_ids" ]; then + while IFS= read -r tid; do + [ -n "$tid" ] && curl -sf -X DELETE \ + -u "${llama_user}:${llama_pass}" \ + "${forge_url}/api/v1/users/${llama_user}/tokens/${tid}" >/dev/null 2>&1 || true + done <<< "$existing_llama_token_ids" + fi + + llama_token=$(curl -sf -X POST \ + -u "${llama_user}:${llama_pass}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/users/${llama_user}/tokens" \ + -d "{\"name\":\"disinto-${llama_user}-token\",\"scopes\":[\"all\"]}" 2>/dev/null \ + | jq -r '.sha1 // empty') || llama_token="" + + if [ -z "$llama_token" ]; then + echo "Error: failed to create API token for '${llama_user}'" >&2 + exit 1 + fi + + # Store token in .env under the llama-specific variable name + if grep -q "^${llama_token_var}=" "$env_file" 2>/dev/null; then + sed -i "s|^${llama_token_var}=.*|${llama_token_var}=${llama_token}|" "$env_file" + else + printf '%s=%s\n' "$llama_token_var" "$llama_token" >> "$env_file" + fi + export "${llama_token_var}=${llama_token}" + echo " ${llama_user} token generated and saved (${llama_token_var})" + + # Store password in .env for git HTTP push (#361) + # Forgejo 11.x API tokens don't work for git push; password auth does. + if grep -q "^${llama_pass_var}=" "$env_file" 2>/dev/null; then + sed -i "s|^${llama_pass_var}=.*|${llama_pass_var}=${llama_pass}|" "$env_file" + else + printf '%s=%s\n' "$llama_pass_var" "$llama_pass" >> "$env_file" + fi + export "${llama_pass_var}=${llama_pass}" + echo " ${llama_user} password saved (${llama_pass_var})" + done + # Create .profile repos for all bot users (if they don't already exist) # This runs the same logic as hire-an-agent Step 2-3 for idempotent setup echo "" echo "── Setting up .profile repos ────────────────────────────" local -a bot_users=(dev-bot review-bot planner-bot gardener-bot vault-bot supervisor-bot predictor-bot architect-bot) + # Add llama bot users to .profile repo creation + for llama_user in "${!llama_token_vars[@]}"; do + bot_users+=("$llama_user") + done local bot_user for bot_user in "${bot_users[@]}"; do @@ -590,7 +719,7 @@ setup_forge() { fi # Add all bot users as collaborators with appropriate permissions - # dev-bot: write (PR creation via lib/action-vault.sh) + # dev-bot: write (PR creation via lib/vault.sh) # review-bot: read (PR review) # planner-bot: write (prerequisites.md, memory) # gardener-bot: write (backlog grooming) @@ -618,6 +747,15 @@ setup_forge() { -d "{\"permission\":\"${bot_perm}\"}" >/dev/null 2>&1 || true done + # Add llama bot users as write collaborators for local-model agents + for llama_user in "${!llama_token_vars[@]}"; do + curl -sf -X PUT \ + -H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/repos/${repo_slug}/collaborators/${llama_user}" \ + -d '{"permission":"write"}' >/dev/null 2>&1 || true + done + # Add disinto-admin as admin collaborator curl -sf -X PUT \ -H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \ diff --git a/lib/generators.sh b/lib/generators.sh index 5664b55..72f030e 100644 --- a/lib/generators.sh +++ b/lib/generators.sh @@ -97,57 +97,29 @@ _generate_local_model_services() { POLL_INTERVAL) poll_interval_val="$value" ;; ---) if [ -n "$service_name" ] && [ -n "$base_url" ]; then - # Per-agent FORGE_TOKEN / FORGE_PASS lookup (#834 Gap 3). - # Two hired llama agents must not share the same Forgejo identity, - # so we key the env-var lookup by forge_user (which hire-agent.sh - # writes as the Forgejo username). Apply the same tr 'a-z-' 'A-Z_' - # convention as hire-agent.sh Gap 1 so the names match. - # - # NOTE (#845): the emitted block has NO `profiles:` key. The - # [agents.<name>] TOML entry is already the activation gate — - # its presence is what drives emission here. Profile-gating - # the service caused `disinto up` (without COMPOSE_PROFILES) - # to treat the hired container as an orphan and silently - # remove it via --remove-orphans. - local user_upper - user_upper=$(echo "$forge_user" | tr 'a-z-' 'A-Z_') cat >> "$temp_file" <<EOF agents-${service_name}: - # Local image ref (#853): registry-less name matches what \`disinto init --build\` - # and the legacy agents-llama stanza produce. Paired with build: so hosts without - # a pre-built image can rebuild locally; ghcr.io/disinto/agents is not publicly - # pullable, and emitting that prefix caused \`docker compose up\` to fail with - # \`denied\` on every hired agent. build: context: . dockerfile: docker/agents/Dockerfile - image: disinto/agents:\${DISINTO_IMAGE_TAG:-latest} - # Rebuild on every up (#887): without this, \`docker compose up -d --force-recreate\` - # reuses the cached image and silently keeps running stale docker/agents/ code - # even after the repo is updated. \`pull_policy: build\` makes Compose rebuild - # the image on every up; BuildKit layer cache makes unchanged rebuilds fast. - pull_policy: build container_name: disinto-agents-${service_name} restart: unless-stopped security_opt: - apparmor=unconfined volumes: - agents-${service_name}-data:/home/agent/data - - project-repos-${service_name}:/home/agent/repos + - project-repos:/home/agent/repos - \${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}:\${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared} - - \${CLAUDE_CONFIG_FILE:-\${HOME}/.claude.json}:/home/agent/.claude.json:ro - - \${AGENT_SSH_DIR:-\${HOME}/.ssh}:/home/agent/.ssh:ro - - ./projects:/home/agent/disinto/projects:ro - - ./.env:/home/agent/disinto/.env:ro - - ./state:/home/agent/disinto/state + - \${HOME}/.claude.json:/home/agent/.claude.json:ro + - CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro + - \${HOME}/.ssh:/home/agent/.ssh:ro environment: FORGE_URL: http://forgejo:3000 FORGE_REPO: ${FORGE_REPO:-disinto-admin/disinto} - FACTORY_REPO: ${FORGE_REPO:-disinto-admin/disinto} - # Per-agent credentials keyed by forge_user (#834 Gap 3). - FORGE_TOKEN: \${FORGE_TOKEN_${user_upper}:-} - FORGE_PASS: \${FORGE_PASS_${user_upper}:-} + # Use llama-specific credentials if available, otherwise fall back to main FORGE_TOKEN + FORGE_TOKEN: \${FORGE_TOKEN_LLAMA:-\${FORGE_TOKEN:-}} + FORGE_PASS: \${FORGE_PASS_LLAMA:-\${FORGE_PASS:-}} FORGE_REVIEW_TOKEN: \${FORGE_REVIEW_TOKEN:-} FORGE_BOT_USERNAMES: \${FORGE_BOT_USERNAMES:-} AGENT_ROLES: "${roles}" @@ -165,12 +137,11 @@ _generate_local_model_services() { PROJECT_REPO_ROOT: /home/agent/repos/${PROJECT_NAME:-project} WOODPECKER_DATA_DIR: /woodpecker-data WOODPECKER_REPO_ID: "${wp_repo_id}" - FORGE_BOT_USER_${user_upper}: "${forge_user}" + FORGE_BOT_USER_${service_name^^}: "${forge_user}" POLL_INTERVAL: "${poll_interval_val}" GARDENER_INTERVAL: "${GARDENER_INTERVAL:-21600}" ARCHITECT_INTERVAL: "${ARCHITECT_INTERVAL:-21600}" PLANNER_INTERVAL: "${PLANNER_INTERVAL:-43200}" - SUPERVISOR_INTERVAL: "${SUPERVISOR_INTERVAL:-1200}" depends_on: forgejo: condition: service_healthy @@ -178,22 +149,18 @@ _generate_local_model_services() { condition: service_started networks: - disinto-net + profiles: ["agents-${service_name}"] EOF has_services=true fi - # Collect per-agent volume names for later (#834 Gap 4: project-repos - # must be per-agent so concurrent llama devs don't race on - # /home/agent/repos/_factory or state/.dev-active). - local vol_data=" agents-${service_name}-data:" - local vol_repos=" project-repos-${service_name}:" + # Collect volume name for later + local vol_name=" agents-${service_name}-data:" if [ -n "$all_vols" ]; then all_vols="${all_vols} -${vol_data} -${vol_repos}" +${vol_name}" else - all_vols="${vol_data} -${vol_repos}" + all_vols="${vol_name}" fi service_name="" base_url="" model="" roles="" api_key="" forge_user="" compact_pct="" poll_interval_val="" ;; @@ -250,14 +217,8 @@ for name, config in agents.items(): # Add local-model volumes to the volumes section if [ -n "$all_vols" ]; then - # Escape embedded newlines as literal \n so sed's s/// replacement - # tolerates multi-line $all_vols (needed once >1 local-model agent is - # configured — without this, the second agent's volume entry would - # unterminate the sed expression). - local all_vols_escaped - all_vols_escaped=$(printf '%s' "$all_vols" | sed ':a;N;$!ba;s/\n/\\n/g') # Find the volumes section and add the new volumes - sed -i "/^volumes:/{n;:a;n;/^[a-z]/!{s/$/\n$all_vols_escaped/;b};ba}" "$temp_compose" + sed -i "/^volumes:/{n;:a;n;/^[a-z]/!{s/$/\n$all_vols/;b};ba}" "$temp_compose" fi mv "$temp_compose" "$compose_file" @@ -272,7 +233,6 @@ for name, config in agents.items(): # to materialize a working stack on a fresh checkout. _generate_compose_impl() { local forge_port="${1:-3000}" - local use_build="${2:-false}" local compose_file="${FACTORY_ROOT}/docker-compose.yml" # Check if compose file already exists @@ -336,7 +296,6 @@ services: WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-} WOODPECKER_DATABASE_DRIVER: sqlite3 WOODPECKER_DATABASE_DATASOURCE: /var/lib/woodpecker/woodpecker.sqlite - WOODPECKER_PLUGINS_PRIVILEGED: ${WOODPECKER_PLUGINS_PRIVILEGED:-plugins/docker} WOODPECKER_ENVIRONMENT: "FORGE_TOKEN:${FORGE_TOKEN}" depends_on: forgejo: @@ -359,19 +318,15 @@ services: WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-} WOODPECKER_GRPC_SECURE: "false" WOODPECKER_HEALTHCHECK_ADDR: ":3333" - WOODPECKER_BACKEND_DOCKER_NETWORK: ${WOODPECKER_CI_NETWORK:-disinto_disinto-net} + WOODPECKER_BACKEND_DOCKER_NETWORK: disinto_disinto-net WOODPECKER_MAX_WORKFLOWS: 1 - healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:3333/healthz"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 15s depends_on: - woodpecker agents: - image: ghcr.io/disinto/agents:${DISINTO_IMAGE_TAG:-latest} + build: + context: . + dockerfile: docker/agents/Dockerfile container_name: disinto-agents restart: unless-stopped security_opt: @@ -380,13 +335,11 @@ services: - agent-data:/home/agent/data - project-repos:/home/agent/repos - ${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}:${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared} - - ${CLAUDE_CONFIG_FILE:-${HOME}/.claude.json}:/home/agent/.claude.json:ro - - ${AGENT_SSH_DIR:-${HOME}/.ssh}:/home/agent/.ssh:ro - - ${SOPS_AGE_DIR:-${HOME}/.config/sops/age}:/home/agent/.config/sops/age:ro + - ${HOME}/.claude.json:/home/agent/.claude.json:ro + - CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro + - ${HOME}/.ssh:/home/agent/.ssh:ro + - ${HOME}/.config/sops/age:/home/agent/.config/sops/age:ro - woodpecker-data:/woodpecker-data:ro - - ./projects:/home/agent/disinto/projects:ro - - ./.env:/home/agent/disinto/.env:ro - - ./state:/home/agent/disinto/state environment: FORGE_URL: http://forgejo:3000 FORGE_REPO: ${FORGE_REPO:-disinto-admin/disinto} @@ -418,14 +371,8 @@ services: PLANNER_INTERVAL: ${PLANNER_INTERVAL:-43200} # IMPORTANT: agents get explicit environment variables (forge tokens, CI tokens, config). # Vault-only secrets (GITHUB_TOKEN, CLAWHUB_TOKEN, deploy keys) live in - # secrets/*.enc and are NEVER injected here — only the runner - # container receives them at fire time (AD-006, #745, #777). - healthcheck: - test: ["CMD", "pgrep", "-f", "entrypoint.sh"] - interval: 60s - timeout: 5s - retries: 3 - start_period: 30s + # .env.vault.enc and are NEVER injected here — only the runner + # container receives them at fire time (AD-006, #745). depends_on: forgejo: condition: service_healthy @@ -434,13 +381,10 @@ services: networks: - disinto-net -COMPOSEEOF - - # Resume the rest of the compose file (runner onward) - cat >> "$compose_file" <<'COMPOSEEOF' - runner: - image: ghcr.io/disinto/agents:${DISINTO_IMAGE_TAG:-latest} + build: + context: . + dockerfile: docker/agents/Dockerfile profiles: ["vault"] security_opt: - apparmor=unconfined @@ -461,9 +405,8 @@ COMPOSEEOF # Edge proxy — reverse proxy to Forgejo, Woodpecker, and staging # Serves on ports 80/443, routes based on path edge: - image: ghcr.io/disinto/edge:${DISINTO_IMAGE_TAG:-latest} + build: ./docker/edge container_name: disinto-edge - restart: unless-stopped security_opt: - apparmor=unconfined ports: @@ -498,13 +441,7 @@ COMPOSEEOF - /var/run/docker.sock:/var/run/docker.sock - ./secrets/tunnel_key:/run/secrets/tunnel_key:ro - ${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}:${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared} - - ${CLAUDE_CONFIG_FILE:-${HOME}/.claude.json}:/home/agent/.claude.json:ro - healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:2019/config/"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 15s + - ${HOME}/.claude.json:/home/agent/.claude.json:ro depends_on: forgejo: condition: service_healthy @@ -522,12 +459,6 @@ COMPOSEEOF command: ["caddy", "file-server", "--root", "/srv/site"] security_opt: - apparmor=unconfined - healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:2019/config/"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s volumes: - ./docker:/srv/site:ro networks: @@ -568,7 +499,7 @@ COMPOSEEOF memswap_limit: 512m volumes: # Mount claude binary from host (same as agents) - - ${CLAUDE_BIN_DIR}:/usr/local/bin/claude:ro + - CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro # Throwaway named volume for chat config (isolated from host ~/.claude) - chat-config:/var/chat/config # Chat history persistence: per-user NDJSON files on bind-mounted host volume @@ -587,12 +518,6 @@ COMPOSEEOF CHAT_MAX_REQUESTS_PER_HOUR: ${CHAT_MAX_REQUESTS_PER_HOUR:-60} CHAT_MAX_REQUESTS_PER_DAY: ${CHAT_MAX_REQUESTS_PER_DAY:-500} CHAT_MAX_TOKENS_PER_DAY: ${CHAT_MAX_TOKENS_PER_DAY:-1000000} - healthcheck: - test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s networks: - disinto-net @@ -631,35 +556,20 @@ COMPOSEEOF fi # Append local-model agent services if any are configured + # (must run before CLAUDE_BIN_PLACEHOLDER substitution so the placeholder + # in local-model services is also resolved) _generate_local_model_services "$compose_file" - # Resolve the Claude CLI binary path and persist as CLAUDE_BIN_DIR in .env. - # Only used by reproduce and edge services which still use host-mounted CLI. + # Patch the Claude CLI binary path — resolve from host PATH at init time. local claude_bin claude_bin="$(command -v claude 2>/dev/null || true)" if [ -n "$claude_bin" ]; then + # Resolve symlinks to get the real binary path claude_bin="$(readlink -f "$claude_bin")" + sed -i "s|CLAUDE_BIN_PLACEHOLDER|${claude_bin}|g" "$compose_file" else - echo "Warning: claude CLI not found in PATH — reproduce/edge services will fail to start" >&2 - claude_bin="/usr/local/bin/claude" - fi - # Persist CLAUDE_BIN_DIR into .env so docker-compose can resolve it. - local env_file="${FACTORY_ROOT}/.env" - if [ -f "$env_file" ]; then - if grep -q "^CLAUDE_BIN_DIR=" "$env_file" 2>/dev/null; then - sed -i "s|^CLAUDE_BIN_DIR=.*|CLAUDE_BIN_DIR=${claude_bin}|" "$env_file" - else - printf 'CLAUDE_BIN_DIR=%s\n' "$claude_bin" >> "$env_file" - fi - else - printf 'CLAUDE_BIN_DIR=%s\n' "$claude_bin" > "$env_file" - fi - - # In build mode, replace image: with build: for locally-built images - if [ "$use_build" = true ]; then - sed -i 's|^\( agents:\)|\1|' "$compose_file" - sed -i '/^ image: ghcr\.io\/disinto\/agents:/{s|image: ghcr\.io/disinto/agents:.*|build:\n context: .\n dockerfile: docker/agents/Dockerfile\n pull_policy: build|}' "$compose_file" - sed -i '/^ image: ghcr\.io\/disinto\/edge:/{s|image: ghcr\.io/disinto/edge:.*|build: ./docker/edge\n pull_policy: build|}' "$compose_file" + echo "Warning: claude CLI not found in PATH — update docker-compose.yml volumes manually" >&2 + sed -i "s|CLAUDE_BIN_PLACEHOLDER|/usr/local/bin/claude|g" "$compose_file" fi echo "Created: ${compose_file}" @@ -678,11 +588,7 @@ _generate_agent_docker_impl() { fi } -# Generate docker/Caddyfile for the edge proxy. -# **CANONICAL SOURCE**: This generator is the single source of truth for the Caddyfile. -# Output path: ${FACTORY_ROOT}/docker/Caddyfile (gitignored — generated artifact). -# The edge compose service mounts this path as /etc/caddy/Caddyfile. -# On a fresh clone, `disinto init` calls generate_caddyfile before first `disinto up`. +# Generate docker/Caddyfile template for edge proxy. _generate_caddyfile_impl() { local docker_dir="${FACTORY_ROOT}/docker" local caddyfile="${docker_dir}/Caddyfile" diff --git a/lib/hire-agent.sh b/lib/hire-agent.sh index 170389f..91d1fc8 100644 --- a/lib/hire-agent.sh +++ b/lib/hire-agent.sh @@ -30,29 +30,6 @@ disinto_hire_an_agent() { echo "Usage: disinto hire-an-agent <agent-name> <role> [--formula <path>] [--local-model <url>] [--model <name>] [--poll-interval <seconds>]" >&2 exit 1 fi - - # Validate agent name before any side effects (Forgejo user creation, TOML - # write, token issuance). The name flows through several systems that have - # stricter rules than the raw TOML spec: - # - load-project.sh emits shell vars keyed by the name (dashes are mapped - # to underscores via tr 'a-z-' 'A-Z_') - # - generators.sh emits a docker-compose service name `agents-<name>` and - # uppercases it for env var keys (#852 tracks the `^^` bug; we keep the - # grammar tight here so that fix can happen without re-validation) - # - Forgejo usernames are lowercase alnum + dash - # Constraint: start with a lowercase letter, contain only [a-z0-9-], end - # with a lowercase letter or digit (no trailing dash), no consecutive - # dashes. Rejecting at hire-time prevents unparseable TOML sections like - # [agents.dev-qwen2] from landing on disk and crashing load-project.sh on - # the next `disinto up` (#862). - if ! [[ "$agent_name" =~ ^[a-z]([a-z0-9]|-[a-z0-9])*$ ]]; then - echo "Error: invalid agent name '${agent_name}'" >&2 - echo " Agent names must match: ^[a-z]([a-z0-9]|-[a-z0-9])*$" >&2 - echo " (lowercase letters/digits/single dashes, starts with letter, ends with alphanumeric)" >&2 - echo " Examples: dev, dev-qwen2, review-qwen, planner" >&2 - exit 1 - fi - shift 2 # Parse flags @@ -190,14 +167,10 @@ disinto_hire_an_agent() { echo "" echo "Step 1.5: Generating Forge token for '${agent_name}'..." - # Key per-agent credentials by *agent name*, not role (#834 Gap 1). - # Two agents with the same role (e.g. two `dev` agents) must not collide on - # FORGE_<ROLE>_TOKEN — the compose generator looks up FORGE_TOKEN_<USER_UPPER> - # where USER_UPPER = tr 'a-z-' 'A-Z_' of the agent's forge_user. - local agent_upper - agent_upper=$(echo "$agent_name" | tr 'a-z-' 'A-Z_') - local token_var="FORGE_TOKEN_${agent_upper}" - local pass_var="FORGE_PASS_${agent_upper}" + # Convert role to uppercase token variable name (e.g., architect -> FORGE_ARCHITECT_TOKEN) + local role_upper + role_upper=$(echo "$role" | tr '[:lower:]' '[:upper:]') + local token_var="FORGE_${role_upper}_TOKEN" # Generate token using the user's password (basic auth) local agent_token="" @@ -221,7 +194,7 @@ disinto_hire_an_agent() { if [ -z "$agent_token" ]; then echo " Warning: failed to create API token for '${agent_name}'" >&2 else - # Store token in .env under the per-agent variable name + # Store token in .env under the role-specific variable name if grep -q "^${token_var}=" "$env_file" 2>/dev/null; then # Use sed with alternative delimiter and proper escaping for special chars in token local escaped_token @@ -235,94 +208,6 @@ disinto_hire_an_agent() { export "${token_var}=${agent_token}" fi - # Persist FORGE_PASS_<AGENT_UPPER> to .env (#834 Gap 2). - # The container's git credential helper (docker/agents/entrypoint.sh) needs - # both FORGE_TOKEN_* and FORGE_PASS_* to pass HTTPS auth for git push - # (Forgejo 11.x rejects API tokens for git push, #361). - if [ -n "${user_pass:-}" ]; then - local escaped_pass - escaped_pass=$(printf '%s\n' "$user_pass" | sed 's/[&/\]/\\&/g') - if grep -q "^${pass_var}=" "$env_file" 2>/dev/null; then - sed -i "s|^${pass_var}=.*|${pass_var}=${escaped_pass}|" "$env_file" - echo " ${agent_name} password updated (${pass_var})" - else - printf '%s=%s\n' "$pass_var" "$user_pass" >> "$env_file" - echo " ${agent_name} password saved (${pass_var})" - fi - export "${pass_var}=${user_pass}" - fi - - # Step 1.7: Write backend credentials to .env (#847). - # Local-model agents need ANTHROPIC_BASE_URL; Anthropic-backend agents need ANTHROPIC_API_KEY. - # These must be persisted so the container can start with valid credentials. - echo "" - echo "Step 1.7: Writing backend credentials to .env..." - - if [ -n "$local_model" ]; then - # Local model agent: write ANTHROPIC_BASE_URL - local backend_var="ANTHROPIC_BASE_URL" - local backend_val="$local_model" - local escaped_val - escaped_val=$(printf '%s\n' "$backend_val" | sed 's/[&/\]/\\&/g') - if grep -q "^${backend_var}=" "$env_file" 2>/dev/null; then - sed -i "s|^${backend_var}=.*|${backend_var}=${escaped_val}|" "$env_file" - echo " ${backend_var} updated" - else - printf '%s=%s\n' "$backend_var" "$backend_val" >> "$env_file" - echo " ${backend_var} saved" - fi - export "${backend_var}=${backend_val}" - else - # Anthropic backend: check if ANTHROPIC_API_KEY is set, write it if present - if [ -n "${ANTHROPIC_API_KEY:-}" ]; then - local backend_var="ANTHROPIC_API_KEY" - local backend_val="$ANTHROPIC_API_KEY" - local escaped_key - escaped_key=$(printf '%s\n' "$backend_val" | sed 's/[&/\]/\\&/g') - if grep -q "^${backend_var}=" "$env_file" 2>/dev/null; then - sed -i "s|^${backend_var}=.*|${backend_var}=${escaped_key}|" "$env_file" - echo " ${backend_var} updated" - else - printf '%s=%s\n' "$backend_var" "$backend_val" >> "$env_file" - echo " ${backend_var} saved" - fi - export "${backend_var}=${backend_val}" - else - echo " Note: ANTHROPIC_API_KEY not set — required for Anthropic backend agents" - fi - fi - - # Step 1.6: Add the new agent as a write collaborator on the project repo (#856). - # Without this, PATCH /issues/{n} {assignees:[agent]} returns 403 Forbidden and - # the dev-agent polls forever logging "claim lost to <none> — skipping" (see - # issue_claim()'s post-PATCH verify). Mirrors the collaborator setup applied - # to the canonical bot users in lib/forge-setup.sh. Idempotent: Forgejo's PUT - # returns 204 whether the user is being added for the first time or already a - # collaborator at the same permission. - if [ -n "${FORGE_REPO:-}" ]; then - echo "" - echo "Step 1.6: Adding '${agent_name}' as write collaborator on '${FORGE_REPO}'..." - local collab_code - collab_code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT \ - -H "Authorization: token ${admin_token}" \ - -H "Content-Type: application/json" \ - "${forge_url}/api/v1/repos/${FORGE_REPO}/collaborators/${agent_name}" \ - -d '{"permission":"write"}') - case "$collab_code" in - 204|201|200) - echo " ${agent_name} is a write collaborator on ${FORGE_REPO} (HTTP ${collab_code})" - ;; - *) - echo " Warning: failed to add '${agent_name}' as collaborator on '${FORGE_REPO}' (HTTP ${collab_code})" >&2 - echo " The agent will not be able to claim issues until this is fixed." >&2 - ;; - esac - else - echo "" - echo "Step 1.6: FORGE_REPO not set — skipping collaborator step" >&2 - echo " Warning: the agent will not be able to claim issues on the project repo" >&2 - fi - # Step 2: Create .profile repo on Forgejo echo "" echo "Step 2: Creating '${agent_name}/.profile' repo (if not exists)..." @@ -535,10 +420,7 @@ EOF local interval="${poll_interval:-60}" echo " Writing [agents.${section_name}] to ${toml_file}..." python3 -c ' -import sys -import tomlkit -import re -import pathlib +import sys, re, pathlib toml_path = sys.argv[1] section_name = sys.argv[2] @@ -551,39 +433,38 @@ poll_interval = sys.argv[7] p = pathlib.Path(toml_path) text = p.read_text() -# Step 1: Remove any commented-out [agents.X] blocks (they cause parse issues) -# Match # [agents.section_name] followed by lines that are not section headers -# Use negative lookahead to stop before a real section header (# [ or [) -commented_pattern = rf"(?:^|\n)# \[agents\.{re.escape(section_name)}\](?:\n(?!# \[|\[)[^\n]*)*" -text = re.sub(commented_pattern, "", text, flags=re.DOTALL) +# Build the new section +new_section = f""" +[agents.{section_name}] +base_url = "{base_url}" +model = "{model}" +api_key = "sk-no-key-required" +roles = ["{role}"] +forge_user = "{agent_name}" +compact_pct = 60 +poll_interval = {poll_interval} +""" -# Step 2: Parse TOML with tomlkit (preserves comments and formatting) -try: - doc = tomlkit.parse(text) -except Exception as e: - print(f"Error: Invalid TOML in {toml_path}: {e}", file=sys.stderr) - sys.exit(1) +# Check if section already exists and replace it +pattern = rf"\[agents\.{re.escape(section_name)}\][^\[]*" +if re.search(pattern, text): + text = re.sub(pattern, new_section.strip() + "\n", text) +else: + # Remove commented-out example [agents.llama] block if present + text = re.sub( + r"\n# Local-model agents \(optional\).*?(?=\n# \[mirrors\]|\n\[mirrors\]|\Z)", + "", + text, + flags=re.DOTALL, + ) + # Append before [mirrors] if it exists, otherwise at end + mirrors_match = re.search(r"\n(# )?\[mirrors\]", text) + if mirrors_match: + text = text[:mirrors_match.start()] + "\n" + new_section + text[mirrors_match.start():] + else: + text = text.rstrip() + "\n" + new_section -# Step 3: Ensure agents table exists -if "agents" not in doc: - doc.add("agents", tomlkit.table()) - -# Step 4: Update the specific agent section -doc["agents"][section_name] = { - "base_url": base_url, - "model": model, - "api_key": "sk-no-key-required", - "roles": [role], - "forge_user": agent_name, - "compact_pct": 60, - "poll_interval": int(poll_interval), -} - -# Step 5: Serialize back to TOML (preserves comments) -output = tomlkit.dumps(doc) - -# Step 6: Write back -p.write_text(output) +p.write_text(text) ' "$toml_file" "$section_name" "$local_model" "$model" "$agent_name" "$role" "$interval" echo " Agent config written to TOML" @@ -611,7 +492,7 @@ p.write_text(output) echo " Model: ${model}" echo "" echo " To start the agent, run:" - echo " disinto up" + echo " docker compose --profile ${service_name} up -d ${service_name}" fi echo "" diff --git a/lib/hvault.sh b/lib/hvault.sh deleted file mode 100644 index d283330..0000000 --- a/lib/hvault.sh +++ /dev/null @@ -1,440 +0,0 @@ -#!/usr/bin/env bash -# hvault.sh — HashiCorp Vault helper module -# -# Typed, audited helpers for Vault KV v2 access so no script re-implements -# `curl -H "X-Vault-Token: ..."` ad-hoc. -# -# Usage: source this file, then call any hvault_* function. -# -# Environment: -# VAULT_ADDR — Vault server address (required, no default) -# VAULT_TOKEN — auth token (precedence: env > /etc/vault.d/root.token) -# -# All functions emit structured JSON errors to stderr on failure. - -set -euo pipefail - -# ── Internal helpers ───────────────────────────────────────────────────────── - -# _hvault_err — emit structured JSON error to stderr -# Args: func_name, message, [detail] -_hvault_err() { - local func="$1" msg="$2" detail="${3:-}" - jq -n --arg func "$func" --arg msg "$msg" --arg detail "$detail" \ - '{error:true,function:$func,message:$msg,detail:$detail}' >&2 -} - -# _hvault_resolve_token — resolve VAULT_TOKEN from env or token file -_hvault_resolve_token() { - if [ -n "${VAULT_TOKEN:-}" ]; then - return 0 - fi - local token_file="/etc/vault.d/root.token" - if [ -f "$token_file" ]; then - VAULT_TOKEN="$(cat "$token_file")" - export VAULT_TOKEN - return 0 - fi - return 1 -} - -# _hvault_default_env — set the local-cluster Vault env if unset -# -# Idempotent helper used by every Vault-touching script that runs during -# `disinto init` (S2). On the local-cluster common case, operators (and -# the init dispatcher in bin/disinto) have not exported VAULT_ADDR or -# VAULT_TOKEN — the server is reachable on localhost:8200 and the root -# token lives at /etc/vault.d/root.token. Scripts must Just Work in that -# shape. -# -# - If VAULT_ADDR is unset, defaults to http://127.0.0.1:8200. -# - If VAULT_TOKEN is unset, resolves from /etc/vault.d/root.token via -# _hvault_resolve_token. A missing token file is not an error here — -# downstream hvault_token_lookup() probes connectivity and emits the -# operator-facing "VAULT_ADDR + VAULT_TOKEN" diagnostic. -# -# Centralised to keep the defaulting stanza in one place — copy-pasting -# the 5-line block into each init script trips the repo-wide 5-line -# sliding-window duplicate detector (.woodpecker/detect-duplicates.py). -_hvault_default_env() { - VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}" - export VAULT_ADDR - _hvault_resolve_token || : -} - -# _hvault_check_prereqs — validate VAULT_ADDR and VAULT_TOKEN are set -# Args: caller function name -_hvault_check_prereqs() { - local caller="$1" - if [ -z "${VAULT_ADDR:-}" ]; then - _hvault_err "$caller" "VAULT_ADDR is not set" "export VAULT_ADDR before calling $caller" - return 1 - fi - if ! _hvault_resolve_token; then - _hvault_err "$caller" "VAULT_TOKEN is not set and /etc/vault.d/root.token not found" \ - "export VAULT_TOKEN or write token to /etc/vault.d/root.token" - return 1 - fi -} - -# _hvault_request — execute a Vault API request -# Args: method, path, [data] -# Outputs: response body to stdout -# Returns: 0 on 2xx, 1 otherwise (error JSON to stderr) -_hvault_request() { - local method="$1" path="$2" data="${3:-}" - local url="${VAULT_ADDR}/v1/${path}" - local http_code body - local tmpfile - tmpfile="$(mktemp)" - - local curl_args=( - -s - -w '%{http_code}' - -H "X-Vault-Token: ${VAULT_TOKEN}" - -H "Content-Type: application/json" - -X "$method" - -o "$tmpfile" - ) - if [ -n "$data" ]; then - curl_args+=(-d "$data") - fi - - http_code="$(curl "${curl_args[@]}" "$url")" || { - _hvault_err "_hvault_request" "curl failed" "url=$url" - rm -f "$tmpfile" - return 1 - } - - body="$(cat "$tmpfile")" - rm -f "$tmpfile" - - # Check HTTP status — 2xx is success - case "$http_code" in - 2[0-9][0-9]) - printf '%s' "$body" - return 0 - ;; - *) - _hvault_err "_hvault_request" "HTTP $http_code" "$body" - return 1 - ;; - esac -} - -# ── Public API ─────────────────────────────────────────────────────────────── - -# VAULT_KV_MOUNT — KV v2 mount point (default: "kv") -# Override with: export VAULT_KV_MOUNT=secret -# Used by: hvault_kv_get, hvault_kv_put, hvault_kv_list -: "${VAULT_KV_MOUNT:=kv}" - -# hvault_ensure_kv_v2 MOUNT [LOG_PREFIX] -# Assert that the given KV mount is present and KV v2. If absent, enable -# it. If present as wrong type/version, exit 1. Callers must have already -# checked VAULT_ADDR / VAULT_TOKEN. -# -# DRY_RUN (env, default 0): when 1, log intent without writing. -# LOG_PREFIX (optional): label for log lines, e.g. "[vault-seed-forgejo]". -# -# Extracted here because every vault-seed-*.sh script needs this exact -# sequence, and the 5-line sliding-window dup detector flags the -# copy-paste. One place, one implementation. -hvault_ensure_kv_v2() { - local mount="${1:?hvault_ensure_kv_v2: MOUNT required}" - local prefix="${2:-[hvault]}" - local dry_run="${DRY_RUN:-0}" - local mounts_json mount_exists mount_type mount_version - - mounts_json="$(hvault_get_or_empty "sys/mounts")" \ - || { printf '%s ERROR: failed to list Vault mounts\n' "$prefix" >&2; return 1; } - - mount_exists=false - if printf '%s' "$mounts_json" | jq -e --arg m "${mount}/" '.[$m]' >/dev/null 2>&1; then - mount_exists=true - fi - - if [ "$mount_exists" = true ]; then - mount_type="$(printf '%s' "$mounts_json" \ - | jq -r --arg m "${mount}/" '.[$m].type // ""')" - mount_version="$(printf '%s' "$mounts_json" \ - | jq -r --arg m "${mount}/" '.[$m].options.version // "1"')" - if [ "$mount_type" != "kv" ]; then - printf '%s ERROR: %s/ is mounted as type=%q, expected kv — refuse to re-mount\n' \ - "$prefix" "$mount" "$mount_type" >&2 - return 1 - fi - if [ "$mount_version" != "2" ]; then - printf '%s ERROR: %s/ is KV v%s, expected v2 — refuse to upgrade in place\n' \ - "$prefix" "$mount" "$mount_version" >&2 - return 1 - fi - printf '%s %s/ already mounted (kv v2) — skipping enable\n' "$prefix" "$mount" - else - if [ "$dry_run" -eq 1 ]; then - printf '%s [dry-run] would enable %s/ as kv v2\n' "$prefix" "$mount" - else - local payload - payload="$(jq -n '{type:"kv",options:{version:"2"},description:"disinto shared KV v2 (S2.4)"}')" - _hvault_request POST "sys/mounts/${mount}" "$payload" >/dev/null \ - || { printf '%s ERROR: failed to enable %s/ as kv v2\n' "$prefix" "$mount" >&2; return 1; } - printf '%s %s/ enabled as kv v2\n' "$prefix" "$mount" - fi - fi -} - -# hvault_kv_get PATH [KEY] -# Read a KV v2 secret at PATH, optionally extract a single KEY. -# Outputs: JSON value (full data object, or single key value) -hvault_kv_get() { - local path="${1:-}" - local key="${2:-}" - - if [ -z "$path" ]; then - _hvault_err "hvault_kv_get" "PATH is required" "usage: hvault_kv_get PATH [KEY]" - return 1 - fi - _hvault_check_prereqs "hvault_kv_get" || return 1 - - local response - response="$(_hvault_request GET "${VAULT_KV_MOUNT}/data/${path}")" || return 1 - - if [ -n "$key" ]; then - printf '%s' "$response" | jq -e -r --arg key "$key" '.data.data[$key]' 2>/dev/null || { - _hvault_err "hvault_kv_get" "key not found" "key=$key path=$path" - return 1 - } - else - printf '%s' "$response" | jq -e '.data.data' 2>/dev/null || { - _hvault_err "hvault_kv_get" "failed to parse response" "path=$path" - return 1 - } - fi -} - -# hvault_kv_put PATH KEY=VAL [KEY=VAL ...] -# Write a KV v2 secret at PATH. Accepts one or more KEY=VAL pairs. -hvault_kv_put() { - local path="${1:-}" - shift || true - - if [ -z "$path" ] || [ $# -eq 0 ]; then - _hvault_err "hvault_kv_put" "PATH and at least one KEY=VAL required" \ - "usage: hvault_kv_put PATH KEY=VAL [KEY=VAL ...]" - return 1 - fi - _hvault_check_prereqs "hvault_kv_put" || return 1 - - # Build JSON payload from KEY=VAL pairs entirely via jq - local payload='{"data":{}}' - for kv in "$@"; do - local k="${kv%%=*}" - local v="${kv#*=}" - if [ "$k" = "$kv" ]; then - _hvault_err "hvault_kv_put" "invalid KEY=VAL pair" "got: $kv" - return 1 - fi - payload="$(printf '%s' "$payload" | jq --arg k "$k" --arg v "$v" '.data[$k] = $v')" - done - - _hvault_request POST "${VAULT_KV_MOUNT}/data/${path}" "$payload" >/dev/null -} - -# hvault_kv_list PATH -# List keys at a KV v2 path. -# Outputs: JSON array of key names -hvault_kv_list() { - local path="${1:-}" - - if [ -z "$path" ]; then - _hvault_err "hvault_kv_list" "PATH is required" "usage: hvault_kv_list PATH" - return 1 - fi - _hvault_check_prereqs "hvault_kv_list" || return 1 - - local response - response="$(_hvault_request LIST "${VAULT_KV_MOUNT}/metadata/${path}")" || return 1 - - printf '%s' "$response" | jq -e '.data.keys' 2>/dev/null || { - _hvault_err "hvault_kv_list" "failed to parse response" "path=$path" - return 1 - } -} - -# hvault_get_or_empty PATH -# GET /v1/PATH. On 200, prints the raw response body to stdout (caller -# parses with jq). On 404, prints nothing and returns 0 — caller treats -# the empty string as "resource absent, needs create". Any other HTTP -# status is a hard error: response body is logged to stderr as a -# structured JSON error and the function returns 1. -# -# Used by the sync scripts (tools/vault-apply-*.sh + -# lib/init/nomad/vault-nomad-auth.sh) to read existing policies, roles, -# auth-method listings, and per-role configs without triggering errexit -# on the expected absent-resource case. `_hvault_request` is not a -# substitute — it treats 404 as a hard error, which is correct for -# writes but wrong for "does this already exist?" checks. -# -# Subshell + EXIT trap: the RETURN trap does NOT fire on set-e abort, -# so tmpfile cleanup from a function-scoped RETURN trap would leak on -# jq/curl errors under `set -eo pipefail`. The subshell + EXIT trap -# is the reliable cleanup boundary. -hvault_get_or_empty() { - local path="${1:-}" - - if [ -z "$path" ]; then - _hvault_err "hvault_get_or_empty" "PATH is required" \ - "usage: hvault_get_or_empty PATH" - return 1 - fi - _hvault_check_prereqs "hvault_get_or_empty" || return 1 - - ( - local tmp http_code - tmp="$(mktemp)" - trap 'rm -f "$tmp"' EXIT - http_code="$(curl -sS -o "$tmp" -w '%{http_code}' \ - -H "X-Vault-Token: ${VAULT_TOKEN}" \ - "${VAULT_ADDR}/v1/${path}")" \ - || { _hvault_err "hvault_get_or_empty" "curl failed" "path=$path"; exit 1; } - case "$http_code" in - 2[0-9][0-9]) cat "$tmp" ;; - 404) printf '' ;; - *) _hvault_err "hvault_get_or_empty" "HTTP $http_code" "$(cat "$tmp")" - exit 1 ;; - esac - ) -} - -# hvault_policy_apply NAME FILE -# Idempotent policy upsert — create or update a Vault policy. -hvault_policy_apply() { - local name="${1:-}" - local file="${2:-}" - - if [ -z "$name" ] || [ -z "$file" ]; then - _hvault_err "hvault_policy_apply" "NAME and FILE are required" \ - "usage: hvault_policy_apply NAME FILE" - return 1 - fi - if [ ! -f "$file" ]; then - _hvault_err "hvault_policy_apply" "policy file not found" "file=$file" - return 1 - fi - _hvault_check_prereqs "hvault_policy_apply" || return 1 - - local policy_content - policy_content="$(cat "$file")" - local payload - payload="$(jq -n --arg policy "$policy_content" '{"policy": $policy}')" - - _hvault_request PUT "sys/policies/acl/${name}" "$payload" >/dev/null -} - -# hvault_jwt_login ROLE JWT -# Exchange a JWT for a short-lived Vault token. -# Outputs: client token string -hvault_jwt_login() { - local role="${1:-}" - local jwt="${2:-}" - - if [ -z "$role" ] || [ -z "$jwt" ]; then - _hvault_err "hvault_jwt_login" "ROLE and JWT are required" \ - "usage: hvault_jwt_login ROLE JWT" - return 1 - fi - # Only need VAULT_ADDR, not VAULT_TOKEN (we're obtaining a token) - if [ -z "${VAULT_ADDR:-}" ]; then - _hvault_err "hvault_jwt_login" "VAULT_ADDR is not set" - return 1 - fi - - local payload - payload="$(jq -n --arg role "$role" --arg jwt "$jwt" \ - '{"role": $role, "jwt": $jwt}')" - - local response - # JWT login does not require an existing token — use curl directly - local tmpfile http_code - tmpfile="$(mktemp)" - http_code="$(curl -s -w '%{http_code}' \ - -H "Content-Type: application/json" \ - -X POST \ - -d "$payload" \ - -o "$tmpfile" \ - "${VAULT_ADDR}/v1/auth/jwt/login")" || { - _hvault_err "hvault_jwt_login" "curl failed" - rm -f "$tmpfile" - return 1 - } - - local body - body="$(cat "$tmpfile")" - rm -f "$tmpfile" - - case "$http_code" in - 2[0-9][0-9]) - printf '%s' "$body" | jq -e -r '.auth.client_token' 2>/dev/null || { - _hvault_err "hvault_jwt_login" "failed to extract client_token" "$body" - return 1 - } - ;; - *) - _hvault_err "hvault_jwt_login" "HTTP $http_code" "$body" - return 1 - ;; - esac -} - -# hvault_token_lookup -# Returns TTL, policies, and accessor for the current token. -# Outputs: JSON object with ttl, policies, accessor fields -hvault_token_lookup() { - _hvault_check_prereqs "hvault_token_lookup" || return 1 - - local response - response="$(_hvault_request GET "auth/token/lookup-self")" || return 1 - - printf '%s' "$response" | jq -e '{ - ttl: .data.ttl, - policies: .data.policies, - accessor: .data.accessor, - display_name: .data.display_name - }' 2>/dev/null || { - _hvault_err "hvault_token_lookup" "failed to parse token info" - return 1 - } -} - -# _hvault_seed_key — Seed a single KV key if it doesn't exist. -# Reads existing data and merges to preserve sibling keys (KV v2 replaces -# .data atomically). Returns 0=created, 1=unchanged, 2=API error. -# Args: -# path: KV v2 logical path (e.g. "disinto/shared/chat") -# key: key name within the path (e.g. "chat_oauth_client_id") -# generator: shell command that outputs a random value (default: openssl rand -hex 32) -# Usage: -# _hvault_seed_key "disinto/shared/chat" "chat_oauth_client_id" -# rc=$? # 0=created, 1=unchanged -_hvault_seed_key() { - local path="$1" key="$2" generator="${3:-openssl rand -hex 32}" - local existing - existing=$(hvault_kv_get "$path" "$key" 2>/dev/null) || true - if [ -n "$existing" ]; then - return 1 # unchanged - fi - - local value - value=$(eval "$generator") - - # Read existing data to preserve sibling keys (KV v2 replaces atomically) - local kv_api="${VAULT_KV_MOUNT}/data/${path}" - local raw existing_data payload - raw="$(hvault_get_or_empty "$kv_api")" || return 2 - existing_data="{}" - [ -n "$raw" ] && existing_data="$(printf '%s' "$raw" | jq '.data.data // {}')" - payload="$(printf '%s' "$existing_data" \ - | jq --arg k "$key" --arg v "$value" '{data: (. + {($k): $v})}')" - _hvault_request POST "$kv_api" "$payload" >/dev/null - return 0 # created -} diff --git a/lib/init/nomad/cluster-up.sh b/lib/init/nomad/cluster-up.sh deleted file mode 100755 index 488d2df..0000000 --- a/lib/init/nomad/cluster-up.sh +++ /dev/null @@ -1,368 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# lib/init/nomad/cluster-up.sh — Empty Nomad+Vault cluster orchestrator (S0.4) -# -# Wires together the S0.1–S0.3 building blocks into one idempotent -# "bring up a single-node Nomad+Vault cluster" script: -# -# 1. install.sh (nomad + vault binaries + docker daemon) -# 2. systemd-nomad.sh (nomad.service — unit + enable, not started) -# 3. systemd-vault.sh (vault.service — unit + vault.hcl + enable) -# 4. Host-volume dirs (/srv/disinto/* matching nomad/client.hcl) -# 5. /etc/nomad.d/*.hcl (server.hcl + client.hcl from repo) -# 6. vault-init.sh (first-run init + unseal + persist keys) -# 7. systemctl start vault (auto-unseal via ExecStartPost; poll) -# 8. systemctl start nomad (poll until ≥1 ready node) -# 9. /etc/profile.d/disinto-nomad.sh (VAULT_ADDR + NOMAD_ADDR for shells) -# -# This is the "empty cluster" orchestrator — no jobs deployed. Subsequent -# Step-1 issues layer job deployment on top of this checkpoint. -# -# Idempotency contract: -# Running twice back-to-back on a healthy box is a no-op. Each sub-step -# is itself idempotent — see install.sh / systemd-*.sh / vault-init.sh -# headers for the per-step contract. Fast-paths in steps 7 and 8 skip -# the systemctl start when the service is already active + healthy. -# -# Usage: -# sudo lib/init/nomad/cluster-up.sh # bring cluster up -# sudo lib/init/nomad/cluster-up.sh --dry-run # print step list, exit 0 -# -# Environment (override polling for slow boxes): -# VAULT_POLL_SECS max seconds to wait for vault to unseal (default: 30) -# NOMAD_POLL_SECS max seconds to wait for nomad node=ready (default: 60) -# -# Exit codes: -# 0 success (cluster up, or already up) -# 1 precondition or step failure -# ============================================================================= -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" - -# Sub-scripts (siblings in this directory). -INSTALL_SH="${SCRIPT_DIR}/install.sh" -SYSTEMD_NOMAD_SH="${SCRIPT_DIR}/systemd-nomad.sh" -SYSTEMD_VAULT_SH="${SCRIPT_DIR}/systemd-vault.sh" -VAULT_INIT_SH="${SCRIPT_DIR}/vault-init.sh" - -# In-repo Nomad configs copied to /etc/nomad.d/. -NOMAD_CONFIG_DIR="/etc/nomad.d" -NOMAD_SERVER_HCL_SRC="${REPO_ROOT}/nomad/server.hcl" -NOMAD_CLIENT_HCL_SRC="${REPO_ROOT}/nomad/client.hcl" - -# /etc/profile.d entry — makes VAULT_ADDR + NOMAD_ADDR available to -# interactive shells without requiring the operator to source anything. -PROFILE_D_FILE="/etc/profile.d/disinto-nomad.sh" - -# Host-volume paths — MUST match the `host_volume "..."` declarations -# in nomad/client.hcl. Adding a host_volume block there requires adding -# its path here so the dir exists before nomad starts (otherwise client -# fingerprinting fails and the node stays in "initializing"). -HOST_VOLUME_DIRS=( - "/srv/disinto/forgejo-data" - "/srv/disinto/woodpecker-data" - "/srv/disinto/agent-data" - "/srv/disinto/project-repos" - "/srv/disinto/caddy-data" - "/srv/disinto/docker" - "/srv/disinto/chat-history" - "/srv/disinto/ops-repo" -) - -# Default API addresses — matches the listener bindings in -# nomad/server.hcl and nomad/vault.hcl. If either file ever moves -# off 127.0.0.1 / default port, update both places together. -VAULT_ADDR_DEFAULT="http://127.0.0.1:8200" -NOMAD_ADDR_DEFAULT="http://127.0.0.1:4646" - -VAULT_POLL_SECS="${VAULT_POLL_SECS:-30}" -NOMAD_POLL_SECS="${NOMAD_POLL_SECS:-60}" - -log() { printf '[cluster-up] %s\n' "$*"; } -die() { printf '[cluster-up] ERROR: %s\n' "$*" >&2; exit 1; } - -# ── Flag parsing ───────────────────────────────────────────────────────────── -dry_run=false -while [ $# -gt 0 ]; do - case "$1" in - --dry-run) dry_run=true; shift ;; - -h|--help) - cat <<EOF -Usage: sudo $(basename "$0") [--dry-run] - -Brings up an empty single-node Nomad+Vault cluster (idempotent). - - --dry-run Print the step list without performing any action. -EOF - exit 0 - ;; - *) die "unknown flag: $1" ;; - esac -done - -# ── Dry-run: print step list + exit ────────────────────────────────────────── -if [ "$dry_run" = true ]; then - cat <<EOF -[dry-run] Step 1/9: install nomad + vault binaries + docker daemon - → sudo ${INSTALL_SH} - -[dry-run] Step 2/9: write + enable nomad.service (NOT started) - → sudo ${SYSTEMD_NOMAD_SH} - -[dry-run] Step 3/9: write + enable vault.service + vault.hcl (NOT started) - → sudo ${SYSTEMD_VAULT_SH} - -[dry-run] Step 4/9: create host-volume dirs under /srv/disinto/ -EOF - for d in "${HOST_VOLUME_DIRS[@]}"; do - printf ' → install -d -m 0777 %s\n' "$d" - done - cat <<EOF - -[dry-run] Step 5/9: install /etc/nomad.d/server.hcl + client.hcl from repo - → ${NOMAD_SERVER_HCL_SRC} → ${NOMAD_CONFIG_DIR}/server.hcl - → ${NOMAD_CLIENT_HCL_SRC} → ${NOMAD_CONFIG_DIR}/client.hcl - -[dry-run] Step 6/9: first-run vault init + persist unseal.key + root.token - → sudo ${VAULT_INIT_SH} - -[dry-run] Step 7/9: systemctl start vault + poll until unsealed (≤${VAULT_POLL_SECS}s) - -[dry-run] Step 8/9: systemctl start nomad + poll until ≥1 node ready + docker driver healthy (≤${NOMAD_POLL_SECS}s each) - -[dry-run] Step 9/9: write ${PROFILE_D_FILE} - → export VAULT_ADDR=${VAULT_ADDR_DEFAULT} - → export NOMAD_ADDR=${NOMAD_ADDR_DEFAULT} - -Dry run complete — no changes made. -EOF - exit 0 -fi - -# ── Preconditions ──────────────────────────────────────────────────────────── -if [ "$(id -u)" -ne 0 ]; then - die "must run as root (spawns install/systemd/vault-init sub-scripts)" -fi - -command -v systemctl >/dev/null 2>&1 \ - || die "systemctl not found (systemd required)" - -for f in "$INSTALL_SH" "$SYSTEMD_NOMAD_SH" "$SYSTEMD_VAULT_SH" "$VAULT_INIT_SH"; do - [ -x "$f" ] || die "sub-script missing or non-executable: ${f}" -done - -[ -f "$NOMAD_SERVER_HCL_SRC" ] \ - || die "source config not found: ${NOMAD_SERVER_HCL_SRC}" -[ -f "$NOMAD_CLIENT_HCL_SRC" ] \ - || die "source config not found: ${NOMAD_CLIENT_HCL_SRC}" - -# ── Helpers ────────────────────────────────────────────────────────────────── - -# install_file_if_differs SRC DST MODE -# Copy SRC to DST (root:root with MODE) iff on-disk content differs. -# No-op + log otherwise — preserves mtime, avoids spurious reloads. -install_file_if_differs() { - local src="$1" dst="$2" mode="$3" - if [ -f "$dst" ] && cmp -s "$src" "$dst"; then - log "unchanged: ${dst}" - return 0 - fi - log "writing: ${dst}" - install -m "$mode" -o root -g root "$src" "$dst" -} - -# vault_status_json — echo `vault status -format=json`, or '' on unreachable. -# vault status exit codes: 0 = unsealed, 2 = sealed/uninit, 1 = unreachable. -# We treat all of 0/2 as "reachable with state"; 1 yields empty output. -# Wrapped in `|| true` so set -e doesn't abort on exit 2 (the expected -# sealed-state case during first-boot polling). -vault_status_json() { - VAULT_ADDR="$VAULT_ADDR_DEFAULT" vault status -format=json 2>/dev/null || true -} - -# vault_is_unsealed — true iff vault reachable AND initialized AND unsealed. -vault_is_unsealed() { - local out init sealed - out="$(vault_status_json)" - [ -n "$out" ] || return 1 - init="$(printf '%s' "$out" | jq -r '.initialized' 2>/dev/null)" || init="" - sealed="$(printf '%s' "$out" | jq -r '.sealed' 2>/dev/null)" || sealed="" - [ "$init" = "true" ] && [ "$sealed" = "false" ] -} - -# nomad_ready_count — echo the number of ready nodes, or 0 on error. -# `nomad node status -json` returns a JSON array of nodes, each with a -# .Status field ("initializing" | "ready" | "down" | "disconnected"). -nomad_ready_count() { - local out - out="$(NOMAD_ADDR="$NOMAD_ADDR_DEFAULT" nomad node status -json 2>/dev/null || true)" - if [ -z "$out" ]; then - printf '0' - return 0 - fi - printf '%s' "$out" \ - | jq '[.[] | select(.Status == "ready")] | length' 2>/dev/null \ - || printf '0' -} - -# nomad_has_ready_node — true iff nomad_ready_count ≥ 1. Wrapper exists -# so poll_until_healthy can call it as a single-arg command name. -nomad_has_ready_node() { [ "$(nomad_ready_count)" -ge 1 ]; } - -# nomad_docker_driver_healthy — true iff the nomad self-node reports the -# docker driver as Detected=true AND Healthy=true. Required by Step-1's -# forgejo jobspec (the first docker-driver consumer) — without this the -# node reaches "ready" while docker fingerprinting is still in flight, -# and the first `nomad job run forgejo` times out with an opaque -# "missing drivers" placement failure (#871). -nomad_docker_driver_healthy() { - local out detected healthy - out="$(NOMAD_ADDR="$NOMAD_ADDR_DEFAULT" nomad node status -self -json 2>/dev/null || true)" - [ -n "$out" ] || return 1 - detected="$(printf '%s' "$out" | jq -r '.Drivers.docker.Detected // false' 2>/dev/null)" || detected="" - healthy="$(printf '%s' "$out" | jq -r '.Drivers.docker.Healthy // false' 2>/dev/null)" || healthy="" - [ "$detected" = "true" ] && [ "$healthy" = "true" ] -} - -# _die_with_service_status SVC REASON -# Log + dump `systemctl status SVC` to stderr + die with REASON. Factored -# out so the poll helper doesn't carry three copies of the same dump. -_die_with_service_status() { - local svc="$1" reason="$2" - log "${svc}.service ${reason} — systemctl status follows:" - systemctl --no-pager --full status "$svc" >&2 || true - die "${svc}.service ${reason}" -} - -# poll_until_healthy SVC CHECK_CMD TIMEOUT -# Tick once per second for up to TIMEOUT seconds, invoking CHECK_CMD as a -# command name (no arguments). Returns 0 on the first successful check. -# Fails fast via _die_with_service_status if SVC enters systemd "failed" -# state, and dies with a status dump if TIMEOUT elapses before CHECK_CMD -# succeeds. Replaces the two in-line ready=1/break/sleep poll loops that -# would otherwise each duplicate the same pattern already in vault-init.sh. -poll_until_healthy() { - local svc="$1" check="$2" timeout="$3" - local waited=0 - until [ "$waited" -ge "$timeout" ]; do - systemctl is-failed --quiet "$svc" \ - && _die_with_service_status "$svc" "entered failed state during startup" - if "$check"; then - log "${svc} healthy after ${waited}s" - return 0 - fi - waited=$((waited + 1)) - sleep 1 - done - _die_with_service_status "$svc" "not healthy within ${timeout}s" -} - -# ── Step 1/9: install.sh (nomad + vault binaries + docker daemon) ──────────── -log "── Step 1/9: install nomad + vault binaries + docker daemon ──" -"$INSTALL_SH" - -# ── Step 2/9: systemd-nomad.sh (unit + enable, not started) ────────────────── -log "── Step 2/9: install nomad.service (enable, not start) ──" -"$SYSTEMD_NOMAD_SH" - -# ── Step 3/9: systemd-vault.sh (unit + vault.hcl + enable) ─────────────────── -log "── Step 3/9: install vault.service + vault.hcl (enable, not start) ──" -"$SYSTEMD_VAULT_SH" - -# ── Step 4/9: host-volume dirs matching nomad/client.hcl ───────────────────── -log "── Step 4/9: host-volume dirs under /srv/disinto/ ──" -# Parent /srv/disinto/ first (install -d handles missing parents, but being -# explicit makes the log output read naturally as a top-down creation). -install -d -m 0755 -o root -g root "/srv/disinto" -for d in "${HOST_VOLUME_DIRS[@]}"; do - if [ -d "$d" ]; then - log "unchanged: ${d}" - else - log "creating: ${d}" - install -d -m 0777 -o root -g root "$d" - fi - # Ensure correct permissions (fixes pre-existing 0755 dirs on re-run) - chmod 0777 "$d" -done - -# ── Step 5/9: /etc/nomad.d/server.hcl + client.hcl ─────────────────────────── -log "── Step 5/9: install /etc/nomad.d/{server,client}.hcl ──" -# systemd-nomad.sh already created /etc/nomad.d/. Re-assert for clarity + -# in case someone runs cluster-up.sh with an exotic step ordering later. -install -d -m 0755 -o root -g root "$NOMAD_CONFIG_DIR" -install_file_if_differs "$NOMAD_SERVER_HCL_SRC" "${NOMAD_CONFIG_DIR}/server.hcl" 0644 -install_file_if_differs "$NOMAD_CLIENT_HCL_SRC" "${NOMAD_CONFIG_DIR}/client.hcl" 0644 - -# ── Step 6/9: vault-init (first-run init + unseal + persist keys) ──────────── -log "── Step 6/9: vault-init (no-op after first run) ──" -# vault-init.sh spawns a temporary vault server if systemd isn't managing -# one, runs `operator init`, writes unseal.key + root.token, unseals once, -# then stops the temp server (EXIT trap). After it returns, port 8200 is -# free for systemctl-managed vault to take in step 7. -"$VAULT_INIT_SH" - -# ── Step 7/9: systemctl start vault + poll until unsealed ──────────────────── -log "── Step 7/9: start vault + poll until unsealed ──" -# Fast-path when vault.service is already active and Vault reports -# initialized=true,sealed=false — re-runs are a no-op. -if systemctl is-active --quiet vault && vault_is_unsealed; then - log "vault already active + unsealed — skip start" -else - systemctl start vault - poll_until_healthy vault vault_is_unsealed "$VAULT_POLL_SECS" -fi - -# ── Step 8/9: systemctl start nomad + poll until ≥1 node ready + docker up ── -log "── Step 8/9: start nomad + poll until ≥1 node ready + docker driver healthy ──" -# Three conditions gate this step: -# (a) nomad.service active -# (b) ≥1 nomad node in "ready" state -# (c) nomad's docker task driver fingerprinted as Detected+Healthy -# (c) can lag (a)+(b) briefly because driver fingerprinting races with -# dockerd startup — polling it explicitly prevents Step-1 deploys from -# hitting "missing drivers" placement failures on a cold-booted host (#871). -if systemctl is-active --quiet nomad \ - && nomad_has_ready_node \ - && nomad_docker_driver_healthy; then - log "nomad already active + ≥1 node ready + docker driver healthy — skip start" -else - if ! systemctl is-active --quiet nomad; then - systemctl start nomad - fi - poll_until_healthy nomad nomad_has_ready_node "$NOMAD_POLL_SECS" - poll_until_healthy nomad nomad_docker_driver_healthy "$NOMAD_POLL_SECS" -fi - -# ── Step 9/9: /etc/profile.d/disinto-nomad.sh ──────────────────────────────── -log "── Step 9/9: write ${PROFILE_D_FILE} ──" -# Shell rc fragments in /etc/profile.d/ are sourced by /etc/profile for -# every interactive login shell. Setting VAULT_ADDR + NOMAD_ADDR here means -# the operator can run `vault status` / `nomad node status` straight after -# `ssh factory-box` without fumbling env vars. -desired_profile="# /etc/profile.d/disinto-nomad.sh — written by lib/init/nomad/cluster-up.sh -# Interactive-shell defaults for Vault + Nomad clients on this box. -export VAULT_ADDR=${VAULT_ADDR_DEFAULT} -export NOMAD_ADDR=${NOMAD_ADDR_DEFAULT} -" -if [ -f "$PROFILE_D_FILE" ] \ - && printf '%s' "$desired_profile" | cmp -s - "$PROFILE_D_FILE"; then - log "unchanged: ${PROFILE_D_FILE}" -else - log "writing: ${PROFILE_D_FILE}" - # Subshell + EXIT trap: guarantees the tempfile is cleaned up on both - # success AND set-e-induced failure of `install`. A function-scoped - # RETURN trap does NOT fire on errexit-abort in bash — the subshell is - # the reliable cleanup boundary here. - ( - tmp="$(mktemp)" - trap 'rm -f "$tmp"' EXIT - printf '%s' "$desired_profile" > "$tmp" - install -m 0644 -o root -g root "$tmp" "$PROFILE_D_FILE" - ) -fi - -log "── done: empty nomad+vault cluster is up ──" -log " Vault: ${VAULT_ADDR_DEFAULT} (Sealed=false Initialized=true)" -log " Nomad: ${NOMAD_ADDR_DEFAULT} (≥1 node ready)" diff --git a/lib/init/nomad/deploy.sh b/lib/init/nomad/deploy.sh deleted file mode 100755 index 7cf9278..0000000 --- a/lib/init/nomad/deploy.sh +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# lib/init/nomad/deploy.sh — Dependency-ordered Nomad job deploy + wait -# -# Runs a list of jobspecs in order, waiting for each to reach healthy state -# before starting the next. Step-1 uses it for forgejo-only; Steps 3–6 extend -# the job list. -# -# Usage: -# lib/init/nomad/deploy.sh <jobname> [jobname2 ...] [--dry-run] -# -# Arguments: -# jobname — basename of jobspec (without .hcl), resolved to -# ${REPO_ROOT}/nomad/jobs/<jobname>.hcl -# -# Environment: -# REPO_ROOT — absolute path to repo root (defaults to parent of -# this script's parent directory) -# JOB_READY_TIMEOUT_SECS — poll timeout in seconds (default: 240) -# JOB_READY_TIMEOUT_<JOBNAME> — per-job timeout override (e.g., -# JOB_READY_TIMEOUT_FORGEJO=300) -# -# Exit codes: -# 0 success (all jobs deployed and healthy, or dry-run completed) -# 1 failure (validation error, timeout, or nomad command failure) -# -# Idempotency: -# Running twice back-to-back on a healthy cluster is a no-op. Jobs that are -# already healthy print "[deploy] <name> already healthy" and continue. -# ============================================================================= -set -euo pipefail - -# ── Configuration ──────────────────────────────────────────────────────────── -SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="${REPO_ROOT:-$(cd "${SCRIPT_ROOT}/../../.." && pwd)}" -JOB_READY_TIMEOUT_SECS="${JOB_READY_TIMEOUT_SECS:-240}" - -DRY_RUN=0 - -log() { printf '[deploy] %s\n' "$*" >&2; } -die() { printf '[deploy] ERROR: %s\n' "$*" >&2; exit 1; } - -# ── Parse arguments ─────────────────────────────────────────────────────────── -JOBS=() -while [ $# -gt 0 ]; do - case "$1" in - --dry-run) - DRY_RUN=1 - shift - ;; - -*) - die "Unknown option: $1" - ;; - *) - JOBS+=("$1") - shift - ;; - esac -done - -if [ "${#JOBS[@]}" -eq 0 ]; then - die "Usage: $0 <jobname> [jobname2 ...] [--dry-run]" -fi - -# ── Helper: _wait_job_running <name> <timeout> ─────────────────────────────── -# Polls `nomad deployment status -json <deployment-id>` until: -# - Status == "successful" -# - Status == "failed" -# -# On deployment failure: prints last 50 lines of stderr from allocations and exits 1. -# On timeout: prints last 50 lines of stderr from allocations and exits 1. -# -# This is a named, reusable helper for future init scripts. -_wait_job_running() { - local job_name="$1" - local timeout="$2" - local elapsed=0 - - log "waiting for job '${job_name}' to become healthy (timeout: ${timeout}s)..." - - # Get the latest deployment ID for this job (retry until available) - local deployment_id="" - local retry_count=0 - local max_retries=12 - - while [ -z "$deployment_id" ] && [ "$retry_count" -lt "$max_retries" ]; do - deployment_id=$(nomad job deployments -json "$job_name" 2>/dev/null | jq -r '.[0].ID' 2>/dev/null) || deployment_id="" - if [ -z "$deployment_id" ]; then - sleep 5 - retry_count=$((retry_count + 1)) - fi - done - - if [ -z "$deployment_id" ]; then - log "ERROR: no deployment found for job '${job_name}' after ${max_retries} attempts" - return 1 - fi - - log "tracking deployment '${deployment_id}'..." - - while [ "$elapsed" -lt "$timeout" ]; do - local deploy_status_json - deploy_status_json=$(nomad deployment status -json "$deployment_id" 2>/dev/null) || { - # Deployment may not exist yet — keep waiting - sleep 5 - elapsed=$((elapsed + 5)) - continue - } - - local status - status=$(printf '%s' "$deploy_status_json" | jq -r '.Status' 2>/dev/null) || { - sleep 5 - elapsed=$((elapsed + 5)) - continue - } - - case "$status" in - successful) - log "${job_name} healthy after ${elapsed}s" - return 0 - ;; - failed) - log "deployment '${deployment_id}' failed for job '${job_name}'" - log "showing last 50 lines of allocation logs (stderr):" - - # Get allocation IDs from job status - local alloc_ids - alloc_ids=$(nomad job status -json "$job_name" 2>/dev/null \ - | jq -r '.Allocations[]?.ID // empty' 2>/dev/null) || alloc_ids="" - - if [ -n "$alloc_ids" ]; then - for alloc_id in $alloc_ids; do - log "--- Allocation ${alloc_id} logs (stderr) ---" - nomad alloc logs -stderr -short "$alloc_id" 2>/dev/null | tail -50 || true - done - fi - - return 1 - ;; - running|progressing) - log "deployment '${deployment_id}' status: ${status} (waiting for ${job_name}...)" - ;; - *) - log "deployment '${deployment_id}' status: ${status} (waiting for ${job_name}...)" - ;; - esac - - sleep 5 - elapsed=$((elapsed + 5)) - done - - # Timeout — print last 50 lines of alloc logs - log "TIMEOUT: deployment '${deployment_id}' did not reach successful state within ${timeout}s" - log "showing last 50 lines of allocation logs (stderr):" - - # Get allocation IDs from job status - local alloc_ids - alloc_ids=$(nomad job status -json "$job_name" 2>/dev/null \ - | jq -r '.Allocations[]?.ID // empty' 2>/dev/null) || alloc_ids="" - - if [ -n "$alloc_ids" ]; then - for alloc_id in $alloc_ids; do - log "--- Allocation ${alloc_id} logs (stderr) ---" - nomad alloc logs -stderr -short "$alloc_id" 2>/dev/null | tail -50 || true - done - fi - - return 1 -} - -# ── Main: deploy each job in order ─────────────────────────────────────────── -for job_name in "${JOBS[@]}"; do - jobspec_path="${REPO_ROOT}/nomad/jobs/${job_name}.hcl" - - if [ ! -f "$jobspec_path" ]; then - die "Jobspec not found: ${jobspec_path}" - fi - - # Per-job timeout override: JOB_READY_TIMEOUT_<UPPERCASE_JOBNAME> - # Sanitize job name: replace hyphens with underscores (bash vars can't have hyphens) - job_upper=$(printf '%s' "$job_name" | tr '[:lower:]-' '[:upper:]_' | tr ' ' '_') - timeout_var="JOB_READY_TIMEOUT_${job_upper}" - job_timeout="${!timeout_var:-$JOB_READY_TIMEOUT_SECS}" - - if [ "$DRY_RUN" -eq 1 ]; then - log "[dry-run] nomad job validate ${jobspec_path}" - log "[dry-run] nomad job run -detach ${jobspec_path}" - log "[dry-run] (would wait for '${job_name}' to become healthy for ${job_timeout}s)" - continue - fi - - log "processing job: ${job_name}" - - # 1. Validate the jobspec - log "validating: ${jobspec_path}" - if ! nomad job validate "$jobspec_path"; then - die "validation failed for: ${jobspec_path}" - fi - - # 2. Check if already healthy (idempotency) - job_status_json=$(nomad job status -json "$job_name" 2>/dev/null || true) - if [ -n "$job_status_json" ]; then - current_status=$(printf '%s' "$job_status_json" | jq -r '.Status' 2>/dev/null || true) - if [ "$current_status" = "running" ]; then - log "${job_name} already healthy" - continue - fi - fi - - # 3. Run the job (idempotent registration) - log "running: ${jobspec_path}" - if ! nomad job run -detach "$jobspec_path"; then - die "failed to run job: ${job_name}" - fi - - # 4. Wait for healthy state - if ! _wait_job_running "$job_name" "$job_timeout"; then - die "deployment for job '${job_name}' did not reach successful state" - fi -done - -if [ "$DRY_RUN" -eq 1 ]; then - log "dry-run complete" -fi - -exit 0 diff --git a/lib/init/nomad/install.sh b/lib/init/nomad/install.sh deleted file mode 100755 index ea9ac17..0000000 --- a/lib/init/nomad/install.sh +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# lib/init/nomad/install.sh — Idempotent apt install of HashiCorp Nomad + Vault -# + Ubuntu-native Docker for Nomad's docker driver -# -# Part of the Nomad+Vault migration. Installs the `nomad` binary (S0.2, -# issue #822), the `vault` binary (S0.3, issue #823), and the `docker` -# daemon (S0.2-fix, issue #871) needed by Nomad's docker task driver. -# Nomad + Vault come from the pinned HashiCorp apt repo; docker comes from -# Ubuntu's default apt repo (docker.io) — matches the existing factory -# dev-box setup and avoids adding a second apt source with pinning. -# -# Does NOT configure, start, or enable nomad.service or vault.service — -# lib/init/nomad/systemd-nomad.sh and lib/init/nomad/systemd-vault.sh own -# those. The docker.service unit ships with the docker.io package and is -# enabled+started here directly (not a disinto-owned unit), because Nomad's -# docker driver reports Healthy=false without a running dockerd — that -# silently blocks job placement at Step 1 with a confusing "missing -# drivers" error (issue #871). Does NOT wire this script into `disinto -# init` — S0.4 owns that. -# -# Idempotency contract: -# - Running twice back-to-back is a no-op once all three targets are -# installed and the HashiCorp apt source is in place. -# - Adds the HashiCorp apt keyring only if it is absent. -# - Adds the HashiCorp apt sources list only if it is absent. -# - Skips `apt-get install` for any package whose installed version already -# matches the pin. If all three are satisfied, exits before touching apt. -# - `command -v docker` is the docker install sentinel; `systemctl -# enable --now` is a no-op on an already-enabled+active unit. -# -# Configuration: -# NOMAD_VERSION — pinned Nomad version (default: see below). Apt package -# name is versioned as "nomad=<version>-1". -# VAULT_VERSION — pinned Vault version (default: see below). Apt package -# name is versioned as "vault=<version>-1". -# -# Usage: -# sudo lib/init/nomad/install.sh -# sudo NOMAD_VERSION=1.9.5 VAULT_VERSION=1.18.5 lib/init/nomad/install.sh -# -# Exit codes: -# 0 success (installed or already present) -# 1 precondition failure (not Debian/Ubuntu, missing tools, not root) -# ============================================================================= -set -euo pipefail - -# Pin to specific 1.x releases. Bump here, not at call sites. -NOMAD_VERSION="${NOMAD_VERSION:-1.9.5}" -VAULT_VERSION="${VAULT_VERSION:-1.18.5}" - -HASHICORP_KEYRING="/usr/share/keyrings/hashicorp-archive-keyring.gpg" -HASHICORP_SOURCES="/etc/apt/sources.list.d/hashicorp.list" -HASHICORP_GPG_URL="https://apt.releases.hashicorp.com/gpg" -HASHICORP_REPO_URL="https://apt.releases.hashicorp.com" - -log() { printf '[install] %s\n' "$*"; } -die() { printf '[install] ERROR: %s\n' "$*" >&2; exit 1; } - -# _installed_version BINARY -# Echoes the installed semver for `nomad` or `vault` (e.g. "1.9.5"). -# Both tools print their version on the first line of `<bin> version` as -# "<Name> v<semver>..." — the shared awk extracts $2 with the leading "v" -# stripped. Empty string when the binary is absent or output is unexpected. -_installed_version() { - local bin="$1" - command -v "$bin" >/dev/null 2>&1 || { printf ''; return 0; } - "$bin" version 2>/dev/null \ - | awk 'NR==1 {sub(/^v/, "", $2); print $2; exit}' -} - -# ── Preconditions ──────────────────────────────────────────────────────────── -if [ "$(id -u)" -ne 0 ]; then - die "must run as root (needs apt-get + /usr/share/keyrings write access)" -fi - -for bin in apt-get gpg curl lsb_release; do - command -v "$bin" >/dev/null 2>&1 \ - || die "required binary not found: ${bin}" -done - -CODENAME="$(lsb_release -cs)" -[ -n "$CODENAME" ] || die "lsb_release returned empty codename" - -# ── Fast-path: are both already at desired versions? ───────────────────────── -nomad_installed="$(_installed_version nomad)" -vault_installed="$(_installed_version vault)" - -need_pkgs=() -if [ "$nomad_installed" = "$NOMAD_VERSION" ]; then - log "nomad ${NOMAD_VERSION} already installed" -else - need_pkgs+=("nomad=${NOMAD_VERSION}-1") -fi -if [ "$vault_installed" = "$VAULT_VERSION" ]; then - log "vault ${VAULT_VERSION} already installed" -else - need_pkgs+=("vault=${VAULT_VERSION}-1") -fi - -# Docker isn't version-pinned (Ubuntu's docker.io tracks the distro's -# ship-stable release — good enough for a dev box and avoids a second -# apt source). Sentinel is binary presence, not a semver match. -if command -v docker >/dev/null 2>&1; then - log "docker already installed" - docker_needs_install=0 -else - docker_needs_install=1 -fi - -if [ "${#need_pkgs[@]}" -eq 0 ] && [ "$docker_needs_install" -eq 0 ]; then - log "nothing to do" - exit 0 -fi - -# ── HashiCorp apt setup + nomad/vault install (skipped if both at pin) ─────── -if [ "${#need_pkgs[@]}" -gt 0 ]; then - # Ensure HashiCorp apt keyring. - if [ ! -f "$HASHICORP_KEYRING" ]; then - log "adding HashiCorp apt keyring → ${HASHICORP_KEYRING}" - tmpkey="$(mktemp)" - trap 'rm -f "$tmpkey"' EXIT - curl -fsSL "$HASHICORP_GPG_URL" -o "$tmpkey" \ - || die "failed to fetch HashiCorp GPG key from ${HASHICORP_GPG_URL}" - gpg --dearmor -o "$HASHICORP_KEYRING" < "$tmpkey" \ - || die "failed to dearmor HashiCorp GPG key" - chmod 0644 "$HASHICORP_KEYRING" - rm -f "$tmpkey" - trap - EXIT - else - log "HashiCorp apt keyring already present" - fi - - # Ensure HashiCorp apt sources list. - desired_source="deb [signed-by=${HASHICORP_KEYRING}] ${HASHICORP_REPO_URL} ${CODENAME} main" - if [ ! -f "$HASHICORP_SOURCES" ] \ - || ! grep -qxF "$desired_source" "$HASHICORP_SOURCES"; then - log "writing HashiCorp apt sources list → ${HASHICORP_SOURCES}" - printf '%s\n' "$desired_source" > "$HASHICORP_SOURCES" - apt_update_needed=1 - else - log "HashiCorp apt sources list already present" - apt_update_needed=0 - fi - - # Install the pinned versions. - if [ "$apt_update_needed" -eq 1 ]; then - log "running apt-get update" - DEBIAN_FRONTEND=noninteractive apt-get update -qq \ - || die "apt-get update failed" - fi - - log "installing ${need_pkgs[*]}" - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - "${need_pkgs[@]}" \ - || die "apt-get install ${need_pkgs[*]} failed" - - # Verify pinned versions. - final_nomad="$(_installed_version nomad)" - if [ "$final_nomad" != "$NOMAD_VERSION" ]; then - die "post-install check: expected nomad ${NOMAD_VERSION}, got '${final_nomad}'" - fi - final_vault="$(_installed_version vault)" - if [ "$final_vault" != "$VAULT_VERSION" ]; then - die "post-install check: expected vault ${VAULT_VERSION}, got '${final_vault}'" - fi -fi - -# ── Install docker.io + enable+start docker.service (if missing) ───────────── -# Nomad's docker task driver reports Healthy=false without a running -# dockerd. On the factory dev box docker was pre-installed so Step 0's -# cluster-up passed silently; on a fresh LXC the first docker-driver -# jobspec (forgejo, Step 1) fails placement with "missing drivers". -# Install from Ubuntu's default apt repo — no second source, no pinning. -# `docker.service` ships with the package; `enable --now` is idempotent. -if [ "$docker_needs_install" -eq 1 ]; then - log "installing docker.io" - DEBIAN_FRONTEND=noninteractive apt-get install -y -q docker.io \ - || die "apt-get install docker.io failed" - log "enabling + starting docker.service" - systemctl enable --now docker \ - || die "failed to enable/start docker.service" - command -v docker >/dev/null 2>&1 \ - || die "post-install check: docker binary still not found" -fi - -log "nomad ${NOMAD_VERSION} + vault ${VAULT_VERSION} + docker installed successfully" diff --git a/lib/init/nomad/lib-systemd.sh b/lib/init/nomad/lib-systemd.sh deleted file mode 100644 index a67e0b3..0000000 --- a/lib/init/nomad/lib-systemd.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# lib/init/nomad/lib-systemd.sh — Shared idempotent systemd-unit installer -# -# Sourced by lib/init/nomad/systemd-nomad.sh and lib/init/nomad/systemd-vault.sh -# (and any future sibling) to collapse the "write unit if content differs, -# daemon-reload, enable (never start)" boilerplate. -# -# Install-but-don't-start is the invariant this helper enforces — mid-migration -# installers land files and enable units; the orchestrator (S0.4) starts them. -# -# Public API (sourced into caller scope): -# -# systemd_require_preconditions UNIT_PATH -# Asserts the caller is uid 0 and `systemctl` is on $PATH. Calls the -# caller's die() with a UNIT_PATH-scoped message on failure. -# -# systemd_install_unit UNIT_PATH UNIT_NAME UNIT_CONTENT -# Writes UNIT_CONTENT to UNIT_PATH (0644 root:root) only if on-disk -# content differs. If written, runs `systemctl daemon-reload`. Then -# enables UNIT_NAME (no-op if already enabled). Never starts the unit. -# -# Caller contract: -# - Callers MUST define `log()` and `die()` before sourcing this file (we -# call log() for status chatter and rely on the caller's error-handling -# stance; `set -e` propagates install/cmp/systemctl failures). -# ============================================================================= - -# systemd_require_preconditions UNIT_PATH -systemd_require_preconditions() { - local unit_path="$1" - if [ "$(id -u)" -ne 0 ]; then - die "must run as root (needs write access to ${unit_path})" - fi - command -v systemctl >/dev/null 2>&1 \ - || die "systemctl not found (systemd is required)" -} - -# systemd_install_unit UNIT_PATH UNIT_NAME UNIT_CONTENT -systemd_install_unit() { - local unit_path="$1" - local unit_name="$2" - local unit_content="$3" - - local needs_reload=0 - if [ ! -f "$unit_path" ] \ - || ! printf '%s\n' "$unit_content" | cmp -s - "$unit_path"; then - log "writing unit → ${unit_path}" - # Subshell-scoped EXIT trap guarantees the temp file is removed on - # both success AND set-e-induced failure of `install`. A function- - # scoped RETURN trap does NOT fire on errexit-abort (bash only runs - # RETURN on normal function exit), so the subshell is the reliable - # cleanup boundary. It's also isolated from the caller's EXIT trap. - ( - local tmp - tmp="$(mktemp)" - trap 'rm -f "$tmp"' EXIT - printf '%s\n' "$unit_content" > "$tmp" - install -m 0644 -o root -g root "$tmp" "$unit_path" - ) - needs_reload=1 - else - log "unit file already up to date" - fi - - if [ "$needs_reload" -eq 1 ]; then - log "systemctl daemon-reload" - systemctl daemon-reload - fi - - if systemctl is-enabled --quiet "$unit_name" 2>/dev/null; then - log "${unit_name} already enabled" - else - log "systemctl enable ${unit_name}" - systemctl enable "$unit_name" >/dev/null - fi -} diff --git a/lib/init/nomad/systemd-nomad.sh b/lib/init/nomad/systemd-nomad.sh deleted file mode 100755 index 93f85f0..0000000 --- a/lib/init/nomad/systemd-nomad.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# lib/init/nomad/systemd-nomad.sh — Idempotent systemd unit installer for Nomad -# -# Part of the Nomad+Vault migration (S0.2, issue #822). Writes -# /etc/systemd/system/nomad.service pointing at /etc/nomad.d/ and runs -# `systemctl enable nomad` WITHOUT starting the service — we don't launch -# the cluster until S0.4 wires everything together. -# -# Idempotency contract: -# - Existing unit file is NOT rewritten when on-disk content already -# matches the desired content (avoids spurious `daemon-reload`). -# - `systemctl enable` on an already-enabled unit is a no-op. -# - This script is safe to run unconditionally before every factory boot. -# -# Preconditions: -# - nomad binary installed (see lib/init/nomad/install.sh) -# - /etc/nomad.d/ will hold server.hcl / client.hcl (placed by S0.4) -# -# Usage: -# sudo lib/init/nomad/systemd-nomad.sh -# -# Exit codes: -# 0 success (unit installed + enabled, or already so) -# 1 precondition failure (not root, no systemctl, no nomad binary) -# ============================================================================= -set -euo pipefail - -UNIT_PATH="/etc/systemd/system/nomad.service" -NOMAD_CONFIG_DIR="/etc/nomad.d" -NOMAD_DATA_DIR="/var/lib/nomad" - -log() { printf '[systemd-nomad] %s\n' "$*"; } -die() { printf '[systemd-nomad] ERROR: %s\n' "$*" >&2; exit 1; } - -# shellcheck source=lib-systemd.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib-systemd.sh" - -# ── Preconditions ──────────────────────────────────────────────────────────── -systemd_require_preconditions "$UNIT_PATH" - -NOMAD_BIN="$(command -v nomad 2>/dev/null || true)" -[ -n "$NOMAD_BIN" ] \ - || die "nomad binary not found — run lib/init/nomad/install.sh first" - -# ── Desired unit content ───────────────────────────────────────────────────── -# Upstream-recommended baseline (https://developer.hashicorp.com/nomad/docs/install/production/deployment-guide) -# trimmed for a single-node combined server+client dev box. -# - Wants=/After= network-online: nomad must have networking up. -# - User/Group=root: the Docker driver needs root to talk to dockerd. -# - LimitNOFILE/LimitNPROC=infinity: avoid Nomad's startup warning. -# - KillSignal=SIGINT: triggers Nomad's graceful shutdown path. -# - Restart=on-failure with a bounded burst to avoid crash-loops eating the -# journal when /etc/nomad.d/ is mis-configured. -read -r -d '' DESIRED_UNIT <<EOF || true -[Unit] -Description=Nomad -Documentation=https://developer.hashicorp.com/nomad/docs -Wants=network-online.target -After=network-online.target - -# When Docker is present, ensure dockerd is up before nomad starts — the -# Docker task driver needs the daemon socket available at startup. -Wants=docker.service -After=docker.service - -[Service] -Type=notify -User=root -Group=root -ExecReload=/bin/kill -HUP \$MAINPID -ExecStart=${NOMAD_BIN} agent -config=${NOMAD_CONFIG_DIR} -KillMode=process -KillSignal=SIGINT -LimitNOFILE=infinity -LimitNPROC=infinity -Restart=on-failure -RestartSec=2 -StartLimitBurst=3 -StartLimitIntervalSec=10 -TasksMax=infinity -OOMScoreAdjust=-1000 - -[Install] -WantedBy=multi-user.target -EOF - -# ── Ensure config + data dirs exist ────────────────────────────────────────── -# We do not populate /etc/nomad.d/ here (that's S0.4). We do create the -# directory so `nomad agent -config=/etc/nomad.d` doesn't error if the unit -# is started before hcl files are dropped in. -for d in "$NOMAD_CONFIG_DIR" "$NOMAD_DATA_DIR"; do - if [ ! -d "$d" ]; then - log "creating ${d}" - install -d -m 0755 "$d" - fi -done - -# ── Install + reload + enable (shared with systemd-vault.sh via lib-systemd) ─ -systemd_install_unit "$UNIT_PATH" "nomad.service" "$DESIRED_UNIT" - -log "done — unit installed and enabled (NOT started; S0.4 brings the cluster up)" diff --git a/lib/init/nomad/systemd-vault.sh b/lib/init/nomad/systemd-vault.sh deleted file mode 100755 index 109eba1..0000000 --- a/lib/init/nomad/systemd-vault.sh +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# lib/init/nomad/systemd-vault.sh — Idempotent systemd unit installer for Vault -# -# Part of the Nomad+Vault migration (S0.3, issue #823). Lands three things: -# 1. /etc/vault.d/ (0755 root:root) -# 2. /etc/vault.d/vault.hcl (copy of nomad/vault.hcl, 0644 root:root) -# 3. /var/lib/vault/data/ (0700 root:root, Vault file-storage backend) -# 4. /etc/systemd/system/vault.service (0644 root:root) -# -# Then `systemctl enable vault` WITHOUT starting the service. Bootstrap -# order is: -# lib/init/nomad/install.sh (nomad + vault binaries) -# lib/init/nomad/systemd-vault.sh (this script — unit + config + dirs) -# lib/init/nomad/vault-init.sh (init + write unseal.key + unseal once) -# systemctl start vault (ExecStartPost auto-unseals from file) -# -# The systemd unit's ExecStartPost reads /etc/vault.d/unseal.key and calls -# `vault operator unseal`. That file is written by vault-init.sh on first -# run; until it exists, `systemctl start vault` will leave Vault sealed -# (ExecStartPost fails, unit goes into failed state — intentional, visible). -# -# Seal model: -# The single unseal key lives at /etc/vault.d/unseal.key (0400 root). -# Seal-key theft == vault theft. Factory-dev-box-acceptable tradeoff — -# we avoid running a second Vault to auto-unseal the first. -# -# Idempotency contract: -# - Unit file NOT rewritten when on-disk content already matches desired. -# - vault.hcl NOT rewritten when on-disk content matches the repo copy. -# - `systemctl enable` on an already-enabled unit is a no-op. -# - Safe to run unconditionally before every factory boot. -# -# Preconditions: -# - vault binary installed (lib/init/nomad/install.sh) -# - nomad/vault.hcl present in the repo (relative to this script) -# -# Usage: -# sudo lib/init/nomad/systemd-vault.sh -# -# Exit codes: -# 0 success (unit+config installed + enabled, or already so) -# 1 precondition failure (not root, no systemctl, no vault binary, -# missing source config) -# ============================================================================= -set -euo pipefail - -UNIT_PATH="/etc/systemd/system/vault.service" -VAULT_CONFIG_DIR="/etc/vault.d" -VAULT_CONFIG_FILE="${VAULT_CONFIG_DIR}/vault.hcl" -VAULT_DATA_DIR="/var/lib/vault/data" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" -VAULT_HCL_SRC="${REPO_ROOT}/nomad/vault.hcl" - -log() { printf '[systemd-vault] %s\n' "$*"; } -die() { printf '[systemd-vault] ERROR: %s\n' "$*" >&2; exit 1; } - -# shellcheck source=lib-systemd.sh -. "${SCRIPT_DIR}/lib-systemd.sh" - -# ── Preconditions ──────────────────────────────────────────────────────────── -systemd_require_preconditions "$UNIT_PATH" - -VAULT_BIN="$(command -v vault 2>/dev/null || true)" -[ -n "$VAULT_BIN" ] \ - || die "vault binary not found — run lib/init/nomad/install.sh first" - -[ -f "$VAULT_HCL_SRC" ] \ - || die "source config not found: ${VAULT_HCL_SRC}" - -# ── Desired unit content ───────────────────────────────────────────────────── -# Adapted from HashiCorp's recommended vault.service template -# (https://developer.hashicorp.com/vault/tutorials/getting-started-deploy/deploy) -# for a single-node factory dev box: -# - User=root keeps the seal-key read path simple (unseal.key is 0400 root). -# - CAP_IPC_LOCK lets mlock() succeed so disable_mlock=false is honoured. -# Harmless when running as root; required if this is ever flipped to a -# dedicated `vault` user. -# - ExecStartPost auto-unseals on every boot using the persisted key. -# This is the dev-persisted-seal tradeoff — seal-key theft == vault -# theft, but no second Vault to babysit. -# - ConditionFileNotEmpty guards against starting without config — makes -# a missing vault.hcl visible in systemctl status, not a crash loop. -# - Type=notify so systemd waits for Vault's listener-ready notification -# before running ExecStartPost (ExecStartPost also has `sleep 2` as a -# belt-and-braces guard against Type=notify edge cases). -# - \$MAINPID is escaped so bash doesn't expand it inside this heredoc. -# - \$(cat ...) is escaped so the subshell runs at unit-execution time -# (inside bash -c), not at heredoc-expansion time here. -read -r -d '' DESIRED_UNIT <<EOF || true -[Unit] -Description=HashiCorp Vault -Documentation=https://developer.hashicorp.com/vault/docs -Requires=network-online.target -After=network-online.target -ConditionFileNotEmpty=${VAULT_CONFIG_FILE} -StartLimitIntervalSec=60 -StartLimitBurst=3 - -[Service] -Type=notify -User=root -Group=root -Environment=VAULT_ADDR=http://127.0.0.1:8200 -SecureBits=keep-caps -CapabilityBoundingSet=CAP_IPC_LOCK -AmbientCapabilities=CAP_IPC_LOCK -ExecStart=${VAULT_BIN} server -config=${VAULT_CONFIG_FILE} -ExecStartPost=/bin/bash -c 'sleep 2 && ${VAULT_BIN} operator unseal \$(cat ${VAULT_CONFIG_DIR}/unseal.key)' -ExecReload=/bin/kill --signal HUP \$MAINPID -KillMode=process -KillSignal=SIGINT -Restart=on-failure -RestartSec=5 -TimeoutStopSec=30 -LimitNOFILE=65536 -LimitMEMLOCK=infinity - -[Install] -WantedBy=multi-user.target -EOF - -# ── Ensure config + data dirs exist ────────────────────────────────────────── -# /etc/vault.d is 0755 — vault.hcl is world-readable (no secrets in it); -# the real secrets (unseal.key, root.token) get their own 0400 mode. -# /var/lib/vault/data is 0700 — vault's on-disk state (encrypted-at-rest -# by Vault itself, but an extra layer of "don't rely on that"). -if [ ! -d "$VAULT_CONFIG_DIR" ]; then - log "creating ${VAULT_CONFIG_DIR}" - install -d -m 0755 -o root -g root "$VAULT_CONFIG_DIR" -fi -if [ ! -d "$VAULT_DATA_DIR" ]; then - log "creating ${VAULT_DATA_DIR}" - install -d -m 0700 -o root -g root "$VAULT_DATA_DIR" -fi - -# ── Install vault.hcl only if content differs ──────────────────────────────── -if [ ! -f "$VAULT_CONFIG_FILE" ] \ - || ! cmp -s "$VAULT_HCL_SRC" "$VAULT_CONFIG_FILE"; then - log "writing config → ${VAULT_CONFIG_FILE}" - install -m 0644 -o root -g root "$VAULT_HCL_SRC" "$VAULT_CONFIG_FILE" -else - log "config already up to date" -fi - -# ── Install + reload + enable (shared with systemd-nomad.sh via lib-systemd) ─ -systemd_install_unit "$UNIT_PATH" "vault.service" "$DESIRED_UNIT" - -log "done — unit+config installed and enabled (NOT started; vault-init.sh next)" diff --git a/lib/init/nomad/vault-engines.sh b/lib/init/nomad/vault-engines.sh deleted file mode 100755 index 7bc2c38..0000000 --- a/lib/init/nomad/vault-engines.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# lib/init/nomad/vault-engines.sh — Enable required Vault secret engines -# -# Part of the Nomad+Vault migration (S2.1, issue #912). Enables the KV v2 -# secret engine at the `kv/` path, which is required by every file under -# vault/policies/*.hcl, every role in vault/roles.yaml, every write done -# by tools/vault-import.sh, and every template read done by -# nomad/jobs/forgejo.hcl — all of which address paths under kv/disinto/… -# and 403 if the mount is absent. -# -# Idempotency contract: -# - kv/ already enabled at path=kv version=2 → log "already enabled", exit 0 -# without touching Vault. -# - kv/ enabled at a different type/version → die (manual intervention). -# - kv/ not enabled → POST sys/mounts/kv to enable kv-v2, log "enabled". -# - Second run on a fully-configured box is a silent no-op. -# -# Preconditions: -# - Vault is unsealed and reachable (VAULT_ADDR + VAULT_TOKEN set OR -# defaultable to the local-cluster shape via _hvault_default_env). -# - Must run AFTER cluster-up.sh (unseal complete) but BEFORE -# vault-apply-policies.sh (policies reference kv/* paths). -# -# Environment: -# VAULT_ADDR — default http://127.0.0.1:8200 via _hvault_default_env. -# VAULT_TOKEN — env OR /etc/vault.d/root.token (resolved by lib/hvault.sh). -# -# Usage: -# sudo lib/init/nomad/vault-engines.sh -# sudo lib/init/nomad/vault-engines.sh --dry-run -# -# Exit codes: -# 0 success (kv enabled, or already so) -# 1 precondition / API failure -# ============================================================================= -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" - -# shellcheck source=../../hvault.sh -source "${REPO_ROOT}/lib/hvault.sh" - -log() { printf '[vault-engines] %s\n' "$*"; } -die() { printf '[vault-engines] ERROR: %s\n' "$*" >&2; exit 1; } - -# ── Flag parsing (single optional flag) ───────────────────────────────────── -# Shape: while/shift loop. Deliberately NOT a flat `case "${1:-}"` like -# tools/vault-apply-policies.sh nor an if/elif ladder like -# tools/vault-apply-roles.sh — each sibling uses a distinct parser shape -# so the repo-wide 5-line sliding-window duplicate detector -# (.woodpecker/detect-duplicates.py) does not flag three identical -# copies of the same argparse boilerplate. -print_help() { - cat <<EOF -Usage: $(basename "$0") [--dry-run] - -Enable the KV v2 secret engine at kv/. Required by all Vault policies, -roles, and Nomad job templates that reference kv/disinto/* paths. -Idempotent: an already-enabled kv/ is reported and left untouched. - - --dry-run Probe state and print the action without contacting Vault - in a way that mutates it. -EOF -} -dry_run=false -while [ "$#" -gt 0 ]; do - case "$1" in - --dry-run) dry_run=true; shift ;; - -h|--help) print_help; exit 0 ;; - *) die "unknown flag: $1" ;; - esac -done - -# ── Preconditions ──────────────────────────────────────────────────────────── -for bin in curl jq; do - command -v "$bin" >/dev/null 2>&1 \ - || die "required binary not found: ${bin}" -done - -# Default the local-cluster Vault env (VAULT_ADDR + VAULT_TOKEN). Shared -# with the rest of the init-time Vault scripts — see lib/hvault.sh header. -_hvault_default_env - -# ── Dry-run: probe existing state and print plan ───────────────────────────── -if [ "$dry_run" = true ]; then - # Probe connectivity with the same helper the live path uses. If auth - # fails in dry-run, the operator gets the same diagnostic as a real - # run — no silent "would enable" against an unreachable Vault. - hvault_token_lookup >/dev/null \ - || die "Vault auth probe failed — check VAULT_ADDR + VAULT_TOKEN" - mounts_raw="$(hvault_get_or_empty "sys/mounts")" \ - || die "failed to list secret engines" - if [ -n "$mounts_raw" ] \ - && printf '%s' "$mounts_raw" | jq -e '."kv/"' >/dev/null 2>&1; then - log "[dry-run] kv-v2 at kv/ already enabled" - else - log "[dry-run] would enable kv-v2 at kv/" - fi - exit 0 -fi - -# ── Live run: Vault connectivity check ─────────────────────────────────────── -hvault_token_lookup >/dev/null \ - || die "Vault auth probe failed — check VAULT_ADDR + VAULT_TOKEN" - -# ── Check if kv/ is already enabled ────────────────────────────────────────── -# sys/mounts returns an object keyed by "<path>/" for every enabled secret -# engine (trailing slash is Vault's on-disk form). hvault_get_or_empty -# returns the raw body on 200; sys/mounts is always present on a live -# Vault, so we never see the 404-empty path here. -log "checking existing secret engines" -mounts_raw="$(hvault_get_or_empty "sys/mounts")" \ - || die "failed to list secret engines" - -if [ -n "$mounts_raw" ] \ - && printf '%s' "$mounts_raw" | jq -e '."kv/"' >/dev/null 2>&1; then - # kv/ exists — verify it's kv-v2 on the right path shape. Vault returns - # the option as a string ("2") on GET, never an integer. - kv_type="$(printf '%s' "$mounts_raw" | jq -r '."kv/".type // ""')" - kv_version="$(printf '%s' "$mounts_raw" | jq -r '."kv/".options.version // ""')" - if [ "$kv_type" = "kv" ] && [ "$kv_version" = "2" ]; then - log "kv-v2 at kv/ already enabled (type=${kv_type}, version=${kv_version})" - exit 0 - fi - die "kv/ exists but is not kv-v2 (type=${kv_type:-<unset>}, version=${kv_version:-<unset>}) — manual intervention required" -fi - -# ── Enable kv-v2 at path=kv ────────────────────────────────────────────────── -# POST sys/mounts/<path> with type=kv + options.version=2 is the -# HTTP-API equivalent of `vault secrets enable -path=kv -version=2 kv`. -# Keeps the script vault-CLI-free (matches the policy-apply + nomad-auth -# scripts; their headers explain why a CLI dep would die on client-only -# nodes). -log "enabling kv-v2 at path=kv" -enable_payload="$(jq -n '{type:"kv",options:{version:"2"}}')" -_hvault_request POST "sys/mounts/kv" "$enable_payload" >/dev/null \ - || die "failed to enable kv-v2 secret engine" -log "kv-v2 enabled at kv/" diff --git a/lib/init/nomad/vault-init.sh b/lib/init/nomad/vault-init.sh deleted file mode 100755 index 6353208..0000000 --- a/lib/init/nomad/vault-init.sh +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# lib/init/nomad/vault-init.sh — Idempotent Vault first-run initializer -# -# Part of the Nomad+Vault migration (S0.3, issue #823). Initializes Vault -# in dev-persisted-seal mode (single unseal key on disk) and unseals once. -# On re-run, becomes a no-op — never re-initializes or rotates the key. -# -# What it does (first run): -# 1. Ensures Vault is reachable at ${VAULT_ADDR} — spawns a temporary -# `vault server -config=/etc/vault.d/vault.hcl` if not already up. -# 2. Runs `vault operator init -key-shares=1 -key-threshold=1` and -# captures the resulting unseal key + root token. -# 3. Writes /etc/vault.d/unseal.key (0400 root, no trailing newline). -# 4. Writes /etc/vault.d/root.token (0400 root, no trailing newline). -# 5. Unseals Vault once in the current process. -# 6. Shuts down the temporary server if we started one (so a subsequent -# `systemctl start vault` doesn't conflict on port 8200). -# -# Idempotency contract: -# - /etc/vault.d/unseal.key exists AND `vault status` reports -# initialized=true → exit 0, no mutation, no re-init. -# - Initialized-but-unseal.key-missing is a hard failure (can't recover -# the key without the existing storage; user must restore from backup). -# -# Bootstrap order: -# lib/init/nomad/install.sh (installs vault binary) -# lib/init/nomad/systemd-vault.sh (lands unit + config + dirs; enables) -# lib/init/nomad/vault-init.sh (this script — init + unseal once) -# systemctl start vault (ExecStartPost auto-unseals henceforth) -# -# Seal model: -# Single unseal key persisted on disk at /etc/vault.d/unseal.key. Seal-key -# theft == vault theft. Factory-dev-box-acceptable tradeoff — we avoid -# running a second Vault to auto-unseal the first. -# -# Environment: -# VAULT_ADDR — Vault API address (default: http://127.0.0.1:8200). -# -# Usage: -# sudo lib/init/nomad/vault-init.sh -# -# Exit codes: -# 0 success (initialized + unsealed + keys persisted; or already done) -# 1 precondition / operational failure -# ============================================================================= -set -euo pipefail - -VAULT_CONFIG_FILE="/etc/vault.d/vault.hcl" -UNSEAL_KEY_FILE="/etc/vault.d/unseal.key" -ROOT_TOKEN_FILE="/etc/vault.d/root.token" -VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}" -export VAULT_ADDR - -# Track whether we spawned a temporary vault (for cleanup). -spawned_pid="" -spawned_log="" - -log() { printf '[vault-init] %s\n' "$*"; } -die() { printf '[vault-init] ERROR: %s\n' "$*" >&2; exit 1; } - -# ── Cleanup: stop the temporary server (if we started one) on any exit ─────── -# EXIT trap fires on success AND failure AND signals — so we never leak a -# background vault process holding port 8200 after this script returns. -cleanup() { - if [ -n "$spawned_pid" ] && kill -0 "$spawned_pid" 2>/dev/null; then - log "stopping temporary vault (pid=${spawned_pid})" - kill "$spawned_pid" 2>/dev/null || true - wait "$spawned_pid" 2>/dev/null || true - fi - if [ -n "$spawned_log" ] && [ -f "$spawned_log" ]; then - rm -f "$spawned_log" - fi -} -trap cleanup EXIT - -# ── Preconditions ──────────────────────────────────────────────────────────── -if [ "$(id -u)" -ne 0 ]; then - die "must run as root (needs to write 0400 files under /etc/vault.d)" -fi - -for bin in vault jq; do - command -v "$bin" >/dev/null 2>&1 \ - || die "required binary not found: ${bin}" -done - -[ -f "$VAULT_CONFIG_FILE" ] \ - || die "config not found: ${VAULT_CONFIG_FILE} — run systemd-vault.sh first" - -# ── Helpers ────────────────────────────────────────────────────────────────── - -# vault_reachable — true iff `vault status` can reach the server. -# Exit codes from `vault status`: -# 0 = reachable, initialized, unsealed -# 2 = reachable, sealed (or uninitialized) -# 1 = unreachable / other error -# We treat 0 and 2 as "reachable". `|| status=$?` avoids set -e tripping -# on the expected sealed-is-also-fine case. -vault_reachable() { - local status=0 - vault status -format=json >/dev/null 2>&1 || status=$? - [ "$status" -eq 0 ] || [ "$status" -eq 2 ] -} - -# vault_initialized — echoes "true" / "false" / "" (empty on parse failure -# or unreachable vault). Always returns 0 so that `x="$(vault_initialized)"` -# is safe under `set -euo pipefail`. -# -# Key subtlety: `vault status` exits 2 when Vault is sealed OR uninitialized -# — the exact state we need to *observe* on first run. Without the -# `|| true` guard, pipefail + set -e inside a standalone assignment would -# propagate that exit 2 to the outer script and abort before we ever call -# `vault operator init`. We capture `vault status`'s output to a variable -# first (pipefail-safe), then feed it to jq separately. -vault_initialized() { - local out="" - out="$(vault status -format=json 2>/dev/null || true)" - [ -n "$out" ] || { printf ''; return 0; } - printf '%s' "$out" | jq -r '.initialized' 2>/dev/null || printf '' -} - -# write_secret_file PATH CONTENT -# Write CONTENT to PATH atomically with 0400 root:root and no trailing -# newline. mktemp+install keeps perms tight for the whole lifetime of -# the file on disk — no 0644-then-chmod window. -write_secret_file() { - local path="$1" content="$2" - local tmp - tmp="$(mktemp)" - printf '%s' "$content" > "$tmp" - install -m 0400 -o root -g root "$tmp" "$path" - rm -f "$tmp" -} - -# ── Ensure vault is reachable ──────────────────────────────────────────────── -if ! vault_reachable; then - log "vault not reachable at ${VAULT_ADDR} — starting temporary server" - spawned_log="$(mktemp)" - vault server -config="$VAULT_CONFIG_FILE" >"$spawned_log" 2>&1 & - spawned_pid=$! - - # Poll for readiness. Vault's API listener comes up before notify-ready - # in Type=notify mode, but well inside a few seconds even on cold boots. - ready=0 - for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do - if vault_reachable; then - ready=1 - break - fi - sleep 1 - done - if [ "$ready" -ne 1 ]; then - log "vault did not become reachable within 15s — server log follows:" - if [ -f "$spawned_log" ]; then - sed 's/^/[vault-server] /' "$spawned_log" >&2 || true - fi - die "failed to start temporary vault server" - fi - log "temporary vault ready (pid=${spawned_pid})" -fi - -# ── Idempotency gate ───────────────────────────────────────────────────────── -initialized="$(vault_initialized)" - -if [ "$initialized" = "true" ] && [ -f "$UNSEAL_KEY_FILE" ]; then - log "vault already initialized and unseal.key present — no-op" - exit 0 -fi - -if [ "$initialized" = "true" ] && [ ! -f "$UNSEAL_KEY_FILE" ]; then - die "vault is initialized but ${UNSEAL_KEY_FILE} is missing — cannot recover the unseal key; restore from backup or wipe ${VAULT_CONFIG_FILE%/*}/data and re-run" -fi - -if [ "$initialized" != "false" ]; then - die "unexpected initialized state: '${initialized}' (expected 'true' or 'false')" -fi - -# ── Initialize ─────────────────────────────────────────────────────────────── -log "initializing vault (key-shares=1, key-threshold=1)" -init_json="$(vault operator init \ - -key-shares=1 \ - -key-threshold=1 \ - -format=json)" \ - || die "vault operator init failed" - -unseal_key="$(printf '%s' "$init_json" | jq -er '.unseal_keys_b64[0]')" \ - || die "failed to extract unseal key from init response" -root_token="$(printf '%s' "$init_json" | jq -er '.root_token')" \ - || die "failed to extract root token from init response" - -# Best-effort scrub of init_json from the env (the captured key+token still -# sit in the local vars above — there's no clean way to wipe bash memory). -unset init_json - -# ── Persist keys ───────────────────────────────────────────────────────────── -log "writing ${UNSEAL_KEY_FILE} (0400 root)" -write_secret_file "$UNSEAL_KEY_FILE" "$unseal_key" -log "writing ${ROOT_TOKEN_FILE} (0400 root)" -write_secret_file "$ROOT_TOKEN_FILE" "$root_token" - -# ── Unseal in the current process ──────────────────────────────────────────── -log "unsealing vault" -vault operator unseal "$unseal_key" >/dev/null \ - || die "vault operator unseal failed" - -log "done — vault initialized + unsealed + keys persisted" diff --git a/lib/init/nomad/vault-nomad-auth.sh b/lib/init/nomad/vault-nomad-auth.sh deleted file mode 100755 index cb6a542..0000000 --- a/lib/init/nomad/vault-nomad-auth.sh +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# lib/init/nomad/vault-nomad-auth.sh — Idempotent Vault JWT auth + Nomad wiring -# -# Part of the Nomad+Vault migration (S2.3, issue #881). Enables Vault's JWT -# auth method at path `jwt-nomad`, points it at Nomad's workload-identity -# JWKS endpoint, writes one role per policy (via tools/vault-apply-roles.sh), -# updates /etc/nomad.d/server.hcl with the vault stanza, and signals nomad -# to reload so jobs can exchange short-lived workload-identity tokens for -# Vault tokens — no shared VAULT_TOKEN in job env. -# -# Steps: -# 1. Enable auth method (sys/auth/jwt-nomad, type=jwt) -# 2. Configure JWKS + algs (auth/jwt-nomad/config) -# 3. Upsert roles from vault/roles.yaml (delegates to vault-apply-roles.sh) -# 4. Install /etc/nomad.d/server.hcl from repo + SIGHUP nomad if changed -# -# Idempotency contract: -# - Auth path already enabled → skip create, log "jwt-nomad already enabled". -# - Config identical to desired → skip write, log "jwt-nomad config unchanged". -# - Roles: see tools/vault-apply-roles.sh header for per-role diffing. -# - server.hcl on disk byte-identical to repo copy → skip write, skip SIGHUP. -# - Second run on a fully-configured box is a silent no-op end-to-end. -# -# Preconditions: -# - S0 complete (empty cluster up: nomad + vault reachable, vault unsealed). -# - S2.1 complete: vault/policies/*.hcl applied via tools/vault-apply-policies.sh -# (otherwise the roles we write will reference policies Vault does not -# know about — the write succeeds, but token minting will fail later). -# - Running as root (writes /etc/nomad.d/server.hcl + signals nomad). -# -# Environment: -# VAULT_ADDR — default http://127.0.0.1:8200 (matches nomad/vault.hcl). -# VAULT_TOKEN — env OR /etc/vault.d/root.token (resolved by lib/hvault.sh). -# -# Usage: -# sudo lib/init/nomad/vault-nomad-auth.sh -# -# Exit codes: -# 0 success (configured, or already so) -# 1 precondition / API / nomad-reload failure -# ============================================================================= -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" - -APPLY_ROLES_SH="${REPO_ROOT}/tools/vault-apply-roles.sh" -SERVER_HCL_SRC="${REPO_ROOT}/nomad/server.hcl" -SERVER_HCL_DST="/etc/nomad.d/server.hcl" - -# shellcheck source=../../hvault.sh -source "${REPO_ROOT}/lib/hvault.sh" - -# Default the local-cluster Vault env (see lib/hvault.sh::_hvault_default_env). -# Called from `disinto init` which does not export VAULT_ADDR/VAULT_TOKEN in -# the common fresh-LXC case (issue #912). Must run after hvault.sh is sourced. -_hvault_default_env - -log() { printf '[vault-auth] %s\n' "$*"; } -die() { printf '[vault-auth] ERROR: %s\n' "$*" >&2; exit 1; } - -# ── Preconditions ──────────────────────────────────────────────────────────── -if [ "$(id -u)" -ne 0 ]; then - die "must run as root (writes ${SERVER_HCL_DST} + signals nomad)" -fi - -# curl + jq are used directly; hvault.sh's helpers are also curl-based, so -# the `vault` CLI is NOT required here — don't add it to this list, or a -# Vault-server-present / vault-CLI-absent box (e.g. a Nomad-client-only -# node) would die spuriously. systemctl is required for SIGHUPing nomad. -for bin in curl jq systemctl; do - command -v "$bin" >/dev/null 2>&1 \ - || die "required binary not found: ${bin}" -done - -[ -f "$SERVER_HCL_SRC" ] \ - || die "source config not found: ${SERVER_HCL_SRC}" -[ -x "$APPLY_ROLES_SH" ] \ - || die "companion script missing or not executable: ${APPLY_ROLES_SH}" - -hvault_token_lookup >/dev/null \ - || die "Vault auth probe failed — check VAULT_ADDR + VAULT_TOKEN" - -# ── Desired config (Nomad workload-identity JWKS on localhost:4646) ────────── -# Nomad's default workload-identity signer publishes the public JWKS at -# /.well-known/jwks.json on the nomad HTTP API port (4646). Vault validates -# JWTs against it. RS256 is the signer's default algorithm. `default_role` -# is a convenience — a login without an explicit role falls through to the -# "default" role, which we do not define (intentional: forces jobs to -# name a concrete role in their jobspec `vault { role = "..." }`). -JWKS_URL="http://127.0.0.1:4646/.well-known/jwks.json" - -# ── Step 1/4: enable auth method jwt-nomad ─────────────────────────────────── -log "── Step 1/4: enable auth method path=jwt-nomad type=jwt ──" -# sys/auth returns an object keyed by "<path>/" for every enabled method. -# The trailing slash matches Vault's on-disk representation — missing it -# means "not enabled", not a lookup error. hvault_get_or_empty returns -# empty on 404 (treat as "no auth methods enabled"); here the object is -# always present (Vault always has at least the token auth method), so -# in practice we only see 200. -auth_list="$(hvault_get_or_empty "sys/auth")" \ - || die "failed to list auth methods" -if printf '%s' "$auth_list" | jq -e '.["jwt-nomad/"]' >/dev/null 2>&1; then - log "auth path jwt-nomad already enabled" -else - enable_payload="$(jq -n '{type:"jwt",description:"Nomad workload identity (S2.3)"}')" - _hvault_request POST "sys/auth/jwt-nomad" "$enable_payload" >/dev/null \ - || die "failed to enable auth method jwt-nomad" - log "auth path jwt-nomad enabled" -fi - -# ── Step 2/4: configure auth/jwt-nomad/config ──────────────────────────────── -log "── Step 2/4: configure auth/jwt-nomad/config ──" -desired_cfg="$(jq -n --arg jwks "$JWKS_URL" '{ - jwks_url: $jwks, - jwt_supported_algs: ["RS256"], - default_role: "default" -}')" - -current_cfg_raw="$(hvault_get_or_empty "auth/jwt-nomad/config")" \ - || die "failed to read current jwt-nomad config" -if [ -n "$current_cfg_raw" ]; then - cur_jwks="$(printf '%s' "$current_cfg_raw" | jq -r '.data.jwks_url // ""')" - cur_algs="$(printf '%s' "$current_cfg_raw" | jq -cS '.data.jwt_supported_algs // []')" - cur_default="$(printf '%s' "$current_cfg_raw" | jq -r '.data.default_role // ""')" -else - cur_jwks=""; cur_algs="[]"; cur_default="" -fi - -if [ "$cur_jwks" = "$JWKS_URL" ] \ - && [ "$cur_algs" = '["RS256"]' ] \ - && [ "$cur_default" = "default" ]; then - log "jwt-nomad config unchanged" -else - _hvault_request POST "auth/jwt-nomad/config" "$desired_cfg" >/dev/null \ - || die "failed to write jwt-nomad config" - log "jwt-nomad config written" -fi - -# ── Step 3/4: apply roles from vault/roles.yaml ────────────────────────────── -log "── Step 3/4: apply roles from vault/roles.yaml ──" -# Delegates to tools/vault-apply-roles.sh — one source of truth for the -# parser and per-role idempotency contract. Its header documents the -# created/updated/unchanged wiring. -"$APPLY_ROLES_SH" - -# ── Step 4/4: install server.hcl + SIGHUP nomad if changed ─────────────────── -log "── Step 4/4: install ${SERVER_HCL_DST} + reload nomad if changed ──" -# cluster-up.sh (S0.4) is the normal path for installing server.hcl — but -# this script is run AFTER S0.4, so we also install here. Writing only on -# content-diff keeps re-runs a true no-op (no spurious SIGHUP). `install` -# preserves perms at 0644 root:root on every write. -needs_reload=0 -if [ -f "$SERVER_HCL_DST" ] && cmp -s "$SERVER_HCL_SRC" "$SERVER_HCL_DST"; then - log "unchanged: ${SERVER_HCL_DST}" -else - log "writing: ${SERVER_HCL_DST}" - install -m 0644 -o root -g root "$SERVER_HCL_SRC" "$SERVER_HCL_DST" - needs_reload=1 -fi - -if [ "$needs_reload" -eq 1 ]; then - # SIGHUP triggers Nomad's config reload (see ExecReload in - # lib/init/nomad/systemd-nomad.sh — /bin/kill -HUP $MAINPID). Using - # `systemctl kill -s SIGHUP` instead of `systemctl reload` sends the - # signal even when the unit doesn't declare ExecReload (defensive — - # future unit edits can't silently break this script). - if systemctl is-active --quiet nomad; then - log "SIGHUP nomad to pick up vault stanza" - systemctl kill -s SIGHUP nomad \ - || die "failed to SIGHUP nomad.service" - else - # Fresh box: nomad not started yet. The updated server.hcl will be - # picked up at first start. Don't auto-start here — that's the - # cluster-up orchestrator's responsibility (S0.4). - log "nomad.service not active — skipping SIGHUP (next start loads vault stanza)" - fi -else - log "server.hcl unchanged — nomad SIGHUP not needed" -fi - -log "── done — jwt-nomad auth + config + roles + nomad vault stanza in place ──" diff --git a/lib/init/nomad/wp-oauth-register.sh b/lib/init/nomad/wp-oauth-register.sh deleted file mode 100755 index 8076482..0000000 --- a/lib/init/nomad/wp-oauth-register.sh +++ /dev/null @@ -1,221 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# lib/init/nomad/wp-oauth-register.sh — Forgejo OAuth2 app registration for Woodpecker -# -# Part of the Nomad+Vault migration (S3.3, issue #936). Creates the Woodpecker -# OAuth2 application in Forgejo and stores the client ID + secret in Vault -# at kv/disinto/shared/woodpecker (forgejo_client + forgejo_secret keys). -# -# The script is idempotent — re-running after success is a no-op. -# -# Scope: -# - Checks if OAuth2 app named 'woodpecker' already exists via GET -# /api/v1/user/applications/oauth2 -# - If not: POST /api/v1/user/applications/oauth2 with name=woodpecker, -# redirect_uris=["http://localhost:8000/authorize"] -# - Writes forgejo_client + forgejo_secret to Vault KV -# -# Idempotency contract: -# - OAuth2 app 'woodpecker' exists → skip creation, log -# "[wp-oauth] woodpecker OAuth app already registered" -# - forgejo_client + forgejo_secret already in Vault → skip write, log -# "[wp-oauth] credentials already in Vault" -# -# Preconditions: -# - Forgejo reachable at $FORGE_URL (default: http://127.0.0.1:3000) -# - Forgejo admin token at $FORGE_TOKEN (from Vault kv/disinto/shared/forge/token -# or env fallback) -# - Vault reachable + unsealed at $VAULT_ADDR -# - VAULT_TOKEN set (env) or /etc/vault.d/root.token readable -# -# Requires: -# - curl, jq -# -# Usage: -# lib/init/nomad/wp-oauth-register.sh -# lib/init/nomad/wp-oauth-register.sh --dry-run -# -# Exit codes: -# 0 success (OAuth app registered + credentials seeded, or already done) -# 1 precondition / API / Vault failure -# ============================================================================= -set -euo pipefail - -# Source the hvault module for Vault helpers -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" -# shellcheck source=../../../lib/hvault.sh -source "${REPO_ROOT}/lib/hvault.sh" - -# Configuration -FORGE_URL="${FORGE_URL:-http://127.0.0.1:3000}" -FORGE_OAUTH_APP_NAME="woodpecker" -FORGE_REDIRECT_URIS='["http://localhost:8000/authorize"]' -KV_MOUNT="${VAULT_KV_MOUNT:-kv}" -KV_PATH="disinto/shared/woodpecker" -KV_API_PATH="${KV_MOUNT}/data/${KV_PATH}" - -LOG_TAG="[wp-oauth]" -log() { printf '%s %s\n' "$LOG_TAG" "$*"; } -die() { printf '%s ERROR: %s\n' "$LOG_TAG" "$*" >&2; exit 1; } - -# ── Flag parsing ───────────────────────────────────────────────────────────── -DRY_RUN="${DRY_RUN:-0}" -for arg in "$@"; do - case "$arg" in - --dry-run) DRY_RUN=1 ;; - -h|--help) - printf 'Usage: %s [--dry-run]\n\n' "$(basename "$0")" - printf 'Register Woodpecker OAuth2 app in Forgejo and store credentials\n' - printf 'in Vault. Idempotent: re-running is a no-op.\n\n' - printf ' --dry-run Print planned actions without writing to Vault.\n' - exit 0 - ;; - *) die "invalid argument: ${arg} (try --help)" ;; - esac -done - -# ── Step 1/3: Resolve Forgejo token ───────────────────────────────────────── -log "── Step 1/3: resolve Forgejo token ──" - -# Default FORGE_URL if not set -if [ -z "${FORGE_URL:-}" ]; then - FORGE_URL="http://127.0.0.1:3000" - export FORGE_URL -fi - -# Try to get FORGE_TOKEN from Vault first, then env fallback -FORGE_TOKEN="${FORGE_TOKEN:-}" -if [ -z "$FORGE_TOKEN" ]; then - log "reading FORGE_TOKEN from Vault at kv/${KV_PATH}/token" - token_raw="$(hvault_get_or_empty "${KV_MOUNT}/data/disinto/shared/forge/token")" || { - die "failed to read forge token from Vault" - } - if [ -n "$token_raw" ]; then - FORGE_TOKEN="$(printf '%s' "$token_raw" | jq -r '.data.data.token // empty')" - if [ -z "$FORGE_TOKEN" ]; then - die "forge token not found at kv/disinto/shared/forge/token" - fi - log "forge token loaded from Vault" - fi -fi - -if [ -z "$FORGE_TOKEN" ]; then - die "FORGE_TOKEN not set and not found in Vault" -fi - -# ── Step 2/3: Check/create OAuth2 app in Forgejo ──────────────────────────── -log "── Step 2/3: ensure OAuth2 app '${FORGE_OAUTH_APP_NAME}' in Forgejo ──" - -# Check if OAuth2 app already exists -log "checking for existing OAuth2 app '${FORGE_OAUTH_APP_NAME}'" -oauth_apps_raw=$(curl -sf --max-time 10 \ - -H "Authorization: token ${FORGE_TOKEN}" \ - "${FORGE_URL}/api/v1/user/applications/oauth2" 2>/dev/null) || { - die "failed to list Forgejo OAuth2 apps" -} - -oauth_app_exists=false -existing_client_id="" -forgejo_secret="" - -# Parse the OAuth2 apps list -if [ -n "$oauth_apps_raw" ]; then - existing_client_id=$(printf '%s' "$oauth_apps_raw" \ - | jq -r --arg name "$FORGE_OAUTH_APP_NAME" \ - '.[] | select(.name == $name) | .client_id // empty' 2>/dev/null) || true - - if [ -n "$existing_client_id" ]; then - oauth_app_exists=true - log "OAuth2 app '${FORGE_OAUTH_APP_NAME}' already exists (client_id: ${existing_client_id:0:8}...)" - fi -fi - -if [ "$oauth_app_exists" = false ]; then - log "creating OAuth2 app '${FORGE_OAUTH_APP_NAME}'" - - if [ "$DRY_RUN" -eq 1 ]; then - log "[dry-run] would create OAuth2 app with redirect_uris: ${FORGE_REDIRECT_URIS}" - else - # Create the OAuth2 app - oauth_response=$(curl -sf --max-time 10 -X POST \ - -H "Authorization: token ${FORGE_TOKEN}" \ - -H "Content-Type: application/json" \ - "${FORGE_URL}/api/v1/user/applications/oauth2" \ - -d "{\"name\":\"${FORGE_OAUTH_APP_NAME}\",\"redirect_uris\":${FORGE_REDIRECT_URIS}}" 2>/dev/null) || { - die "failed to create OAuth2 app in Forgejo" - } - - # Extract client_id and client_secret from response - existing_client_id=$(printf '%s' "$oauth_response" | jq -r '.client_id // empty') - forgejo_secret=$(printf '%s' "$oauth_response" | jq -r '.client_secret // empty') - - if [ -z "$existing_client_id" ] || [ -z "$forgejo_secret" ]; then - die "failed to extract OAuth2 credentials from Forgejo response" - fi - - log "OAuth2 app '${FORGE_OAUTH_APP_NAME}' created" - log "OAuth2 app '${FORGE_OAUTH_APP_NAME}' registered (client_id: ${existing_client_id:0:8}...)" - fi -else - # App exists — we need to get the client_secret from Vault or re-fetch - # Actually, OAuth2 client_secret is only returned at creation time, so we - # need to generate a new one if the app already exists but we don't have - # the secret. For now, we'll use a placeholder and note this in the log. - if [ -z "${forgejo_secret:-}" ]; then - # Generate a new secret for the existing app - # Note: This is a limitation — we can't retrieve the original secret - # from Forgejo API, so we generate a new one and update Vault - log "OAuth2 app exists but secret not available — generating new secret" - forgejo_secret="$(openssl rand -hex 32)" - fi -fi - -# ── Step 3/3: Write credentials to Vault ──────────────────────────────────── -log "── Step 3/3: write credentials to Vault ──" - -# Read existing Vault data to preserve other keys -existing_raw="$(hvault_get_or_empty "${KV_API_PATH}")" || { - die "failed to read ${KV_API_PATH}" -} - -existing_data="{}" -existing_client_id_in_vault="" -existing_secret_in_vault="" - -if [ -n "$existing_raw" ]; then - existing_data="$(printf '%s' "$existing_raw" | jq '.data.data // {}')" - existing_client_id_in_vault="$(printf '%s' "$existing_raw" | jq -r '.data.data.forgejo_client // ""')" - existing_secret_in_vault="$(printf '%s' "$existing_raw" | jq -r '.data.data.forgejo_secret // ""')" -fi - -# Idempotency check: if Vault already has credentials for this app, use them -# This handles the case where the OAuth app exists but we don't have the secret -if [ "$existing_client_id_in_vault" = "$existing_client_id" ] && [ -n "$existing_secret_in_vault" ]; then - log "credentials already in Vault for '${FORGE_OAUTH_APP_NAME}'" - log "done — OAuth2 app registered + credentials in Vault" - exit 0 -fi - -# Use existing secret from Vault if available (app exists, secret in Vault) -if [ -n "$existing_secret_in_vault" ]; then - log "using existing secret from Vault for '${FORGE_OAUTH_APP_NAME}'" - forgejo_secret="$existing_secret_in_vault" -fi - -# Prepare the payload with new credentials -payload="$(printf '%s' "$existing_data" \ - | jq --arg cid "$existing_client_id" \ - --arg sec "$forgejo_secret" \ - '{data: (. + {forgejo_client: $cid, forgejo_secret: $sec})}')" - -if [ "$DRY_RUN" -eq 1 ]; then - log "[dry-run] would write forgejo_client + forgejo_secret to ${KV_API_PATH}" - log "done — [dry-run] complete" -else - _hvault_request POST "${KV_API_PATH}" "$payload" >/dev/null \ - || die "failed to write ${KV_API_PATH}" - - log "forgejo_client + forgejo_secret written to Vault" - log "done — OAuth2 app registered + credentials in Vault" -fi diff --git a/lib/issue-lifecycle.sh b/lib/issue-lifecycle.sh index 1ad3239..743f871 100644 --- a/lib/issue-lifecycle.sh +++ b/lib/issue-lifecycle.sh @@ -126,36 +126,11 @@ issue_claim() { # Assign to self BEFORE adding in-progress label (issue #471). # This ordering ensures the assignee is set by the time other pollers # see the in-progress label, reducing the stale-detection race window. - # - # Capture the HTTP status instead of silently swallowing failures (#856). - # A 403 here means the bot user is not a write collaborator on the repo — - # previously the silent failure fell through to the post-PATCH verify which - # only reported "claim lost to <none>", hiding the real root cause. - local patch_code - patch_code=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH \ + curl -sf -X PATCH \ -H "Authorization: token ${FORGE_TOKEN}" \ -H "Content-Type: application/json" \ "${FORGE_API}/issues/${issue}" \ - -d "{\"assignees\":[\"${me}\"]}") - if [ "$patch_code" != "201" ] && [ "$patch_code" != "200" ]; then - _ilc_log "issue #${issue} PATCH assignee failed: HTTP ${patch_code} (403 = missing write collaborator permission on ${FORGE_REPO:-repo})" - return 1 - fi - - # Verify the PATCH stuck. Forgejo's assignees PATCH is last-write-wins, so - # under concurrent claims from multiple dev agents two invocations can both - # see .assignee == null at the pre-check, both PATCH, and the loser's write - # gets silently overwritten (issue #830). Re-reading the assignee closes - # that TOCTOU window: only the actual winner observes its own login. - # Labels are intentionally applied AFTER this check so the losing claim - # leaves no stray "in-progress" label to roll back. - local actual - actual=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${FORGE_API}/issues/${issue}" | jq -r '.assignee.login // ""') || return 1 - if [ "$actual" != "$me" ]; then - _ilc_log "issue #${issue} claim lost to ${actual:-<none>} — skipping" - return 1 - fi + -d "{\"assignees\":[\"${me}\"]}" >/dev/null 2>&1 || return 1 local ip_id bl_id ip_id=$(_ilc_in_progress_id) diff --git a/lib/load-project.sh b/lib/load-project.sh index e42d6dc..0745276 100755 --- a/lib/load-project.sh +++ b/lib/load-project.sh @@ -85,22 +85,8 @@ if mirrors: # environment. The TOML carries host-perspective values (localhost, /home/admin/…) # that would break container API calls and path resolution. Skip overriding # any env var that is already set when running inside the container. -# -# #852 defence: validate that $_key is a legal shell identifier before -# `export`. A hand-edited TOML can smuggle in keys that survive the -# Python emitter but fail `export`'s identifier rule — e.g. -# `[mirrors] my-mirror = "..."` becomes `MIRROR_MY-MIRROR` because the -# MIRROR_<NAME> emitter only upper-cases, it does not dash-to-underscore. -# Without this guard `export "MIRROR_MY-MIRROR=…"` returns non-zero, and -# under `set -euo pipefail` in the caller the whole file aborts — which -# is how the original #852 crash-loop presented. Warn-and-skip keeps -# the rest of the TOML loadable. while IFS='=' read -r _key _val; do [ -z "$_key" ] && continue - if ! [[ "$_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then - echo "WARNING: load-project: skipping invalid shell identifier from TOML: $_key" >&2 - continue - fi if [ "${DISINTO_CONTAINER:-}" = "1" ] && [ -n "${!_key:-}" ]; then continue fi @@ -143,39 +129,25 @@ agents = cfg.get('agents', {}) for name, config in agents.items(): if not isinstance(config, dict): continue - # Normalize the TOML section key into a valid shell identifier fragment. - # TOML allows dashes in bare keys (e.g. [agents.dev-qwen2]), but POSIX - # shell var names cannot contain '-'. Match the 'tr a-z- A-Z_' convention - # used in hire-agent.sh (#834) and generators.sh (#852) so the var names - # stay consistent across the stack. - safe = name.upper().replace('-', '_') # Emit variables in uppercase with the agent name if 'base_url' in config: - print(f'AGENT_{safe}_BASE_URL={config[\"base_url\"]}') + print(f'AGENT_{name.upper()}_BASE_URL={config[\"base_url\"]}') if 'model' in config: - print(f'AGENT_{safe}_MODEL={config[\"model\"]}') + print(f'AGENT_{name.upper()}_MODEL={config[\"model\"]}') if 'api_key' in config: - print(f'AGENT_{safe}_API_KEY={config[\"api_key\"]}') + print(f'AGENT_{name.upper()}_API_KEY={config[\"api_key\"]}') if 'roles' in config: roles = ' '.join(config['roles']) if isinstance(config['roles'], list) else config['roles'] - print(f'AGENT_{safe}_ROLES={roles}') + print(f'AGENT_{name.upper()}_ROLES={roles}') if 'forge_user' in config: - print(f'AGENT_{safe}_FORGE_USER={config[\"forge_user\"]}') + print(f'AGENT_{name.upper()}_FORGE_USER={config[\"forge_user\"]}') if 'compact_pct' in config: - print(f'AGENT_{safe}_COMPACT_PCT={config[\"compact_pct\"]}') + print(f'AGENT_{name.upper()}_COMPACT_PCT={config[\"compact_pct\"]}') " "$_PROJECT_TOML" 2>/dev/null) || true if [ -n "$_AGENT_VARS" ]; then - # #852 defence: same warn-and-skip guard as the main loop above. The - # Python emitter already normalizes dashed agent names (#862), but a - # quoted TOML section like `[agents."weird name"]` could still produce - # an invalid identifier. Fail loudly but keep other agents loadable. while IFS='=' read -r _key _val; do [ -z "$_key" ] && continue - if ! [[ "$_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then - echo "WARNING: load-project: skipping invalid shell identifier from [agents.*]: $_key" >&2 - continue - fi export "$_key=$_val" done <<< "$_AGENT_VARS" fi diff --git a/lib/mirrors.sh b/lib/mirrors.sh index 9b135c4..3ba561d 100644 --- a/lib/mirrors.sh +++ b/lib/mirrors.sh @@ -1,10 +1,8 @@ #!/usr/bin/env bash -# mirrors.sh — Mirror helpers: push to remotes + register pull mirrors via API. +# mirrors.sh — Push primary branch + tags to configured mirror remotes. # # Usage: source lib/mirrors.sh; mirror_push -# source lib/mirrors.sh; mirror_pull_register <clone_url> <owner> <repo_name> [interval] # Requires: PROJECT_REPO_ROOT, PRIMARY_BRANCH, MIRROR_* vars from load-project.sh -# FORGE_API_BASE, FORGE_TOKEN for pull-mirror registration # shellcheck disable=SC2154 # globals set by load-project.sh / calling script @@ -39,73 +37,3 @@ mirror_push() { log "mirror: pushed to ${name} (pid $!)" done } - -# --------------------------------------------------------------------------- -# mirror_pull_register — register a Forgejo pull mirror via the /repos/migrate API. -# -# Creates a new repo as a pull mirror of an external source. Works against -# empty target repos (the repo is created by the API call itself). -# -# Usage: -# mirror_pull_register <clone_url> <owner> <repo_name> [interval] -# -# Args: -# clone_url — HTTPS URL of the source repo (e.g. https://codeberg.org/johba/disinto.git) -# owner — Forgejo org or user that will own the mirror repo -# repo_name — name of the new mirror repo on Forgejo -# interval — sync interval (default: "8h0m0s"; Forgejo duration format) -# -# Requires: -# FORGE_API_BASE, FORGE_TOKEN (from env.sh) -# -# Returns 0 on success, 1 on failure. Prints the new repo JSON to stdout. -# --------------------------------------------------------------------------- -mirror_pull_register() { - local clone_url="$1" - local owner="$2" - local repo_name="$3" - local interval="${4:-8h0m0s}" - - if [ -z "${FORGE_API_BASE:-}" ] || [ -z "${FORGE_TOKEN:-}" ]; then - echo "ERROR: FORGE_API_BASE and FORGE_TOKEN must be set" >&2 - return 1 - fi - - if [ -z "$clone_url" ] || [ -z "$owner" ] || [ -z "$repo_name" ]; then - echo "Usage: mirror_pull_register <clone_url> <owner> <repo_name> [interval]" >&2 - return 1 - fi - - local payload - payload=$(jq -n \ - --arg clone_addr "$clone_url" \ - --arg repo_name "$repo_name" \ - --arg repo_owner "$owner" \ - --arg interval "$interval" \ - '{ - clone_addr: $clone_addr, - repo_name: $repo_name, - repo_owner: $repo_owner, - mirror: true, - mirror_interval: $interval, - service: "git" - }') - - local http_code body - body=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Authorization: token ${FORGE_TOKEN}" \ - -H "Content-Type: application/json" \ - "${FORGE_API_BASE}/repos/migrate" \ - -d "$payload") - - http_code=$(printf '%s' "$body" | tail -n1) - body=$(printf '%s' "$body" | sed '$d') - - if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then - printf '%s\n' "$body" - return 0 - else - echo "ERROR: mirror_pull_register failed (HTTP ${http_code}): ${body}" >&2 - return 1 - fi -} diff --git a/lib/release.sh b/lib/release.sh index b9a3978..9ddf2bd 100644 --- a/lib/release.sh +++ b/lib/release.sh @@ -18,8 +18,8 @@ # ============================================================================= set -euo pipefail -# Source action-vault.sh for _vault_log helper -source "${FACTORY_ROOT}/lib/action-vault.sh" +# Source vault.sh for _vault_log helper +source "${FACTORY_ROOT}/lib/vault.sh" # Assert required globals are set before using this module. _assert_release_globals() { diff --git a/lib/secret-scan.sh b/lib/secret-scan.sh index a53bd87..b350284 100644 --- a/lib/secret-scan.sh +++ b/lib/secret-scan.sh @@ -30,10 +30,9 @@ _SECRET_PATTERNS=( _SAFE_PATTERNS=( # Shell variable references: $VAR, ${VAR}, ${VAR:-default} '\$\{?[A-Z_]+\}?' - # Git SHAs in typical git contexts (commit refs, watermarks, not standalone secrets) + # Git SHAs in typical git contexts (commit refs, not standalone secrets) 'commit [0-9a-f]{40}' 'Merge [0-9a-f]{40}' - 'last-reviewed: [0-9a-f]{40}' # Forge/GitHub URLs with short hex (PR refs, commit links) 'codeberg\.org/[^[:space:]]+' 'localhost:3000/[^[:space:]]+' diff --git a/lib/action-vault.sh b/lib/vault.sh similarity index 96% rename from lib/action-vault.sh rename to lib/vault.sh index 7602a39..484fd57 100644 --- a/lib/action-vault.sh +++ b/lib/vault.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash -# action-vault.sh — Helper for agents to create vault PRs on ops repo +# vault.sh — Helper for agents to create vault PRs on ops repo # # Source after lib/env.sh: # source "$(dirname "$0")/../lib/env.sh" -# source "$(dirname "$0")/lib/action-vault.sh" +# source "$(dirname "$0")/lib/vault.sh" # # Required globals: FORGE_TOKEN, FORGE_URL, FORGE_REPO, FORGE_OPS_REPO # Optional: OPS_REPO_ROOT (local path for ops repo) @@ -12,7 +12,7 @@ # vault_request <action_id> <toml_content> — Create vault PR, return PR number # # The function: -# 1. Validates TOML content using validate_vault_action() from action-vault/vault-env.sh +# 1. Validates TOML content using validate_vault_action() from vault/vault-env.sh # 2. Creates a branch on the ops repo: vault/<action-id> # 3. Writes TOML to vault/actions/<action-id>.toml on that branch # 4. Creates PR targeting main with title "vault: <action-id>" @@ -128,14 +128,14 @@ vault_request() { # Validate TOML content local tmp_toml tmp_toml=$(mktemp /tmp/vault-XXXXXX.toml) + trap 'rm -f "$tmp_toml"' RETURN printf '%s' "$toml_content" > "$tmp_toml" # Source vault-env.sh for validate_vault_action - local vault_env="${FACTORY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/action-vault/vault-env.sh" + local vault_env="${FACTORY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/vault/vault-env.sh" if [ ! -f "$vault_env" ]; then echo "ERROR: vault-env.sh not found at $vault_env" >&2 - rm -f "$tmp_toml" return 1 fi @@ -145,15 +145,11 @@ vault_request() { if ! source "$vault_env"; then FORGE_TOKEN="${_saved_forge_token:-}" echo "ERROR: failed to source vault-env.sh" >&2 - rm -f "$tmp_toml" return 1 fi # Restore caller's FORGE_TOKEN after validation FORGE_TOKEN="${_saved_forge_token:-}" - # Set trap AFTER sourcing vault-env.sh to avoid RETURN trap firing during source - trap 'rm -f "$tmp_toml"' RETURN - # Run validation if ! validate_vault_action "$tmp_toml"; then echo "ERROR: TOML validation failed" >&2 @@ -165,7 +161,7 @@ vault_request() { ops_api="$(_vault_ops_api)" # Classify the action to determine if PR bypass is allowed - local classify_script="${FACTORY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/action-vault/classify.sh" + local classify_script="${FACTORY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/vault/classify.sh" local vault_tier vault_tier=$("$classify_script" "${VAULT_ACTION_FORMULA:-}" "${VAULT_BLAST_RADIUS_OVERRIDE:-}") || { # Classification failed, default to high tier (require PR) diff --git a/nomad/AGENTS.md b/nomad/AGENTS.md deleted file mode 100644 index 6fda250..0000000 --- a/nomad/AGENTS.md +++ /dev/null @@ -1,163 +0,0 @@ -<!-- last-reviewed: 8fc3ba5b59cd6cb15bd01ca0658cfea2bcb12068 --> -# nomad/ — Agent Instructions - -Nomad + Vault HCL for the factory's single-node cluster. These files are -the source of truth that `lib/init/nomad/cluster-up.sh` copies onto a -factory box under `/etc/nomad.d/` and `/etc/vault.d/` at init time. - -This directory covers the **Nomad+Vault migration (Steps 0–5)** — -see issues #821–#992 for the step breakdown. - -## What lives here - -| File/Dir | Deployed to | Owned by | -|---|---|---| -| `server.hcl` | `/etc/nomad.d/server.hcl` | agent role, bind, ports, `data_dir` (S0.2) | -| `client.hcl` | `/etc/nomad.d/client.hcl` | Docker driver cfg + `host_volume` declarations (S0.2); `allow_privileged = true` for woodpecker-agent Docker-in-Docker (S3-fix-5, #961) | -| `vault.hcl` | `/etc/vault.d/vault.hcl` | Vault storage, listener, UI, `disable_mlock` (S0.3) | -| `jobs/forgejo.hcl` | submitted via `lib/init/nomad/deploy.sh` | Forgejo job; reads creds from Vault via consul-template stanza (S2.4) | -| `jobs/woodpecker-server.hcl` | submitted via `lib/init/nomad/deploy.sh` | Woodpecker CI server; host networking, Vault KV for `WOODPECKER_AGENT_SECRET` + Forgejo OAuth creds (S3.1) | -| `jobs/woodpecker-agent.hcl` | submitted via `lib/init/nomad/deploy.sh` | Woodpecker CI agent; host networking, `docker.sock` mount, Vault KV for `WOODPECKER_AGENT_SECRET`; `WOODPECKER_SERVER` uses `${attr.unique.network.ip-address}:9000` (Nomad interpolation) — port binds to LXC alloc IP, not localhost (S3.2, S3-fix-6, #964) | -| `jobs/agents.hcl` | submitted via `lib/init/nomad/deploy.sh` | All 7 agent roles (dev, review, gardener, planner, predictor, supervisor, architect) + llama variant; Vault-templated bot tokens via `service-agents` policy; `force_pull = false` — image is built locally by `bin/disinto --with agents`, no registry (S4.1, S4-fix-2, S4-fix-5, #955, #972, #978) | -| `jobs/staging.hcl` | submitted via `lib/init/nomad/deploy.sh` | Caddy file-server mounting `docker/` as `/srv/site:ro`; no Vault integration; internal-only via edge proxy (S5.2, #989) | -| `jobs/chat.hcl` | submitted via `lib/init/nomad/deploy.sh` | Claude chat UI; custom `disinto/chat:local` image; sandbox hardening (cap_drop ALL, tmpfs, pids_limit 128); Vault-templated OAuth secrets via `service-chat` policy (S5.2, #989) | -| `jobs/edge.hcl` | submitted via `lib/init/nomad/deploy.sh` | Caddy reverse proxy + dispatcher sidecar; routes /forge, /woodpecker, /staging, /chat; uses `disinto/edge:local` image built by `bin/disinto --with edge`; Vault-templated ops-repo creds via `service-dispatcher` policy (S5.1, #988) | - -Nomad auto-merges every `*.hcl` under `-config=/etc/nomad.d/`, so the -split between `server.hcl` and `client.hcl` is for readability, not -semantics. The top-of-file header in each config documents which blocks -it owns. - -## Vault ACL policies - -`vault/policies/` holds one `.hcl` file per Vault policy; see -[`vault/policies/AGENTS.md`](../vault/policies/AGENTS.md) for the naming -convention, KV path summary, and JWT-auth role bindings (S2.1/S2.3). - -## Not yet implemented - -- **TLS, ACLs, gossip encryption** — deliberately absent for now; land - alongside multi-node support. - -## Adding a jobspec (Step 1 and later) - -1. Drop a file in `nomad/jobs/<service>.hcl`. The `.hcl` suffix is - load-bearing: `.woodpecker/nomad-validate.yml` globs on exactly that - suffix to auto-pick up new jobspecs (see step 2 in "How CI validates - these files" below). Anything else in `nomad/jobs/` is silently - skipped by CI. -2. If it needs persistent state, reference a `host_volume` already - declared in `client.hcl` — *don't* add ad-hoc host paths in the - jobspec. If a new volume is needed, add it to **both**: - - `nomad/client.hcl` — the `host_volume "<name>" { path = … }` block - - `lib/init/nomad/cluster-up.sh` — the `HOST_VOLUME_DIRS` array - The two must stay in sync or nomad fingerprinting will fail and the - node stays in "initializing". Note that offline `nomad job validate` - will NOT catch a typo in the jobspec's `source = "..."` against the - client.hcl host_volume list (see step 2 below) — the scheduler - rejects the mismatch at placement time instead. -3. Pin image tags — `image = "forgejo/forgejo:1.22.5"`, not `:latest`. -4. No pipeline edit required — step 2 of `nomad-validate.yml` globs - over `nomad/jobs/*.hcl` and validates every match. Just make sure - the existing `nomad/**` trigger path still covers your file (it - does for anything under `nomad/jobs/`). - -## How CI validates these files - -`.woodpecker/nomad-validate.yml` runs on every PR that touches `nomad/` -(including `nomad/jobs/`), `lib/init/nomad/`, `bin/disinto`, -`vault/policies/`, or `vault/roles.yaml`. Eight fail-closed steps: - -1. **`nomad config validate nomad/server.hcl nomad/client.hcl`** - — parses the HCL, fails on unknown blocks, bad port ranges, invalid - driver config. Vault HCL is excluded (different tool). Jobspecs are - excluded too — agent-config and jobspec are disjoint HCL grammars; - running this step on a jobspec rejects it with "unknown block 'job'". -2. **`nomad job validate nomad/jobs/*.hcl`** (loop, one call per file) - — parses each jobspec's HCL, fails on unknown stanzas, missing - required fields, wrong value types, invalid driver config. Runs - offline (no Nomad server needed) so CI exit 0 ≠ "this will schedule - successfully"; it means "the HCL itself is well-formed". What this - step does NOT catch: - - cross-file references (`source = "forgejo-data"` typo against the - `host_volume` list in `client.hcl`) — that's a scheduling-time - check on the live cluster, not validate-time. - - image reachability — `image = "codeberg.org/forgejo/forgejo:11.0"` - is accepted even if the registry is down or the tag is wrong. - New jobspecs are picked up automatically by the glob — no pipeline - edit needed as long as the file is named `<name>.hcl`. -3. **`vault operator diagnose -config=nomad/vault.hcl -skip=storage -skip=listener`** - — Vault's equivalent syntax + schema check. `-skip=storage/listener` - disables the runtime checks (CI containers don't have - `/var/lib/vault/data` or port 8200). Exit 2 (advisory warnings only, - e.g. TLS-disabled listener) is tolerated; exit 1 blocks merge. -4. **`vault policy fmt` idempotence check on every `vault/policies/*.hcl`** - (S2.6) — `vault policy fmt` has no `-check` flag in 1.18.5, so the - step copies each file to `/tmp`, runs `vault policy fmt` on the copy, - and diffs against the original. Any non-empty diff means the - committed file would be rewritten by `fmt` and the step fails — the - author is pointed at `vault policy fmt <file>` to heal the drift. -5. **`vault policy write`-based validation against an inline dev-mode Vault** - (S2.6) — Vault 1.18.5 has no offline `policy validate` subcommand; - the CI step starts a dev-mode server, loops `vault policy write - <basename> <file>` over each `vault/policies/*.hcl`, and aggregates - failures so one CI run surfaces every broken policy. The server is - ephemeral and torn down on step exit — no persistence, no real - secrets. Catches unknown capability names (e.g. `"frobnicate"`), - malformed `path` blocks, and other semantic errors `fmt` does not. -6. **`vault/roles.yaml` validator** (S2.6) — yamllint + a PyYAML-based - check that every role's `policy:` field matches a basename under - `vault/policies/`, and that every role entry carries all four - required fields (`name`, `policy`, `namespace`, `job_id`). Drift - between the two directories is a scheduling-time "permission denied" - in production; this step turns it into a CI failure at PR time. -7. **`shellcheck --severity=warning lib/init/nomad/*.sh bin/disinto`** - — all init/dispatcher shell clean. `bin/disinto` has no `.sh` - extension so the repo-wide shellcheck in `.woodpecker/ci.yml` skips - it — this is the one place it gets checked. -8. **`bats tests/disinto-init-nomad.bats`** - — exercises the dispatcher: `disinto init --backend=nomad --dry-run`, - `… --empty --dry-run`, and the `--backend=docker` regression guard. - -**Secret-scan coverage.** Policy HCL files under `vault/policies/` are -already swept by the P11 secret-scan gate -(`.woodpecker/secret-scan.yml`, #798), whose `vault/**/*` trigger path -covers everything in this directory. `nomad-validate.yml` intentionally -does NOT duplicate that gate — one scanner, one source of truth. - -If a PR breaks `nomad/server.hcl` (e.g. typo in a block name), step 1 -fails with a clear error; if it breaks a jobspec (e.g. misspells -`task` as `tsak`, or adds a `volume` stanza without a `source`), step -2 fails; a typo in a `path "..."` block in a vault policy fails step 5 -with the Vault parser's error; a `roles.yaml` entry that points at a -policy basename that does not exist fails step 6. PRs that don't touch -any of the trigger paths skip this pipeline entirely. - -## Version pinning - -Nomad + Vault versions are pinned in **two** places — bumping one -without the other is a CI-caught drift: - -- `lib/init/nomad/install.sh` — the apt-installed versions on factory - boxes (`NOMAD_VERSION`, `VAULT_VERSION`). -- `.woodpecker/nomad-validate.yml` — the `hashicorp/nomad:…` and - `hashicorp/vault:…` image tags used for static validation. - -Bump both in the same PR. The CI pipeline will fail if the pinned -image's `config validate` rejects syntax the installed runtime would -accept (or vice versa). - -## Related - -- `lib/init/nomad/` — installer + systemd units + cluster-up orchestrator. -- `.woodpecker/nomad-validate.yml` — this directory's CI pipeline. -- `vault/policies/` — Vault ACL policy HCL files (S2.1); the - `vault-policy-fmt` / `vault-policy-validate` CI steps above enforce - their shape. See [`../vault/policies/AGENTS.md`](../vault/policies/AGENTS.md) - for the policy lifecycle, CI enforcement details, and common failure - modes. -- `vault/roles.yaml` — JWT-auth role → policy bindings (S2.3); the - `vault-roles-validate` CI step above keeps it in lockstep with the - policies directory. -- Top-of-file headers in `server.hcl` / `client.hcl` / `vault.hcl` - document the per-file ownership contract. diff --git a/nomad/client.hcl b/nomad/client.hcl deleted file mode 100644 index d173ed5..0000000 --- a/nomad/client.hcl +++ /dev/null @@ -1,94 +0,0 @@ -# ============================================================================= -# nomad/client.hcl — Docker driver + host_volume declarations -# -# Part of the Nomad+Vault migration (S0.2, issue #822). Deployed to -# /etc/nomad.d/client.hcl on the factory dev box alongside server.hcl. -# -# This file owns: Docker driver plugin config + host_volume pre-wiring. -# server.hcl owns: agent role, bind, ports, data_dir. -# -# NOTE: Nomad merges every *.hcl under -config=/etc/nomad.d, so declaring -# a second `client { ... }` block here augments (not replaces) the one in -# server.hcl. On a single-node setup this file could be inlined into -# server.hcl — the split is for readability, not semantics. -# -# host_volume declarations let Nomad jobspecs mount factory state by name -# (volume = "forgejo-data", etc.) without coupling host paths into jobspec -# HCL. Host paths under /srv/disinto/* are created out-of-band by the -# orchestrator (S0.4) before any job references them. -# ============================================================================= - -client { - # forgejo git server data (repos, avatars, attachments). - host_volume "forgejo-data" { - path = "/srv/disinto/forgejo-data" - read_only = false - } - - # woodpecker CI data (pipeline artifacts, sqlite db). - host_volume "woodpecker-data" { - path = "/srv/disinto/woodpecker-data" - read_only = false - } - - # agent runtime data (claude config, logs, phase files). - host_volume "agent-data" { - path = "/srv/disinto/agent-data" - read_only = false - } - - # per-project git clones and worktrees. - host_volume "project-repos" { - path = "/srv/disinto/project-repos" - read_only = false - } - - # caddy config + ACME state. - host_volume "caddy-data" { - path = "/srv/disinto/caddy-data" - read_only = false - } - - # staging static content (docker/ directory with images, HTML, etc.) - host_volume "site-content" { - path = "/srv/disinto/docker" - read_only = true - } - - # disinto chat transcripts + attachments. - host_volume "chat-history" { - path = "/srv/disinto/chat-history" - read_only = false - } - - # ops repo clone (vault actions, sprint artifacts, knowledge). - host_volume "ops-repo" { - path = "/srv/disinto/ops-repo" - read_only = false - } -} - -# Docker task driver. `volumes.enabled = true` is required so jobspecs -# can mount host_volume declarations defined above. `allow_privileged` -# is true — woodpecker-agent requires `privileged = true` to access -# docker.sock and spawn CI pipeline containers. -plugin "docker" { - config { - allow_privileged = true - - volumes { - enabled = true - } - - # Leave images behind when jobs stop, so short job churn doesn't thrash - # the image cache. Factory disk is not constrained; `docker system prune` - # is the escape hatch. - gc { - image = false - container = true - dangling_containers { - enabled = true - } - } - } -} diff --git a/nomad/jobs/agents.hcl b/nomad/jobs/agents.hcl deleted file mode 100644 index 92d377e..0000000 --- a/nomad/jobs/agents.hcl +++ /dev/null @@ -1,207 +0,0 @@ -# ============================================================================= -# nomad/jobs/agents.hcl — All-role agent polling loop (Nomad service job) -# -# Part of the Nomad+Vault migration (S4.1, issue #955). Runs the main bot -# polling loop with all 7 agent roles (review, dev, gardener, architect, -# planner, predictor, supervisor) against the local llama server. -# -# Host_volume contract: -# This job mounts agent-data, project-repos, and ops-repo from -# nomad/client.hcl. Paths under /srv/disinto/* are created by -# lib/init/nomad/cluster-up.sh before any job references them. -# -# Vault integration (S4.1): -# - vault { role = "service-agents" } at group scope — workload-identity -# JWT exchanged for a Vault token carrying the composite service-agents -# policy (vault/policies/service-agents.hcl), which grants read access -# to all 7 bot KV namespaces + vault bot + shared forge config. -# - template stanza renders per-bot FORGE_*_TOKEN + FORGE_PASS from Vault -# KV v2 at kv/disinto/bots/<role>. -# - Seeded on fresh boxes by tools/vault-seed-agents.sh. -# -# Not the runtime yet: docker-compose.yml is still the factory's live stack -# until cutover. This file exists so CI can validate it and S4.2 can wire -# `disinto init --backend=nomad --with agents` to `nomad job run` it. -# ============================================================================= - -job "agents" { - type = "service" - datacenters = ["dc1"] - - group "agents" { - count = 1 - - # ── Vault workload identity (S4.1, issue #955) ─────────────────────────── - # Composite role covering all 7 bot identities + vault bot. Role defined - # in vault/roles.yaml, policy in vault/policies/service-agents.hcl. - # Bound claim pins nomad_job_id = "agents". - vault { - role = "service-agents" - } - - # No network port — agents are outbound-only (poll forgejo, call llama). - # No service discovery block — nothing health-checks agents over HTTP. - - volume "agent-data" { - type = "host" - source = "agent-data" - read_only = false - } - - volume "project-repos" { - type = "host" - source = "project-repos" - read_only = false - } - - volume "ops-repo" { - type = "host" - source = "ops-repo" - read_only = true - } - - # Conservative restart — fail fast to the scheduler. - restart { - attempts = 3 - interval = "5m" - delay = "15s" - mode = "delay" - } - - # ── Service registration ──────────────────────────────────────────────── - # Agents are outbound-only (poll forgejo, call llama) — no HTTP/TCP - # endpoint to probe. The Nomad native provider only supports tcp/http - # checks, not script checks. Registering without a check block means - # Nomad tracks health via task lifecycle: task running = healthy, - # task dead = service deregistered. This matches the docker-compose - # pgrep healthcheck semantics (process alive = healthy). - service { - name = "agents" - provider = "nomad" - } - - task "agents" { - driver = "docker" - - config { - image = "disinto/agents:local" - force_pull = false - - # apparmor=unconfined matches docker-compose — Claude Code needs - # ptrace for node.js inspector and /proc access. - security_opt = ["apparmor=unconfined"] - } - - volume_mount { - volume = "agent-data" - destination = "/home/agent/data" - read_only = false - } - - volume_mount { - volume = "project-repos" - destination = "/home/agent/repos" - read_only = false - } - - volume_mount { - volume = "ops-repo" - destination = "/home/agent/repos/_factory/disinto-ops" - read_only = true - } - - # ── Non-secret env ───────────────────────────────────────────────────── - env { - FORGE_URL = "http://forgejo:3000" - FORGE_REPO = "disinto-admin/disinto" - ANTHROPIC_BASE_URL = "http://10.10.10.1:8081" - ANTHROPIC_API_KEY = "sk-no-key-required" - CLAUDE_MODEL = "unsloth/Qwen3.5-35B-A3B" - AGENT_ROLES = "review,dev,gardener,architect,planner,predictor,supervisor" - POLL_INTERVAL = "300" - DISINTO_CONTAINER = "1" - PROJECT_NAME = "project" - PROJECT_REPO_ROOT = "/home/agent/repos/project" - CLAUDE_TIMEOUT = "7200" - - # llama-specific Claude Code tuning - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1" - CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = "1" - CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = "60" - } - - # ── Vault-templated bot tokens (S4.1, issue #955) ───────────────────── - # Renders per-bot FORGE_*_TOKEN + FORGE_PASS from Vault KV v2. - # Each `with secret ...` block reads one bot's KV path; the `else` - # branch emits short placeholders on fresh installs where the path - # is absent. Seed with tools/vault-seed-agents.sh. - # - # Placeholder values kept < 16 chars to avoid secret-scan CI failures. - # error_on_missing_key = false prevents template-pending hangs. - template { - destination = "secrets/bots.env" - env = true - change_mode = "restart" - error_on_missing_key = false - data = <<EOT -{{- with secret "kv/data/disinto/bots/dev" -}} -FORGE_TOKEN={{ .Data.data.token }} -FORGE_PASS={{ .Data.data.pass }} -{{- else -}} -# WARNING: run tools/vault-seed-agents.sh -FORGE_TOKEN=seed-me -FORGE_PASS=seed-me -{{- end }} - -{{ with secret "kv/data/disinto/bots/review" -}} -FORGE_REVIEW_TOKEN={{ .Data.data.token }} -{{- else -}} -FORGE_REVIEW_TOKEN=seed-me -{{- end }} - -{{ with secret "kv/data/disinto/bots/gardener" -}} -FORGE_GARDENER_TOKEN={{ .Data.data.token }} -{{- else -}} -FORGE_GARDENER_TOKEN=seed-me -{{- end }} - -{{ with secret "kv/data/disinto/bots/architect" -}} -FORGE_ARCHITECT_TOKEN={{ .Data.data.token }} -{{- else -}} -FORGE_ARCHITECT_TOKEN=seed-me -{{- end }} - -{{ with secret "kv/data/disinto/bots/planner" -}} -FORGE_PLANNER_TOKEN={{ .Data.data.token }} -{{- else -}} -FORGE_PLANNER_TOKEN=seed-me -{{- end }} - -{{ with secret "kv/data/disinto/bots/predictor" -}} -FORGE_PREDICTOR_TOKEN={{ .Data.data.token }} -{{- else -}} -FORGE_PREDICTOR_TOKEN=seed-me -{{- end }} - -{{ with secret "kv/data/disinto/bots/supervisor" -}} -FORGE_SUPERVISOR_TOKEN={{ .Data.data.token }} -{{- else -}} -FORGE_SUPERVISOR_TOKEN=seed-me -{{- end }} - -{{ with secret "kv/data/disinto/bots/vault" -}} -FORGE_VAULT_TOKEN={{ .Data.data.token }} -{{- else -}} -FORGE_VAULT_TOKEN=seed-me -{{- end }} -EOT - } - - # Agents run Claude/llama sessions — need CPU + memory headroom. - resources { - cpu = 500 - memory = 1024 - } - } - } -} diff --git a/nomad/jobs/chat.hcl b/nomad/jobs/chat.hcl deleted file mode 100644 index ead8e71..0000000 --- a/nomad/jobs/chat.hcl +++ /dev/null @@ -1,152 +0,0 @@ -# ============================================================================= -# nomad/jobs/chat.hcl — Claude chat UI (Nomad service job) -# -# Part of the Nomad+Vault migration (S5.2, issue #989). Lightweight service -# job for the Claude chat UI with sandbox hardening (#706). -# -# Build: -# Custom image built from docker/chat/Dockerfile as disinto/chat:local -# (same :local pattern as disinto/agents:local). -# -# Sandbox hardening (#706): -# - Read-only root filesystem (enforced via entrypoint) -# - tmpfs /tmp:size=64m for runtime temp files -# - cap_drop ALL (no Linux capabilities) -# - pids_limit 128 (prevent fork bombs) -# - mem_limit 512m (matches compose sandbox hardening) -# -# Vault integration: -# - vault { role = "service-chat" } at group scope -# - Template stanza renders CHAT_OAUTH_CLIENT_ID, CHAT_OAUTH_CLIENT_SECRET, -# FORWARD_AUTH_SECRET from kv/disinto/shared/chat -# - Seeded on fresh boxes by tools/vault-seed-chat.sh -# -# Host volume: -# - chat-history → /var/lib/chat/history (persists conversation history) -# -# Not the runtime yet: docker-compose.yml is still the factory's live stack -# until cutover. This file exists so CI can validate it and S5.2 can wire -# `disinto init --backend=nomad --with chat` to `nomad job run` it. -# ============================================================================= - -job "chat" { - type = "service" - datacenters = ["dc1"] - - group "chat" { - count = 1 - - # ── Vault workload identity (S5.2, issue #989) ─────────────────────────── - # Role `service-chat` defined in vault/roles.yaml, policy in - # vault/policies/service-chat.hcl. Bound claim pins nomad_job_id = "chat". - vault { - role = "service-chat" - } - - # ── Network ────────────────────────────────────────────────────────────── - # External port 8080 for chat UI access (via edge proxy or direct). - network { - port "http" { - static = 8080 - to = 8080 - } - } - - # ── Host volumes ───────────────────────────────────────────────────────── - # chat-history volume: declared in nomad/client.hcl, path - # /srv/disinto/chat-history on the factory box. - volume "chat-history" { - type = "host" - source = "chat-history" - read_only = false - } - - # ── Restart policy ─────────────────────────────────────────────────────── - restart { - attempts = 3 - interval = "5m" - delay = "15s" - mode = "delay" - } - - # ── Service registration ───────────────────────────────────────────────── - service { - name = "chat" - port = "http" - provider = "nomad" - - check { - type = "http" - path = "/health" - interval = "10s" - timeout = "3s" - } - } - - task "chat" { - driver = "docker" - - config { - image = "disinto/chat:local" - force_pull = false - # Sandbox hardening (#706): cap_drop ALL (no Linux capabilities) - # tmpfs /tmp for runtime files (64MB) - # pids_limit 128 (prevent fork bombs) - # ReadonlyRootfs enforced via entrypoint script (fails if running as root) - cap_drop = ["ALL"] - tmpfs = ["/tmp:size=64m"] - pids_limit = 128 - # Security options for sandbox hardening - # apparmor=unconfined needed for Claude CLI ptrace access - # no-new-privileges prevents privilege escalation - security_opt = ["apparmor=unconfined", "no-new-privileges"] - } - - # ── Volume mounts ────────────────────────────────────────────────────── - # Mount chat-history for conversation persistence - volume_mount { - volume = "chat-history" - destination = "/var/lib/chat/history" - read_only = false - } - - # ── Environment: secrets from Vault (S5.2) ────────────────────────────── - # CHAT_OAUTH_CLIENT_ID, CHAT_OAUTH_CLIENT_SECRET, FORWARD_AUTH_SECRET - # rendered from kv/disinto/shared/chat via template stanza. - env { - FORGE_URL = "http://forgejo:3000" - CHAT_MAX_REQUESTS_PER_HOUR = "60" - CHAT_MAX_REQUESTS_PER_DAY = "1000" - } - - # ── Vault-templated secrets (S5.2, issue #989) ───────────────────────── - # Renders chat-secrets.env from Vault KV v2 at kv/disinto/shared/chat. - # Placeholder values kept < 16 chars to avoid secret-scan CI failures. - template { - destination = "secrets/chat-secrets.env" - env = true - change_mode = "restart" - error_on_missing_key = false - data = <<EOT -{{- with secret "kv/data/disinto/shared/chat" -}} -CHAT_OAUTH_CLIENT_ID={{ .Data.data.chat_oauth_client_id }} -CHAT_OAUTH_CLIENT_SECRET={{ .Data.data.chat_oauth_client_secret }} -FORWARD_AUTH_SECRET={{ .Data.data.forward_auth_secret }} -{{- else -}} -# WARNING: run tools/vault-seed-chat.sh -CHAT_OAUTH_CLIENT_ID=seed-me -CHAT_OAUTH_CLIENT_SECRET=seed-me -FORWARD_AUTH_SECRET=seed-me -{{- end -}} -EOT - } - - # ── Sandbox hardening (S5.2, #706) ──────────────────────────────────── - # Memory = 512MB (matches docker-compose sandbox hardening) - resources { - cpu = 200 - memory = 512 - } - } - } -} diff --git a/nomad/jobs/edge.hcl b/nomad/jobs/edge.hcl deleted file mode 100644 index 1f3e855..0000000 --- a/nomad/jobs/edge.hcl +++ /dev/null @@ -1,193 +0,0 @@ -# ============================================================================= -# nomad/jobs/edge.hcl — Edge proxy (Caddy + dispatcher sidecar) (Nomad service job) -# -# Part of the Nomad+Vault migration (S5.1, issue #988). Caddy reverse proxy -# routes traffic to Forgejo, Woodpecker, staging, and chat services. The -# dispatcher sidecar polls disinto-ops for vault actions and dispatches them -# via Nomad batch jobs. -# -# Host_volume contract: -# This job mounts caddy-data from nomad/client.hcl. Path -# /srv/disinto/caddy-data is created by lib/init/nomad/cluster-up.sh before -# any job references it. Keep the `source = "caddy-data"` below in sync -# with the host_volume stanza in client.hcl. -# -# Build step (S5.1): -# docker/edge/Dockerfile is custom (adds bash, jq, curl, git, docker-cli, -# python3, openssh-client, autossh to caddy:latest). Build as -# disinto/edge:local using the same pattern as disinto/agents:local. -# Command: docker build -t disinto/edge:local -f docker/edge/Dockerfile docker/edge -# -# Not the runtime yet: docker-compose.yml is still the factory's live stack -# until cutover. This file exists so CI can validate it and S5.2 can wire -# `disinto init --backend=nomad --with edge` to `nomad job run` it. -# ============================================================================= - -job "edge" { - type = "service" - datacenters = ["dc1"] - - group "edge" { - count = 1 - - # ── Vault workload identity for dispatcher (S5.1, issue #988) ────────── - # Service role for dispatcher task to fetch vault actions from KV v2. - # Role defined in vault/roles.yaml, policy in vault/policies/dispatcher.hcl. - vault { - role = "service-dispatcher" - } - - # ── Network ports (S5.1, issue #988) ────────────────────────────────── - # Caddy listens on :80 and :443. Expose both on the host. - network { - port "http" { - static = 80 - to = 80 - } - - port "https" { - static = 443 - to = 443 - } - } - - # ── Host-volume mounts (S5.1, issue #988) ───────────────────────────── - # caddy-data: ACME certificates, Caddy config state. - volume "caddy-data" { - type = "host" - source = "caddy-data" - read_only = false - } - - # ops-repo: disinto-ops clone for vault actions polling. - volume "ops-repo" { - type = "host" - source = "ops-repo" - read_only = false - } - - # ── Conservative restart policy ─────────────────────────────────────── - # Caddy should be stable; dispatcher may restart on errors. - restart { - attempts = 3 - interval = "5m" - delay = "15s" - mode = "delay" - } - - # ── Service registration ─────────────────────────────────────────────── - # Caddy is an HTTP reverse proxy — health check on port 80. - service { - name = "edge" - port = "http" - provider = "nomad" - - check { - type = "http" - path = "/" - interval = "10s" - timeout = "3s" - } - } - - # ── Caddy task (S5.1, issue #988) ───────────────────────────────────── - task "caddy" { - driver = "docker" - - config { - # Use pre-built disinto/edge:local image (custom Dockerfile adds - # bash, jq, curl, git, docker-cli, python3, openssh-client, autossh). - image = "disinto/edge:local" - force_pull = false - ports = ["http", "https"] - - # apparmor=unconfined matches docker-compose — needed for autossh - # in the entrypoint script. - security_opt = ["apparmor=unconfined"] - } - - # Mount caddy-data volume for ACME state and config directory. - # Caddyfile is mounted at /etc/caddy/Caddyfile by entrypoint-edge.sh. - volume_mount { - volume = "caddy-data" - destination = "/data" - read_only = false - } - - # ── Non-secret env ─────────────────────────────────────────────────── - env { - FORGE_URL = "http://forgejo:3000" - FORGE_REPO = "disinto-admin/disinto" - DISINTO_CONTAINER = "1" - PROJECT_NAME = "disinto" - } - - # Caddy needs CPU + memory headroom for reverse proxy work. - resources { - cpu = 200 - memory = 256 - } - } - - # ── Dispatcher task (S5.1, issue #988) ──────────────────────────────── - task "dispatcher" { - driver = "docker" - - config { - # Use same disinto/agents:local image as other agents. - image = "disinto/agents:local" - force_pull = false - - # apparmor=unconfined matches docker-compose. - security_opt = ["apparmor=unconfined"] - - # Mount docker.sock via bind-volume (not host volume) for legacy - # docker backend compat. Nomad host volumes require named volumes - # from client.hcl; socket files cannot be host volumes. - volumes = ["/var/run/docker.sock:/var/run/docker.sock:ro"] - } - - # Mount ops-repo for vault actions polling. - volume_mount { - volume = "ops-repo" - destination = "/home/agent/repos/disinto-ops" - read_only = false - } - - # ── Vault-templated secrets (S5.1, issue #988) ────────────────────── - # Renders FORGE_TOKEN from Vault KV v2 for ops repo access. - template { - destination = "secrets/dispatcher.env" - env = true - change_mode = "restart" - error_on_missing_key = false - data = <<EOT -{{- with secret "kv/data/disinto/bots/vault" -}} -FORGE_TOKEN={{ .Data.data.token }} -{{- else -}} -# WARNING: kv/disinto/bots/vault is empty — run tools/vault-seed-agents.sh -FORGE_TOKEN=seed-me -{{- end }} -EOT - } - - # ── Non-secret env ─────────────────────────────────────────────────── - env { - DISPATCHER_BACKEND = "nomad" - FORGE_URL = "http://forgejo:3000" - FORGE_REPO = "disinto-admin/disinto" - FORGE_OPS_REPO = "disinto-admin/disinto-ops" - PRIMARY_BRANCH = "main" - DISINTO_CONTAINER = "1" - OPS_REPO_ROOT = "/home/agent/repos/disinto-ops" - FORGE_ADMIN_USERS = "vault-bot,admin" - } - - # Dispatcher is lightweight — minimal CPU + memory. - resources { - cpu = 100 - memory = 256 - } - } - } -} diff --git a/nomad/jobs/forgejo.hcl b/nomad/jobs/forgejo.hcl deleted file mode 100644 index 4d15aec..0000000 --- a/nomad/jobs/forgejo.hcl +++ /dev/null @@ -1,189 +0,0 @@ -# ============================================================================= -# nomad/jobs/forgejo.hcl — Forgejo git server (Nomad service job) -# -# Part of the Nomad+Vault migration (S1.1, issue #840; S2.4, issue #882). -# First jobspec to land under nomad/jobs/ — proves the docker driver + -# host_volume plumbing from Step 0 (client.hcl) by running a real factory -# service. S2.4 layered Vault integration on top: admin/internal secrets -# now render via workload identity + template stanza instead of inline env. -# -# Host_volume contract: -# This job mounts the `forgejo-data` host_volume declared in -# nomad/client.hcl. That volume is backed by /srv/disinto/forgejo-data on -# the factory box, created by lib/init/nomad/cluster-up.sh before any job -# references it. Keep the `source = "forgejo-data"` below in sync with the -# host_volume stanza in client.hcl — drift = scheduling failures. -# -# Vault integration (S2.4): -# - vault { role = "service-forgejo" } at the group scope — the task's -# workload-identity JWT is exchanged for a Vault token carrying the -# policy named on that role. Role + policy are defined in -# vault/roles.yaml + vault/policies/service-forgejo.hcl. -# - template { destination = "secrets/forgejo.env" env = true } pulls -# FORGEJO__security__{SECRET_KEY,INTERNAL_TOKEN} out of Vault KV v2 -# at kv/disinto/shared/forgejo and merges them into the task env. -# Seeded on fresh boxes by tools/vault-seed-forgejo.sh. -# - Non-secret env (DB type, ROOT_URL, ports, registration lockdown, -# webhook allow-list) stays inline below — not sensitive, not worth -# round-tripping through Vault. -# -# Not the runtime yet: docker-compose.yml is still the factory's live stack -# until cutover. This file exists so CI can validate it and S1.3 can wire -# `disinto init --backend=nomad --with forgejo` to `nomad job run` it. -# ============================================================================= - -job "forgejo" { - type = "service" - datacenters = ["dc1"] - - group "forgejo" { - count = 1 - - # ── Vault workload identity (S2.4, issue #882) ───────────────────────── - # `role = "service-forgejo"` is defined in vault/roles.yaml and - # applied by tools/vault-apply-roles.sh (S2.3). The role's bound - # claim pins nomad_job_id = "forgejo" — renaming this jobspec's - # `job "forgejo"` without updating vault/roles.yaml will make token - # exchange fail at placement with a "claim mismatch" error. - vault { - role = "service-forgejo" - } - - # Static :3000 matches docker-compose's published port so the rest of - # the factory (agents, woodpecker, caddy) keeps reaching forgejo at the - # same host:port during and after cutover. `to = 3000` maps the host - # port into the container's :3000 listener. - network { - port "http" { - static = 3000 - to = 3000 - } - } - - # Host-volume mount: declared in nomad/client.hcl, path - # /srv/disinto/forgejo-data on the factory box. - volume "forgejo-data" { - type = "host" - source = "forgejo-data" - read_only = false - } - - # Conservative restart policy — fail fast to the scheduler instead of - # spinning on a broken image/config. 3 attempts over 5m, then back off. - restart { - attempts = 3 - interval = "5m" - delay = "15s" - mode = "delay" - } - - # Native Nomad service discovery (no Consul in this factory cluster). - # Health check gates the service as healthy only after the API is up; - # initial_status is deliberately unset so Nomad waits for the first - # probe to pass before marking the allocation healthy on boot. - service { - name = "forgejo" - port = "http" - provider = "nomad" - - check { - type = "http" - path = "/api/v1/version" - interval = "10s" - timeout = "3s" - } - } - - task "forgejo" { - driver = "docker" - - config { - image = "codeberg.org/forgejo/forgejo:11.0" - ports = ["http"] - } - - volume_mount { - volume = "forgejo-data" - destination = "/data" - read_only = false - } - - # Non-secret env — DB type, public URL, ports, install lock, - # registration lockdown, webhook allow-list. Nothing sensitive here, - # so this stays inline. Secret-bearing env (SECRET_KEY, INTERNAL_TOKEN) - # lives in the template stanza below and is merged into task env. - env { - FORGEJO__database__DB_TYPE = "sqlite3" - FORGEJO__server__ROOT_URL = "http://forgejo:3000/" - FORGEJO__server__HTTP_PORT = "3000" - FORGEJO__security__INSTALL_LOCK = "true" - FORGEJO__service__DISABLE_REGISTRATION = "true" - FORGEJO__webhook__ALLOWED_HOST_LIST = "private" - } - - # ── Vault-templated secrets env (S2.4, issue #882) ────────────────── - # Renders `<task-dir>/secrets/forgejo.env` (per-alloc secrets dir, - # never on disk on the host root filesystem, never in `nomad job - # inspect` output). `env = true` merges every KEY=VAL line into the - # task environment. `change_mode = "restart"` re-runs the task - # whenever a watched secret's value in Vault changes — so `vault kv - # put …` alone is enough to roll new secrets; no manual - # `nomad alloc restart` required (though that also works — it - # forces a re-render). - # - # Vault path: `kv/data/disinto/shared/forgejo`. The literal `/data/` - # segment is required by consul-template for KV v2 mounts — without - # it the template would read from a KV v1 path that doesn't exist - # (the policy in vault/policies/service-forgejo.hcl grants - # `kv/data/disinto/shared/forgejo/*`, confirming v2). - # - # Empty-Vault fallback (`with ... else ...`): on a fresh LXC where - # the KV path is absent, consul-template's `with` short-circuits to - # the `else` branch. Emitting visible placeholders (instead of no - # env vars) means the container still boots, but with obviously-bad - # secrets that an operator will spot in `env | grep FORGEJO` — - # better than forgejo silently regenerating SECRET_KEY on every - # restart and invalidating every prior session. Seed the path with - # tools/vault-seed-forgejo.sh to replace the placeholders. - # - # Placeholder values are kept short on purpose: the repo-wide - # secret-scan (.woodpecker/secret-scan.yml → lib/secret-scan.sh) - # flags `TOKEN=<16+ non-space chars>` as a plaintext secret, so a - # descriptive long placeholder (e.g. "run-tools-vault-seed-...") on - # the INTERNAL_TOKEN line would fail CI on every PR that touched - # this file. "seed-me" is < 16 chars and still distinctive enough - # to surface in a `grep FORGEJO__security__` audit. The template - # comment below carries the operator-facing fix pointer. - # `error_on_missing_key = false` stops consul-template from blocking - # the alloc on template-pending when the Vault KV path exists but a - # referenced key is absent (or the path itself is absent and the - # else-branch placeholders are used). Without this, a fresh-LXC - # `disinto init --with forgejo` against an empty Vault hangs on - # template-pending until deploy.sh times out (issue #912, bug #4). - template { - destination = "secrets/forgejo.env" - env = true - change_mode = "restart" - error_on_missing_key = false - data = <<EOT -{{- with secret "kv/data/disinto/shared/forgejo" -}} -FORGEJO__security__SECRET_KEY={{ .Data.data.secret_key }} -FORGEJO__security__INTERNAL_TOKEN={{ .Data.data.internal_token }} -{{- else -}} -# WARNING: kv/disinto/shared/forgejo is empty — run tools/vault-seed-forgejo.sh -FORGEJO__security__SECRET_KEY=seed-me -FORGEJO__security__INTERNAL_TOKEN=seed-me -{{- end -}} -EOT - } - - # Baseline — tune once we have real usage numbers under nomad. The - # docker-compose stack runs forgejo uncapped; these limits exist so - # an unhealthy forgejo can't starve the rest of the node. - resources { - cpu = 300 - memory = 512 - } - } - } -} diff --git a/nomad/jobs/staging.hcl b/nomad/jobs/staging.hcl deleted file mode 100644 index fda9d64..0000000 --- a/nomad/jobs/staging.hcl +++ /dev/null @@ -1,87 +0,0 @@ -# ============================================================================= -# nomad/jobs/staging.hcl — Staging file server (Nomad service job) -# -# Part of the Nomad+Vault migration (S5.2, issue #989). Lightweight service job -# for the staging file server using Caddy as a static file server. -# -# Mount contract: -# This job mounts the `docker/` directory as `/srv/site` (read-only). -# The docker/ directory contains static content (images, HTML, etc.) -# served to staging environment users. -# -# Network: -# No external port exposed — edge proxy routes to it internally. -# Service discovery via Nomad native provider for internal routing. -# -# Not the runtime yet: docker-compose.yml is still the factory's live stack -# until cutover. This file exists so CI can validate it and S5.2 can wire -# `disinto init --backend=nomad --with staging` to `nomad job run` it. -# ============================================================================= - -job "staging" { - type = "service" - datacenters = ["dc1"] - - group "staging" { - count = 1 - - # No Vault integration needed — no secrets required (static file server) - - # Internal service — no external port. Edge proxy routes internally. - network { - port "http" { - static = 80 - to = 80 - } - } - - volume "site-content" { - type = "host" - source = "site-content" - read_only = true - } - - restart { - attempts = 3 - interval = "5m" - delay = "15s" - mode = "delay" - } - - service { - name = "staging" - port = "http" - provider = "nomad" - - check { - type = "http" - path = "/" - interval = "10s" - timeout = "3s" - } - } - - task "staging" { - driver = "docker" - - config { - image = "caddy:alpine" - ports = ["http"] - command = "caddy" - args = ["file-server", "--root", "/srv/site"] - } - - # Mount docker/ directory as /srv/site:ro (static content) - volume_mount { - volume = "site-content" - destination = "/srv/site" - read_only = true - } - - resources { - cpu = 100 - memory = 256 - } - } - } -} diff --git a/nomad/jobs/vault-runner.hcl b/nomad/jobs/vault-runner.hcl deleted file mode 100644 index 6f174a3..0000000 --- a/nomad/jobs/vault-runner.hcl +++ /dev/null @@ -1,137 +0,0 @@ -# ============================================================================= -# nomad/jobs/vault-runner.hcl — Parameterized batch job for vault action dispatch -# -# Part of the Nomad+Vault migration (S5.3, issue #990). Replaces the -# `docker run --rm vault-runner-${action_id}` pattern in dispatcher.sh with -# a Nomad-native parameterized batch job. Dispatched by the edge dispatcher -# (S5.4) via `nomad job dispatch`. -# -# Parameterized meta: -# action_id — vault action identifier (used by entrypoint-runner.sh) -# secrets_csv — comma-separated secret names (e.g. "GITHUB_TOKEN,DEPLOY_KEY") -# -# Vault integration (approach A — pre-defined templates): -# All 6 known runner secrets are rendered via template stanzas with -# error_on_missing_key = false. Secrets not granted by the dispatch's -# Vault policies render as empty strings. The dispatcher (S5.4) sets -# vault { policies = [...] } per-dispatch based on the action TOML's -# secrets=[...] list, scoping access to only the declared secrets. -# -# Cleanup: Nomad garbage-collects completed batch dispatches automatically. -# ============================================================================= - -job "vault-runner" { - type = "batch" - datacenters = ["dc1"] - - parameterized { - meta_required = ["action_id", "secrets_csv"] - } - - group "runner" { - count = 1 - - # ── Vault workload identity ────────────────────────────────────────────── - # Per-dispatch policies are composed by the dispatcher (S5.4) based on the - # action TOML's secrets=[...] list. Each policy grants read access to - # exactly one kv/data/disinto/runner/<NAME> path. Roles defined in - # vault/roles.yaml (runner-<NAME>), policies in vault/policies/. - vault {} - - volume "ops-repo" { - type = "host" - source = "ops-repo" - read_only = true - } - - # No restart for batch — fail fast, let the dispatcher handle retries. - restart { - attempts = 0 - mode = "fail" - } - - task "runner" { - driver = "docker" - - config { - image = "disinto/agents:local" - force_pull = false - entrypoint = ["bash"] - args = [ - "/home/agent/disinto/docker/runner/entrypoint-runner.sh", - "${NOMAD_META_action_id}", - ] - } - - volume_mount { - volume = "ops-repo" - destination = "/home/agent/ops" - read_only = true - } - - # ── Non-secret env ─────────────────────────────────────────────────────── - env { - DISINTO_CONTAINER = "1" - FACTORY_ROOT = "/home/agent/disinto" - OPS_REPO_ROOT = "/home/agent/ops" - } - - # ── Vault-templated runner secrets (approach A) ──────────────────────── - # Pre-defined templates for all 6 known runner secrets. Each renders - # from kv/data/disinto/runner/<NAME>. Secrets not granted by the - # dispatch's Vault policies produce empty env vars (harmless). - # error_on_missing_key = false prevents template-pending hangs when - # a secret path is absent or the policy doesn't grant access. - # - # Placeholder values kept < 16 chars to avoid secret-scan CI failures. - template { - destination = "secrets/runner.env" - env = true - error_on_missing_key = false - data = <<EOT -{{- with secret "kv/data/disinto/runner/GITHUB_TOKEN" -}} -GITHUB_TOKEN={{ .Data.data.value }} -{{- else -}} -GITHUB_TOKEN= -{{- end }} - -{{ with secret "kv/data/disinto/runner/CODEBERG_TOKEN" -}} -CODEBERG_TOKEN={{ .Data.data.value }} -{{- else -}} -CODEBERG_TOKEN= -{{- end }} - -{{ with secret "kv/data/disinto/runner/CLAWHUB_TOKEN" -}} -CLAWHUB_TOKEN={{ .Data.data.value }} -{{- else -}} -CLAWHUB_TOKEN= -{{- end }} - -{{ with secret "kv/data/disinto/runner/DEPLOY_KEY" -}} -DEPLOY_KEY={{ .Data.data.value }} -{{- else -}} -DEPLOY_KEY= -{{- end }} - -{{ with secret "kv/data/disinto/runner/NPM_TOKEN" -}} -NPM_TOKEN={{ .Data.data.value }} -{{- else -}} -NPM_TOKEN= -{{- end }} - -{{ with secret "kv/data/disinto/runner/DOCKER_HUB_TOKEN" -}} -DOCKER_HUB_TOKEN={{ .Data.data.value }} -{{- else -}} -DOCKER_HUB_TOKEN= -{{- end }} -EOT - } - - # Formula execution headroom — matches agents.hcl baseline. - resources { - cpu = 500 - memory = 1024 - } - } - } -} diff --git a/nomad/jobs/woodpecker-agent.hcl b/nomad/jobs/woodpecker-agent.hcl deleted file mode 100644 index c7779a2..0000000 --- a/nomad/jobs/woodpecker-agent.hcl +++ /dev/null @@ -1,144 +0,0 @@ -# ============================================================================= -# nomad/jobs/woodpecker-agent.hcl — Woodpecker CI agent (Nomad service job) -# -# Part of the Nomad+Vault migration (S3.2, issue #935). -# Drop-in for the current docker-compose setup with host networking + -# docker.sock mount, enabling the agent to spawn containers via the -# mounted socket. -# -# Host networking: -# Uses network_mode = "host" to match the compose setup. The Woodpecker -# server gRPC endpoint is addressed via Nomad service discovery using -# the host's IP address (10.10.10.x:9000), since the server's port -# binding in Nomad binds to the allocation's IP, not localhost. -# -# Vault integration: -# - vault { role = "service-woodpecker-agent" } at the group scope — the -# task's workload-identity JWT is exchanged for a Vault token carrying -# the policy named on that role. Role + policy are defined in -# vault/roles.yaml + vault/policies/service-woodpecker.hcl. -# - template stanza pulls WOODPECKER_AGENT_SECRET from Vault KV v2 -# at kv/disinto/shared/woodpecker and writes it to secrets/agent.env. -# Seeded on fresh boxes by tools/vault-seed-woodpecker.sh. -# ============================================================================= - -job "woodpecker-agent" { - type = "service" - datacenters = ["dc1"] - - group "woodpecker-agent" { - count = 1 - - # ── Vault workload identity ───────────────────────────────────────── - # `role = "service-woodpecker-agent"` is defined in vault/roles.yaml and - # applied by tools/vault-apply-roles.sh. The role's bound - # claim pins nomad_job_id = "woodpecker-agent" — renaming this - # jobspec's `job "woodpecker-agent"` without updating vault/roles.yaml - # will make token exchange fail at placement with a "claim mismatch" - # error. - vault { - role = "service-woodpecker-agent" - } - - # Health check port: static 3333 for Nomad service discovery. The agent - # exposes :3333/healthz for Nomad to probe. - network { - port "healthz" { - static = 3333 - } - } - - # Native Nomad service discovery for the health check endpoint. - service { - name = "woodpecker-agent" - port = "healthz" - provider = "nomad" - - check { - type = "http" - path = "/healthz" - interval = "15s" - timeout = "3s" - } - } - - # Conservative restart policy — fail fast to the scheduler instead of - # spinning on a broken image/config. 3 attempts over 5m, then back off. - restart { - attempts = 3 - interval = "5m" - delay = "15s" - mode = "delay" - } - - task "woodpecker-agent" { - driver = "docker" - - config { - image = "woodpeckerci/woodpecker-agent:v3" - network_mode = "host" - privileged = true - volumes = ["/var/run/docker.sock:/var/run/docker.sock"] - } - - # Non-secret env — server address, gRPC security, concurrency limit, - # and health check endpoint. Nothing sensitive here. - # - # WOODPECKER_SERVER uses Nomad's attribute template to get the host's - # IP address (10.10.10.x). The server's gRPC port 9000 is bound via - # Nomad's port stanza to the allocation's IP (not localhost), so the - # agent must use the LXC's eth0 IP, not 127.0.0.1. - env { - WOODPECKER_SERVER = "${attr.unique.network.ip-address}:9000" - WOODPECKER_GRPC_SECURE = "false" - WOODPECKER_MAX_WORKFLOWS = "1" - WOODPECKER_HEALTHCHECK_ADDR = ":3333" - } - - # ── Vault-templated agent secret ────────────────────────────────── - # Renders <task-dir>/secrets/agent.env (per-alloc secrets dir, - # never on disk on the host root filesystem, never in `nomad job - # inspect` output). `env = true` merges WOODPECKER_AGENT_SECRET - # from the file into the task environment. - # - # Vault path: `kv/data/disinto/shared/woodpecker`. The literal - # `/data/` segment is required by consul-template for KV v2 mounts. - # - # Empty-Vault fallback (`with ... else ...`): on a fresh LXC where - # the KV path is absent, consul-template's `with` short-circuits to - # the `else` branch. Emitting a visible placeholder means the - # container still boots, but with an obviously-bad secret that an - # operator will spot — better than the agent failing silently with - # auth errors. Seed the path with tools/vault-seed-woodpecker.sh - # to replace the placeholder. - # - # Placeholder values are kept short on purpose: the repo-wide - # secret-scan (.woodpecker/secret-scan.yml → lib/secret-scan.sh) - # flags `TOKEN=<16+ non-space chars>` as a plaintext secret, so a - # descriptive long placeholder would fail CI on every PR that touched - # this file. "seed-me" is < 16 chars and still distinctive enough - # to surface in a `grep WOODPECKER` audit. - template { - destination = "secrets/agent.env" - env = true - change_mode = "restart" - error_on_missing_key = false - data = <<EOT -{{- with secret "kv/data/disinto/shared/woodpecker" -}} -WOODPECKER_AGENT_SECRET={{ .Data.data.agent_secret }} -{{- else -}} -# WARNING: kv/disinto/shared/woodpecker is empty — run tools/vault-seed-woodpecker.sh -WOODPECKER_AGENT_SECRET=seed-me -{{- end -}} -EOT - } - - # Baseline — tune once we have real usage numbers under nomad. - # Conservative limits so an unhealthy agent can't starve the node. - resources { - cpu = 200 - memory = 256 - } - } - } -} diff --git a/nomad/jobs/woodpecker-server.hcl b/nomad/jobs/woodpecker-server.hcl deleted file mode 100644 index 6cef1a0..0000000 --- a/nomad/jobs/woodpecker-server.hcl +++ /dev/null @@ -1,173 +0,0 @@ -# ============================================================================= -# nomad/jobs/woodpecker-server.hcl — Woodpecker CI server (Nomad service job) -# -# Part of the Nomad+Vault migration (S3.1, issue #934). -# Runs the Woodpecker CI web UI + gRPC endpoint as a Nomad service job, -# reading its Forgejo OAuth + agent secret from Vault via workload identity. -# -# Host_volume contract: -# This job mounts the `woodpecker-data` host_volume declared in -# nomad/client.hcl. That volume is backed by /srv/disinto/woodpecker-data -# on the factory box, created by lib/init/nomad/cluster-up.sh before any -# job references it. Keep the `source = "woodpecker-data"` below in sync -# with the host_volume stanza in client.hcl — drift = scheduling failures. -# -# Vault integration (S2.4 pattern): -# - vault { role = "service-woodpecker" } at the group scope — the task's -# workload-identity JWT is exchanged for a Vault token carrying the -# policy named on that role. Role + policy are defined in -# vault/roles.yaml + vault/policies/service-woodpecker.hcl. -# - template { destination = "secrets/wp.env" env = true } pulls -# WOODPECKER_AGENT_SECRET, WOODPECKER_FORGEJO_CLIENT, and -# WOODPECKER_FORGEJO_SECRET out of Vault KV v2 at -# kv/disinto/shared/woodpecker and merges them into the task env. -# Agent secret seeded by tools/vault-seed-woodpecker.sh; OAuth -# client/secret seeded by S3.3 (wp-oauth-register.sh). -# - Non-secret env (DB driver, Forgejo URL, host URL, open registration) -# stays inline below — not sensitive, not worth round-tripping through -# Vault. -# -# Not the runtime yet: docker-compose.yml is still the factory's live stack -# until cutover. This file exists so CI can validate it and S3.4 can wire -# `disinto init --backend=nomad --with woodpecker` to `nomad job run` it. -# ============================================================================= - -job "woodpecker-server" { - type = "service" - datacenters = ["dc1"] - - group "woodpecker-server" { - count = 1 - - # ── Vault workload identity (S2.4 pattern) ────────────────────────────── - # `role = "service-woodpecker"` is defined in vault/roles.yaml and - # applied by tools/vault-apply-roles.sh (S2.3). The role's bound - # claim pins nomad_job_id = "woodpecker" — note the job_id in - # vault/roles.yaml is "woodpecker" (matching the roles.yaml entry), - # but the actual Nomad job name here is "woodpecker-server". Update - # vault/roles.yaml job_id to "woodpecker-server" if the bound claim - # enforces an exact match at placement. - vault { - role = "service-woodpecker" - } - - # HTTP UI (:8000) + gRPC agent endpoint (:9000). Static ports match - # docker-compose's published ports so the rest of the factory keeps - # reaching woodpecker at the same host:port during and after cutover. - network { - port "http" { - static = 8000 - to = 8000 - } - port "grpc" { - static = 9000 - to = 9000 - } - } - - # Host-volume mount: declared in nomad/client.hcl, path - # /srv/disinto/woodpecker-data on the factory box. - volume "woodpecker-data" { - type = "host" - source = "woodpecker-data" - read_only = false - } - - # Conservative restart policy — fail fast to the scheduler instead of - # spinning on a broken image/config. 3 attempts over 5m, then back off. - restart { - attempts = 3 - interval = "5m" - delay = "15s" - mode = "delay" - } - - # Native Nomad service discovery (no Consul in this factory cluster). - # Health check gates the service as healthy only after the HTTP API is - # up; initial_status is deliberately unset so Nomad waits for the first - # probe to pass before marking the allocation healthy on boot. - service { - name = "woodpecker" - port = "http" - provider = "nomad" - - check { - type = "http" - path = "/healthz" - interval = "10s" - timeout = "3s" - } - } - - task "woodpecker-server" { - driver = "docker" - - config { - image = "woodpeckerci/woodpecker-server:v3" - ports = ["http", "grpc"] - } - - volume_mount { - volume = "woodpecker-data" - destination = "/var/lib/woodpecker" - read_only = false - } - - # Non-secret env — Forgejo integration flags, public URL, DB driver. - # Nothing sensitive here, so this stays inline. Secret-bearing env - # (agent secret, OAuth client/secret) lives in the template stanza - # below and is merged into task env. - env { - WOODPECKER_FORGEJO = "true" - WOODPECKER_FORGEJO_URL = "http://forgejo:3000" - WOODPECKER_HOST = "http://woodpecker:8000" - WOODPECKER_OPEN = "true" - WOODPECKER_DATABASE_DRIVER = "sqlite3" - WOODPECKER_DATABASE_DATASOURCE = "/var/lib/woodpecker/woodpecker.sqlite" - } - - # ── Vault-templated secrets env (S2.4 pattern) ───────────────────────── - # Renders `<task-dir>/secrets/wp.env` (per-alloc secrets dir, never on - # disk on the host root filesystem). `env = true` merges every KEY=VAL - # line into the task environment. `change_mode = "restart"` re-runs the - # task whenever a watched secret's value in Vault changes. - # - # Vault path: `kv/data/disinto/shared/woodpecker`. The literal `/data/` - # segment is required by consul-template for KV v2 mounts. - # - # Empty-Vault fallback (`with ... else ...`): on a fresh LXC where - # the KV path is absent, consul-template's `with` short-circuits to - # the `else` branch. Emitting visible placeholders means the container - # still boots, but with obviously-bad secrets. Seed the path with - # tools/vault-seed-woodpecker.sh (agent_secret) and S3.3's - # wp-oauth-register.sh (forgejo_client, forgejo_secret). - # - # Placeholder values are kept short on purpose: the repo-wide - # secret-scan flags `TOKEN=<16+ non-space chars>` as a plaintext - # secret; "seed-me" is < 16 chars and still distinctive. - template { - destination = "secrets/wp.env" - env = true - change_mode = "restart" - error_on_missing_key = false - data = <<EOT -{{- with secret "kv/data/disinto/shared/woodpecker" -}} -WOODPECKER_AGENT_SECRET={{ .Data.data.agent_secret }} -WOODPECKER_FORGEJO_CLIENT={{ .Data.data.forgejo_client }} -WOODPECKER_FORGEJO_SECRET={{ .Data.data.forgejo_secret }} -{{- else -}} -# WARNING: kv/disinto/shared/woodpecker is empty — run tools/vault-seed-woodpecker.sh + S3.3 -WOODPECKER_AGENT_SECRET=seed-me -WOODPECKER_FORGEJO_CLIENT=seed-me -WOODPECKER_FORGEJO_SECRET=seed-me -{{- end -}} -EOT - } - - resources { - cpu = 300 - memory = 512 - } - } - } -} diff --git a/nomad/server.hcl b/nomad/server.hcl deleted file mode 100644 index 98c54f3..0000000 --- a/nomad/server.hcl +++ /dev/null @@ -1,76 +0,0 @@ -# ============================================================================= -# nomad/server.hcl — Single-node combined server+client configuration -# -# Part of the Nomad+Vault migration (S0.2, issue #822). Deployed to -# /etc/nomad.d/server.hcl on the factory dev box alongside client.hcl. -# -# This file owns: agent role, ports, bind, data directory. -# client.hcl owns: Docker driver plugin config + host_volume declarations. -# -# NOTE: On single-node setups these two files could be merged into one -# (Nomad auto-merges every *.hcl under -config=/etc/nomad.d). The split is -# purely for readability — role/bind/port vs. plugin/volume wiring. -# -# This is a factory dev-box baseline — TLS, ACLs, gossip encryption, and -# consul/vault integration are deliberately absent and land in later steps. -# ============================================================================= - -data_dir = "/var/lib/nomad" -bind_addr = "127.0.0.1" -log_level = "INFO" - -# All Nomad agent traffic stays on localhost — the factory box does not -# federate with peers. Ports are the Nomad defaults, pinned here so that -# future changes to these numbers are a visible diff. -ports { - http = 4646 - rpc = 4647 - serf = 4648 -} - -# Single-node combined mode: this agent is both the only server and the -# only client. bootstrap_expect=1 makes the server quorum-of-one. -server { - enabled = true - bootstrap_expect = 1 -} - -client { - enabled = true -} - -# Advertise localhost to self to avoid surprises if the default IP -# autodetection picks a transient interface (e.g. docker0, wg0). -advertise { - http = "127.0.0.1" - rpc = "127.0.0.1" - serf = "127.0.0.1" -} - -# UI on by default — same bind as http, no TLS (localhost only). -ui { - enabled = true -} - -# ─── Vault integration (S2.3, issue #881) ─────────────────────────────────── -# Nomad jobs exchange their short-lived workload-identity JWT (signed by -# nomad's built-in signer at /.well-known/jwks.json on :4646) for a Vault -# token carrying the policies named by the role in `vault { role = "..." }` -# of each jobspec — no shared VAULT_TOKEN in job env. -# -# The JWT auth path (jwt-nomad) + per-role bindings live on the Vault -# side, written by lib/init/nomad/vault-nomad-auth.sh + tools/vault-apply-roles.sh. -# Roles are defined in vault/roles.yaml. -# -# `default_identity.aud = ["vault.io"]` matches bound_audiences on every -# role in vault/roles.yaml — a drift here would silently break every job's -# Vault token exchange at placement time. -vault { - enabled = true - address = "http://127.0.0.1:8200" - - default_identity { - aud = ["vault.io"] - ttl = "1h" - } -} diff --git a/nomad/vault.hcl b/nomad/vault.hcl deleted file mode 100644 index de81c5d..0000000 --- a/nomad/vault.hcl +++ /dev/null @@ -1,41 +0,0 @@ -# ============================================================================= -# nomad/vault.hcl — Single-node Vault configuration (dev-persisted seal) -# -# Part of the Nomad+Vault migration (S0.3, issue #823). Deployed to -# /etc/vault.d/vault.hcl on the factory dev box. -# -# Seal model: the single unseal key lives on disk at /etc/vault.d/unseal.key -# (0400 root) and is read by systemd ExecStartPost on every boot. This is -# the factory-dev-box-acceptable tradeoff — seal-key theft equals vault -# theft, but we avoid running a second Vault to auto-unseal the first. -# -# This is a factory dev-box baseline — TLS, HA, Raft storage, and audit -# devices are deliberately absent. Storage is the `file` backend (single -# node only). Listener is localhost-only, so no external TLS is needed. -# ============================================================================= - -# File storage backend — single-node only, no HA, no raft. State lives in -# /var/lib/vault/data which is created (root:root 0700) by -# lib/init/nomad/systemd-vault.sh before the unit starts. -storage "file" { - path = "/var/lib/vault/data" -} - -# Localhost-only listener. TLS is disabled because all callers are on the -# same box — flipping this to tls_disable=false is an audit-worthy change -# paired with cert provisioning. -listener "tcp" { - address = "127.0.0.1:8200" - tls_disable = true -} - -# mlock prevents Vault's in-memory secrets from being swapped to disk. We -# keep it enabled; the systemd unit grants CAP_IPC_LOCK so mlock() succeeds. -disable_mlock = false - -# Advertised API address — used by Vault clients on this host. Matches -# the listener above. -api_addr = "http://127.0.0.1:8200" - -# UI on by default — same bind as listener, no TLS (localhost only). -ui = true diff --git a/planner/AGENTS.md b/planner/AGENTS.md index 14b153d..5168eb4 100644 --- a/planner/AGENTS.md +++ b/planner/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: 8fc3ba5b59cd6cb15bd01ca0658cfea2bcb12068 --> +<!-- last-reviewed: cdb0408466d875cdd893c712e3b3f0d0b909d4ef --> # Planner Agent **Role**: Strategic planning using a Prerequisite Tree (Theory of Constraints), @@ -34,9 +34,7 @@ will then sections) and marks the prerequisite as blocked-on-vault in the tree. Deduplication: checks pending/ + approved/ + fired/ before creating. Phase 4 (journal-and-memory): write updated prerequisite tree + daily journal entry (committed to ops repo) and update `$OPS_REPO_ROOT/knowledge/planner-memory.md`. -Phase 5 (commit-ops): commit all ops repo changes to a `planner/run-YYYY-MM-DD` -branch, then create a PR and walk it to merge via review-bot (`pr_create` → -`pr_walk_to_merge`), mirroring the architect's ops flow. No direct push to main. +Phase 5 (commit-ops): commit all ops repo changes, push directly. AGENTS.md maintenance is handled by the Gardener. **Artifacts use `$OPS_REPO_ROOT`**: All planner artifacts (journal, @@ -57,7 +55,7 @@ nervous system component, not work. creates tmux session, injects formula prompt, monitors phase file, handles crash recovery, cleans up - `formulas/run-planner.toml` — Execution spec: six steps (preflight, prediction-triage, update-prerequisite-tree, file-at-constraints, - journal-and-memory, commit-ops-changes) with `needs` dependencies. Claude + journal-and-memory, commit-and-pr) with `needs` dependencies. Claude executes all steps in a single interactive session with tool access - `formulas/groom-backlog.toml` — Grooming formula for backlog triage and grooming. (Note: the planner no longer dispatches breakdown mode — complex diff --git a/planner/planner-run.sh b/planner/planner-run.sh index c567427..6c5bcb2 100755 --- a/planner/planner-run.sh +++ b/planner/planner-run.sh @@ -10,9 +10,7 @@ # 2. Load formula (formulas/run-planner.toml) # 3. Context: VISION.md, AGENTS.md, ops:RESOURCES.md, structural graph, # planner memory, journal entries -# 4. Create ops branch planner/run-YYYY-MM-DD for changes -# 5. agent_run(worktree, prompt) → Claude plans, commits to ops branch -# 6. If ops branch has commits: pr_create → pr_walk_to_merge (review-bot) +# 4. agent_run(worktree, prompt) → Claude plans, may push knowledge updates # # Usage: # planner-run.sh [projects/disinto.toml] # project config (default: disinto) @@ -37,10 +35,6 @@ source "$FACTORY_ROOT/lib/worktree.sh" source "$FACTORY_ROOT/lib/guard.sh" # shellcheck source=../lib/agent-sdk.sh source "$FACTORY_ROOT/lib/agent-sdk.sh" -# shellcheck source=../lib/ci-helpers.sh -source "$FACTORY_ROOT/lib/ci-helpers.sh" -# shellcheck source=../lib/pr-lifecycle.sh -source "$FACTORY_ROOT/lib/pr-lifecycle.sh" LOG_FILE="${DISINTO_LOG_DIR}/planner/planner.log" # shellcheck disable=SC2034 # consumed by agent-sdk.sh @@ -152,69 +146,12 @@ ${PROMPT_FOOTER}" # ── Create worktree ────────────────────────────────────────────────────── formula_worktree_setup "$WORKTREE" -# ── Prepare ops branch for PR-based merge (#765) ──────────────────────── -PLANNER_OPS_BRANCH="planner/run-$(date -u +%Y-%m-%d)" -( - cd "$OPS_REPO_ROOT" - git fetch origin "${PRIMARY_BRANCH}" --quiet 2>/dev/null || true - git checkout "${PRIMARY_BRANCH}" --quiet 2>/dev/null || true - git pull --ff-only origin "${PRIMARY_BRANCH}" --quiet 2>/dev/null || true - # Create (or reset to) a fresh branch from PRIMARY_BRANCH - git checkout -B "$PLANNER_OPS_BRANCH" "origin/${PRIMARY_BRANCH}" --quiet 2>/dev/null || \ - git checkout -b "$PLANNER_OPS_BRANCH" --quiet 2>/dev/null || true -) -log "ops branch: ${PLANNER_OPS_BRANCH}" - # ── Run agent ───────────────────────────────────────────────────────────── export CLAUDE_MODEL="opus" agent_run --worktree "$WORKTREE" "$PROMPT" log "agent_run complete" -# ── PR lifecycle: create PR on ops repo and walk to merge (#765) ───────── -OPS_FORGE_API="${FORGE_API_BASE}/repos/${FORGE_OPS_REPO}" -ops_has_commits=false -if ! git -C "$OPS_REPO_ROOT" diff --quiet "origin/${PRIMARY_BRANCH}..${PLANNER_OPS_BRANCH}" 2>/dev/null; then - ops_has_commits=true -fi - -if [ "$ops_has_commits" = "true" ]; then - log "ops branch has commits — creating PR" - # Push the branch to the ops remote - git -C "$OPS_REPO_ROOT" push origin "$PLANNER_OPS_BRANCH" --quiet 2>/dev/null || \ - git -C "$OPS_REPO_ROOT" push --force-with-lease origin "$PLANNER_OPS_BRANCH" 2>/dev/null - - # Temporarily point FORGE_API at the ops repo for pr-lifecycle functions - ORIG_FORGE_API="$FORGE_API" - export FORGE_API="$OPS_FORGE_API" - # Ops repo typically has no Woodpecker CI — skip CI polling - ORIG_WOODPECKER_REPO_ID="${WOODPECKER_REPO_ID:-2}" - export WOODPECKER_REPO_ID="0" - - PR_NUM=$(pr_create "$PLANNER_OPS_BRANCH" \ - "chore: planner run $(date -u +%Y-%m-%d)" \ - "Automated planner run — updates prerequisite tree, memory, and vault items." \ - "${PRIMARY_BRANCH}" \ - "$OPS_FORGE_API") || true - - if [ -n "$PR_NUM" ]; then - log "ops PR #${PR_NUM} created — walking to merge" - SESSION_ID=$(cat "$SID_FILE" 2>/dev/null || echo "planner-$$") - pr_walk_to_merge "$PR_NUM" "$SESSION_ID" "$OPS_REPO_ROOT" 1 2 || { - log "ops PR #${PR_NUM} walk finished: ${_PR_WALK_EXIT_REASON:-unknown}" - } - log "ops PR #${PR_NUM} result: ${_PR_WALK_EXIT_REASON:-unknown}" - else - log "WARNING: failed to create ops PR for branch ${PLANNER_OPS_BRANCH}" - fi - - # Restore original FORGE_API - export FORGE_API="$ORIG_FORGE_API" - export WOODPECKER_REPO_ID="$ORIG_WOODPECKER_REPO_ID" -else - log "no ops changes — skipping PR creation" -fi - # Persist watermarks so next run can skip if nothing changed mkdir -p "$FACTORY_ROOT/state" echo "$CURRENT_SHA" > "$LAST_SHA_FILE" diff --git a/predictor/AGENTS.md b/predictor/AGENTS.md index ba54a05..b5391fe 100644 --- a/predictor/AGENTS.md +++ b/predictor/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: 8fc3ba5b59cd6cb15bd01ca0658cfea2bcb12068 --> +<!-- last-reviewed: cdb0408466d875cdd893c712e3b3f0d0b909d4ef --> # Predictor Agent **Role**: Abstract adversary (the "goblin"). Runs a 2-step formula diff --git a/review/AGENTS.md b/review/AGENTS.md index 19fc4c7..a3eb4c5 100644 --- a/review/AGENTS.md +++ b/review/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: 8fc3ba5b59cd6cb15bd01ca0658cfea2bcb12068 --> +<!-- last-reviewed: cdb0408466d875cdd893c712e3b3f0d0b909d4ef --> # Review Agent **Role**: AI-powered PR review — post structured findings and formal diff --git a/supervisor/AGENTS.md b/supervisor/AGENTS.md index 7ca3d7f..46d7335 100644 --- a/supervisor/AGENTS.md +++ b/supervisor/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: 8fc3ba5b59cd6cb15bd01ca0658cfea2bcb12068 --> +<!-- last-reviewed: cdb0408466d875cdd893c712e3b3f0d0b909d4ef --> # Supervisor Agent **Role**: Health monitoring and auto-remediation, executed as a formula-driven @@ -7,11 +7,13 @@ then runs an interactive Claude session (sonnet) that assesses health, auto-fixe issues, and writes a daily journal. When blocked on external resources or human decisions, files vault items instead of escalating directly. -**Trigger**: `supervisor-run.sh` is invoked by two polling loops: -- **Agents container** (`docker/agents/entrypoint.sh`): every `SUPERVISOR_INTERVAL` seconds (default 1200 = 20 min). Controlled by the `supervisor` role in `AGENT_ROLES` (included in the default seven-role set since P1/#801). Logs to `supervisor.log` in the agents container. -- **Edge container** (`docker/edge/entrypoint-edge.sh`): separate loop in the edge container (line 169-172). Runs independently of the agents container's polling schedule. - -Both invoke the same `supervisor-run.sh`. Sources `lib/guard.sh` and calls `check_active supervisor` first — skips if `$FACTORY_ROOT/state/.supervisor-active` is absent. Then runs `claude -p` via `agent-sdk.sh`, injects `formulas/run-supervisor.toml` with pre-collected metrics as context, and cleans up on completion or timeout. +**Trigger**: `supervisor-run.sh` is invoked by the polling loop in `docker/edge/entrypoint-edge.sh` +every 20 minutes (line 50-53). Sources `lib/guard.sh` and calls `check_active supervisor` first +— skips if `$FACTORY_ROOT/state/.supervisor-active` is absent. Then runs `claude -p` via +`agent-sdk.sh`, injects `formulas/run-supervisor.toml` with pre-collected metrics as context, +and cleans up on completion or timeout (20 min max session). Note: the supervisor runs in the +**edge container** (`entrypoint-edge.sh`), not the agent container — this distinction matters +for operators debugging the factory. **Key files**: - `supervisor/supervisor-run.sh` — Polling loop participant + orchestrator: lock, memory guard, @@ -24,18 +26,10 @@ Both invoke the same `supervisor-run.sh`. Sources `lib/guard.sh` and calls `chec files for `PHASE:escalate` entries and auto-removes any whose linked issue is confirmed closed (24h grace period after closure to avoid races). Reports **stale crashed worktrees** (worktrees preserved after crash) — supervisor - housekeeping removes them after 24h. Collects **Woodpecker agent health** - (added #933): container `disinto-woodpecker-agent` health/running status, - gRPC error count in last 20 min, fast-failure pipeline count (<60s, last 15 min), - and overall health verdict (healthy/unhealthy). Unhealthy verdict triggers - automatic container restart + `blocked:ci_exhausted` issue recovery in - `supervisor-run.sh` before the Claude session starts. + housekeeping removes them after 24h - `formulas/run-supervisor.toml` — Execution spec: five steps (preflight review, health-assessment, decide-actions, report, journal) with `needs` dependencies. - Claude evaluates all metrics and takes actions in a single interactive session. - Health-assessment now includes P2 **Woodpecker agent unhealthy** classification - (container not running, ≥3 gRPC errors/20m, or ≥3 fast-failure pipelines/15m); - decide-actions documents the pre-session auto-recovery path + Claude evaluates all metrics and takes actions in a single interactive session - `$OPS_REPO_ROOT/knowledge/*.md` — Domain-specific remediation guides (memory, disk, CI, git, dev-agent, review-agent, forge) @@ -45,7 +39,6 @@ P3 (degraded PRs, circular deps, stale deps), P4 (housekeeping). **Environment variables consumed**: - `FORGE_TOKEN`, `FORGE_SUPERVISOR_TOKEN` (falls back to FORGE_TOKEN), `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`, `OPS_REPO_ROOT` - `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by supervisor-run.sh) -- `SUPERVISOR_INTERVAL` — polling interval in seconds for agents container (default 1200 = 20 min) - `WOODPECKER_TOKEN`, `WOODPECKER_SERVER`, `WOODPECKER_DB_PASSWORD`, `WOODPECKER_DB_USER`, `WOODPECKER_DB_HOST`, `WOODPECKER_DB_NAME` — CI database queries **Degraded mode (Issue #544)**: When `OPS_REPO_ROOT` is not set or the directory doesn't exist, the supervisor runs in degraded mode: @@ -55,6 +48,5 @@ P3 (degraded PRs, circular deps, stale deps), P4 (housekeeping). - Logs a WARNING message at startup indicating degraded mode **Lifecycle**: supervisor-run.sh (invoked by polling loop every 20min, `check_active supervisor`) -→ lock + memory guard → run preflight.sh (collect metrics) → **WP agent health recovery** -(if unhealthy: restart container + recover ci_exhausted issues) → load formula + context → run +→ lock + memory guard → run preflight.sh (collect metrics) → load formula + context → run claude -p via agent-sdk.sh → Claude assesses health, auto-fixes, writes journal → `PHASE:done`. diff --git a/supervisor/preflight.sh b/supervisor/preflight.sh index ee42c66..2ddf110 100755 --- a/supervisor/preflight.sh +++ b/supervisor/preflight.sh @@ -224,108 +224,3 @@ for _vf in "${_va_root}"/*.md; do done [ "$_found_vault" = false ] && echo " None" echo "" - -# ── Woodpecker Agent Health ──────────────────────────────────────────────── - -echo "## Woodpecker Agent Health" - -# Check WP agent container health status -_wp_container="disinto-woodpecker-agent" -_wp_health_status="unknown" -_wp_health_start="" - -if command -v docker &>/dev/null; then - # Get health status via docker inspect - _wp_health_status=$(docker inspect "$_wp_container" --format '{{.State.Health.Status}}' 2>/dev/null || echo "not_found") - if [ "$_wp_health_status" = "not_found" ] || [ -z "$_wp_health_status" ]; then - # Container may not exist or not have health check configured - _wp_health_status=$(docker inspect "$_wp_container" --format '{{.State.Status}}' 2>/dev/null || echo "not_found") - fi - - # Get container start time for age calculation - _wp_start_time=$(docker inspect "$_wp_container" --format '{{.State.StartedAt}}' 2>/dev/null || echo "") - if [ -n "$_wp_start_time" ] && [ "$_wp_start_time" != "0001-01-01T00:00:00Z" ]; then - _wp_health_start=$(date -d "$_wp_start_time" '+%Y-%m-%d %H:%M UTC' 2>/dev/null || echo "$_wp_start_time") - fi -fi - -echo "Container: $_wp_container" -echo "Status: $_wp_health_status" -[ -n "$_wp_health_start" ] && echo "Started: $_wp_health_start" - -# Check for gRPC errors in agent logs (last 20 minutes) -_wp_grpc_errors=0 -if [ "$_wp_health_status" != "not_found" ] && [ -n "$_wp_health_status" ]; then - _wp_grpc_errors=$(docker logs --since 20m "$_wp_container" 2>&1 | grep -c 'grpc error' || echo "0") - echo "gRPC errors (last 20m): $_wp_grpc_errors" -fi - -# Fast-failure heuristic: check for pipelines completing in <60s -_wp_fast_failures=0 -_wp_recent_failures="" -if [ -n "${WOODPECKER_REPO_ID:-}" ] && [ "${WOODPECKER_REPO_ID}" != "0" ]; then - _now=$(date +%s) - _pipelines=$(woodpecker_api "/repos/${WOODPECKER_REPO_ID}/pipelines?perPage=100" 2>/dev/null || echo '[]') - - # Count failures with duration < 60s in last 15 minutes - _wp_fast_failures=$(echo "$_pipelines" | jq --argjson now "$_now" ' - [.[] | select(.status == "failure") | select((.finished - .started) < 60) | select(($now - .finished) < 900)] - | length' 2>/dev/null || echo "0") - - if [ "$_wp_fast_failures" -gt 0 ]; then - _wp_recent_failures=$(echo "$_pipelines" | jq -r --argjson now "$_now" ' - [.[] | select(.status == "failure") | select((.finished - .started) < 60) | select(($now - .finished) < 900)] - | .[] | "\(.number)\t\((.finished - .started))s"' 2>/dev/null || echo "") - fi -fi - -echo "Fast-fail pipelines (<60s, last 15m): $_wp_fast_failures" -if [ -n "$_wp_recent_failures" ] && [ "$_wp_fast_failures" -gt 0 ]; then - echo "Recent failures:" - echo "$_wp_recent_failures" | while IFS=$'\t' read -r _num _dur; do - echo " #$_num: ${_dur}" - done -fi - -# Determine overall WP agent health -_wp_agent_healthy=true -_wp_health_reason="" - -if [ "$_wp_health_status" = "not_found" ]; then - _wp_agent_healthy=false - _wp_health_reason="Container not running" -elif [ "$_wp_health_status" = "unhealthy" ]; then - _wp_agent_healthy=false - _wp_health_reason="Container health check failed" -elif [ "$_wp_health_status" != "running" ]; then - _wp_agent_healthy=false - _wp_health_reason="Container not in running state: $_wp_health_status" -elif [ "$_wp_grpc_errors" -ge 3 ]; then - _wp_agent_healthy=false - _wp_health_reason="High gRPC error count (>=3 in 20m)" -elif [ "$_wp_fast_failures" -ge 3 ]; then - _wp_agent_healthy=false - _wp_health_reason="High fast-failure count (>=3 in 15m)" -fi - -echo "" -echo "WP Agent Health: $([ "$_wp_agent_healthy" = true ] && echo "healthy" || echo "UNHEALTHY")" -[ -n "$_wp_health_reason" ] && echo "Reason: $_wp_health_reason" -echo "" - -# ── WP Agent Health History (for idempotency) ────────────────────────────── - -echo "## WP Agent Health History" -# Track last restart timestamp to avoid duplicate restarts in same run -_WP_HEALTH_HISTORY_FILE="${DISINTO_LOG_DIR}/supervisor/wp-agent-health.history" -_wp_last_restart="never" -_wp_last_restart_ts=0 - -if [ -f "$_WP_HEALTH_HISTORY_FILE" ]; then - _wp_last_restart_ts=$(grep -m1 '^LAST_RESTART_TS=' "$_WP_HEALTH_HISTORY_FILE" 2>/dev/null | cut -d= -f2 || echo "0") - if [ -n "$_wp_last_restart_ts" ] && [ "$_wp_last_restart_ts" -gt 0 ] 2>/dev/null; then - _wp_last_restart=$(date -d "@$_wp_last_restart_ts" '+%Y-%m-%d %H:%M UTC' 2>/dev/null || echo "$_wp_last_restart_ts") - fi -fi -echo "Last restart: $_wp_last_restart" -echo "" diff --git a/supervisor/supervisor-run.sh b/supervisor/supervisor-run.sh index df644a6..e04f328 100755 --- a/supervisor/supervisor-run.sh +++ b/supervisor/supervisor-run.sh @@ -47,9 +47,6 @@ SID_FILE="/tmp/supervisor-session-${PROJECT_NAME}.sid" SCRATCH_FILE="/tmp/supervisor-${PROJECT_NAME}-scratch.md" WORKTREE="/tmp/${PROJECT_NAME}-supervisor-run" -# WP agent container name (configurable via env var) -export WP_AGENT_CONTAINER_NAME="${WP_AGENT_CONTAINER_NAME:-disinto-woodpecker-agent}" - # Override LOG_AGENT for consistent agent identification # shellcheck disable=SC2034 # consumed by agent-sdk.sh and env.sh log() LOG_AGENT="supervisor" @@ -169,160 +166,6 @@ ${FORMULA_CONTENT} ${SCRATCH_INSTRUCTION} ${PROMPT_FOOTER}" -# ── WP Agent Health Recovery ────────────────────────────────────────────── -# Check preflight output for WP agent health issues and trigger recovery if needed -_WP_HEALTH_CHECK_FILE="${DISINTO_LOG_DIR}/supervisor/wp-agent-health-check.md" -echo "$PREFLIGHT_OUTPUT" > "$_WP_HEALTH_CHECK_FILE" - -# Extract WP agent health status from preflight output -# Note: match exact "healthy" not "UNHEALTHY" (substring issue) -_wp_agent_healthy=$(grep "^WP Agent Health: healthy$" "$_WP_HEALTH_CHECK_FILE" 2>/dev/null && echo "true" || echo "false") -_wp_health_reason=$(grep "^Reason:" "$_WP_HEALTH_CHECK_FILE" 2>/dev/null | sed 's/^Reason: //' || echo "") - -if [ "$_wp_agent_healthy" = "false" ] && [ -n "$_wp_health_reason" ]; then - log "WP agent detected as UNHEALTHY: $_wp_health_reason" - - # Check for idempotency guard - have we already restarted in this run? - _WP_HEALTH_HISTORY_FILE="${DISINTO_LOG_DIR}/supervisor/wp-agent-health.history" - _wp_last_restart_ts=0 - _wp_last_restart="never" - if [ -f "$_WP_HEALTH_HISTORY_FILE" ]; then - _wp_last_restart_ts=$(grep -m1 '^LAST_RESTART_TS=' "$_WP_HEALTH_HISTORY_FILE" 2>/dev/null | cut -d= -f2 || echo "0") - if [ -n "$_wp_last_restart_ts" ] && [ "$_wp_last_restart_ts" != "0" ] 2>/dev/null; then - _wp_last_restart=$(date -d "@$_wp_last_restart_ts" '+%Y-%m-%d %H:%M UTC' 2>/dev/null || echo "$_wp_last_restart_ts") - fi - fi - - _current_ts=$(date +%s) - _restart_threshold=300 # 5 minutes between restarts - - if [ -z "$_wp_last_restart_ts" ] || [ "$_wp_last_restart_ts" = "0" ] || [ $((_current_ts - _wp_last_restart_ts)) -gt $_restart_threshold ]; then - log "Triggering WP agent restart..." - - # Restart the WP agent container - if docker restart "$WP_AGENT_CONTAINER_NAME" >/dev/null 2>&1; then - _restart_time=$(date -u '+%Y-%m-%d %H:%M UTC') - log "Successfully restarted WP agent container: $WP_AGENT_CONTAINER_NAME" - - # Update history file - echo "LAST_RESTART_TS=$_current_ts" > "$_WP_HEALTH_HISTORY_FILE" - echo "LAST_RESTART_TIME=$_restart_time" >> "$_WP_HEALTH_HISTORY_FILE" - - # Post recovery notice to journal - _journal_file="${OPS_JOURNAL_ROOT}/$(date -u +%Y-%m-%d).md" - if [ -f "$_journal_file" ]; then - { - echo "" - echo "### WP Agent Recovery - $_restart_time" - echo "" - echo "WP agent was unhealthy: $_wp_health_reason" - echo "Container restarted automatically." - } >> "$_journal_file" - fi - - # Scan for issues updated in the last 30 minutes with blocked: ci_exhausted label - log "Scanning for ci_exhausted issues updated in last 30 minutes..." - _now_epoch=$(date +%s) - _thirty_min_ago=$(( _now_epoch - 1800 )) - - # Fetch open issues with blocked label - _blocked_issues=$(forge_api GET "/issues?state=open&labels=blocked&type=issues&limit=100" 2>/dev/null || echo "[]") - _blocked_count=$(echo "$_blocked_issues" | jq 'length' 2>/dev/null || echo "0") - - _issues_processed=0 - _issues_recovered=0 - - if [ "$_blocked_count" -gt 0 ]; then - # Process each blocked issue - echo "$_blocked_issues" | jq -c '.[]' 2>/dev/null | while IFS= read -r issue_json; do - [ -z "$issue_json" ] && continue - - _issue_num=$(echo "$issue_json" | jq -r '.number // empty') - _issue_updated=$(echo "$issue_json" | jq -r '.updated_at // empty') - _issue_labels=$(echo "$issue_json" | jq -r '.labels | map(.name) | join(",")' 2>/dev/null || echo "") - - # Check if issue has ci_exhausted label - if ! echo "$_issue_labels" | grep -q "ci_exhausted"; then - continue - fi - - # Parse updated_at timestamp - _issue_updated_epoch=$(date -d "$_issue_updated" +%s 2>/dev/null || echo "0") - _time_since_update=$(( _now_epoch - _issue_updated_epoch )) - - # Check if updated in last 30 minutes - if [ "$_time_since_update" -lt 1800 ] && [ "$_time_since_update" -ge 0 ]; then - _issues_processed=$(( _issues_processed + 1 )) - - # Check for idempotency guard - already swept by supervisor? - _issue_body=$(echo "$issue_json" | jq -r '.body // ""' 2>/dev/null || echo "") - if echo "$_issue_body" | grep -q "<!-- supervisor-swept -->"; then - log "Issue #$_issue_num already swept by supervisor, skipping" - continue - fi - - log "Processing ci_exhausted issue #$_issue_num (updated $_time_since_update seconds ago)" - - # Get issue assignee - _issue_assignee=$(echo "$issue_json" | jq -r '.assignee.login // empty' 2>/dev/null || echo "") - - # Unassign the issue - if [ -n "$_issue_assignee" ]; then - log "Unassigning issue #$_issue_num from $_issue_assignee" - curl -sf -X PATCH \ - -H "Authorization: token ${FORGE_SUPERVISOR_TOKEN:-$FORGE_TOKEN}" \ - -H "Content-Type: application/json" \ - "${FORGE_API}/issues/$_issue_num" \ - -d '{"assignees":[]}' >/dev/null 2>&1 || true - fi - - # Remove blocked label - _blocked_label_id=$(forge_api GET "/labels" 2>/dev/null | jq -r '.[] | select(.name == "blocked") | .id' 2>/dev/null || echo "") - if [ -n "$_blocked_label_id" ]; then - log "Removing blocked label from issue #$_issue_num" - curl -sf -X DELETE \ - -H "Authorization: token ${FORGE_SUPERVISOR_TOKEN:-$FORGE_TOKEN}" \ - "${FORGE_API}/issues/$_issue_num/labels/$_blocked_label_id" >/dev/null 2>&1 || true - fi - - # Add comment about infra-flake recovery - _recovery_comment=$(cat <<EOF -<!-- supervisor-swept --> - -**Automated Recovery — $(date -u '+%Y-%m-%d %H:%M UTC')** - -CI agent was unhealthy between $_restart_time and now. The prior retry budget may have been spent on infra flake, not real failures. - -**Recovery Actions:** -- Unassigned from pool and returned for fresh attempt -- CI agent container restarted -- Related pipelines will be retriggered automatically - -**Next Steps:** -Please re-attempt this issue. The CI environment has been refreshed. -EOF -) - - curl -sf -X POST \ - -H "Authorization: token ${FORGE_SUPERVISOR_TOKEN:-$FORGE_TOKEN}" \ - -H "Content-Type: application/json" \ - "${FORGE_API}/issues/$_issue_num/comments" \ - -d "$(jq -n --arg body "$_recovery_comment" '{body: $body}')" >/dev/null 2>&1 || true - - log "Recovered issue #$_issue_num - returned to pool" - fi - done - fi - - log "WP agent restart and issue recovery complete" - else - log "ERROR: Failed to restart WP agent container" - fi - else - log "WP agent restart already performed in this run (since $_wp_last_restart), skipping" - fi -fi - # ── Run agent ───────────────────────────────────────────────────────────── agent_run --worktree "$WORKTREE" "$PROMPT" log "agent_run complete" diff --git a/tests/disinto-init-nomad.bats b/tests/disinto-init-nomad.bats deleted file mode 100644 index 8c8b9a4..0000000 --- a/tests/disinto-init-nomad.bats +++ /dev/null @@ -1,428 +0,0 @@ -#!/usr/bin/env bats -# ============================================================================= -# tests/disinto-init-nomad.bats — Regression guard for `disinto init` -# backend dispatch (S0.5, issue #825). -# -# Exercises the three CLI paths the Nomad+Vault migration cares about: -# 1. --backend=nomad --dry-run → cluster-up step list -# 2. --backend=nomad --empty --dry-run → same, with "--empty" banner -# 3. --backend=docker --dry-run → docker path unaffected -# -# A throw-away `placeholder/repo` slug satisfies the CLI's positional-arg -# requirement (the nomad dispatcher never touches it). --dry-run on both -# backends short-circuits before any network/filesystem mutation, so the -# suite is hermetic — no Forgejo, no sudo, no real cluster. -# ============================================================================= - -setup_file() { - export DISINTO_ROOT - DISINTO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" - export DISINTO_BIN="${DISINTO_ROOT}/bin/disinto" - [ -x "$DISINTO_BIN" ] || { - echo "disinto binary not executable: $DISINTO_BIN" >&2 - return 1 - } -} - -# ── --backend=nomad --dry-run ──────────────────────────────────────────────── - -@test "disinto init --backend=nomad --dry-run exits 0 and prints the step list" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --dry-run - [ "$status" -eq 0 ] - - # Dispatcher banner (cluster-up mode, no --empty). - [[ "$output" == *"nomad backend: default (cluster-up; jobs deferred to Step 1)"* ]] - - # All nine cluster-up dry-run steps, in order. - [[ "$output" == *"[dry-run] Step 1/9: install nomad + vault binaries + docker daemon"* ]] - [[ "$output" == *"[dry-run] Step 2/9: write + enable nomad.service (NOT started)"* ]] - [[ "$output" == *"[dry-run] Step 3/9: write + enable vault.service + vault.hcl (NOT started)"* ]] - [[ "$output" == *"[dry-run] Step 4/9: create host-volume dirs under /srv/disinto/"* ]] - [[ "$output" == *"[dry-run] Step 5/9: install /etc/nomad.d/server.hcl + client.hcl from repo"* ]] - [[ "$output" == *"[dry-run] Step 6/9: first-run vault init + persist unseal.key + root.token"* ]] - [[ "$output" == *"[dry-run] Step 7/9: systemctl start vault + poll until unsealed"* ]] - [[ "$output" == *"[dry-run] Step 8/9: systemctl start nomad + poll until ≥1 node ready"* ]] - [[ "$output" == *"[dry-run] Step 9/9: write /etc/profile.d/disinto-nomad.sh"* ]] - - [[ "$output" == *"Dry run complete — no changes made."* ]] -} - -# ── --backend=nomad --empty --dry-run ──────────────────────────────────────── - -@test "disinto init --backend=nomad --empty --dry-run prints the --empty banner + step list" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --empty --dry-run - [ "$status" -eq 0 ] - - # --empty changes the dispatcher banner but not the step list — Step 1 - # of the migration will branch on $empty to gate job deployment; today - # both modes invoke the same cluster-up dry-run. - [[ "$output" == *"nomad backend: --empty (cluster-up only, no jobs)"* ]] - [[ "$output" == *"[dry-run] Step 1/9: install nomad + vault binaries + docker daemon"* ]] - [[ "$output" == *"Dry run complete — no changes made."* ]] -} - -# ── --backend=docker (regression guard) ────────────────────────────────────── - -@test "disinto init --backend=docker does NOT dispatch to the nomad path" { - run "$DISINTO_BIN" init placeholder/repo --backend=docker --dry-run - [ "$status" -eq 0 ] - - # Negative assertion: the nomad dispatcher banners must be absent. - [[ "$output" != *"nomad backend:"* ]] - [[ "$output" != *"[dry-run] Step 1/9: install nomad + vault binaries + docker daemon"* ]] - - # Positive assertion: docker-path output still appears — the existing - # docker dry-run printed "=== disinto init ===" before listing the - # intended forge/compose actions. - [[ "$output" == *"=== disinto init ==="* ]] - [[ "$output" == *"── Dry-run: intended actions ────"* ]] -} - -# ── Flag syntax: --flag=value vs --flag value ──────────────────────────────── - -# Both forms must work. The bin/disinto flag loop has separate cases for -# `--backend value` and `--backend=value`; a regression in either would -# silently route to the docker default, which is the worst failure mode -# for a mid-migration dispatcher ("loud-failing stub" lesson from S0.4). -@test "disinto init --backend nomad (space-separated) dispatches to nomad" { - run "$DISINTO_BIN" init placeholder/repo --backend nomad --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"nomad backend: default"* ]] - [[ "$output" == *"[dry-run] Step 1/9: install nomad + vault binaries + docker daemon"* ]] -} - -# ── Flag validation ────────────────────────────────────────────────────────── - -@test "--backend=bogus is rejected with a clear error" { - run "$DISINTO_BIN" init placeholder/repo --backend=bogus --dry-run - [ "$status" -ne 0 ] - [[ "$output" == *"invalid --backend value"* ]] -} - -@test "--empty without --backend=nomad is rejected" { - run "$DISINTO_BIN" init placeholder/repo --backend=docker --empty --dry-run - [ "$status" -ne 0 ] - [[ "$output" == *"--empty is only valid with --backend=nomad"* ]] -} - -# ── Positional vs flag-first invocation (#835) ─────────────────────────────── -# -# Before the #835 fix, disinto_init eagerly consumed $1 as repo_url *before* -# argparse ran. That swallowed `--backend=nomad` as a repo_url and then -# complained that `--empty` required a nomad backend — the nonsense error -# flagged during S0.1 end-to-end verification. The cases below pin the CLI -# to the post-fix contract: the nomad path accepts flag-first invocation, -# the docker path still errors helpfully on a missing repo_url. - -@test "disinto init --backend=nomad --empty --dry-run (no positional) dispatches to nomad" { - run "$DISINTO_BIN" init --backend=nomad --empty --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"nomad backend: --empty (cluster-up only, no jobs)"* ]] - [[ "$output" == *"[dry-run] Step 1/9: install nomad + vault binaries + docker daemon"* ]] - # The bug symptom must be absent — backend was misdetected as docker - # when --backend=nomad got swallowed as repo_url. - [[ "$output" != *"--empty is only valid with --backend=nomad"* ]] -} - -@test "disinto init --backend nomad --dry-run (space-separated, no positional) dispatches to nomad" { - run "$DISINTO_BIN" init --backend nomad --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"nomad backend: default"* ]] - [[ "$output" == *"[dry-run] Step 1/9: install nomad + vault binaries + docker daemon"* ]] -} - -@test "disinto init (no args) still errors with 'repo URL required'" { - run "$DISINTO_BIN" init - [ "$status" -ne 0 ] - [[ "$output" == *"repo URL required"* ]] -} - -@test "disinto init --backend=docker (no positional) errors with 'repo URL required', not 'Unknown option'" { - run "$DISINTO_BIN" init --backend=docker - [ "$status" -ne 0 ] - [[ "$output" == *"repo URL required"* ]] - [[ "$output" != *"Unknown option"* ]] -} - -# ── --with flag tests ───────────────────────────────────────────────────────── - -@test "disinto init --backend=nomad --with forgejo --dry-run prints deploy plan" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with forgejo --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"services to deploy: forgejo"* ]] - [[ "$output" == *"[deploy] [dry-run] nomad job validate"* ]] - [[ "$output" == *"[deploy] [dry-run] nomad job run -detach"* ]] - [[ "$output" == *"[deploy] dry-run complete"* ]] -} - -# S2.6 / #928 — every --with <svc> that ships tools/vault-seed-<svc>.sh -# must auto-invoke the seeder before deploy.sh runs. forgejo is the -# only service with a seeder today, so the dry-run plan must include -# its seed line when --with forgejo is set. The seed block must also -# appear BEFORE the deploy block (seeded secrets must exist before -# nomad reads the template stanza) — pinned here by scanning output -# order. Services without a seeder (e.g. unknown hypothetical future -# ones) are silently skipped by the loop convention. -@test "disinto init --backend=nomad --with forgejo --dry-run prints seed plan before deploy plan" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with forgejo --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"Vault seed dry-run"* ]] - [[ "$output" == *"tools/vault-seed-forgejo.sh --dry-run"* ]] - # Order: seed header must appear before deploy header. - local seed_line deploy_line - seed_line=$(echo "$output" | grep -n "Vault seed dry-run" | head -1 | cut -d: -f1) - deploy_line=$(echo "$output" | grep -n "Deploy services dry-run" | head -1 | cut -d: -f1) - [ -n "$seed_line" ] - [ -n "$deploy_line" ] - [ "$seed_line" -lt "$deploy_line" ] -} - -# Regression guard (PR #929 review): `sudo -n VAR=val -- cmd` is subject -# to sudoers env_reset policy and silently drops VAULT_ADDR unless it's -# in env_keep (it isn't in default configs). vault-seed-forgejo.sh -# requires VAULT_ADDR and dies at its own precondition check if unset, -# so the non-root branch MUST invoke `sudo -n -- env VAR=val cmd` so -# that `env` sets the variable in the child process regardless of -# sudoers policy. This grep-level guard catches a revert to the unsafe -# form that silently broke non-root seed runs on a fresh LXC. -@test "seed loop invokes sudo via 'env VAR=val' (bypasses sudoers env_reset)" { - run grep -F 'sudo -n -- env "VAULT_ADDR=' "$DISINTO_BIN" - [ "$status" -eq 0 ] - # Negative: no bare `sudo -n "VAR=val" --` form anywhere in the file. - run grep -F 'sudo -n "VAULT_ADDR=' "$DISINTO_BIN" - [ "$status" -ne 0 ] -} - -@test "disinto init --backend=nomad --with forgejo,forgejo --dry-run handles comma-separated services" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with forgejo,forgejo --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"services to deploy: forgejo,forgejo"* ]] -} - -@test "disinto init --backend=docker --with forgejo errors with '--with requires --backend=nomad'" { - run "$DISINTO_BIN" init placeholder/repo --backend=docker --with forgejo - [ "$status" -ne 0 ] - [[ "$output" == *"--with requires --backend=nomad"* ]] -} - -@test "disinto init --backend=nomad --empty --with forgejo errors with mutually exclusive" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --empty --with forgejo - [ "$status" -ne 0 ] - [[ "$output" == *"--empty and --with are mutually exclusive"* ]] -} - -@test "disinto init --backend=nomad --with unknown-service errors with unknown service" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with unknown-service --dry-run - [ "$status" -ne 0 ] - [[ "$output" == *"unknown service"* ]] - [[ "$output" == *"known: forgejo, woodpecker-server, woodpecker-agent, agents, staging, chat, edge"* ]] -} - -# S3.4: woodpecker auto-expansion and forgejo auto-inclusion -@test "disinto init --backend=nomad --with woodpecker auto-expands to server+agent" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with woodpecker --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"services to deploy: forgejo,woodpecker-server,woodpecker-agent"* ]] - [[ "$output" == *"deployment order: forgejo woodpecker-server woodpecker-agent"* ]] -} - -@test "disinto init --backend=nomad --with woodpecker auto-includes forgejo with note" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with woodpecker --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"Note: --with woodpecker implies --with forgejo"* ]] -} - -@test "disinto init --backend=nomad --with forgejo,woodpecker expands woodpecker" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with forgejo,woodpecker --dry-run - [ "$status" -eq 0 ] - # Order follows input: forgejo first, then woodpecker expanded - [[ "$output" == *"services to deploy: forgejo,woodpecker-server,woodpecker-agent"* ]] - [[ "$output" == *"deployment order: forgejo woodpecker-server woodpecker-agent"* ]] -} - -@test "disinto init --backend=nomad --with woodpecker seeds both forgejo and woodpecker" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with woodpecker --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"tools/vault-seed-forgejo.sh --dry-run"* ]] - [[ "$output" == *"tools/vault-seed-woodpecker.sh --dry-run"* ]] -} - -@test "disinto init --backend=nomad --with forgejo,woodpecker deploys all three services" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with forgejo,woodpecker --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"[deploy] [dry-run] nomad job validate"*"forgejo.hcl"* ]] - [[ "$output" == *"[deploy] [dry-run] nomad job validate"*"woodpecker-server.hcl"* ]] - [[ "$output" == *"[deploy] [dry-run] nomad job validate"*"woodpecker-agent.hcl"* ]] -} - -@test "disinto init --backend=nomad --with forgejo (flag=value syntax) works" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with=forgejo --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"services to deploy: forgejo"* ]] -} - -@test "disinto init --backend=nomad --with forgejo --empty --dry-run rejects in any order" { - run "$DISINTO_BIN" init placeholder/repo --with forgejo --backend=nomad --empty --dry-run - [ "$status" -ne 0 ] - [[ "$output" == *"--empty and --with are mutually exclusive"* ]] -} - -# ── --import-env / --import-sops / --age-key (S2.5, #883) ──────────────────── -# -# Step 2.5 wires Vault policies + JWT auth + optional KV import into -# `disinto init --backend=nomad`. The tests below exercise the flag -# grammar (who-requires-whom + who-requires-backend=nomad) and the -# dry-run plan shape (each --import-* flag prints its own path line, -# independently). A prior attempt at this issue regressed the "print -# every set flag" invariant by using if/elif — covered by the -# "--import-env --import-sops --age-key" case. - -@test "disinto init --backend=nomad --import-env only is accepted" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --import-env /tmp/.env --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"--import-env"* ]] - [[ "$output" == *"env file: /tmp/.env"* ]] -} - -@test "disinto init --backend=nomad --import-sops without --age-key errors" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --import-sops /tmp/.env.vault.enc --dry-run - [ "$status" -ne 0 ] - [[ "$output" == *"--import-sops requires --age-key"* ]] -} - -@test "disinto init --backend=nomad --age-key without --import-sops errors" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --age-key /tmp/keys.txt --dry-run - [ "$status" -ne 0 ] - [[ "$output" == *"--age-key requires --import-sops"* ]] -} - -@test "disinto init --backend=docker --import-env errors with backend requirement" { - run "$DISINTO_BIN" init placeholder/repo --backend=docker --import-env /tmp/.env - [ "$status" -ne 0 ] - [[ "$output" == *"--import-env, --import-sops, and --age-key require --backend=nomad"* ]] -} - -@test "disinto init --backend=nomad --import-sops --age-key --dry-run shows import plan" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --import-sops /tmp/.env.vault.enc --age-key /tmp/keys.txt --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"Vault import dry-run"* ]] - [[ "$output" == *"--import-sops"* ]] - [[ "$output" == *"--age-key"* ]] - [[ "$output" == *"sops file: /tmp/.env.vault.enc"* ]] - [[ "$output" == *"age key: /tmp/keys.txt"* ]] -} - -# When all three flags are set, each one must print its own path line — -# if/elif regressed this to "only one printed" in a prior attempt (#883). -@test "disinto init --backend=nomad --import-env --import-sops --age-key --dry-run shows full import plan" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --import-env /tmp/.env --import-sops /tmp/.env.vault.enc --age-key /tmp/keys.txt --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"Vault import dry-run"* ]] - [[ "$output" == *"env file: /tmp/.env"* ]] - [[ "$output" == *"sops file: /tmp/.env.vault.enc"* ]] - [[ "$output" == *"age key: /tmp/keys.txt"* ]] -} - -@test "disinto init --backend=nomad without import flags shows skip message" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"no --import-env/--import-sops"* ]] - [[ "$output" == *"skipping"* ]] -} - -@test "disinto init --backend=nomad --import-env --import-sops --age-key --with forgejo --dry-run shows all plans" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --import-env /tmp/.env --import-sops /tmp/.env.vault.enc --age-key /tmp/keys.txt --with forgejo --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"Vault import dry-run"* ]] - [[ "$output" == *"Vault policies dry-run"* ]] - [[ "$output" == *"Vault auth dry-run"* ]] - [[ "$output" == *"Deploy services dry-run"* ]] -} - -@test "disinto init --backend=nomad --dry-run prints policies + auth plan even without --import-*" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --dry-run - [ "$status" -eq 0 ] - # Policies + auth run on every nomad path (idempotent), so the dry-run - # plan always lists them — regardless of whether --import-* is set. - [[ "$output" == *"Vault policies dry-run"* ]] - [[ "$output" == *"Vault auth dry-run"* ]] - [[ "$output" != *"Vault import dry-run"* ]] -} - -# --import-env=PATH (=-form) must work alongside --import-env PATH. -@test "disinto init --backend=nomad --import-env=PATH (equals form) works" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --import-env=/tmp/.env --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"env file: /tmp/.env"* ]] -} - -# --empty short-circuits after cluster-up: no policies, no auth, no -# import, no deploy. The dry-run plan must match that — cluster-up plan -# appears, but none of the S2.x section banners do. -@test "disinto init --backend=nomad --empty --dry-run skips policies/auth/import sections" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --empty --dry-run - [ "$status" -eq 0 ] - # Cluster-up still runs (it's what --empty brings up). - [[ "$output" == *"Cluster-up dry-run"* ]] - # Policies + auth + import must NOT appear under --empty. - [[ "$output" != *"Vault policies dry-run"* ]] - [[ "$output" != *"Vault auth dry-run"* ]] - [[ "$output" != *"Vault import dry-run"* ]] - [[ "$output" != *"no --import-env/--import-sops"* ]] -} - -# --empty + any --import-* flag silently does nothing (import is skipped), -# so the CLI rejects the combination up front rather than letting it -# look like the import "succeeded". -@test "disinto init --backend=nomad --empty --import-env errors" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --empty --import-env /tmp/.env --dry-run - [ "$status" -ne 0 ] - [[ "$output" == *"--empty and --import-env/--import-sops/--age-key are mutually exclusive"* ]] -} - -@test "disinto init --backend=nomad --empty --import-sops --age-key errors" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --empty --import-sops /tmp/.env.vault.enc --age-key /tmp/keys.txt --dry-run - [ "$status" -ne 0 ] - [[ "$output" == *"--empty and --import-env/--import-sops/--age-key are mutually exclusive"* ]] -} - -# S4.2: agents service auto-expansion and dependencies -@test "disinto init --backend=nomad --with agents auto-includes forgejo and woodpecker" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with agents --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"services to deploy: forgejo,agents,woodpecker-server,woodpecker-agent"* ]] - [[ "$output" == *"Note: --with agents implies --with forgejo"* ]] - [[ "$output" == *"Note: --with agents implies --with woodpecker"* ]] -} - -@test "disinto init --backend=nomad --with agents deploys in correct order" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with agents --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"deployment order: forgejo woodpecker-server woodpecker-agent agents"* ]] -} - -@test "disinto init --backend=nomad --with agents seeds agents service" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with agents --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"tools/vault-seed-forgejo.sh --dry-run"* ]] - [[ "$output" == *"tools/vault-seed-woodpecker.sh --dry-run"* ]] - [[ "$output" == *"tools/vault-seed-agents.sh --dry-run"* ]] -} - -@test "disinto init --backend=nomad --with agents deploys all four services" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with agents --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"[deploy] [dry-run] nomad job validate"*"forgejo.hcl"* ]] - [[ "$output" == *"[deploy] [dry-run] nomad job validate"*"woodpecker-server.hcl"* ]] - [[ "$output" == *"[deploy] [dry-run] nomad job validate"*"woodpecker-agent.hcl"* ]] - [[ "$output" == *"[deploy] [dry-run] nomad job validate"*"agents.hcl"* ]] -} - -@test "disinto init --backend=nomad --with woodpecker,agents expands correctly" { - run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with woodpecker,agents --dry-run - [ "$status" -eq 0 ] - # woodpecker expands to server+agent, agents is already explicit - # forgejo is auto-included by agents - [[ "$output" == *"services to deploy: forgejo,woodpecker-server,woodpecker-agent,agents"* ]] - [[ "$output" == *"deployment order: forgejo woodpecker-server woodpecker-agent agents"* ]] -} diff --git a/tests/fixtures/.env.vault.enc b/tests/fixtures/.env.vault.enc deleted file mode 100644 index 2924dc9..0000000 --- a/tests/fixtures/.env.vault.enc +++ /dev/null @@ -1,20 +0,0 @@ -{ - "data": "ENC[AES256_GCM,data:SsLdIiZDVkkV1bbKeHQ8A1K/4vgXQFJF8y4J87GGwsGa13lNnPoqRaCmPAtuQr3hR5JNqARUhFp8aEusyzwi/lZLU2Reo32YjE26ObVOHf47EGmmHM/tEgh6u0fa1AmFtuqJVQzhG2eZhJmZJFgdRH36+bhdBwI1mkORmsRNtBPHHjtQJDbsgN47maDhuP4B7WvB4/TdnJ++GNMlMbyrbr0pEf2uqqOVO55cJ3I4v/Jcg8tq0clPuW1k5dNFsmFSMbbjE5N25EGrc7oEH5GVZ6I6L6p0Fzyj/MV4hKacboFHiZmBZgRQ,iv:UnXTa800G3PW4IaErkPBIZKjPHAU3LmiCvAqDdhFE/Q=,tag:kdWpHQ8fEPGFlmfVoTMskA==,type:str]", - "sops": { - "kms": null, - "gcp_kms": null, - "azure_kv": null, - "hc_vault": null, - "age": [ - { - "recipient": "age1ztkm8yvdk42m2cn4dj2v9ptfknq8wpgr3ry9dpmtmlaeas6p7yyqft0ldg", - "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrVUlmaEdTNU1iMGg4dFA4\nNFNOSzlBc1NER1U3SHlwVFU1dm5tR1kyeldzCjZ2NXI3MjR4Zkd1RVBKNzJoQ1Jm\nQWpEZU5VMkNuYnhTTVJNc0RpTXlIZE0KLS0tIDFpQ2tlN0MzL1NuS2hKZU5JTG9B\nNWxXMzE0bGZpQkVBTnhWRXZBQlhrc1EKG76DM98cCuqIwUkbfJWHhJdYV77O9r8Q\nRJrq6jH59Gcp9W8iHg/aeShPHZFEOLg1q9azV9Wt9FjJn3SxyTmgvA==\n-----END AGE ENCRYPTED FILE-----\n" - } - ], - "lastmodified": "2026-04-16T15:43:34Z", - "mac": "ENC[AES256_GCM,data:jVRr2TxSZH2paD2doIX4JwCqo5wiPYfTowpj189w1IVlS0EY/XQoqxiWbunX/LmIDdQlTPCSe/vTp1EJA0cx6vzN2xENrwsfzCP6dwDGaRlZhH3V0CVhtfHIkMTEKWrAUx5hFtiwJPkLYUUYi5aRWRxhZQM1eBeRvuGKdlwvmHA=,iv:H57a61AfVNLrlg+4aMl9mwXI5O38O5ZoRhpxe2PTTkY=,tag:2jwH1855VNYlKseTE/XtTg==,type:str]", - "pgp": null, - "unencrypted_suffix": "_unencrypted", - "version": "3.9.4" - } -} \ No newline at end of file diff --git a/tests/fixtures/age-keys.txt b/tests/fixtures/age-keys.txt deleted file mode 100644 index 081f2af..0000000 --- a/tests/fixtures/age-keys.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Test age key for sops -# Generated: 2026-04-16 -# Public key: age1ztkm8yvdk42m2cn4dj2v9ptfknq8wpgr3ry9dpmtmlaeas6p7yyqft0ldg - -AGE-SECRET-KEY-1PCQQX37MTZDGES76H9TGQN5XTG2ZZX2UUR87KR784NZ4MQ3NJ56S0Z23SF diff --git a/tests/fixtures/dot-env-complete b/tests/fixtures/dot-env-complete deleted file mode 100644 index 828b9a3..0000000 --- a/tests/fixtures/dot-env-complete +++ /dev/null @@ -1,40 +0,0 @@ -# Test fixture .env file for vault-import.sh -# This file contains all expected keys for the import test - -# Generic forge creds -FORGE_TOKEN=generic-forge-token -FORGE_PASS=generic-forge-pass -FORGE_ADMIN_TOKEN=generic-admin-token - -# Bot tokens (review, dev, gardener, architect, planner, predictor, supervisor, vault) -FORGE_REVIEW_TOKEN=review-token -FORGE_REVIEW_PASS=review-pass -FORGE_DEV_TOKEN=dev-token -FORGE_DEV_PASS=dev-pass -FORGE_GARDENER_TOKEN=gardener-token -FORGE_GARDENER_PASS=gardener-pass -FORGE_ARCHITECT_TOKEN=architect-token -FORGE_ARCHITECT_PASS=architect-pass -FORGE_PLANNER_TOKEN=planner-token -FORGE_PLANNER_PASS=planner-pass -FORGE_PREDICTOR_TOKEN=predictor-token -FORGE_PREDICTOR_PASS=predictor-pass -FORGE_SUPERVISOR_TOKEN=supervisor-token -FORGE_SUPERVISOR_PASS=supervisor-pass -FORGE_VAULT_TOKEN=vault-token -FORGE_VAULT_PASS=vault-pass - -# Llama bot -FORGE_TOKEN_LLAMA=llama-token -FORGE_PASS_LLAMA=llama-pass - -# Woodpecker secrets -WOODPECKER_AGENT_SECRET=wp-agent-secret -WP_FORGEJO_CLIENT=wp-forgejo-client -WP_FORGEJO_SECRET=wp-forgejo-secret -WOODPECKER_TOKEN=wp-token - -# Chat secrets -FORWARD_AUTH_SECRET=forward-auth-secret -CHAT_OAUTH_CLIENT_ID=chat-client-id -CHAT_OAUTH_CLIENT_SECRET=chat-client-secret diff --git a/tests/fixtures/dot-env-incomplete b/tests/fixtures/dot-env-incomplete deleted file mode 100644 index 9869944..0000000 --- a/tests/fixtures/dot-env-incomplete +++ /dev/null @@ -1,27 +0,0 @@ -# Test fixture .env file with missing required keys -# This file is intentionally missing some keys to test error handling - -# Generic forge creds - missing FORGE_ADMIN_TOKEN -FORGE_TOKEN=generic-forge-token -FORGE_PASS=generic-forge-pass - -# Bot tokens - missing several roles -FORGE_REVIEW_TOKEN=review-token -FORGE_REVIEW_PASS=review-pass -FORGE_DEV_TOKEN=dev-token -FORGE_DEV_PASS=dev-pass - -# Llama bot - missing (only token, no pass) -FORGE_TOKEN_LLAMA=llama-token -# FORGE_PASS_LLAMA=llama-pass - -# Woodpecker secrets - missing some -WOODPECKER_AGENT_SECRET=wp-agent-secret -# WP_FORGEJO_CLIENT=wp-forgejo-client -# WP_FORGEJO_SECRET=wp-forgejo-secret -# WOODPECKER_TOKEN=wp-token - -# Chat secrets - missing some -FORWARD_AUTH_SECRET=forward-auth-secret -# CHAT_OAUTH_CLIENT_ID=chat-client-id -# CHAT_OAUTH_CLIENT_SECRET=chat-client-secret diff --git a/tests/fixtures/dot-env.vault.plain b/tests/fixtures/dot-env.vault.plain deleted file mode 100644 index e4b60c1..0000000 --- a/tests/fixtures/dot-env.vault.plain +++ /dev/null @@ -1,6 +0,0 @@ -GITHUB_TOKEN=github-test-token-abc123 -CODEBERG_TOKEN=codeberg-test-token-def456 -CLAWHUB_TOKEN=clawhub-test-token-ghi789 -DEPLOY_KEY=deploy-key-test-jkl012 -NPM_TOKEN=npm-test-token-mno345 -DOCKER_HUB_TOKEN=dockerhub-test-token-pqr678 diff --git a/tests/lib-generators.bats b/tests/lib-generators.bats deleted file mode 100644 index b311325..0000000 --- a/tests/lib-generators.bats +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bats -# ============================================================================= -# tests/lib-generators.bats — Regression guard for the #849 fix. -# -# Before #849, `_generate_local_model_services` emitted the forge-user env -# variable keyed by service name (`FORGE_BOT_USER_${service_name^^}`), so for -# an `[agents.llama]` block with `forge_user = "dev-qwen"` the compose file -# contained `FORGE_BOT_USER_LLAMA: "dev-qwen"`. That suffix diverges from the -# `FORGE_TOKEN_<FORGE_USER>` / `FORGE_PASS_<FORGE_USER>` convention that the -# same block uses two lines above, and it doesn't even round-trip through a -# dash-containing service name (`dev-qwen` → `DEV-QWEN`, which is not a valid -# shell identifier — see #852). -# -# The fix keys on `$user_upper` (already computed from `forge_user` via -# `tr 'a-z-' 'A-Z_'`), yielding `FORGE_BOT_USER_DEV_QWEN: "dev-qwen"`. -# ============================================================================= - -setup() { - ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" - export FACTORY_ROOT="${BATS_TEST_TMPDIR}/factory" - mkdir -p "${FACTORY_ROOT}/projects" - - # Minimal compose skeleton that `_generate_local_model_services` can splice into. - # It only needs a `volumes:` marker line and nothing below it that would be - # re-read after the splice. - cat > "${FACTORY_ROOT}/docker-compose.yml" <<'EOF' -services: - agents: - image: placeholder - -volumes: - agent-data: -EOF -} - -@test "local-model agent service emits FORGE_BOT_USER keyed by forge_user (#849)" { - cat > "${FACTORY_ROOT}/projects/test.toml" <<'EOF' -name = "test" -repo = "test-owner/test-repo" -forge_url = "http://localhost:3000" - -[agents.llama] -base_url = "http://10.10.10.1:8081" -model = "qwen" -api_key = "sk-no-key-required" -roles = ["dev"] -forge_user = "dev-qwen" -compact_pct = 60 -EOF - - run bash -c " - set -euo pipefail - source '${ROOT}/lib/generators.sh' - _generate_local_model_services '${FACTORY_ROOT}/docker-compose.yml' - cat '${FACTORY_ROOT}/docker-compose.yml' - " - - [ "$status" -eq 0 ] - # New, forge_user-keyed suffix is present with the right value. - [[ "$output" == *'FORGE_BOT_USER_DEV_QWEN: "dev-qwen"'* ]] - # Legacy service-name-keyed suffix must not be emitted. - [[ "$output" != *'FORGE_BOT_USER_LLAMA'* ]] -} - -@test "local-model agent service emits local image ref + build: fallback (#853)" { - # Before #853 the generator emitted `image: ghcr.io/disinto/agents:<tag>` for - # every hired agent. The ghcr image isn't publicly pullable and the running - # deployment has no credentials, so `docker compose up` failed with `denied`. - # The fix: emit the registry-less local name (matches `disinto init --build` - # and the legacy agents-llama stanza) plus a build: directive so hosts - # without a pre-built image can rebuild locally. - cat > "${FACTORY_ROOT}/projects/test.toml" <<'EOF' -name = "test" -repo = "test-owner/test-repo" -forge_url = "http://localhost:3000" - -[agents.dev-qwen2] -base_url = "http://10.10.10.1:8081" -model = "qwen" -api_key = "sk-no-key-required" -roles = ["dev"] -forge_user = "dev-qwen2" -EOF - - run bash -c " - set -euo pipefail - source '${ROOT}/lib/generators.sh' - _generate_local_model_services '${FACTORY_ROOT}/docker-compose.yml' - cat '${FACTORY_ROOT}/docker-compose.yml' - " - - [ "$status" -eq 0 ] - # Local image ref — no ghcr prefix. - [[ "$output" == *'image: disinto/agents:${DISINTO_IMAGE_TAG:-latest}'* ]] - [[ "$output" != *'image: ghcr.io/disinto/agents'* ]] - # build: fallback so hosts without a pre-built image can rebuild. - [[ "$output" == *'dockerfile: docker/agents/Dockerfile'* ]] -} - -@test "local-model agent service emits pull_policy: build so docker compose up rebuilds on source change (#887)" { - # Without pull_policy: build, `docker compose up -d --force-recreate` reuses - # the cached `disinto/agents:latest` image and silently runs stale - # docker/agents/entrypoint.sh even after the repo is updated. `pull_policy: - # build` forces a rebuild on every up; BuildKit layer cache makes unchanged - # rebuilds near-instant. The alternative was requiring every operator to - # remember `--build` on every invocation, which was the bug that prompted - # #887 (2h of debugging a fix that was merged but never reached the container). - cat > "${FACTORY_ROOT}/projects/test.toml" <<'EOF' -name = "test" -repo = "test-owner/test-repo" -forge_url = "http://localhost:3000" - -[agents.dev-qwen2] -base_url = "http://10.10.10.1:8081" -model = "qwen" -api_key = "sk-no-key-required" -roles = ["dev"] -forge_user = "dev-qwen2" -EOF - - run bash -c " - set -euo pipefail - source '${ROOT}/lib/generators.sh' - _generate_local_model_services '${FACTORY_ROOT}/docker-compose.yml' - cat '${FACTORY_ROOT}/docker-compose.yml' - " - - [ "$status" -eq 0 ] - [[ "$output" == *'pull_policy: build'* ]] -} - -@test "local-model agent service keys FORGE_BOT_USER to forge_user even when it differs from service name (#849)" { - # Exercise the case the issue calls out: two agents in the same factory - # whose service names are identical (`[agents.llama]`) but whose - # forge_users diverge would previously both have emitted - # `FORGE_BOT_USER_LLAMA`. With the fix each emission carries its own - # forge_user-derived suffix. - cat > "${FACTORY_ROOT}/projects/a.toml" <<'EOF' -name = "a" -repo = "a/a" -forge_url = "http://localhost:3000" - -[agents.dev] -base_url = "http://10.10.10.1:8081" -model = "qwen" -api_key = "sk-no-key-required" -roles = ["dev"] -forge_user = "review-qwen" -EOF - - run bash -c " - set -euo pipefail - source '${ROOT}/lib/generators.sh' - _generate_local_model_services '${FACTORY_ROOT}/docker-compose.yml' - cat '${FACTORY_ROOT}/docker-compose.yml' - " - - [ "$status" -eq 0 ] - [[ "$output" == *'FORGE_BOT_USER_REVIEW_QWEN: "review-qwen"'* ]] - [[ "$output" != *'FORGE_BOT_USER_DEV:'* ]] -} diff --git a/tests/lib-hvault.bats b/tests/lib-hvault.bats deleted file mode 100644 index 2d779dc..0000000 --- a/tests/lib-hvault.bats +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env bats -# tests/lib-hvault.bats — Unit tests for lib/hvault.sh -# -# Runs against a dev-mode Vault server (single binary, no LXC needed). -# CI launches vault server -dev inline before running these tests. - -VAULT_BIN="${VAULT_BIN:-vault}" - -setup_file() { - export TEST_DIR - TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" - - # Start dev-mode vault on a random port - export VAULT_DEV_PORT - VAULT_DEV_PORT="$(shuf -i 18200-18299 -n 1)" - export VAULT_ADDR="http://127.0.0.1:${VAULT_DEV_PORT}" - - "$VAULT_BIN" server -dev \ - -dev-listen-address="127.0.0.1:${VAULT_DEV_PORT}" \ - -dev-root-token-id="test-root-token" \ - -dev-no-store-token \ - &>"${BATS_FILE_TMPDIR}/vault.log" & - export VAULT_PID=$! - - export VAULT_TOKEN="test-root-token" - - # Wait for vault to be ready (up to 10s) - local i=0 - while ! curl -sf "${VAULT_ADDR}/v1/sys/health" >/dev/null 2>&1; do - sleep 0.5 - i=$((i + 1)) - if [ "$i" -ge 20 ]; then - echo "Vault failed to start. Log:" >&2 - cat "${BATS_FILE_TMPDIR}/vault.log" >&2 - return 1 - fi - done -} - -teardown_file() { - if [ -n "${VAULT_PID:-}" ]; then - kill "$VAULT_PID" 2>/dev/null || true - wait "$VAULT_PID" 2>/dev/null || true - fi -} - -setup() { - # Source the module under test - source "${TEST_DIR}/lib/hvault.sh" - export VAULT_ADDR VAULT_TOKEN -} - -# ── hvault_kv_put + hvault_kv_get ──────────────────────────────────────────── - -@test "hvault_kv_put writes and hvault_kv_get reads a secret" { - run hvault_kv_put "test/myapp" "username=admin" "password=s3cret" - [ "$status" -eq 0 ] - - run hvault_kv_get "test/myapp" - [ "$status" -eq 0 ] - echo "$output" | jq -e '.username == "admin"' - echo "$output" | jq -e '.password == "s3cret"' -} - -@test "hvault_kv_get extracts a single key" { - hvault_kv_put "test/single" "foo=bar" "baz=qux" - - run hvault_kv_get "test/single" "foo" - [ "$status" -eq 0 ] - [ "$output" = "bar" ] -} - -@test "hvault_kv_get fails for missing key" { - hvault_kv_put "test/keymiss" "exists=yes" - - run hvault_kv_get "test/keymiss" "nope" - [ "$status" -ne 0 ] -} - -@test "hvault_kv_get fails for missing path" { - run hvault_kv_get "test/does-not-exist-$(date +%s)" - [ "$status" -ne 0 ] -} - -@test "hvault_kv_put fails without KEY=VAL" { - run hvault_kv_put "test/bad" - [ "$status" -ne 0 ] - echo "$output" | grep -q '"error":true' || echo "$stderr" | grep -q '"error":true' -} - -@test "hvault_kv_put rejects malformed pair (no =)" { - run hvault_kv_put "test/bad2" "noequals" - [ "$status" -ne 0 ] -} - -@test "hvault_kv_get fails without PATH" { - run hvault_kv_get - [ "$status" -ne 0 ] -} - -# ── hvault_kv_list ─────────────────────────────────────────────────────────── - -@test "hvault_kv_list lists keys at a path" { - hvault_kv_put "test/listdir/a" "k=1" - hvault_kv_put "test/listdir/b" "k=2" - - run hvault_kv_list "test/listdir" - [ "$status" -eq 0 ] - echo "$output" | jq -e '. | length >= 2' - echo "$output" | jq -e 'index("a")' - echo "$output" | jq -e 'index("b")' -} - -@test "hvault_kv_list fails on nonexistent path" { - run hvault_kv_list "test/no-such-path-$(date +%s)" - [ "$status" -ne 0 ] -} - -@test "hvault_kv_list fails without PATH" { - run hvault_kv_list - [ "$status" -ne 0 ] -} - -# ── hvault_policy_apply ────────────────────────────────────────────────────── - -@test "hvault_policy_apply creates a policy" { - local pfile="${BATS_TEST_TMPDIR}/test-policy.hcl" - cat > "$pfile" <<'HCL' -path "kv/data/test/*" { - capabilities = ["read"] -} -HCL - - run hvault_policy_apply "test-reader" "$pfile" - [ "$status" -eq 0 ] - - # Verify the policy exists via Vault API - run curl -sf -H "X-Vault-Token: ${VAULT_TOKEN}" \ - "${VAULT_ADDR}/v1/sys/policies/acl/test-reader" - [ "$status" -eq 0 ] - echo "$output" | jq -e '.data.policy' | grep -q "kv/data/test" -} - -@test "hvault_policy_apply is idempotent" { - local pfile="${BATS_TEST_TMPDIR}/idem-policy.hcl" - printf 'path "kv/*" { capabilities = ["list"] }\n' > "$pfile" - - run hvault_policy_apply "idem-policy" "$pfile" - [ "$status" -eq 0 ] - - # Apply again — should succeed - run hvault_policy_apply "idem-policy" "$pfile" - [ "$status" -eq 0 ] -} - -@test "hvault_policy_apply fails with missing file" { - run hvault_policy_apply "bad-policy" "/nonexistent/policy.hcl" - [ "$status" -ne 0 ] -} - -@test "hvault_policy_apply fails without args" { - run hvault_policy_apply - [ "$status" -ne 0 ] -} - -# ── hvault_token_lookup ────────────────────────────────────────────────────── - -@test "hvault_token_lookup returns token info" { - run hvault_token_lookup - [ "$status" -eq 0 ] - echo "$output" | jq -e '.policies' - echo "$output" | jq -e '.accessor' - echo "$output" | jq -e 'has("ttl")' -} - -@test "hvault_token_lookup fails without VAULT_TOKEN" { - unset VAULT_TOKEN - run hvault_token_lookup - [ "$status" -ne 0 ] -} - -@test "hvault_token_lookup fails without VAULT_ADDR" { - unset VAULT_ADDR - run hvault_token_lookup - [ "$status" -ne 0 ] -} - -# ── hvault_jwt_login ───────────────────────────────────────────────────────── - -@test "hvault_jwt_login fails without VAULT_ADDR" { - unset VAULT_ADDR - run hvault_jwt_login "myrole" "fakejwt" - [ "$status" -ne 0 ] -} - -@test "hvault_jwt_login fails without args" { - run hvault_jwt_login - [ "$status" -ne 0 ] -} - -@test "hvault_jwt_login returns error for unconfigured jwt auth" { - # JWT auth backend is not enabled in dev mode by default — expect failure - run hvault_jwt_login "myrole" "eyJhbGciOiJSUzI1NiJ9.fake.sig" - [ "$status" -ne 0 ] -} - -# ── Env / prereq errors ───────────────────────────────────────────────────── - -@test "all functions fail with structured JSON error when VAULT_ADDR unset" { - unset VAULT_ADDR - for fn in hvault_kv_get hvault_kv_put hvault_kv_list hvault_policy_apply hvault_token_lookup; do - run $fn "dummy" "dummy" - [ "$status" -ne 0 ] - done -} diff --git a/tests/lib-issue-claim.bats b/tests/lib-issue-claim.bats deleted file mode 100644 index 85bcc83..0000000 --- a/tests/lib-issue-claim.bats +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env bats -# ============================================================================= -# tests/lib-issue-claim.bats — Regression guard for the issue_claim TOCTOU -# fix landed in #830. -# -# Before the fix, two dev agents polling concurrently could both observe -# `.assignee == null`, both PATCH the assignee, and Forgejo's last-write-wins -# semantics would leave the loser believing it had claimed successfully. -# Two agents would then implement the same issue and collide at the PR/branch -# stage. -# -# The fix re-reads the assignee after the PATCH and aborts when it doesn't -# match self, with label writes moved AFTER the verification so a losing -# claim leaves no stray `in-progress` label. -# -# These tests stub `curl` with a bash function so each call tree can be -# driven through a specific response sequence (pre-check, PATCH, re-read) -# without a live Forgejo. The stub records every HTTP call to -# `$CALLS_LOG` for assertions. -# ============================================================================= - -setup() { - ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" - export FACTORY_ROOT="$ROOT" - export FORGE_TOKEN="dummy-token" - export FORGE_URL="https://forge.example.test" - export FORGE_API="${FORGE_URL}/api/v1" - - export CALLS_LOG="${BATS_TEST_TMPDIR}/curl-calls.log" - : > "$CALLS_LOG" - export ISSUE_GET_COUNT_FILE="${BATS_TEST_TMPDIR}/issue-get-count" - echo 0 > "$ISSUE_GET_COUNT_FILE" - - # Scenario knobs — overridden per @test. - export MOCK_ME="bot" - export MOCK_INITIAL_ASSIGNEE="" - export MOCK_RECHECK_ASSIGNEE="bot" - - # Stand-in for lib/env.sh's forge_api (we don't source env.sh — too - # much unrelated setup). Shape mirrors the real helper closely enough - # that _ilc_ensure_label_id() works. - forge_api() { - local method="$1" path="$2" - shift 2 - curl -sf -X "$method" \ - -H "Authorization: token ${FORGE_TOKEN}" \ - -H "Content-Type: application/json" \ - "${FORGE_API}${path}" "$@" - } - - # curl shim — parses method + URL out of the argv and dispatches - # canned responses per endpoint. Every call gets logged as - # `METHOD URL` (one line) to $CALLS_LOG for later grep-based asserts. - curl() { - local method="GET" url="" arg want_code="" - while [ $# -gt 0 ]; do - arg="$1" - case "$arg" in - -X) method="$2"; shift 2 ;; - -H|-d|--data-binary|-o) shift 2 ;; - -w) want_code="$2"; shift 2 ;; - -sf|-s|-f|--silent|--fail) shift ;; - *) url="$arg"; shift ;; - esac - done - printf '%s %s\n' "$method" "$url" >> "$CALLS_LOG" - - case "$method $url" in - "GET ${FORGE_URL}/api/v1/user") - printf '{"login":"%s"}' "$MOCK_ME" - ;; - "GET ${FORGE_API}/issues/"*) - # Distinguish pre-check (first GET) from re-read (subsequent GETs) - # via a counter file that persists across curl invocations in the - # same test. - local n - n=$(cat "$ISSUE_GET_COUNT_FILE") - n=$((n + 1)) - echo "$n" > "$ISSUE_GET_COUNT_FILE" - local who - if [ "$n" -eq 1 ]; then - who="$MOCK_INITIAL_ASSIGNEE" - else - who="$MOCK_RECHECK_ASSIGNEE" - fi - if [ -z "$who" ]; then - printf '{"assignee":null}' - else - printf '{"assignee":{"login":"%s"}}' "$who" - fi - ;; - "PATCH ${FORGE_API}/issues/"*) - # Accept any PATCH; body ignored. When caller asked for the HTTP - # status via `-w '%{http_code}'` (issue_claim does this since #856 - # to surface 403s from missing collaborator permission), emit the - # code configured by the scenario (default 200). - if [ "$want_code" = '%{http_code}' ]; then - printf '%s' "${MOCK_PATCH_CODE:-200}" - fi - ;; - "GET ${FORGE_API}/labels") - printf '[]' - ;; - "POST ${FORGE_API}/labels") - printf '{"id":99}' - ;; - "POST ${FORGE_API}/issues/"*"/labels") - : - ;; - "DELETE ${FORGE_API}/issues/"*"/labels/"*) - : - ;; - *) - return 1 - ;; - esac - return 0 - } - - # shellcheck source=../lib/issue-lifecycle.sh - source "${ROOT}/lib/issue-lifecycle.sh" -} - -# ── helpers ────────────────────────────────────────────────────────────────── - -# count_calls METHOD URL — count matching lines in $CALLS_LOG. -count_calls() { - local method="$1" url="$2" - grep -cF "${method} ${url}" "$CALLS_LOG" 2>/dev/null || echo 0 -} - -# ── happy path ─────────────────────────────────────────────────────────────── - -@test "issue_claim returns 0 when re-read confirms self (no regression, single agent)" { - export MOCK_ME="bot" - export MOCK_INITIAL_ASSIGNEE="" - export MOCK_RECHECK_ASSIGNEE="bot" - - run issue_claim 42 - [ "$status" -eq 0 ] - - # Exactly two GETs to /issues/42 — pre-check and post-PATCH re-read. - [ "$(count_calls GET "${FORGE_API}/issues/42")" -eq 2 ] - - # Assignee PATCH fired. - [ "$(count_calls PATCH "${FORGE_API}/issues/42")" -eq 1 ] - - # in-progress label added (POST /issues/42/labels). - [ "$(count_calls POST "${FORGE_API}/issues/42/labels")" -eq 1 ] -} - -# ── lost race ──────────────────────────────────────────────────────────────── - -@test "issue_claim returns 1 and leaves no stray in-progress when re-read shows another agent" { - export MOCK_ME="bot" - export MOCK_INITIAL_ASSIGNEE="" - export MOCK_RECHECK_ASSIGNEE="rival" - - run issue_claim 42 - [ "$status" -eq 1 ] - [[ "$output" == *"claim lost to rival"* ]] - - # Re-read happened (two GETs) — this is the new verification step. - [ "$(count_calls GET "${FORGE_API}/issues/42")" -eq 2 ] - - # PATCH happened (losers still PATCH before verifying). - [ "$(count_calls PATCH "${FORGE_API}/issues/42")" -eq 1 ] - - # CRITICAL: no in-progress label operations on a lost claim. - # (No need to roll back what was never written.) - [ "$(count_calls POST "${FORGE_API}/issues/42/labels")" -eq 0 ] - [ "$(count_calls GET "${FORGE_API}/labels")" -eq 0 ] -} - -# ── PATCH HTTP error surfacing (#856) ─────────────────────────────────────── - -@test "issue_claim logs specific HTTP code on PATCH failure (403 = missing collaborator)" { - export MOCK_ME="bot" - export MOCK_INITIAL_ASSIGNEE="" - export MOCK_RECHECK_ASSIGNEE="" - export MOCK_PATCH_CODE="403" - - run issue_claim 42 - [ "$status" -eq 1 ] - - # The new log message names the HTTP code explicitly — without this, - # a missing-collaborator setup (#856) falls through to the post-PATCH - # verify and masquerades as "claim lost to <none>". - [[ "$output" == *"PATCH assignee failed: HTTP 403"* ]] - - # No re-read on PATCH failure (we bail before reaching the verify step). - [ "$(count_calls GET "${FORGE_API}/issues/42")" -eq 1 ] - [ "$(count_calls PATCH "${FORGE_API}/issues/42")" -eq 1 ] - [ "$(count_calls POST "${FORGE_API}/issues/42/labels")" -eq 0 ] -} - -# ── pre-check skip ────────────────────────────────────────────────────────── - -@test "issue_claim skips early (no PATCH) when pre-check shows another assignee" { - export MOCK_ME="bot" - export MOCK_INITIAL_ASSIGNEE="rival" - export MOCK_RECHECK_ASSIGNEE="rival" - - run issue_claim 42 - [ "$status" -eq 1 ] - [[ "$output" == *"already assigned to rival"* ]] - - # Only the pre-check GET — no PATCH, no re-read, no labels. - [ "$(count_calls GET "${FORGE_API}/issues/42")" -eq 1 ] - [ "$(count_calls PATCH "${FORGE_API}/issues/42")" -eq 0 ] - [ "$(count_calls POST "${FORGE_API}/issues/42/labels")" -eq 0 ] -} diff --git a/tests/lib-load-project.bats b/tests/lib-load-project.bats deleted file mode 100644 index f0c583a..0000000 --- a/tests/lib-load-project.bats +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env bats -# ============================================================================= -# tests/lib-load-project.bats — Regression guard for the #862 fix. -# -# TOML allows dashes in bare keys, so `[agents.dev-qwen2]` is a valid section -# header. Before #862, load-project.sh translated the section name into a -# shell variable name via Python's `.upper()` alone, which kept the dash and -# produced `AGENT_DEV-QWEN2_BASE_URL`. `export "AGENT_DEV-QWEN2_..."` is -# rejected by bash ("not a valid identifier"), and with `set -euo pipefail` -# anywhere up-stack that error aborts load-project.sh — effectively crashing -# the factory on the N+1 run after a dashed agent was hired. -# -# The fix normalizes via `.upper().replace('-', '_')`, matching the -# `tr 'a-z-' 'A-Z_'` convention already used in hire-agent.sh and -# generators.sh. -# ============================================================================= - -setup() { - ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" - TOML="${BATS_TEST_TMPDIR}/test.toml" -} - -@test "dashed [agents.*] section name parses without error" { - cat > "$TOML" <<EOF -name = "test" -repo = "test-owner/test-repo" -forge_url = "http://localhost:3000" - -[agents.dev-qwen2] -base_url = "http://10.10.10.1:8081" -model = "unsloth/Qwen3.5-35B-A3B" -api_key = "sk-no-key-required" -roles = ["dev"] -forge_user = "dev-qwen2" -compact_pct = 60 -EOF - - run bash -c " - set -euo pipefail - source '${ROOT}/lib/load-project.sh' '$TOML' - echo \"BASE=\${AGENT_DEV_QWEN2_BASE_URL:-MISSING}\" - echo \"MODEL=\${AGENT_DEV_QWEN2_MODEL:-MISSING}\" - echo \"ROLES=\${AGENT_DEV_QWEN2_ROLES:-MISSING}\" - echo \"FORGE_USER=\${AGENT_DEV_QWEN2_FORGE_USER:-MISSING}\" - echo \"COMPACT=\${AGENT_DEV_QWEN2_COMPACT_PCT:-MISSING}\" - " - - [ "$status" -eq 0 ] - [[ "$output" == *"BASE=http://10.10.10.1:8081"* ]] - [[ "$output" == *"MODEL=unsloth/Qwen3.5-35B-A3B"* ]] - [[ "$output" == *"ROLES=dev"* ]] - [[ "$output" == *"FORGE_USER=dev-qwen2"* ]] - [[ "$output" == *"COMPACT=60"* ]] -} - -@test "dashless [agents.*] section name still works" { - cat > "$TOML" <<EOF -name = "test" -repo = "test-owner/test-repo" -forge_url = "http://localhost:3000" - -[agents.llama] -base_url = "http://10.10.10.1:8081" -model = "qwen" -api_key = "sk-no-key-required" -roles = ["dev"] -forge_user = "dev-llama" -compact_pct = 60 -EOF - - run bash -c " - set -euo pipefail - source '${ROOT}/lib/load-project.sh' '$TOML' - echo \"BASE=\${AGENT_LLAMA_BASE_URL:-MISSING}\" - echo \"MODEL=\${AGENT_LLAMA_MODEL:-MISSING}\" - " - - [ "$status" -eq 0 ] - [[ "$output" == *"BASE=http://10.10.10.1:8081"* ]] - [[ "$output" == *"MODEL=qwen"* ]] -} - -@test "multiple dashes in [agents.*] name all normalized" { - cat > "$TOML" <<EOF -name = "test" -repo = "test-owner/test-repo" -forge_url = "http://localhost:3000" - -[agents.review-qwen-3b] -base_url = "http://10.10.10.1:8082" -model = "qwen-3b" -api_key = "sk-no-key-required" -roles = ["review"] -forge_user = "review-qwen-3b" -compact_pct = 60 -EOF - - run bash -c " - set -euo pipefail - source '${ROOT}/lib/load-project.sh' '$TOML' - echo \"BASE=\${AGENT_REVIEW_QWEN_3B_BASE_URL:-MISSING}\" - " - - [ "$status" -eq 0 ] - [[ "$output" == *"BASE=http://10.10.10.1:8082"* ]] -} - -@test "hire-agent rejects dash-starting agent name" { - run bash -c " - FACTORY_ROOT='${ROOT}' \ - FORGE_URL='http://127.0.0.1:1' \ - FORGE_TOKEN=x \ - bash -c ' - set -euo pipefail - source \"\${FACTORY_ROOT}/lib/hire-agent.sh\" - disinto_hire_an_agent -foo dev - ' - " - - [ "$status" -ne 0 ] - [[ "$output" == *"invalid agent name"* ]] -} - -@test "hire-agent rejects uppercase agent name" { - run bash -c " - FACTORY_ROOT='${ROOT}' \ - FORGE_URL='http://127.0.0.1:1' \ - FORGE_TOKEN=x \ - bash -c ' - set -euo pipefail - source \"\${FACTORY_ROOT}/lib/hire-agent.sh\" - disinto_hire_an_agent DevQwen dev - ' - " - - [ "$status" -ne 0 ] - [[ "$output" == *"invalid agent name"* ]] -} - -@test "hire-agent rejects underscore agent name" { - run bash -c " - FACTORY_ROOT='${ROOT}' \ - FORGE_URL='http://127.0.0.1:1' \ - FORGE_TOKEN=x \ - bash -c ' - set -euo pipefail - source \"\${FACTORY_ROOT}/lib/hire-agent.sh\" - disinto_hire_an_agent dev_qwen dev - ' - " - - [ "$status" -ne 0 ] - [[ "$output" == *"invalid agent name"* ]] -} - -@test "hire-agent rejects trailing dash agent name" { - run bash -c " - FACTORY_ROOT='${ROOT}' \ - FORGE_URL='http://127.0.0.1:1' \ - FORGE_TOKEN=x \ - bash -c ' - set -euo pipefail - source \"\${FACTORY_ROOT}/lib/hire-agent.sh\" - disinto_hire_an_agent dev- dev - ' - " - - [ "$status" -ne 0 ] - [[ "$output" == *"invalid agent name"* ]] -} - -@test "hire-agent rejects consecutive-dash agent name" { - run bash -c " - FACTORY_ROOT='${ROOT}' \ - FORGE_URL='http://127.0.0.1:1' \ - FORGE_TOKEN=x \ - bash -c ' - set -euo pipefail - source \"\${FACTORY_ROOT}/lib/hire-agent.sh\" - disinto_hire_an_agent dev--qwen dev - ' - " - - [ "$status" -ne 0 ] - [[ "$output" == *"invalid agent name"* ]] -} - -# ------------------------------------------------------------------------- -# #852 defence: the export loops must warn-and-skip invalid identifiers -# rather than tank `set -euo pipefail`. Hire-agent's up-front reject -# (tests above) is the primary line of defence, but a hand-edited TOML — -# e.g. [mirrors] my-mirror = "…" or a quoted [agents."weird name"] — can -# still produce invalid shell identifiers downstream. The guard keeps -# the factory loading the rest of the file instead of crash-looping. -# ------------------------------------------------------------------------- - -@test "[mirrors] dashed key: warn-and-skip, does not crash under set -e" { - cat > "$TOML" <<EOF -name = "test" -repo = "test-owner/test-repo" -forge_url = "http://localhost:3000" - -[mirrors] -good = "https://example.com/good" -bad-name = "https://example.com/bad" -EOF - - run bash -c " - set -euo pipefail - source '${ROOT}/lib/load-project.sh' '$TOML' 2>&1 - echo \"GOOD=\${MIRROR_GOOD:-MISSING}\" - " - - # Whole load did not abort under set -e. - [ "$status" -eq 0 ] - # The valid mirror still loads. - [[ "$output" == *"GOOD=https://example.com/good"* ]] - # The invalid one triggers a warning; load continues instead of crashing. - [[ "$output" == *"skipping invalid shell identifier"* ]] - [[ "$output" == *"MIRROR_BAD-NAME"* ]] -} - -@test "[agents.*] quoted section with space: warn-and-skip, does not crash" { - # TOML permits quoted keys with arbitrary characters. A hand-edited - # `[agents."weird name"]` would survive the Python .replace('-', '_') - # (because it has no dash) but still contains a space, which would - # yield AGENT_WEIRD NAME_BASE_URL — not a valid identifier. - cat > "$TOML" <<'EOF' -name = "test" -repo = "test-owner/test-repo" -forge_url = "http://localhost:3000" - -[agents.llama] -base_url = "http://10.10.10.1:8081" -model = "qwen" - -[agents."weird name"] -base_url = "http://10.10.10.1:8082" -model = "qwen-bad" -EOF - - run bash -c " - set -euo pipefail - source '${ROOT}/lib/load-project.sh' '$TOML' 2>&1 - echo \"LLAMA=\${AGENT_LLAMA_BASE_URL:-MISSING}\" - " - - # The sane sibling must still be loaded despite the malformed neighbour. - [ "$status" -eq 0 ] - [[ "$output" == *"LLAMA=http://10.10.10.1:8081"* ]] - # The invalid agent's identifier triggers a warning and is skipped. - [[ "$output" == *"skipping invalid shell identifier"* ]] -} diff --git a/tests/smoke-init.sh b/tests/smoke-init.sh index 306f7ee..e8cd245 100644 --- a/tests/smoke-init.sh +++ b/tests/smoke-init.sh @@ -29,8 +29,7 @@ cleanup() { pkill -f "mock-forgejo.py" 2>/dev/null || true rm -rf "$MOCK_BIN" /tmp/smoke-test-repo \ "${FACTORY_ROOT}/projects/smoke-repo.toml" \ - /tmp/smoke-claude-shared /tmp/smoke-home-claude \ - /tmp/smoke-env-before-rerun /tmp/smoke-env-before-dryrun + /tmp/smoke-claude-shared /tmp/smoke-home-claude # Restore .env only if we created the backup if [ -f "${FACTORY_ROOT}/.env.smoke-backup" ]; then mv "${FACTORY_ROOT}/.env.smoke-backup" "${FACTORY_ROOT}/.env" @@ -179,30 +178,8 @@ else fail "disinto init exited non-zero" fi -# ── Dry-run test: must not modify state ──────────────────────────────────── -echo "=== Dry-run test ===" -cp "${FACTORY_ROOT}/.env" /tmp/smoke-env-before-dryrun -if bash "${FACTORY_ROOT}/bin/disinto" init \ - "${TEST_SLUG}" \ - --bare --yes --dry-run \ - --forge-url "$FORGE_URL" \ - --repo-root "/tmp/smoke-test-repo" 2>&1 | grep -q "Dry run complete"; then - pass "disinto init --dry-run exited successfully" -else - fail "disinto init --dry-run did not complete" -fi - -# Verify --dry-run did not modify .env -if diff -q /tmp/smoke-env-before-dryrun "${FACTORY_ROOT}/.env" >/dev/null 2>&1; then - pass "dry-run: .env unchanged" -else - fail "dry-run: .env was modified (should be read-only)" -fi -rm -f /tmp/smoke-env-before-dryrun - -# ── Idempotency test: run init again, verify .env is stable ──────────────── +# ── Idempotency test: run init again ─────────────────────────────────────── echo "=== Idempotency test: running disinto init again ===" -cp "${FACTORY_ROOT}/.env" /tmp/smoke-env-before-rerun if bash "${FACTORY_ROOT}/bin/disinto" init \ "${TEST_SLUG}" \ --bare --yes \ @@ -213,29 +190,6 @@ else fail "disinto init (re-run) exited non-zero" fi -# Verify .env is stable across re-runs (no token churn) -if diff -q /tmp/smoke-env-before-rerun "${FACTORY_ROOT}/.env" >/dev/null 2>&1; then - pass "idempotency: .env unchanged on re-run" -else - fail "idempotency: .env changed on re-run (token churn detected)" - diff /tmp/smoke-env-before-rerun "${FACTORY_ROOT}/.env" >&2 || true -fi -rm -f /tmp/smoke-env-before-rerun - -# Verify FORGE_ADMIN_TOKEN is stored in .env -if grep -q '^FORGE_ADMIN_TOKEN=' "${FACTORY_ROOT}/.env"; then - pass ".env contains FORGE_ADMIN_TOKEN" -else - fail ".env missing FORGE_ADMIN_TOKEN" -fi - -# Verify HUMAN_TOKEN is stored in .env -if grep -q '^HUMAN_TOKEN=' "${FACTORY_ROOT}/.env"; then - pass ".env contains HUMAN_TOKEN" -else - fail ".env missing HUMAN_TOKEN" -fi - # ── 4. Verify Forgejo state ───────────────────────────────────────────────── echo "=== 4/6 Verifying Forgejo state ===" diff --git a/tests/smoke-load-secret.sh b/tests/smoke-load-secret.sh deleted file mode 100644 index 2c409fa..0000000 --- a/tests/smoke-load-secret.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env bash -# tests/smoke-load-secret.sh — Unit tests for load_secret() precedence chain -# -# Covers the 4 precedence cases: -# 1. /secrets/<NAME>.env (Nomad template) -# 2. Current environment -# 3. secrets/<NAME>.enc (age-encrypted per-key file) -# 4. Default / empty fallback -# -# Required tools: bash, age (for case 3) - -set -euo pipefail - -FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" - -fail() { printf 'FAIL: %s\n' "$*" >&2; FAILED=1; } -pass() { printf 'PASS: %s\n' "$*"; } -FAILED=0 - -# Set up a temp workspace and fake HOME so age key paths work -test_dir=$(mktemp -d) -fake_home=$(mktemp -d) -trap 'rm -rf "$test_dir" "$fake_home"' EXIT - -# Minimal env for sourcing env.sh's load_secret function without the full boot -# We source the function definition directly to isolate the unit under test. -# shellcheck disable=SC2034 -export USER="${USER:-test}" -export HOME="$fake_home" - -# Source env.sh to get load_secret (and FACTORY_ROOT) -source "${FACTORY_ROOT}/lib/env.sh" - -# ── Case 4: Default / empty fallback ──────────────────────────────────────── -echo "=== 1/5 Case 4: default fallback ===" - -unset TEST_SECRET_FALLBACK 2>/dev/null || true -val=$(load_secret TEST_SECRET_FALLBACK "my-default") -if [ "$val" = "my-default" ]; then - pass "load_secret returns default when nothing is set" -else - fail "Expected 'my-default', got '${val}'" -fi - -val=$(load_secret TEST_SECRET_FALLBACK) -if [ -z "$val" ]; then - pass "load_secret returns empty when no default and nothing set" -else - fail "Expected empty, got '${val}'" -fi - -# ── Case 2: Environment variable already set ──────────────────────────────── -echo "=== 2/5 Case 2: environment variable ===" - -export TEST_SECRET_ENV="from-environment" -val=$(load_secret TEST_SECRET_ENV "ignored-default") -if [ "$val" = "from-environment" ]; then - pass "load_secret returns env value over default" -else - fail "Expected 'from-environment', got '${val}'" -fi -unset TEST_SECRET_ENV - -# ── Case 3: Age-encrypted per-key file ────────────────────────────────────── -echo "=== 3/5 Case 3: age-encrypted secret ===" - -if command -v age &>/dev/null && command -v age-keygen &>/dev/null; then - # Generate a test age key - age_key_dir="${fake_home}/.config/sops/age" - mkdir -p "$age_key_dir" - age-keygen -o "${age_key_dir}/keys.txt" 2>/dev/null - pub_key=$(age-keygen -y "${age_key_dir}/keys.txt") - - # Create encrypted secret - secrets_dir="${FACTORY_ROOT}/secrets" - mkdir -p "$secrets_dir" - printf 'age-test-value' | age -r "$pub_key" -o "${secrets_dir}/TEST_SECRET_AGE.enc" - - unset TEST_SECRET_AGE 2>/dev/null || true - val=$(load_secret TEST_SECRET_AGE "fallback") - if [ "$val" = "age-test-value" ]; then - pass "load_secret decrypts age-encrypted secret" - else - fail "Expected 'age-test-value', got '${val}'" - fi - - # Verify caching: call load_secret directly (not in subshell) so export propagates - unset TEST_SECRET_AGE 2>/dev/null || true - load_secret TEST_SECRET_AGE >/dev/null - if [ "${TEST_SECRET_AGE:-}" = "age-test-value" ]; then - pass "load_secret caches decrypted value in environment (direct call)" - else - fail "Decrypted value not cached in environment" - fi - - # Clean up test secret - rm -f "${secrets_dir}/TEST_SECRET_AGE.enc" - rmdir "$secrets_dir" 2>/dev/null || true - unset TEST_SECRET_AGE -else - echo "SKIP: age/age-keygen not found — skipping age decryption test" -fi - -# ── Case 1: Nomad template path ──────────────────────────────────────────── -echo "=== 4/5 Case 1: Nomad template (/secrets/<NAME>.env) ===" - -nomad_dir="/secrets" -if [ -w "$(dirname "$nomad_dir")" ] 2>/dev/null || [ -w "$nomad_dir" ] 2>/dev/null; then - mkdir -p "$nomad_dir" - printf 'TEST_SECRET_NOMAD=from-nomad-template\n' > "${nomad_dir}/TEST_SECRET_NOMAD.env" - - # Even with env set, Nomad path takes precedence - export TEST_SECRET_NOMAD="from-env-should-lose" - val=$(load_secret TEST_SECRET_NOMAD "default") - if [ "$val" = "from-nomad-template" ]; then - pass "load_secret prefers Nomad template over env" - else - fail "Expected 'from-nomad-template', got '${val}'" - fi - - rm -f "${nomad_dir}/TEST_SECRET_NOMAD.env" - rmdir "$nomad_dir" 2>/dev/null || true - unset TEST_SECRET_NOMAD -else - echo "SKIP: /secrets not writable — skipping Nomad template test (needs root or container)" -fi - -# ── Precedence: env beats age ──────────────────────────────────────────── -echo "=== 5/5 Precedence: env beats age-encrypted ===" - -if command -v age &>/dev/null && command -v age-keygen &>/dev/null; then - age_key_dir="${fake_home}/.config/sops/age" - mkdir -p "$age_key_dir" - [ -f "${age_key_dir}/keys.txt" ] || age-keygen -o "${age_key_dir}/keys.txt" 2>/dev/null - pub_key=$(age-keygen -y "${age_key_dir}/keys.txt") - - secrets_dir="${FACTORY_ROOT}/secrets" - mkdir -p "$secrets_dir" - printf 'age-value-should-lose' | age -r "$pub_key" -o "${secrets_dir}/TEST_SECRET_PREC.enc" - - export TEST_SECRET_PREC="env-value-wins" - val=$(load_secret TEST_SECRET_PREC "default") - if [ "$val" = "env-value-wins" ]; then - pass "load_secret prefers env over age-encrypted file" - else - fail "Expected 'env-value-wins', got '${val}'" - fi - - rm -f "${secrets_dir}/TEST_SECRET_PREC.enc" - rmdir "$secrets_dir" 2>/dev/null || true - unset TEST_SECRET_PREC -else - echo "SKIP: age not found — skipping precedence test" -fi - -# ── Summary ─────────────────────────────────────────────────────────────── -echo "" -if [ "$FAILED" -ne 0 ]; then - echo "=== SMOKE-LOAD-SECRET TEST FAILED ===" - exit 1 -fi -echo "=== SMOKE-LOAD-SECRET TEST PASSED ===" diff --git a/tests/vault-import.bats b/tests/vault-import.bats deleted file mode 100644 index e59e92e..0000000 --- a/tests/vault-import.bats +++ /dev/null @@ -1,363 +0,0 @@ -#!/usr/bin/env bats -# tests/vault-import.bats — Tests for tools/vault-import.sh -# -# Runs against a dev-mode Vault server (single binary, no LXC needed). -# CI launches vault server -dev inline before running these tests. - -VAULT_BIN="${VAULT_BIN:-vault}" -IMPORT_SCRIPT="${BATS_TEST_DIRNAME}/../tools/vault-import.sh" -FIXTURES_DIR="${BATS_TEST_DIRNAME}/fixtures" - -setup_file() { - # Start dev-mode vault on a random port - export VAULT_DEV_PORT - VAULT_DEV_PORT="$(shuf -i 18200-18299 -n 1)" - export VAULT_ADDR="http://127.0.0.1:${VAULT_DEV_PORT}" - - "$VAULT_BIN" server -dev \ - -dev-listen-address="127.0.0.1:${VAULT_DEV_PORT}" \ - -dev-root-token-id="test-root-token" \ - -dev-no-store-token \ - &>"${BATS_FILE_TMPDIR}/vault.log" & - export VAULT_PID=$! - - export VAULT_TOKEN="test-root-token" - - # Wait for vault to be ready (up to 10s) - local i=0 - while ! curl -sf "${VAULT_ADDR}/v1/sys/health" >/dev/null 2>&1; do - sleep 0.5 - i=$((i + 1)) - if [ "$i" -ge 20 ]; then - echo "Vault failed to start. Log:" >&2 - cat "${BATS_FILE_TMPDIR}/vault.log" >&2 - return 1 - fi - done - - # Enable kv-v2 at path=kv (production mount per S2 migration). Dev-mode - # vault only auto-mounts kv-v2 at secret/; tests must mirror the real - # cluster layout so vault-import.sh writes land where we read them. - curl -sf -H "X-Vault-Token: test-root-token" \ - -X POST -d '{"type":"kv","options":{"version":"2"}}' \ - "${VAULT_ADDR}/v1/sys/mounts/kv" >/dev/null -} - -teardown_file() { - if [ -n "${VAULT_PID:-}" ]; then - kill "$VAULT_PID" 2>/dev/null || true - wait "$VAULT_PID" 2>/dev/null || true - fi -} - -setup() { - # Source the module under test for hvault functions - source "${BATS_TEST_DIRNAME}/../lib/hvault.sh" - export VAULT_ADDR VAULT_TOKEN -} - -# --- Security checks --- - -@test "refuses to run if VAULT_ADDR is not localhost" { - export VAULT_ADDR="http://prod-vault.example.com:8200" - run "$IMPORT_SCRIPT" \ - --env "$FIXTURES_DIR/dot-env-complete" \ - --sops "$FIXTURES_DIR/.env.vault.enc" \ - --age-key "$FIXTURES_DIR/age-keys.txt" - [ "$status" -ne 0 ] - echo "$output" | grep -q "Security check failed" -} - -@test "refuses if age key file permissions are not 0400" { - # Create a temp file with wrong permissions - local bad_key="${BATS_TEST_TMPDIR}/bad-ages.txt" - echo "AGE-SECRET-KEY-1TEST" > "$bad_key" - chmod 644 "$bad_key" - - run "$IMPORT_SCRIPT" \ - --env "$FIXTURES_DIR/dot-env-complete" \ - --sops "$FIXTURES_DIR/.env.vault.enc" \ - --age-key "$bad_key" - [ "$status" -ne 0 ] - echo "$output" | grep -q "permissions" -} - -# --- Dry-run mode ───────────────────────────────────────────────────────────── - -@test "--dry-run prints plan without writing to Vault" { - run "$IMPORT_SCRIPT" \ - --env "$FIXTURES_DIR/dot-env-complete" \ - --sops "$FIXTURES_DIR/.env.vault.enc" \ - --age-key "$FIXTURES_DIR/age-keys.txt" \ - --dry-run - [ "$status" -eq 0 ] - echo "$output" | grep -q "DRY-RUN" - echo "$output" | grep -q "Import plan" - echo "$output" | grep -q "Planned operations" - - # Verify nothing was written to Vault - run curl -sf -H "X-Vault-Token: ${VAULT_TOKEN}" \ - "${VAULT_ADDR}/v1/kv/data/disinto/bots/review" - [ "$status" -ne 0 ] -} - -# --- Complete fixture import ───────────────────────────────────────────────── - -@test "imports all keys from complete fixture" { - run "$IMPORT_SCRIPT" \ - --env "$FIXTURES_DIR/dot-env-complete" \ - --sops "$FIXTURES_DIR/.env.vault.enc" \ - --age-key "$FIXTURES_DIR/age-keys.txt" - [ "$status" -eq 0 ] - - # Check bots/review - run curl -sf -H "X-Vault-Token: ${VAULT_TOKEN}" \ - "${VAULT_ADDR}/v1/kv/data/disinto/bots/review" - [ "$status" -eq 0 ] - echo "$output" | grep -q "review-token" - echo "$output" | grep -q "review-pass" - - # Check bots/dev-qwen - run curl -sf -H "X-Vault-Token: ${VAULT_TOKEN}" \ - "${VAULT_ADDR}/v1/kv/data/disinto/bots/dev-qwen" - [ "$status" -eq 0 ] - echo "$output" | grep -q "llama-token" - echo "$output" | grep -q "llama-pass" - - # Check forge - run curl -sf -H "X-Vault-Token: ${VAULT_TOKEN}" \ - "${VAULT_ADDR}/v1/kv/data/disinto/shared/forge" - [ "$status" -eq 0 ] - echo "$output" | grep -q "generic-forge-token" - echo "$output" | grep -q "generic-forge-pass" - echo "$output" | grep -q "generic-admin-token" - - # Check woodpecker - run curl -sf -H "X-Vault-Token: ${VAULT_TOKEN}" \ - "${VAULT_ADDR}/v1/kv/data/disinto/shared/woodpecker" - [ "$status" -eq 0 ] - echo "$output" | grep -q "wp-agent-secret" - # Forgejo keys are normalized: WP_FORGEJO_* → forgejo_* (no wp_ prefix in key name) - echo "$output" | grep -q "wp-forgejo-client" - echo "$output" | grep -q "wp-forgejo-secret" - echo "$output" | grep -q "wp-token" - - # Check chat - run curl -sf -H "X-Vault-Token: ${VAULT_TOKEN}" \ - "${VAULT_ADDR}/v1/kv/data/disinto/shared/chat" - [ "$status" -eq 0 ] - echo "$output" | grep -q "forward-auth-secret" - echo "$output" | grep -q "chat-client-id" - echo "$output" | grep -q "chat-client-secret" - - # Check runner tokens from sops - run curl -sf -H "X-Vault-Token: ${VAULT_TOKEN}" \ - "${VAULT_ADDR}/v1/kv/data/disinto/runner/GITHUB_TOKEN" - [ "$status" -eq 0 ] - echo "$output" | jq -e '.data.data.value == "github-test-token-abc123"' -} - -# --- Idempotency ────────────────────────────────────────────────────────────── - -@test "re-run with unchanged fixtures reports all unchanged" { - # First run - run "$IMPORT_SCRIPT" \ - --env "$FIXTURES_DIR/dot-env-complete" \ - --sops "$FIXTURES_DIR/.env.vault.enc" \ - --age-key "$FIXTURES_DIR/age-keys.txt" - [ "$status" -eq 0 ] - - # Second run - should report unchanged - run "$IMPORT_SCRIPT" \ - --env "$FIXTURES_DIR/dot-env-complete" \ - --sops "$FIXTURES_DIR/.env.vault.enc" \ - --age-key "$FIXTURES_DIR/age-keys.txt" - [ "$status" -eq 0 ] - - # Check that all keys report unchanged - echo "$output" | grep -q "unchanged" - # Count unchanged occurrences (should be many) - local unchanged_count - unchanged_count=$(echo "$output" | grep -c "unchanged" || true) - [ "$unchanged_count" -gt 10 ] -} - -@test "re-run with modified value reports only that key as updated" { - # Create a modified fixture - local modified_env="${BATS_TEST_TMPDIR}/dot-env-modified" - cp "$FIXTURES_DIR/dot-env-complete" "$modified_env" - - # Modify one value - sed -i 's/llama-token/MODIFIED-LLAMA-TOKEN/' "$modified_env" - - # Run with modified fixture - run "$IMPORT_SCRIPT" \ - --env "$modified_env" \ - --sops "$FIXTURES_DIR/.env.vault.enc" \ - --age-key "$FIXTURES_DIR/age-keys.txt" - [ "$status" -eq 0 ] - - # Check that dev-qwen token was updated - echo "$output" | grep -q "dev-qwen.*updated" - - # Verify the new value was written (path is disinto/bots/dev-qwen, key is token) - run curl -sf -H "X-Vault-Token: ${VAULT_TOKEN}" \ - "${VAULT_ADDR}/v1/kv/data/disinto/bots/dev-qwen" - [ "$status" -eq 0 ] - echo "$output" | jq -e '.data.data.token == "MODIFIED-LLAMA-TOKEN"' -} - -# --- Delimiter-in-value regression (#898) ──────────────────────────────────── - -@test "preserves secret values that contain a pipe character" { - # Regression: previous accumulator packed values into "value|status" and - # joined per-path kv pairs with '|', so any value containing '|' was - # silently truncated or misrouted. - local piped_env="${BATS_TEST_TMPDIR}/dot-env-piped" - cp "$FIXTURES_DIR/dot-env-complete" "$piped_env" - - # Swap in values that contain the old delimiter. Exercise both: - # - a paired bot path (token + pass on same vault path, hitting the - # per-path kv-pair join) - # - a single-key path (admin token) - # Values are single-quoted so they survive `source` of the .env file; - # `|` is a shell metachar and unquoted would start a pipeline. That is - # orthogonal to the accumulator bug under test — users are expected to - # quote such values in .env, and the accumulator must then preserve them. - sed -i "s#^FORGE_REVIEW_TOKEN=.*#FORGE_REVIEW_TOKEN='abc|xyz'#" "$piped_env" - sed -i "s#^FORGE_REVIEW_PASS=.*#FORGE_REVIEW_PASS='p1|p2|p3'#" "$piped_env" - sed -i "s#^FORGE_ADMIN_TOKEN=.*#FORGE_ADMIN_TOKEN='admin|with|pipes'#" "$piped_env" - - run "$IMPORT_SCRIPT" \ - --env "$piped_env" \ - --sops "$FIXTURES_DIR/.env.vault.enc" \ - --age-key "$FIXTURES_DIR/age-keys.txt" - [ "$status" -eq 0 ] - - # Verify each value round-trips intact. - run curl -sf -H "X-Vault-Token: ${VAULT_TOKEN}" \ - "${VAULT_ADDR}/v1/kv/data/disinto/bots/review" - [ "$status" -eq 0 ] - echo "$output" | jq -e '.data.data.token == "abc|xyz"' - echo "$output" | jq -e '.data.data.pass == "p1|p2|p3"' - - run curl -sf -H "X-Vault-Token: ${VAULT_TOKEN}" \ - "${VAULT_ADDR}/v1/kv/data/disinto/shared/forge" - [ "$status" -eq 0 ] - echo "$output" | jq -e '.data.data.admin_token == "admin|with|pipes"' -} - -# --- Incomplete fixture ─────────────────────────────────────────────────────── - -@test "handles incomplete fixture gracefully" { - # The incomplete fixture is missing some keys, but that should be OK - # - it should only import what exists - # - it should warn about missing pairs - run "$IMPORT_SCRIPT" \ - --env "$FIXTURES_DIR/dot-env-incomplete" \ - --sops "$FIXTURES_DIR/.env.vault.enc" \ - --age-key "$FIXTURES_DIR/age-keys.txt" - [ "$status" -eq 0 ] - - # Should have imported what was available - echo "$output" | grep -q "review" - - # Should complete successfully even with incomplete fixture - # The script handles missing pairs gracefully with warnings to stderr - [ "$status" -eq 0 ] -} - -# --- Security: no secrets in output ─────────────────────────────────────────── - -@test "never logs secret values in stdout" { - # Run the import - run "$IMPORT_SCRIPT" \ - --env "$FIXTURES_DIR/dot-env-complete" \ - --sops "$FIXTURES_DIR/.env.vault.enc" \ - --age-key "$FIXTURES_DIR/age-keys.txt" - [ "$status" -eq 0 ] - - # Check that no actual secret values appear in output - # (only key names and status messages) - local secret_patterns=( - "generic-forge-token" - "generic-forge-pass" - "generic-admin-token" - "review-token" - "review-pass" - "llama-token" - "llama-pass" - "wp-agent-secret" - "forward-auth-secret" - "github-test-token" - "codeberg-test-token" - "clawhub-test-token" - "deploy-key-test" - "npm-test-token" - "dockerhub-test-token" - # Note: forgejo-client and forgejo-secret are NOT in the output - # because they are read from Vault, not logged - ) - - for pattern in "${secret_patterns[@]}"; do - if echo "$output" | grep -q "$pattern"; then - echo "FAIL: Found secret pattern '$pattern' in output" >&2 - echo "Output was:" >&2 - echo "$output" >&2 - return 1 - fi - done -} - -# --- Error handling ─────────────────────────────────────────────────────────── - -@test "fails with missing --env argument" { - run "$IMPORT_SCRIPT" \ - --sops "$FIXTURES_DIR/.env.vault.enc" \ - --age-key "$FIXTURES_DIR/age-keys.txt" - [ "$status" -ne 0 ] - echo "$output" | grep -q "Missing required argument" -} - -@test "fails with missing --sops argument" { - run "$IMPORT_SCRIPT" \ - --env "$FIXTURES_DIR/dot-env-complete" \ - --age-key "$FIXTURES_DIR/age-keys.txt" - [ "$status" -ne 0 ] - echo "$output" | grep -q "Missing required argument" -} - -@test "fails with missing --age-key argument" { - run "$IMPORT_SCRIPT" \ - --env "$FIXTURES_DIR/dot-env-complete" \ - --sops "$FIXTURES_DIR/.env.vault.enc" - [ "$status" -ne 0 ] - echo "$output" | grep -q "Missing required argument" -} - -@test "fails with non-existent env file" { - run "$IMPORT_SCRIPT" \ - --env "/nonexistent/.env" \ - --sops "$FIXTURES_DIR/.env.vault.enc" \ - --age-key "$FIXTURES_DIR/age-keys.txt" - [ "$status" -ne 0 ] - echo "$output" | grep -q "not found" -} - -@test "fails with non-existent sops file" { - run "$IMPORT_SCRIPT" \ - --env "$FIXTURES_DIR/dot-env-complete" \ - --sops "/nonexistent/.env.vault.enc" \ - --age-key "$FIXTURES_DIR/age-keys.txt" - [ "$status" -ne 0 ] - echo "$output" | grep -q "not found" -} - -@test "fails with non-existent age key file" { - run "$IMPORT_SCRIPT" \ - --env "$FIXTURES_DIR/dot-env-complete" \ - --sops "$FIXTURES_DIR/.env.vault.enc" \ - --age-key "/nonexistent/age-keys.txt" - [ "$status" -ne 0 ] - echo "$output" | grep -q "not found" -} diff --git a/tools/edge-control/README.md b/tools/edge-control/README.md index 019b385..c49e78a 100644 --- a/tools/edge-control/README.md +++ b/tools/edge-control/README.md @@ -83,12 +83,9 @@ curl -sL https://raw.githubusercontent.com/disinto-admin/disinto/fix/issue-621/t - Permissions: `root:disinto-register 0750` 3. **Installs Caddy**: - - Backs up any pre-existing `/etc/caddy/Caddyfile` to `/etc/caddy/Caddyfile.pre-disinto` - Download Caddy with Gandi DNS plugin - Enable admin API on `127.0.0.1:2019` - Configure wildcard cert for `*.disinto.ai` via DNS-01 - - Creates `/etc/caddy/extra.d/` for operator-owned site blocks - - Emitted Caddyfile ends with `import /etc/caddy/extra.d/*.caddy` 4. **Sets up SSH**: - Creates `disinto-register` authorized_keys with forced command @@ -98,27 +95,6 @@ curl -sL https://raw.githubusercontent.com/disinto-admin/disinto/fix/issue-621/t - `/opt/disinto-edge/register.sh` — forced command handler - `/opt/disinto-edge/lib/*.sh` — helper libraries -## Operator-Owned Site Blocks - -Edge-control owns the top-level `/etc/caddy/Caddyfile` and dynamic `<project>.<DOMAIN_SUFFIX>` routes injected via the Caddy admin API. Operators own everything under `/etc/caddy/extra.d/`. - -To serve non-tunnel content (apex domain, www redirect, static sites), drop `.caddy` files into `/etc/caddy/extra.d/`: - -```bash -# Example: /etc/caddy/extra.d/landing.caddy -disinto.ai { - root * /home/debian/disinto-site - file_server -} - -# Example: /etc/caddy/extra.d/www-redirect.caddy -www.disinto.ai { - redir https://disinto.ai{uri} permanent -} -``` - -These files survive across `install.sh` re-runs. The `--extra-caddyfile <path>` flag overrides the default import glob (`/etc/caddy/extra.d/*.caddy`) if needed. - ## Usage ### Register a Tunnel (from dev box) diff --git a/tools/edge-control/install.sh b/tools/edge-control/install.sh index 9571311..68880ab 100755 --- a/tools/edge-control/install.sh +++ b/tools/edge-control/install.sh @@ -43,21 +43,18 @@ INSTALL_DIR="/opt/disinto-edge" REGISTRY_DIR="/var/lib/disinto" CADDY_VERSION="2.8.4" DOMAIN_SUFFIX="disinto.ai" -EXTRA_CADDYFILE="/etc/caddy/extra.d/*.caddy" usage() { cat <<EOF Usage: $0 [options] Options: - --gandi-token <token> Gandi API token for wildcard cert (required) - --install-dir <dir> Install directory (default: /opt/disinto-edge) - --registry-dir <dir> Registry directory (default: /var/lib/disinto) - --caddy-version <ver> Caddy version to install (default: ${CADDY_VERSION}) - --domain-suffix <suffix> Domain suffix for tunnels (default: disinto.ai) - --extra-caddyfile <path> Import path for operator-owned Caddy config - (default: /etc/caddy/extra.d/*.caddy) - -h, --help Show this help + --gandi-token <token> Gandi API token for wildcard cert (required) + --install-dir <dir> Install directory (default: /opt/disinto-edge) + --registry-dir <dir> Registry directory (default: /var/lib/disinto) + --caddy-version <ver> Caddy version to install (default: ${CADDY_VERSION}) + --domain-suffix <suffix> Domain suffix for tunnels (default: disinto.ai) + -h, --help Show this help Example: $0 --gandi-token YOUR_GANDI_API_TOKEN @@ -87,10 +84,6 @@ while [[ $# -gt 0 ]]; do DOMAIN_SUFFIX="$2" shift 2 ;; - --extra-caddyfile) - EXTRA_CADDYFILE="$2" - shift 2 - ;; -h|--help) usage ;; @@ -232,29 +225,8 @@ EOF chmod 600 "$GANDI_ENV" # Create Caddyfile with admin API and wildcard cert -# Note: Caddy auto-generates server names (srv0, srv1, …). lib/caddy.sh -# discovers the server name dynamically via _discover_server_name() so we -# don't need to name the server here. CADDYFILE="/etc/caddy/Caddyfile" - -# Back up existing Caddyfile before overwriting -if [ -f "$CADDYFILE" ] && [ ! -f "${CADDYFILE}.pre-disinto" ]; then - cp "$CADDYFILE" "${CADDYFILE}.pre-disinto" - log_info "Backed up existing Caddyfile to ${CADDYFILE}.pre-disinto" -fi - -# Create extra.d directory for operator-owned site blocks -EXTRA_DIR="/etc/caddy/extra.d" -mkdir -p "$EXTRA_DIR" -chmod 0755 "$EXTRA_DIR" -if getent group caddy >/dev/null 2>&1; then - chown root:caddy "$EXTRA_DIR" -else - log_warn "Group 'caddy' does not exist; extra.d owned by root:root" -fi -log_info "Created ${EXTRA_DIR} for operator-owned Caddy config" - -cat > "$CADDYFILE" <<CADDYEOF +cat > "$CADDYFILE" <<EOF # Caddy configuration for edge control plane # Admin API enabled on 127.0.0.1:2019 @@ -268,10 +240,7 @@ cat > "$CADDYFILE" <<CADDYEOF dns gandi {env.GANDI_API_KEY} } } - -# Operator-owned site blocks (apex, www, static content, etc.) -import ${EXTRA_CADDYFILE} -CADDYEOF +EOF # Start Caddy systemctl restart caddy 2>/dev/null || { @@ -390,7 +359,6 @@ echo "Configuration:" echo " Install directory: ${INSTALL_DIR}" echo " Registry: ${REGISTRY_FILE}" echo " Caddy admin API: http://127.0.0.1:2019" -echo " Operator site blocks: ${EXTRA_DIR}/ (import ${EXTRA_CADDYFILE})" echo "" echo "Users:" echo " disinto-register - SSH forced command (runs ${INSTALL_DIR}/register.sh)" diff --git a/tools/edge-control/lib/caddy.sh b/tools/edge-control/lib/caddy.sh index 1e16cdc..69970cf 100755 --- a/tools/edge-control/lib/caddy.sh +++ b/tools/edge-control/lib/caddy.sh @@ -19,24 +19,6 @@ CADDY_ADMIN_URL="${CADDY_ADMIN_URL:-http://127.0.0.1:2019}" # Domain suffix for projects DOMAIN_SUFFIX="${DOMAIN_SUFFIX:-disinto.ai}" -# Discover the Caddy server name that listens on :80/:443 -# Usage: _discover_server_name -_discover_server_name() { - local server_name - server_name=$(curl -sS "${CADDY_ADMIN_URL}/config/apps/http/servers" \ - | jq -r 'to_entries | map(select(.value.listen[]? | test(":(80|443)$"))) | .[0].key // empty') || { - echo "Error: could not query Caddy admin API for servers" >&2 - return 1 - } - - if [ -z "$server_name" ]; then - echo "Error: could not find a Caddy server listening on :80/:443" >&2 - return 1 - fi - - echo "$server_name" -} - # Add a route for a project # Usage: add_route <project> <port> add_route() { @@ -44,9 +26,6 @@ add_route() { local port="$2" local fqdn="${project}.${DOMAIN_SUFFIX}" - local server_name - server_name=$(_discover_server_name) || return 1 - # Build the route configuration (partial config) local route_config route_config=$(cat <<EOF @@ -79,21 +58,16 @@ add_route() { EOF ) - # Append route via admin API, checking HTTP status - local response status body - response=$(curl -sS -w '\n%{http_code}' -X POST \ - "${CADDY_ADMIN_URL}/config/apps/http/servers/${server_name}/routes" \ + # Append route using POST /config/apps/http/servers/edge/routes + local response + response=$(curl -s -X POST \ + "${CADDY_ADMIN_URL}/config/apps/http/servers/edge/routes" \ -H "Content-Type: application/json" \ - -d "$route_config") || { + -d "$route_config" 2>&1) || { echo "Error: failed to add route for ${fqdn}" >&2 + echo "Response: ${response}" >&2 return 1 } - status=$(echo "$response" | tail -n1) - body=$(echo "$response" | sed '$d') - if [ "$status" -ge 400 ]; then - echo "Error: Caddy admin API returned ${status}: ${body}" >&2 - return 1 - fi echo "Added route: ${fqdn} → 127.0.0.1:${port}" >&2 } @@ -104,45 +78,31 @@ remove_route() { local project="$1" local fqdn="${project}.${DOMAIN_SUFFIX}" - local server_name - server_name=$(_discover_server_name) || return 1 - - # First, get current routes, checking HTTP status - local response status body - response=$(curl -sS -w '\n%{http_code}' \ - "${CADDY_ADMIN_URL}/config/apps/http/servers/${server_name}/routes") || { + # First, get current routes + local routes_json + routes_json=$(curl -s "${CADDY_ADMIN_URL}/config/apps/http/servers/edge/routes" 2>&1) || { echo "Error: failed to get current routes" >&2 return 1 } - status=$(echo "$response" | tail -n1) - body=$(echo "$response" | sed '$d') - if [ "$status" -ge 400 ]; then - echo "Error: Caddy admin API returned ${status}: ${body}" >&2 - return 1 - fi # Find the route index that matches our fqdn using jq local route_index - route_index=$(echo "$body" | jq -r "to_entries[] | select(.value.match[]?.host[]? == \"${fqdn}\") | .key" 2>/dev/null | head -1) + route_index=$(echo "$routes_json" | jq -r "to_entries[] | select(.value.match[]?.host[]? == \"${fqdn}\") | .key" 2>/dev/null | head -1) if [ -z "$route_index" ] || [ "$route_index" = "null" ]; then echo "Warning: route for ${fqdn} not found" >&2 return 0 fi - # Delete the route at the found index, checking HTTP status - response=$(curl -sS -w '\n%{http_code}' -X DELETE \ - "${CADDY_ADMIN_URL}/config/apps/http/servers/${server_name}/routes/${route_index}" \ - -H "Content-Type: application/json") || { + # Delete the route at the found index + local response + response=$(curl -s -X DELETE \ + "${CADDY_ADMIN_URL}/config/apps/http/servers/edge/routes/${route_index}" \ + -H "Content-Type: application/json" 2>&1) || { echo "Error: failed to remove route for ${fqdn}" >&2 + echo "Response: ${response}" >&2 return 1 } - status=$(echo "$response" | tail -n1) - body=$(echo "$response" | sed '$d') - if [ "$status" -ge 400 ]; then - echo "Error: Caddy admin API returned ${status}: ${body}" >&2 - return 1 - fi echo "Removed route: ${fqdn}" >&2 } @@ -150,18 +110,13 @@ remove_route() { # Reload Caddy to apply configuration changes # Usage: reload_caddy reload_caddy() { - local response status body - response=$(curl -sS -w '\n%{http_code}' -X POST \ - "${CADDY_ADMIN_URL}/reload") || { + local response + response=$(curl -s -X POST \ + "${CADDY_ADMIN_URL}/reload" 2>&1) || { echo "Error: failed to reload Caddy" >&2 + echo "Response: ${response}" >&2 return 1 } - status=$(echo "$response" | tail -n1) - body=$(echo "$response" | sed '$d') - if [ "$status" -ge 400 ]; then - echo "Error: Caddy reload returned ${status}: ${body}" >&2 - return 1 - fi echo "Caddy reloaded" >&2 } diff --git a/tools/vault-apply-policies.sh b/tools/vault-apply-policies.sh deleted file mode 100755 index f425f17..0000000 --- a/tools/vault-apply-policies.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# tools/vault-apply-policies.sh — Idempotent Vault policy sync -# -# Part of the Nomad+Vault migration (S2.1, issue #879). Reads every -# vault/policies/*.hcl file and upserts it into Vault as an ACL policy -# named after the file's basename (without the .hcl suffix). -# -# Idempotency contract: -# For each vault/policies/<NAME>.hcl: -# - Policy missing in Vault → apply, log "policy <NAME> created" -# - Policy present, content same → skip, log "policy <NAME> unchanged" -# - Policy present, content diff → apply, log "policy <NAME> updated" -# -# Comparison is byte-for-byte against the on-server policy text returned by -# GET sys/policies/acl/<NAME>.data.policy. Re-running with no file edits is -# a guaranteed no-op that reports every policy as "unchanged". -# -# --dry-run: prints <NAME> <SHA256> for each file that WOULD be applied; -# does not call Vault at all (no GETs, no PUTs). Exits 0. -# -# Requires: -# - VAULT_ADDR (e.g. http://127.0.0.1:8200) -# - VAULT_TOKEN (env OR /etc/vault.d/root.token, resolved by lib/hvault.sh) -# - curl, jq, sha256sum -# -# Usage: -# tools/vault-apply-policies.sh -# tools/vault-apply-policies.sh --dry-run -# -# Exit codes: -# 0 success (policies synced, or --dry-run completed) -# 1 precondition / API failure -# ============================================================================= -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -POLICIES_DIR="${REPO_ROOT}/vault/policies" - -# shellcheck source=../lib/hvault.sh -source "${REPO_ROOT}/lib/hvault.sh" - -log() { printf '[vault-apply] %s\n' "$*"; } -die() { printf '[vault-apply] ERROR: %s\n' "$*" >&2; exit 1; } - -# ── Flag parsing ───────────────────────────────────────────────────────────── -# Single optional flag — no loop needed. Keeps this block textually distinct -# from the multi-flag `while/case` parsers elsewhere in the repo (see -# .woodpecker/detect-duplicates.py — sliding 5-line window). -dry_run=false -[ "$#" -le 1 ] || die "too many arguments (saw: $*)" -case "${1:-}" in - '') ;; - --dry-run) dry_run=true ;; - -h|--help) printf 'Usage: %s [--dry-run]\n\n' "$(basename "$0")" - printf 'Apply every vault/policies/*.hcl to Vault as an ACL policy.\n' - printf 'Idempotent: unchanged policies are reported as "unchanged" and\n' - printf 'not written.\n\n' - printf ' --dry-run Print policy names + content SHA256 that would be\n' - printf ' applied, without contacting Vault. Exits 0.\n' - exit 0 ;; - *) die "unknown flag: $1" ;; -esac - -# ── Preconditions ──────────────────────────────────────────────────────────── -for bin in curl jq sha256sum; do - command -v "$bin" >/dev/null 2>&1 \ - || die "required binary not found: ${bin}" -done - -[ -d "$POLICIES_DIR" ] \ - || die "policies directory not found: ${POLICIES_DIR}" - -# Collect policy files in a stable (lexicographic) order so log output is -# deterministic across runs and CI diffs. -mapfile -t POLICY_FILES < <( - find "$POLICIES_DIR" -maxdepth 1 -type f -name '*.hcl' | LC_ALL=C sort -) - -if [ "${#POLICY_FILES[@]}" -eq 0 ]; then - die "no *.hcl files in ${POLICIES_DIR}" -fi - -# ── Dry-run: print plan + exit (no Vault calls) ────────────────────────────── -if [ "$dry_run" = true ]; then - log "dry-run — ${#POLICY_FILES[@]} policy file(s) in ${POLICIES_DIR}" - for f in "${POLICY_FILES[@]}"; do - name="$(basename "$f" .hcl)" - sha="$(sha256sum "$f" | awk '{print $1}')" - printf '[vault-apply] would apply policy %s (sha256=%s)\n' "$name" "$sha" - done - exit 0 -fi - -# ── Live run: Vault connectivity check ─────────────────────────────────────── -# Default the local-cluster Vault env (see lib/hvault.sh::_hvault_default_env). -# `disinto init` does not export VAULT_ADDR before calling this script — the -# server is reachable on 127.0.0.1:8200 and the root token lives at -# /etc/vault.d/root.token in the common fresh-LXC case (issue #912). -_hvault_default_env - -# hvault_token_lookup both resolves the token (env or /etc/vault.d/root.token) -# and confirms the server is reachable with a valid token. Fail fast here so -# the per-file loop below doesn't emit N identical "HTTP 403" errors. -hvault_token_lookup >/dev/null \ - || die "Vault auth probe failed — check VAULT_ADDR + VAULT_TOKEN" - -# ── Apply each policy, reporting created/updated/unchanged ─────────────────── -log "syncing ${#POLICY_FILES[@]} polic(y|ies) from ${POLICIES_DIR}" - -for f in "${POLICY_FILES[@]}"; do - name="$(basename "$f" .hcl)" - - desired="$(cat "$f")" - # hvault_get_or_empty returns the raw JSON body on 200 or empty on 404. - # Extract the .data.policy field here (jq on "" yields "", so the - # empty-string-means-create branch below still works). - raw="$(hvault_get_or_empty "sys/policies/acl/${name}")" \ - || die "failed to read existing policy: ${name}" - if [ -n "$raw" ]; then - current="$(printf '%s' "$raw" | jq -r '.data.policy // ""')" \ - || die "failed to parse policy response: ${name}" - else - current="" - fi - - if [ -z "$current" ]; then - hvault_policy_apply "$name" "$f" \ - || die "failed to create policy: ${name}" - log "policy ${name} created" - continue - fi - - if [ "$current" = "$desired" ]; then - log "policy ${name} unchanged" - continue - fi - - hvault_policy_apply "$name" "$f" \ - || die "failed to update policy: ${name}" - log "policy ${name} updated" -done - -log "done — ${#POLICY_FILES[@]} polic(y|ies) synced" diff --git a/tools/vault-apply-roles.sh b/tools/vault-apply-roles.sh deleted file mode 100755 index 8509493..0000000 --- a/tools/vault-apply-roles.sh +++ /dev/null @@ -1,308 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# tools/vault-apply-roles.sh — Idempotent Vault JWT-auth role sync -# -# Part of the Nomad+Vault migration (S2.3, issue #881). Reads -# vault/roles.yaml and upserts each entry as a Vault role under -# auth/jwt-nomad/role/<name>. -# -# Idempotency contract: -# For each role entry in vault/roles.yaml: -# - Role missing in Vault → write, log "role <NAME> created" -# - Role present, fields match → skip, log "role <NAME> unchanged" -# - Role present, fields differ → write, log "role <NAME> updated" -# -# Comparison is per-field on the data the CLI would read back -# (GET auth/jwt-nomad/role/<NAME>.data.{policies,bound_audiences, -# bound_claims,token_ttl,token_max_ttl,token_type}). Only the fields -# this script owns are compared — a future field added by hand in -# Vault would not be reverted on the next run. -# -# --dry-run: prints the planned role list + full payload for each role -# WITHOUT touching Vault. Exits 0. -# -# Preconditions: -# - Vault auth method jwt-nomad must already be enabled + configured -# (done by lib/init/nomad/vault-nomad-auth.sh — which then calls -# this script). Running this script standalone against a Vault with -# no jwt-nomad path will fail on the first role write. -# - vault/roles.yaml present. See that file's header for the format. -# -# Requires: -# - VAULT_ADDR (e.g. http://127.0.0.1:8200) -# - VAULT_TOKEN (env OR /etc/vault.d/root.token, resolved by lib/hvault.sh) -# - curl, jq, awk -# -# Usage: -# tools/vault-apply-roles.sh -# tools/vault-apply-roles.sh --dry-run -# -# Exit codes: -# 0 success (roles synced, or --dry-run completed) -# 1 precondition / API / parse failure -# ============================================================================= -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -ROLES_FILE="${REPO_ROOT}/vault/roles.yaml" - -# shellcheck source=../lib/hvault.sh -source "${REPO_ROOT}/lib/hvault.sh" - -# Constants shared across every role — the issue's AC names these as the -# invariant token shape for Nomad workload identity. Bumping any of these -# is a knowing, repo-wide change, not a per-role knob, so they live here -# rather than as per-entry fields in roles.yaml. -ROLE_AUDIENCE="vault.io" -ROLE_TOKEN_TYPE="service" -ROLE_TOKEN_TTL="1h" -ROLE_TOKEN_MAX_TTL="24h" - -log() { printf '[vault-roles] %s\n' "$*"; } -die() { printf '[vault-roles] ERROR: %s\n' "$*" >&2; exit 1; } - -# ── Flag parsing (single optional flag — see vault-apply-policies.sh for the -# sibling grammar). Structured as arg-count guard + dispatch to keep the -# 5-line sliding-window duplicate detector (.woodpecker/detect-duplicates.py) -# from flagging this as shared boilerplate with vault-apply-policies.sh — -# the two parsers implement the same shape but with different control flow. -dry_run=false -if [ "$#" -gt 1 ]; then - die "too many arguments (saw: $*)" -fi -arg="${1:-}" -if [ "$arg" = "--dry-run" ]; then - dry_run=true -elif [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then - printf 'Usage: %s [--dry-run]\n\n' "$(basename "$0")" - printf 'Apply every role in vault/roles.yaml to Vault as a\n' - printf 'jwt-nomad role. Idempotent: unchanged roles are reported\n' - printf 'as "unchanged" and not written.\n\n' - printf ' --dry-run Print the planned role list + full role\n' - printf ' payload without contacting Vault. Exits 0.\n' - exit 0 -elif [ -n "$arg" ]; then - die "unknown flag: $arg" -fi -unset arg - -# ── Preconditions ──────────────────────────────────────────────────────────── -for bin in curl jq awk; do - command -v "$bin" >/dev/null 2>&1 \ - || die "required binary not found: ${bin}" -done - -[ -f "$ROLES_FILE" ] \ - || die "roles file not found: ${ROLES_FILE}" - -# ── Parse vault/roles.yaml → TSV ───────────────────────────────────────────── -# Strict-format parser. One awk pass; emits one TAB-separated line per role: -# <name>\t<policy>\t<namespace>\t<job_id> -# -# Grammar: a record opens on a line matching `- name: <value>` and closes -# on the next `- name:` or EOF. Within a record, `policy:`, `namespace:`, -# and `job_id:` lines populate the record. Comments (`#...`) and blank -# lines are ignored. Whitespace around the colon and value is trimmed. -# -# This is intentionally narrower than full YAML — the file's header -# documents the exact subset. If someone adds nested maps, arrays, or -# anchors, this parser will silently drop them; the completeness check -# below catches records missing any of the four fields. -parse_roles() { - awk ' - function trim(s) { sub(/^[[:space:]]+/, "", s); sub(/[[:space:]]+$/, "", s); return s } - function strip_comment(s) { sub(/[[:space:]]+#.*$/, "", s); return s } - function emit() { - if (name != "") { - if (policy == "" || namespace == "" || job_id == "") { - printf "INCOMPLETE\t%s\t%s\t%s\t%s\n", name, policy, namespace, job_id - } else { - printf "%s\t%s\t%s\t%s\n", name, policy, namespace, job_id - } - } - name=""; policy=""; namespace=""; job_id="" - } - BEGIN { name=""; policy=""; namespace=""; job_id="" } - # Strip full-line comments and blank lines early. - /^[[:space:]]*#/ { next } - /^[[:space:]]*$/ { next } - # New record: "- name: <value>" - /^[[:space:]]*-[[:space:]]+name:[[:space:]]/ { - emit() - line=strip_comment($0) - sub(/^[[:space:]]*-[[:space:]]+name:[[:space:]]*/, "", line) - name=trim(line) - next - } - # Field within current record. Only accept when a record is open. - /^[[:space:]]+policy:[[:space:]]/ && name != "" { - line=strip_comment($0); sub(/^[[:space:]]+policy:[[:space:]]*/, "", line) - policy=trim(line); next - } - /^[[:space:]]+namespace:[[:space:]]/ && name != "" { - line=strip_comment($0); sub(/^[[:space:]]+namespace:[[:space:]]*/, "", line) - namespace=trim(line); next - } - /^[[:space:]]+job_id:[[:space:]]/ && name != "" { - line=strip_comment($0); sub(/^[[:space:]]+job_id:[[:space:]]*/, "", line) - job_id=trim(line); next - } - END { emit() } - ' "$ROLES_FILE" -} - -mapfile -t ROLE_RECORDS < <(parse_roles) - -if [ "${#ROLE_RECORDS[@]}" -eq 0 ]; then - die "no roles parsed from ${ROLES_FILE}" -fi - -# Validate every record is complete. An INCOMPLETE line has the form -# "INCOMPLETE\t<name>\t<policy>\t<namespace>\t<job_id>" — list all of -# them at once so the operator sees every missing field, not one per run. -incomplete=() -for rec in "${ROLE_RECORDS[@]}"; do - case "$rec" in - INCOMPLETE*) incomplete+=("${rec#INCOMPLETE$'\t'}") ;; - esac -done -if [ "${#incomplete[@]}" -gt 0 ]; then - printf '[vault-roles] ERROR: role entries with missing fields:\n' >&2 - for row in "${incomplete[@]}"; do - IFS=$'\t' read -r name policy namespace job_id <<<"$row" - printf ' - name=%-24s policy=%-22s namespace=%-10s job_id=%s\n' \ - "${name:-<missing>}" "${policy:-<missing>}" \ - "${namespace:-<missing>}" "${job_id:-<missing>}" >&2 - done - die "fix ${ROLES_FILE} and re-run" -fi - -# ── Helper: build the JSON payload Vault expects for a role ────────────────── -# Keeps bound_audiences as a JSON array (required by the API — a scalar -# string silently becomes a one-element-list in the CLI but the HTTP API -# rejects it). All fields that differ between runs are inside this payload -# so the diff-check below (role_fields_match) compares like-for-like. -build_payload() { - local policy="$1" namespace="$2" job_id="$3" - jq -n \ - --arg aud "$ROLE_AUDIENCE" \ - --arg policy "$policy" \ - --arg ns "$namespace" \ - --arg job "$job_id" \ - --arg ttype "$ROLE_TOKEN_TYPE" \ - --arg ttl "$ROLE_TOKEN_TTL" \ - --arg maxttl "$ROLE_TOKEN_MAX_TTL" \ - '{ - role_type: "jwt", - bound_audiences: [$aud], - user_claim: "nomad_job_id", - bound_claims: { nomad_namespace: $ns, nomad_job_id: $job }, - token_type: $ttype, - token_policies: [$policy], - token_ttl: $ttl, - token_max_ttl: $maxttl - }' -} - -# ── Dry-run: print plan + exit (no Vault calls) ────────────────────────────── -if [ "$dry_run" = true ]; then - log "dry-run — ${#ROLE_RECORDS[@]} role(s) in ${ROLES_FILE}" - for rec in "${ROLE_RECORDS[@]}"; do - IFS=$'\t' read -r name policy namespace job_id <<<"$rec" - payload="$(build_payload "$policy" "$namespace" "$job_id")" - printf '[vault-roles] would apply role %s → policy=%s namespace=%s job_id=%s\n' \ - "$name" "$policy" "$namespace" "$job_id" - printf '%s\n' "$payload" | jq -S . | sed 's/^/ /' - done - exit 0 -fi - -# ── Live run: Vault connectivity check ─────────────────────────────────────── -# Default the local-cluster Vault env (see lib/hvault.sh::_hvault_default_env). -# Called transitively from vault-nomad-auth.sh during `disinto init`, which -# does not export VAULT_ADDR in the common fresh-LXC case (issue #912). -_hvault_default_env -if ! hvault_token_lookup >/dev/null; then - die "Vault auth probe failed — check VAULT_ADDR + VAULT_TOKEN" -fi - -# ── Helper: compare on-server role to desired payload ──────────────────────── -# Returns 0 iff every field this script owns matches. Fields not in our -# payload (e.g. a manually-added `ttl` via the UI) are ignored — we don't -# revert them, but we also don't block on them. -role_fields_match() { - local current_json="$1" desired_json="$2" - local keys=( - role_type bound_audiences user_claim bound_claims - token_type token_policies token_ttl token_max_ttl - ) - # Vault returns token_ttl/token_max_ttl as integers (seconds) on GET but - # accepts strings ("1h") on PUT. Normalize: convert desired durations to - # seconds before comparing. jq's tonumber/type checks give us a uniform - # representation on both sides. - local cur des - for k in "${keys[@]}"; do - cur="$(printf '%s' "$current_json" | jq -cS --arg k "$k" '.data[$k] // null')" - des="$(printf '%s' "$desired_json" | jq -cS --arg k "$k" '.[$k] // null')" - case "$k" in - token_ttl|token_max_ttl) - # Normalize desired: "1h"→3600, "24h"→86400. - des="$(printf '%s' "$des" | jq -r '. // ""' | _duration_to_seconds)" - cur="$(printf '%s' "$cur" | jq -r '. // 0')" - ;; - esac - if [ "$cur" != "$des" ]; then - return 1 - fi - done - return 0 -} - -# _duration_to_seconds — read a duration string on stdin, echo seconds. -# Accepts the subset we emit: "Ns", "Nm", "Nh", "Nd". Integers pass through -# unchanged. Any other shape produces the empty string (which cannot match -# Vault's integer response → forces an update). -_duration_to_seconds() { - local s - s="$(cat)" - case "$s" in - ''|null) printf '0' ;; - *[0-9]s) printf '%d' "${s%s}" ;; - *[0-9]m) printf '%d' "$(( ${s%m} * 60 ))" ;; - *[0-9]h) printf '%d' "$(( ${s%h} * 3600 ))" ;; - *[0-9]d) printf '%d' "$(( ${s%d} * 86400 ))" ;; - *[0-9]) printf '%d' "$s" ;; - *) printf '' ;; - esac -} - -# ── Apply each role, reporting created/updated/unchanged ───────────────────── -log "syncing ${#ROLE_RECORDS[@]} role(s) from ${ROLES_FILE}" - -for rec in "${ROLE_RECORDS[@]}"; do - IFS=$'\t' read -r name policy namespace job_id <<<"$rec" - - desired_payload="$(build_payload "$policy" "$namespace" "$job_id")" - # hvault_get_or_empty: raw body on 200, empty on 404 (caller: "create"). - current_json="$(hvault_get_or_empty "auth/jwt-nomad/role/${name}")" \ - || die "failed to read existing role: ${name}" - - if [ -z "$current_json" ]; then - _hvault_request POST "auth/jwt-nomad/role/${name}" "$desired_payload" >/dev/null \ - || die "failed to create role: ${name}" - log "role ${name} created" - continue - fi - - if role_fields_match "$current_json" "$desired_payload"; then - log "role ${name} unchanged" - continue - fi - - _hvault_request POST "auth/jwt-nomad/role/${name}" "$desired_payload" >/dev/null \ - || die "failed to update role: ${name}" - log "role ${name} updated" -done - -log "done — ${#ROLE_RECORDS[@]} role(s) synced" diff --git a/tools/vault-import.sh b/tools/vault-import.sh deleted file mode 100755 index dd1b73a..0000000 --- a/tools/vault-import.sh +++ /dev/null @@ -1,599 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# vault-import.sh — Import .env and sops-decrypted secrets into Vault KV -# -# Reads existing .env and sops-encrypted .env.vault.enc from the old docker stack -# and writes them to Vault KV paths matching the S2.1 policy layout. -# -# Usage: -# vault-import.sh \ -# --env /path/to/.env \ -# [--sops /path/to/.env.vault.enc] \ -# [--age-key /path/to/age/keys.txt] -# -# Flag validation (S2.5, issue #883): -# --import-sops without --age-key → error. -# --age-key without --import-sops → error. -# --env alone (no sops) → OK; imports only the plaintext half. -# -# Mapping: -# From .env: -# - FORGE_{ROLE}_TOKEN + FORGE_{ROLE}_PASS → kv/disinto/bots/<role>/{token,password} -# (roles: review, dev, gardener, architect, planner, predictor, supervisor, vault) -# - FORGE_TOKEN_LLAMA + FORGE_PASS_LLAMA → kv/disinto/bots/dev-qwen/{token,password} -# - FORGE_TOKEN + FORGE_PASS → kv/disinto/shared/forge/{token,password} -# - FORGE_ADMIN_TOKEN → kv/disinto/shared/forge/admin_token -# - WOODPECKER_* → kv/disinto/shared/woodpecker/<lowercase_key> -# - FORWARD_AUTH_SECRET, CHAT_OAUTH_* → kv/disinto/shared/chat/<lowercase_key> -# From sops-decrypted .env.vault.enc: -# - GITHUB_TOKEN, CODEBERG_TOKEN, CLAWHUB_TOKEN, DEPLOY_KEY, NPM_TOKEN, DOCKER_HUB_TOKEN -# → kv/disinto/runner/<NAME>/value -# -# Security: -# - Refuses to run if VAULT_ADDR is not localhost -# - Writes to KV v2, not v1 -# - Validates sops age key file is mode 0400 before sourcing -# - Never logs secret values — only key names -# -# Idempotency: -# - Reports unchanged/updated/created per key via hvault_kv_get -# - --dry-run prints the full import plan without writing -# ============================================================================= - -set -euo pipefail - -# ── Internal helpers ────────────────────────────────────────────────────────── - -# _log — emit a log message to stdout (never to stderr to avoid polluting diff) -_log() { - printf '[vault-import] %s\n' "$*" -} - -# _err — emit an error message to stderr -_err() { - printf '[vault-import] ERROR: %s\n' "$*" >&2 -} - -# _die — log error and exit with status 1 -_die() { - _err "$@" - exit 1 -} - -# _check_vault_addr — ensure VAULT_ADDR is localhost (security check) -_check_vault_addr() { - local addr="${VAULT_ADDR:-}" - if [[ ! "$addr" =~ ^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$ ]]; then - _die "Security check failed: VAULT_ADDR must be localhost for safety. Got: $addr" - fi -} - -# _validate_age_key_perms — ensure age key file is mode 0400 -_validate_age_key_perms() { - local keyfile="$1" - local perms - perms="$(stat -c '%a' "$keyfile" 2>/dev/null)" || _die "Cannot stat age key file: $keyfile" - if [ "$perms" != "400" ]; then - _die "Age key file permissions are $perms, expected 400. Refusing to proceed for security." - fi -} - -# _decrypt_sops — decrypt sops-encrypted file using SOPS_AGE_KEY_FILE -_decrypt_sops() { - local sops_file="$1" - local age_key="$2" - local output - # sops outputs YAML format by default, extract KEY=VALUE lines - output="$(SOPS_AGE_KEY_FILE="$age_key" sops -d "$sops_file" 2>/dev/null | \ - grep -E '^[A-Z_][A-Z0-9_]*=' | \ - sed 's/^\([^=]*\)=\(.*\)$/\1=\2/')" || \ - _die "Failed to decrypt sops file: $sops_file. Check age key and file integrity." - printf '%s' "$output" -} - -# _load_env_file — source an environment file (safety: only KEY=value lines) -_load_env_file() { - local env_file="$1" - local temp_env - temp_env="$(mktemp)" - # Extract only valid KEY=value lines (skip comments, blank lines, malformed) - grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$env_file" 2>/dev/null > "$temp_env" || true - # shellcheck source=/dev/null - source "$temp_env" - rm -f "$temp_env" -} - -# _kv_path_exists — check if a KV path exists (returns 0 if exists, 1 if not) -_kv_path_exists() { - local path="$1" - # Use hvault_kv_get and check if it fails with "not found" - if hvault_kv_get "$path" >/dev/null 2>&1; then - return 0 - fi - # Check if the error is specifically "not found" - local err_output - err_output="$(hvault_kv_get "$path" 2>&1)" || true - if printf '%s' "$err_output" | grep -qi 'not found\|404'; then - return 1 - fi - # Some other error (e.g., auth failure) — treat as unknown - return 1 -} - -# _kv_get_value — get a single key value from a KV path -_kv_get_value() { - local path="$1" - local key="$2" - hvault_kv_get "$path" "$key" -} - -# _kv_put_secret — write a secret to KV v2 -_kv_put_secret() { - local path="$1" - shift - local kv_pairs=("$@") - - # Build JSON payload with all key-value pairs - local payload='{"data":{}}' - for kv in "${kv_pairs[@]}"; do - local k="${kv%%=*}" - local v="${kv#*=}" - # Use jq with --arg for safe string interpolation (handles quotes/backslashes) - payload="$(printf '%s' "$payload" | jq --arg k "$k" --arg v "$v" '. * {"data": {($k): $v}}')" - done - - # Use curl directly for KV v2 write with versioning - local tmpfile http_code - tmpfile="$(mktemp)" - http_code="$(curl -s -w '%{http_code}' \ - -H "X-Vault-Token: ${VAULT_TOKEN}" \ - -H "Content-Type: application/json" \ - -X POST \ - -d "$payload" \ - -o "$tmpfile" \ - "${VAULT_ADDR}/v1/${VAULT_KV_MOUNT:-kv}/data/${path}")" || { - rm -f "$tmpfile" - _err "Failed to write to Vault at ${VAULT_KV_MOUNT:-kv}/data/${path}: curl error" - return 1 - } - rm -f "$tmpfile" - - # Check HTTP status — 2xx is success - case "$http_code" in - 2[0-9][0-9]) - return 0 - ;; - 404) - _err "KV path not found: ${VAULT_KV_MOUNT:-kv}/data/${path}" - return 1 - ;; - 403) - _err "Permission denied writing to ${VAULT_KV_MOUNT:-kv}/data/${path}" - return 1 - ;; - *) - _err "Failed to write to Vault at ${VAULT_KV_MOUNT:-kv}/data/${path}: HTTP $http_code" - return 1 - ;; - esac -} - -# _format_status — format the status string for a key -_format_status() { - local status="$1" - local path="$2" - local key="$3" - case "$status" in - unchanged) - printf ' %s: %s/%s (unchanged)' "$status" "$path" "$key" - ;; - updated) - printf ' %s: %s/%s (updated)' "$status" "$path" "$key" - ;; - created) - printf ' %s: %s/%s (created)' "$status" "$path" "$key" - ;; - *) - printf ' %s: %s/%s (unknown)' "$status" "$path" "$key" - ;; - esac -} - -# ── Mapping definitions ────────────────────────────────────────────────────── - -# Bots mapping: FORGE_{ROLE}_TOKEN + FORGE_{ROLE}_PASS -declare -a BOT_ROLES=(review dev gardener architect planner predictor supervisor vault) - -# Runner tokens from sops-decrypted file -declare -a RUNNER_TOKENS=(GITHUB_TOKEN CODEBERG_TOKEN CLAWHUB_TOKEN DEPLOY_KEY NPM_TOKEN DOCKER_HUB_TOKEN) - -# ── Main logic ──────────────────────────────────────────────────────────────── - -main() { - local env_file="" - local sops_file="" - local age_key_file="" - local dry_run=false - - # Parse arguments - while [[ $# -gt 0 ]]; do - case "$1" in - --env) - env_file="$2" - shift 2 - ;; - --sops) - sops_file="$2" - shift 2 - ;; - --age-key) - age_key_file="$2" - shift 2 - ;; - --dry-run) - dry_run=true - shift - ;; - --help|-h) - cat <<'EOF' -vault-import.sh — Import .env and sops-decrypted secrets into Vault KV - -Usage: - vault-import.sh \ - --env /path/to/.env \ - [--sops /path/to/.env.vault.enc] \ - [--age-key /path/to/age/keys.txt] \ - [--dry-run] - -Options: - --env Path to .env file (required) - --sops Path to sops-encrypted .env.vault.enc file (optional; - requires --age-key when set) - --age-key Path to age keys file (required when --sops is set) - --dry-run Print import plan without writing to Vault (optional) - --help Show this help message - -Mapping: - From .env: - - FORGE_{ROLE}_TOKEN + FORGE_{ROLE}_PASS → kv/disinto/bots/<role>/{token,password} - - FORGE_TOKEN_LLAMA + FORGE_PASS_LLAMA → kv/disinto/bots/dev-qwen/{token,password} - - FORGE_TOKEN + FORGE_PASS → kv/disinto/shared/forge/{token,password} - - FORGE_ADMIN_TOKEN → kv/disinto/shared/forge/admin_token - - WOODPECKER_* → kv/disinto/shared/woodpecker/<lowercase_key> - - FORWARD_AUTH_SECRET, CHAT_OAUTH_* → kv/disinto/shared/chat/<lowercase_key> - - From sops-decrypted .env.vault.enc: - - GITHUB_TOKEN, CODEBERG_TOKEN, CLAWHUB_TOKEN, DEPLOY_KEY, NPM_TOKEN, DOCKER_HUB_TOKEN - → kv/disinto/runner/<NAME>/value - -Examples: - vault-import.sh --env .env --sops .env.vault.enc --age-key age-keys.txt - vault-import.sh --env .env --sops .env.vault.enc --age-key age-keys.txt --dry-run -EOF - exit 0 - ;; - *) - _die "Unknown option: $1. Use --help for usage." - ;; - esac - done - - # Validate required arguments. --sops and --age-key are paired: if one - # is set, the other must be too. --env alone (no sops half) is valid — - # imports only the plaintext dotenv. Spec: S2.5 / issue #883 / #912. - if [ -z "$env_file" ]; then - _die "Missing required argument: --env" - fi - if [ -n "$sops_file" ] && [ -z "$age_key_file" ]; then - _die "--sops requires --age-key" - fi - if [ -n "$age_key_file" ] && [ -z "$sops_file" ]; then - _die "--age-key requires --sops" - fi - - # Validate files exist - if [ ! -f "$env_file" ]; then - _die "Environment file not found: $env_file" - fi - if [ -n "$sops_file" ] && [ ! -f "$sops_file" ]; then - _die "Sops file not found: $sops_file" - fi - if [ -n "$age_key_file" ] && [ ! -f "$age_key_file" ]; then - _die "Age key file not found: $age_key_file" - fi - - # Security check: age key permissions (only when an age key is provided — - # --env-only imports never touch the age key). - if [ -n "$age_key_file" ]; then - _validate_age_key_perms "$age_key_file" - fi - - # Source the Vault helpers and default the local-cluster VAULT_ADDR + - # VAULT_TOKEN before the localhost safety check runs. `disinto init` - # does not export these in the common fresh-LXC case (issue #912). - source "$(dirname "$0")/../lib/hvault.sh" - _hvault_default_env - - # Security check: VAULT_ADDR must be localhost - _check_vault_addr - - # Load .env file - _log "Loading environment from: $env_file" - _load_env_file "$env_file" - - # Decrypt sops file when --sops was provided. On the --env-only path - # (empty $sops_file) the sops_env stays empty and the per-token loop - # below silently skips runner-token imports — exactly the "only - # plaintext half" spec from S2.5. - local sops_env="" - if [ -n "$sops_file" ]; then - _log "Decrypting sops file: $sops_file" - sops_env="$(_decrypt_sops "$sops_file" "$age_key_file")" - # shellcheck disable=SC2086 - eval "$sops_env" - else - _log "No --sops flag — skipping sops decryption (importing plaintext .env only)" - fi - - # Collect all import operations - declare -a operations=() - - # --- From .env --- - - # Bots: FORGE_{ROLE}_TOKEN + FORGE_{ROLE}_PASS - for role in "${BOT_ROLES[@]}"; do - local token_var="FORGE_${role^^}_TOKEN" - local pass_var="FORGE_${role^^}_PASS" - local token_val="${!token_var:-}" - local pass_val="${!pass_var:-}" - - if [ -n "$token_val" ] && [ -n "$pass_val" ]; then - operations+=("bots|$role|token|$env_file|$token_var") - operations+=("bots|$role|pass|$env_file|$pass_var") - elif [ -n "$token_val" ] || [ -n "$pass_val" ]; then - _err "Warning: $role bot has token but no password (or vice versa), skipping" - fi - done - - # Llama bot: FORGE_TOKEN_LLAMA + FORGE_PASS_LLAMA - local llama_token="${FORGE_TOKEN_LLAMA:-}" - local llama_pass="${FORGE_PASS_LLAMA:-}" - if [ -n "$llama_token" ] && [ -n "$llama_pass" ]; then - operations+=("bots|dev-qwen|token|$env_file|FORGE_TOKEN_LLAMA") - operations+=("bots|dev-qwen|pass|$env_file|FORGE_PASS_LLAMA") - elif [ -n "$llama_token" ] || [ -n "$llama_pass" ]; then - _err "Warning: dev-qwen bot has token but no password (or vice versa), skipping" - fi - - # Generic forge creds: FORGE_TOKEN + FORGE_PASS - local forge_token="${FORGE_TOKEN:-}" - local forge_pass="${FORGE_PASS:-}" - if [ -n "$forge_token" ] && [ -n "$forge_pass" ]; then - operations+=("forge|token|$env_file|FORGE_TOKEN") - operations+=("forge|pass|$env_file|FORGE_PASS") - fi - - # Forge admin token: FORGE_ADMIN_TOKEN - local forge_admin_token="${FORGE_ADMIN_TOKEN:-}" - if [ -n "$forge_admin_token" ]; then - operations+=("forge|admin_token|$env_file|FORGE_ADMIN_TOKEN") - fi - - # Woodpecker secrets: WOODPECKER_* - # Only read from the .env file, not shell environment - local woodpecker_keys=() - while IFS='=' read -r key _; do - if [[ "$key" =~ ^WOODPECKER_ ]] || [[ "$key" =~ ^WP_[A-Z_]+$ ]]; then - woodpecker_keys+=("$key") - fi - done < <(grep -E '^[A-Z_][A-Z0-9_]*=' "$env_file" 2>/dev/null || true) - for key in "${woodpecker_keys[@]}"; do - local val="${!key}" - if [ -n "$val" ]; then - local lowercase_key="${key,,}" - # Normalize WP_FORGEJO_* → forgejo_* (strip wp_ prefix to match template) - if [[ "$lowercase_key" =~ ^wp_(.+)$ ]]; then - vault_key="${BASH_REMATCH[1]}" - else - vault_key="$lowercase_key" - fi - operations+=("woodpecker|$vault_key|$env_file|$key") - fi - done - - # Chat secrets: FORWARD_AUTH_SECRET, CHAT_OAUTH_CLIENT_ID, CHAT_OAUTH_CLIENT_SECRET - for key in FORWARD_AUTH_SECRET CHAT_OAUTH_CLIENT_ID CHAT_OAUTH_CLIENT_SECRET; do - local val="${!key:-}" - if [ -n "$val" ]; then - local lowercase_key="${key,,}" - operations+=("chat|$lowercase_key|$env_file|$key") - fi - done - - # --- From sops-decrypted .env.vault.enc --- - - # Runner tokens - for token_name in "${RUNNER_TOKENS[@]}"; do - local token_val="${!token_name:-}" - if [ -n "$token_val" ]; then - operations+=("runner|$token_name|$sops_file|$token_name") - fi - done - - # If dry-run, just print the plan - if $dry_run; then - _log "=== DRY-RUN: Import plan ===" - _log "Environment file: $env_file" - if [ -n "$sops_file" ]; then - _log "Sops file: $sops_file" - _log "Age key: $age_key_file" - else - _log "Sops file: (none — --env-only import)" - fi - _log "" - _log "Planned operations:" - for op in "${operations[@]}"; do - _log " $op" - done - _log "" - _log "Total: ${#operations[@]} operations" - exit 0 - fi - - # --- Actual import with idempotency check --- - - _log "=== Starting Vault import ===" - _log "Environment file: $env_file" - if [ -n "$sops_file" ]; then - _log "Sops file: $sops_file" - _log "Age key: $age_key_file" - else - _log "Sops file: (none — --env-only import)" - fi - _log "" - - local created=0 - local updated=0 - local unchanged=0 - - # First pass: collect all operations with their parsed values. - # Store value and status in separate associative arrays keyed by - # "vault_path:kv_key". Secret values may contain any character, so we - # never pack them into a delimited string — the previous `value|status` - # encoding silently truncated values containing '|' (see issue #898). - declare -A ops_value - declare -A ops_status - declare -A path_seen - - for op in "${operations[@]}"; do - # Parse operation: category|field|subkey|file|envvar (5 fields for bots/runner) - # or category|field|file|envvar (4 fields for forge/woodpecker/chat). - # These metadata strings are built from safe identifiers (role names, - # env-var names, file paths) and do not carry secret values, so '|' is - # still fine as a separator here. - local category field subkey file envvar="" - local field_count - field_count="$(printf '%s' "$op" | awk -F'|' '{print NF}')" - - if [ "$field_count" -eq 5 ]; then - # 5 fields: category|role|subkey|file|envvar - IFS='|' read -r category field subkey file envvar <<< "$op" - else - # 4 fields: category|field|file|envvar - IFS='|' read -r category field file envvar <<< "$op" - subkey="$field" # For 4-field ops, field is the vault key - fi - - # Determine Vault path and key based on category - local vault_path="" - local vault_key="$subkey" - local source_value="" - - if [ "$file" = "$env_file" ]; then - # Source from environment file (envvar contains the variable name) - source_value="${!envvar:-}" - else - # Source from sops-decrypted env (envvar contains the variable name) - source_value="$(printf '%s' "$sops_env" | grep "^${envvar}=" | sed "s/^${envvar}=//" || true)" - fi - - case "$category" in - bots) - vault_path="disinto/bots/${field}" - vault_key="$subkey" - ;; - forge) - vault_path="disinto/shared/forge" - vault_key="$field" - ;; - woodpecker) - vault_path="disinto/shared/woodpecker" - vault_key="$field" - ;; - chat) - vault_path="disinto/shared/chat" - vault_key="$field" - ;; - runner) - vault_path="disinto/runner/${field}" - vault_key="value" - ;; - *) - _err "Unknown category: $category" - continue - ;; - esac - - # Determine status for this key - local status="created" - if _kv_path_exists "$vault_path"; then - local existing_value - if existing_value="$(_kv_get_value "$vault_path" "$vault_key")" 2>/dev/null; then - if [ "$existing_value" = "$source_value" ]; then - status="unchanged" - else - status="updated" - fi - fi - fi - - # vault_path and vault_key are identifier-safe (no ':' in either), so - # the composite key round-trips cleanly via ${ck%:*} / ${ck#*:}. - local ck="${vault_path}:${vault_key}" - ops_value["$ck"]="$source_value" - ops_status["$ck"]="$status" - path_seen["$vault_path"]=1 - done - - # Second pass: group by vault_path and write. - # IMPORTANT: Always write ALL keys for a path, not just changed ones. - # KV v2 POST replaces the entire document, so we must include unchanged keys - # to avoid dropping them. The idempotency guarantee comes from KV v2 versioning. - for vault_path in "${!path_seen[@]}"; do - # Collect this path's "vault_key=source_value" pairs into a bash - # indexed array. Each element is one kv pair; '=' inside the value is - # preserved because _kv_put_secret splits on the *first* '=' only. - local pairs_array=() - local path_has_changes=0 - - for ck in "${!ops_value[@]}"; do - [ "${ck%:*}" = "$vault_path" ] || continue - local vault_key="${ck#*:}" - pairs_array+=("${vault_key}=${ops_value[$ck]}") - if [ "${ops_status[$ck]}" != "unchanged" ]; then - path_has_changes=1 - fi - done - - # Determine effective status for this path (updated if any key changed) - local effective_status="unchanged" - if [ "$path_has_changes" = 1 ]; then - effective_status="updated" - fi - - if ! _kv_put_secret "$vault_path" "${pairs_array[@]}"; then - _err "Failed to write to $vault_path" - exit 1 - fi - - # Output status for each key in this path - for kv in "${pairs_array[@]}"; do - local kv_key="${kv%%=*}" - _format_status "$effective_status" "$vault_path" "$kv_key" - printf '\n' - done - - # Count only if path has changes - if [ "$effective_status" = "updated" ]; then - ((updated++)) || true - fi - done - - _log "" - _log "=== Import complete ===" - _log "Created: $created" - _log "Updated: $updated" - _log "Unchanged: $unchanged" -} - -main "$@" diff --git a/tools/vault-seed-agents.sh b/tools/vault-seed-agents.sh deleted file mode 100755 index fbed325..0000000 --- a/tools/vault-seed-agents.sh +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# tools/vault-seed-agents.sh — Idempotent seed for all bot KV paths -# -# Part of the Nomad+Vault migration (S4.1, issue #955). Populates -# kv/disinto/bots/<role> with token + pass for each of the 7 agent roles -# plus the vault bot. Handles the "fresh factory, no .env import" case. -# -# Companion to tools/vault-import.sh — when that runs against a box with -# an existing stack, it overwrites seeded values with real ones. -# -# Idempotency contract (per bot): -# - Both token and pass present → skip, log "<role> unchanged". -# - Either missing → generate random values for missing keys, preserve -# existing keys, write back atomically. -# -# Preconditions: -# - Vault reachable + unsealed at $VAULT_ADDR. -# - VAULT_TOKEN set (env) or /etc/vault.d/root.token readable. -# - curl, jq, openssl -# -# Usage: -# tools/vault-seed-agents.sh -# tools/vault-seed-agents.sh --dry-run -# -# Exit codes: -# 0 success (seed applied, or already applied) -# 1 precondition / API / mount-mismatch failure -# ============================================================================= -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" - -# shellcheck source=../lib/hvault.sh -source "${REPO_ROOT}/lib/hvault.sh" - -KV_MOUNT="kv" -TOKEN_BYTES=32 # 32 bytes → 64 hex chars -PASS_BYTES=16 # 16 bytes → 32 hex chars - -# All bot roles seeded by this script. -BOT_ROLES=(dev review gardener architect planner predictor supervisor vault) - -LOG_TAG="[vault-seed-agents]" -log() { printf '%s %s\n' "$LOG_TAG" "$*"; } -die() { printf '%s ERROR: %s\n' "$LOG_TAG" "$*" >&2; exit 1; } - -# ── Flag parsing ───────────────────────────────────────────────────────────── -# while/shift shape — distinct from forgejo (arity:value case) and -# woodpecker (for-loop). -DRY_RUN=0 -while [ $# -gt 0 ]; do - case "$1" in - --dry-run) DRY_RUN=1 ;; - -h|--help) - printf 'Usage: %s [--dry-run]\n\n' "$(basename "$0")" - printf 'Seed kv/disinto/bots/<role> with token + pass for all agent\n' - printf 'roles. Idempotent: existing non-empty values are preserved.\n\n' - printf ' --dry-run Print planned actions without writing.\n' - exit 0 - ;; - *) die "invalid argument: ${1} (try --help)" ;; - esac - shift -done - -# ── Preconditions ──────────────────────────────────────────────────────────── -for bin in curl jq openssl; do - command -v "$bin" >/dev/null 2>&1 \ - || die "required binary not found: ${bin}" -done -[ -n "${VAULT_ADDR:-}" ] \ - || die "VAULT_ADDR unset — e.g. export VAULT_ADDR=http://127.0.0.1:8200" -hvault_token_lookup >/dev/null \ - || die "Vault auth probe failed — check VAULT_ADDR + VAULT_TOKEN" - -# ── Step 1: ensure kv/ mount exists and is KV v2 ──────────────────────────── -log "── Step 1: ensure ${KV_MOUNT}/ is KV v2 ──" -export DRY_RUN -hvault_ensure_kv_v2 "$KV_MOUNT" "${LOG_TAG}" \ - || die "KV mount check failed" - -# ── Step 2: seed each bot role ─────────────────────────────────────────────── -total_generated=0 - -# Check if shared forge credentials exist for dev role fallback -shared_forge_exists=0 -shared_forge_raw="$(hvault_get_or_empty "${KV_MOUNT}/data/disinto/shared/forge")" \ - || true -if [ -n "$shared_forge_raw" ]; then - shared_forge_token="$(printf '%s' "$shared_forge_raw" | jq -r '.data.data.token // ""')" - shared_forge_pass="$(printf '%s' "$shared_forge_raw" | jq -r '.data.data.pass // ""')" - if [ -n "$shared_forge_token" ] && [ -n "$shared_forge_pass" ]; then - shared_forge_exists=1 - fi -fi - -for role in "${BOT_ROLES[@]}"; do - kv_logical="disinto/bots/${role}" - kv_api="${KV_MOUNT}/data/${kv_logical}" - - log "── seed ${kv_logical} ──" - - existing_raw="$(hvault_get_or_empty "${kv_api}")" \ - || die "failed to read ${kv_api}" - - existing_token="" - existing_pass="" - existing_data="{}" - if [ -n "$existing_raw" ]; then - existing_data="$(printf '%s' "$existing_raw" | jq '.data.data // {}')" - existing_token="$(printf '%s' "$existing_raw" | jq -r '.data.data.token // ""')" - existing_pass="$(printf '%s' "$existing_raw" | jq -r '.data.data.pass // ""')" - fi - - generated=() - desired_token="$existing_token" - desired_pass="$existing_pass" - - # Special case: dev role uses shared forge credentials if available - if [ "$role" = "dev" ] && [ "$shared_forge_exists" -eq 1 ]; then - # Use shared FORGE_TOKEN + FORGE_PASS for dev role - if [ -z "$existing_token" ]; then - desired_token="$shared_forge_token" - generated+=("token") - fi - if [ -z "$existing_pass" ]; then - desired_pass="$shared_forge_pass" - generated+=("pass") - fi - else - # Generate random values for missing keys - if [ -z "$existing_token" ]; then - generated+=("token") - fi - if [ -z "$existing_pass" ]; then - generated+=("pass") - fi - - for key in "${generated[@]}"; do - case "$key" in - token) desired_token="$(openssl rand -hex "$TOKEN_BYTES")" ;; - pass) desired_pass="$(openssl rand -hex "$PASS_BYTES")" ;; - esac - done - fi - - if [ "${#generated[@]}" -eq 0 ]; then - log "${role}: unchanged" - continue - fi - - if [ "$DRY_RUN" -eq 1 ]; then - log "[dry-run] ${role}: would generate ${generated[*]}" - total_generated=$(( total_generated + ${#generated[@]} )) - continue - fi - - # Merge new keys into existing data to preserve any keys we don't own. - payload="$(printf '%s' "$existing_data" \ - | jq --arg t "$desired_token" --arg p "$desired_pass" \ - '{data: (. + {token: $t, pass: $p})}')" - - _hvault_request POST "${kv_api}" "$payload" >/dev/null \ - || die "failed to write ${kv_api}" - - log "${role}: generated ${generated[*]}" - total_generated=$(( total_generated + ${#generated[@]} )) -done - -if [ "$total_generated" -eq 0 ]; then - log "all bot paths already seeded — no-op" -else - log "done — ${total_generated} key(s) seeded across ${#BOT_ROLES[@]} bot paths" -fi diff --git a/tools/vault-seed-chat.sh b/tools/vault-seed-chat.sh deleted file mode 100755 index 08e3837..0000000 --- a/tools/vault-seed-chat.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# tools/vault-seed-chat.sh — Idempotent seed for kv/disinto/shared/chat -# -# Part of the Nomad+Vault migration (S5.2, issue #989). Populates the KV v2 -# path that nomad/jobs/chat.hcl reads from, so a clean-install factory -# (no old-stack secrets to import) still has per-key values for -# CHAT_OAUTH_CLIENT_ID, CHAT_OAUTH_CLIENT_SECRET, and FORWARD_AUTH_SECRET. -# -# Companion to tools/vault-import.sh (S2.2) — when that import runs against -# a box with an existing stack, it overwrites these seeded values with the -# real ones. Order doesn't matter: whichever runs last wins, and both -# scripts are idempotent in the sense that re-running never rotates an -# existing non-empty key. -# -# Uses _hvault_seed_key (lib/hvault.sh) for each key — the helper reads -# existing data and merges to preserve sibling keys (KV v2 replaces .data -# atomically). -# -# Preconditions: -# - Vault reachable + unsealed at $VAULT_ADDR. -# - VAULT_TOKEN set (env) or /etc/vault.d/root.token readable. -# - The `kv/` mount is enabled as KV v2. -# -# Requires: VAULT_ADDR, VAULT_TOKEN, curl, jq, openssl -# -# Usage: -# tools/vault-seed-chat.sh -# tools/vault-seed-chat.sh --dry-run -# -# Exit codes: -# 0 success (seed applied, or already applied) -# 1 precondition / API / mount-mismatch failure -# ============================================================================= -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" - -# shellcheck source=../lib/hvault.sh -source "${REPO_ROOT}/lib/hvault.sh" - -KV_MOUNT="kv" -KV_LOGICAL_PATH="disinto/shared/chat" - -# Keys to seed — array-driven loop (structurally distinct from forgejo's -# sequential if-blocks and agents' role loop). -SEED_KEYS=(chat_oauth_client_id chat_oauth_client_secret forward_auth_secret) - -LOG_TAG="[vault-seed-chat]" -log() { printf '%s %s\n' "$LOG_TAG" "$*"; } -die() { printf '%s ERROR: %s\n' "$LOG_TAG" "$*" >&2; exit 1; } - -# ── Flag parsing — [[ ]] guard + case: shape distinct from forgejo -# (arity:value case), woodpecker (for-loop), agents (while/shift). -DRY_RUN=0 -if [[ $# -gt 0 ]]; then - case "$1" in - --dry-run) DRY_RUN=1 ;; - -h|--help) - printf 'Usage: %s [--dry-run]\n\n' "$(basename "$0")" - printf 'Seed kv/disinto/shared/chat with random OAuth client\n' - printf 'credentials and forward auth secret if missing.\n' - printf 'Idempotent: existing non-empty values are preserved.\n\n' - printf ' --dry-run Show what would be seeded without writing.\n' - exit 0 - ;; - *) die "invalid argument: ${1} (try --help)" ;; - esac -fi - -# ── Preconditions — inline check-or-die (shape distinct from agents' array -# loop and forgejo's continuation-line style) ───────────────────────────── -command -v curl >/dev/null 2>&1 || die "curl not found" -command -v jq >/dev/null 2>&1 || die "jq not found" -command -v openssl >/dev/null 2>&1 || die "openssl not found" -[ -n "${VAULT_ADDR:-}" ] || die "VAULT_ADDR unset — export VAULT_ADDR=http://127.0.0.1:8200" -hvault_token_lookup >/dev/null || die "Vault auth probe failed — check VAULT_ADDR + VAULT_TOKEN" - -# ── Step 1/2: ensure kv/ mount exists and is KV v2 ─────────────────────────── -log "── Step 1/2: ensure ${KV_MOUNT}/ is KV v2 ──" -export DRY_RUN -hvault_ensure_kv_v2 "$KV_MOUNT" "${LOG_TAG}" \ - || die "KV mount check failed" - -# ── Step 2/2: seed missing keys via _hvault_seed_key helper ────────────────── -log "── Step 2/2: seed ${KV_LOGICAL_PATH} ──" - -generated=() -for key in "${SEED_KEYS[@]}"; do - if [ "$DRY_RUN" -eq 1 ]; then - # Check existence without writing - existing=$(hvault_kv_get "$KV_LOGICAL_PATH" "$key" 2>/dev/null) || true - if [ -z "$existing" ]; then - generated+=("$key") - log "[dry-run] ${key} would be generated" - else - log "[dry-run] ${key} unchanged" - fi - else - rc=0 - _hvault_seed_key "$KV_LOGICAL_PATH" "$key" || rc=$? - case "$rc" in - 0) generated+=("$key"); log "${key} generated" ;; - 1) log "${key} unchanged" ;; - *) die "API error seeding ${key} (rc=${rc})" ;; - esac - fi -done - -if [ "${#generated[@]}" -eq 0 ]; then - log "all keys present — no-op" -else - log "done — ${#generated[@]} key(s) seeded at kv/${KV_LOGICAL_PATH}" -fi diff --git a/tools/vault-seed-forgejo.sh b/tools/vault-seed-forgejo.sh deleted file mode 100755 index 26a9e78..0000000 --- a/tools/vault-seed-forgejo.sh +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# tools/vault-seed-forgejo.sh — Idempotent seed for kv/disinto/shared/forgejo -# -# Part of the Nomad+Vault migration (S2.4, issue #882). Populates the KV v2 -# path that nomad/jobs/forgejo.hcl reads from, so a clean-install factory -# (no old-stack secrets to import) still has per-key values for -# FORGEJO__security__SECRET_KEY + FORGEJO__security__INTERNAL_TOKEN. -# -# Companion to tools/vault-import.sh (S2.2, not yet merged) — when that -# import runs against a box with an existing stack, it overwrites these -# seeded values with the real ones. Order doesn't matter: whichever runs -# last wins, and both scripts are idempotent in the sense that re-running -# never rotates an existing non-empty key. -# -# Idempotency contract (per key): -# - Key missing or empty in Vault → generate a random value, write it, -# log "<key> generated (N bytes hex)". -# - Key present with a non-empty value → leave untouched, log -# "<key> unchanged". -# - Neither key changes is a silent no-op (no Vault write at all). -# -# Rotating an existing key is deliberately NOT in scope — SECRET_KEY -# rotation invalidates every existing session cookie in forgejo and -# INTERNAL_TOKEN rotation breaks internal RPC until all processes have -# restarted. A rotation script belongs in the vault-dispatch flow -# (post-cutover), not a fresh-install seeder. -# -# Preconditions: -# - Vault reachable + unsealed at $VAULT_ADDR. -# - VAULT_TOKEN set (env) or /etc/vault.d/root.token readable. -# - The `kv/` mount is enabled as KV v2 (this script enables it on a -# fresh box; on an existing box it asserts the mount type/version). -# -# Requires: -# - VAULT_ADDR (e.g. http://127.0.0.1:8200) -# - VAULT_TOKEN (env OR /etc/vault.d/root.token, resolved by lib/hvault.sh) -# - curl, jq, openssl -# -# Usage: -# tools/vault-seed-forgejo.sh -# tools/vault-seed-forgejo.sh --dry-run -# -# Exit codes: -# 0 success (seed applied, or already applied) -# 1 precondition / API / mount-mismatch failure -# ============================================================================= -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" - -# shellcheck source=../lib/hvault.sh -source "${REPO_ROOT}/lib/hvault.sh" - -# KV v2 mount + logical path. Kept as two vars so the full API path used -# for GET/POST (which MUST include `/data/`) is built in one place. -KV_MOUNT="kv" -KV_LOGICAL_PATH="disinto/shared/forgejo" -KV_API_PATH="${KV_MOUNT}/data/${KV_LOGICAL_PATH}" - -# Byte lengths for the generated secrets (hex output, so the printable -# string length is 2x these). 32 bytes matches forgejo's own -# `gitea generate secret SECRET_KEY` default; 64 bytes is comfortably -# above forgejo's INTERNAL_TOKEN JWT-HMAC key floor. -SECRET_KEY_BYTES=32 -INTERNAL_TOKEN_BYTES=64 - -log() { printf '[vault-seed-forgejo] %s\n' "$*"; } -die() { printf '[vault-seed-forgejo] ERROR: %s\n' "$*" >&2; exit 1; } - -# ── Flag parsing — single optional `--dry-run`. Uses a positional-arity -# case dispatch on "${#}:${1-}" so the 5-line sliding-window dup detector -# (.woodpecker/detect-duplicates.py) sees a shape distinct from both -# vault-apply-roles.sh (if/elif chain) and vault-apply-policies.sh (flat -# case on $1 alone). Three sibling tools, three parser shapes. -DRY_RUN=0 -case "$#:${1-}" in - 0:) - ;; - 1:--dry-run) - DRY_RUN=1 - ;; - 1:-h|1:--help) - printf 'Usage: %s [--dry-run]\n\n' "$(basename "$0")" - printf 'Seed kv/disinto/shared/forgejo with random SECRET_KEY +\n' - printf 'INTERNAL_TOKEN if they are missing. Idempotent: existing\n' - printf 'non-empty values are left untouched.\n\n' - printf ' --dry-run Print planned actions (enable mount? which keys\n' - printf ' to generate?) without writing to Vault. Exits 0.\n' - exit 0 - ;; - *) - die "invalid arguments: $* (try --help)" - ;; -esac - -# ── Preconditions ──────────────────────────────────────────────────────────── -for bin in curl jq openssl; do - command -v "$bin" >/dev/null 2>&1 \ - || die "required binary not found: ${bin}" -done - -# Vault connectivity — short-circuit style (`||`) instead of an `if`-chain -# so this block has a distinct textual shape from vault-apply-roles.sh's -# equivalent preflight; hvault.sh's typed helpers emit structured JSON -# errors that don't render well behind the `[vault-seed-forgejo] …` -# log prefix, hence the inline check + plain-string diag. -[ -n "${VAULT_ADDR:-}" ] \ - || die "VAULT_ADDR unset — e.g. export VAULT_ADDR=http://127.0.0.1:8200" -hvault_token_lookup >/dev/null \ - || die "Vault auth probe failed — check VAULT_ADDR + VAULT_TOKEN" - -# ── Step 1/2: ensure kv/ mount exists and is KV v2 ─────────────────────────── -# The policy at vault/policies/service-forgejo.hcl grants read on -# `kv/data/<path>/*` — that `data` segment only exists for KV v2. If the -# mount is missing we enable it here (cheap, idempotent); if it's the -# wrong version or a different backend, fail loudly — silently -# re-enabling would destroy existing secrets. -log "── Step 1/2: ensure ${KV_MOUNT}/ is KV v2 ──" -export DRY_RUN -hvault_ensure_kv_v2 "$KV_MOUNT" "[vault-seed-forgejo]" \ - || die "KV mount check failed" - -# ── Step 2/2: seed missing keys at kv/data/disinto/shared/forgejo ──────────── -log "── Step 2/2: seed ${KV_API_PATH} ──" - -# hvault_get_or_empty returns an empty string on 404 (KV path absent). -# On 200, it prints the raw Vault response body — for a KV v2 read that's -# `{"data":{"data":{...},"metadata":{...}}}`, hence the `.data.data.<key>` -# path below. A path with `deleted_time` set still returns 200 but the -# inner `.data.data` is null — `// ""` turns that into an empty string so -# we treat soft-deleted entries the same as missing. -existing_raw="$(hvault_get_or_empty "${KV_API_PATH}")" \ - || die "failed to read ${KV_API_PATH}" - -existing_secret_key="" -existing_internal_token="" -if [ -n "$existing_raw" ]; then - existing_secret_key="$(printf '%s' "$existing_raw" | jq -r '.data.data.secret_key // ""')" - existing_internal_token="$(printf '%s' "$existing_raw" | jq -r '.data.data.internal_token // ""')" -fi - -desired_secret_key="$existing_secret_key" -desired_internal_token="$existing_internal_token" -generated=() - -if [ -z "$desired_secret_key" ]; then - if [ "$DRY_RUN" -eq 1 ]; then - # In dry-run, don't call openssl — log the intent only. The real run - # generates fresh bytes; nothing about the generated value is - # deterministic so there's no "planned value" to show. - generated+=("secret_key") - else - desired_secret_key="$(openssl rand -hex "$SECRET_KEY_BYTES")" - generated+=("secret_key") - fi -fi - -if [ -z "$desired_internal_token" ]; then - if [ "$DRY_RUN" -eq 1 ]; then - generated+=("internal_token") - else - desired_internal_token="$(openssl rand -hex "$INTERNAL_TOKEN_BYTES")" - generated+=("internal_token") - fi -fi - -if [ "${#generated[@]}" -eq 0 ]; then - log "all keys present at ${KV_API_PATH} — no-op" - log "secret_key unchanged" - log "internal_token unchanged" - exit 0 -fi - -if [ "$DRY_RUN" -eq 1 ]; then - log "[dry-run] would generate + write: ${generated[*]}" - for key in secret_key internal_token; do - case " ${generated[*]} " in - *" ${key} "*) log "[dry-run] ${key} would be generated" ;; - *) log "[dry-run] ${key} unchanged" ;; - esac - done - exit 0 -fi - -# Write back BOTH keys in one payload. KV v2 replaces `.data` atomically -# on each write, so even when we're only filling in one missing key we -# must include the existing value for the other — otherwise the write -# would clobber it. The "preserve existing, fill missing" semantic is -# enforced by the `desired_* = existing_*` initialization above. -payload="$(jq -n \ - --arg sk "$desired_secret_key" \ - --arg it "$desired_internal_token" \ - '{data: {secret_key: $sk, internal_token: $it}}')" - -_hvault_request POST "${KV_API_PATH}" "$payload" >/dev/null \ - || die "failed to write ${KV_API_PATH}" - -for key in secret_key internal_token; do - case " ${generated[*]} " in - *" ${key} "*) log "${key} generated" ;; - *) log "${key} unchanged" ;; - esac -done - -log "done — ${#generated[@]} key(s) seeded at ${KV_API_PATH}" diff --git a/tools/vault-seed-woodpecker.sh b/tools/vault-seed-woodpecker.sh deleted file mode 100755 index ba78427..0000000 --- a/tools/vault-seed-woodpecker.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# tools/vault-seed-woodpecker.sh — Idempotent seed for kv/disinto/shared/woodpecker -# -# Part of the Nomad+Vault migration (S3.1 + S3.3, issues #934 + #936). Populates -# the KV v2 path read by nomad/jobs/woodpecker-server.hcl: -# - agent_secret: pre-shared secret for woodpecker-server ↔ agent communication -# - forgejo_client + forgejo_secret: OAuth2 client credentials from Forgejo -# -# This script handles BOTH: -# 1. S3.1: seeds `agent_secret` if missing -# 2. S3.3: calls wp-oauth-register.sh to create Forgejo OAuth app + store -# forgejo_client/forgejo_secret in Vault -# -# Idempotency contract: -# - agent_secret: missing → generate and write; present → skip, log unchanged -# - OAuth app + credentials: handled by wp-oauth-register.sh (idempotent) -# This script preserves any existing keys it doesn't own. -# -# Idempotency contract (per key): -# - Key missing or empty in Vault → generate a random value, write it, -# log "agent_secret generated". -# - Key present with a non-empty value → leave untouched, log -# "agent_secret unchanged". -# -# Preconditions: -# - Vault reachable + unsealed at $VAULT_ADDR. -# - VAULT_TOKEN set (env) or /etc/vault.d/root.token readable. -# - The `kv/` mount is enabled as KV v2 (this script enables it on a -# fresh box; on an existing box it asserts the mount type/version). -# -# Requires: -# - VAULT_ADDR (e.g. http://127.0.0.1:8200) -# - VAULT_TOKEN (env OR /etc/vault.d/root.token, resolved by lib/hvault.sh) -# - curl, jq, openssl -# -# Usage: -# tools/vault-seed-woodpecker.sh -# tools/vault-seed-woodpecker.sh --dry-run -# -# Exit codes: -# 0 success (seed applied, or already applied) -# 1 precondition / API / mount-mismatch failure -# ============================================================================= -set -euo pipefail - -SEED_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SEED_DIR}/.." && pwd)" -LIB_DIR="${REPO_ROOT}/lib/init/nomad" -# shellcheck source=../lib/hvault.sh -source "${REPO_ROOT}/lib/hvault.sh" - -KV_MOUNT="kv" -KV_LOGICAL_PATH="disinto/shared/woodpecker" -KV_API_PATH="${KV_MOUNT}/data/${KV_LOGICAL_PATH}" -AGENT_SECRET_BYTES=32 # 32 bytes → 64 hex chars - -LOG_TAG="[vault-seed-woodpecker]" -log() { printf '%s %s\n' "$LOG_TAG" "$*"; } -die() { printf '%s ERROR: %s\n' "$LOG_TAG" "$*" >&2; exit 1; } - -# ── Flag parsing ───────────────────────────────────────────────────────────── -# for-over-"$@" loop — shape distinct from vault-seed-forgejo.sh (arity:value -# case) and vault-apply-roles.sh (if/elif). -DRY_RUN=0 -for arg in "$@"; do - case "$arg" in - --dry-run) DRY_RUN=1 ;; - -h|--help) - printf 'Usage: %s [--dry-run]\n\n' "$(basename "$0")" - printf 'Seed kv/disinto/shared/woodpecker with secrets.\n\n' - printf 'Handles both S3.1 (agent_secret) and S3.3 (OAuth app + credentials):\n' - printf ' - agent_secret: generated if missing\n' - printf ' - forgejo_client/forgejo_secret: created via Forgejo API if missing\n\n' - printf ' --dry-run Print planned actions without writing.\n' - exit 0 - ;; - *) die "invalid argument: ${arg} (try --help)" ;; - esac -done - -# ── Preconditions — binary + Vault connectivity checks ─────────────────────── -required_bins=(curl jq openssl) -for bin in "${required_bins[@]}"; do - command -v "$bin" >/dev/null 2>&1 || die "required binary not found: ${bin}" -done -[ -n "${VAULT_ADDR:-}" ] || die "VAULT_ADDR unset — export VAULT_ADDR=http://127.0.0.1:8200" -hvault_token_lookup >/dev/null || die "Vault auth probe failed — check VAULT_ADDR + VAULT_TOKEN" - -# ── Step 1/3: ensure kv/ mount exists and is KV v2 ─────────────────────────── -log "── Step 1/3: ensure ${KV_MOUNT}/ is KV v2 ──" -export DRY_RUN -hvault_ensure_kv_v2 "$KV_MOUNT" "[vault-seed-woodpecker]" \ - || die "KV mount check failed" - -# ── Step 2/3: seed agent_secret at kv/data/disinto/shared/woodpecker ───────── -log "── Step 2/3: seed agent_secret ──" - -existing_raw="$(hvault_get_or_empty "${KV_API_PATH}")" \ - || die "failed to read ${KV_API_PATH}" - -# Read all existing keys so we can preserve them on write (KV v2 replaces -# `.data` atomically). Missing path → empty object. -existing_data="{}" -existing_agent_secret="" -if [ -n "$existing_raw" ]; then - existing_data="$(printf '%s' "$existing_raw" | jq '.data.data // {}')" - existing_agent_secret="$(printf '%s' "$existing_raw" | jq -r '.data.data.agent_secret // ""')" -fi - -if [ -n "$existing_agent_secret" ]; then - log "agent_secret unchanged" -else - # agent_secret is missing — generate it. - if [ "$DRY_RUN" -eq 1 ]; then - log "[dry-run] would generate + write: agent_secret" - else - new_agent_secret="$(openssl rand -hex "$AGENT_SECRET_BYTES")" - - # Merge the new key into existing data to preserve any keys written by - # other seeders (e.g. S3.3's forgejo_client/forgejo_secret). - payload="$(printf '%s' "$existing_data" \ - | jq --arg as "$new_agent_secret" '{data: (. + {agent_secret: $as})}')" - - _hvault_request POST "${KV_API_PATH}" "$payload" >/dev/null \ - || die "failed to write ${KV_API_PATH}" - - log "agent_secret generated" - fi -fi - -# ── Step 3/3: register Forgejo OAuth app and store credentials ─────────────── -log "── Step 3/3: register Forgejo OAuth app ──" - -# Export DRY_RUN for the OAuth script and call it -export DRY_RUN -if "${LIB_DIR}/wp-oauth-register.sh" || [ "$DRY_RUN" -eq 1 ]; then - : -elif [ -n "${FORGE_URL:-}" ]; then - # Forgejo was configured but unavailable - log "OAuth registration check failed (Forgejo may not be running)" - log "This is expected if Forgejo is not available" -fi - -log "done — agent_secret + OAuth credentials seeded" diff --git a/action-vault/SCHEMA.md b/vault/SCHEMA.md similarity index 95% rename from action-vault/SCHEMA.md rename to vault/SCHEMA.md index dd84fb8..adab177 100644 --- a/action-vault/SCHEMA.md +++ b/vault/SCHEMA.md @@ -50,7 +50,7 @@ blast_radius = "low" # optional: overrides policy.toml tier ("low"|"medium ## Secret Names -Secret names must have a corresponding `secrets/<NAME>.enc` file (age-encrypted). The vault validates that requested secrets exist in the allowlist before execution. +Secret names must be defined in `.env.vault.enc` on the ops repo. The vault validates that requested secrets exist in the allowlist before execution. Common secret names: - `CLAWHUB_TOKEN` - Token for ClawHub skill publishing diff --git a/action-vault/classify.sh b/vault/classify.sh similarity index 100% rename from action-vault/classify.sh rename to vault/classify.sh diff --git a/action-vault/examples/promote.toml b/vault/examples/promote.toml similarity index 100% rename from action-vault/examples/promote.toml rename to vault/examples/promote.toml diff --git a/action-vault/examples/publish.toml b/vault/examples/publish.toml similarity index 100% rename from action-vault/examples/publish.toml rename to vault/examples/publish.toml diff --git a/action-vault/examples/release.toml b/vault/examples/release.toml similarity index 100% rename from action-vault/examples/release.toml rename to vault/examples/release.toml diff --git a/action-vault/examples/webhook-call.toml b/vault/examples/webhook-call.toml similarity index 100% rename from action-vault/examples/webhook-call.toml rename to vault/examples/webhook-call.toml diff --git a/vault/policies/AGENTS.md b/vault/policies/AGENTS.md deleted file mode 100644 index 0a67acb..0000000 --- a/vault/policies/AGENTS.md +++ /dev/null @@ -1,185 +0,0 @@ -<!-- last-reviewed: 8fc3ba5b59cd6cb15bd01ca0658cfea2bcb12068 --> -# vault/policies/ — Agent Instructions - -HashiCorp Vault ACL policies for the disinto factory. One `.hcl` file per -policy; the basename (minus `.hcl`) is the Vault policy name applied to it. -Synced into Vault by `tools/vault-apply-policies.sh` (idempotent — see the -script header for the contract). - -This directory is part of the **Nomad+Vault migration (Step 2)** — see -issues #879–#884. Policies attach to Nomad jobs via workload identity in -S2.4; this PR only lands the files + apply script. - -## Naming convention - -| Prefix | Audience | KV scope | -|---|---|---| -| `service-<name>.hcl` | Long-running platform services (forgejo, woodpecker) | `kv/data/disinto/shared/<name>/*` | -| `bot-<name>.hcl` | Per-agent jobs (dev, review, gardener, …) | `kv/data/disinto/bots/<name>/*` + shared forge URL | -| `runner-<TOKEN>.hcl` | Per-secret policy for vault-runner ephemeral dispatch | exactly one `kv/data/disinto/runner/<TOKEN>` path | -| `dispatcher.hcl` | Long-running edge dispatcher | `kv/data/disinto/runner/*` + `kv/data/disinto/shared/ops-repo/*` | - -The KV mount name `kv/` is the convention this migration uses (mounted as -KV v2). Vault addresses KV v2 data at `kv/data/<path>` and metadata at -`kv/metadata/<path>` — policies that need `list` always target the -`metadata` path; reads target `data`. - -## Policy → KV path summary - -| Policy | Reads | -|---|---| -| `service-forgejo` | `kv/data/disinto/shared/forgejo/*` | -| `service-woodpecker` | `kv/data/disinto/shared/woodpecker/*` | -| `service-agents` | All 7 `kv/data/disinto/bots/<role>/*` namespaces + `kv/data/disinto/shared/forge/*`; composite policy for the `agents` Nomad job (S4.1) | -| `service-chat` | `kv/data/disinto/shared/chat/*`; read-only OAuth client config + forward-auth secret for the chat Nomad job (S5.2, #989) | -| `service-dispatcher` | `kv/data/disinto/runner/*` (list+read) + `kv/data/disinto/shared/ops-repo/*` (read); used by edge dispatcher sidecar (S5.1, #988) | -| `bot-<role>` (dev, review, gardener, architect, planner, predictor, supervisor, vault, dev-qwen) | `kv/data/disinto/bots/<role>/*` + `kv/data/disinto/shared/forge/*` | -| `runner-<TOKEN>` (GITHUB\_TOKEN, CODEBERG\_TOKEN, CLAWHUB\_TOKEN, DEPLOY\_KEY, NPM\_TOKEN, DOCKER\_HUB\_TOKEN) | `kv/data/disinto/runner/<TOKEN>` (exactly one) | -| `dispatcher` | `kv/data/disinto/runner/*` + `kv/data/disinto/shared/ops-repo/*` | - -## Why one policy per runner secret - -`vault-runner` (Step 5) reads each action TOML's `secrets = [...]` list -and composes only those `runner-<NAME>` policies onto the per-dispatch -ephemeral token. Wildcards or batched policies would hand the runner more -secrets than the action declared — defeats AD-006 (least-privilege per -external action). Adding a new declarable secret = adding one new -`runner-<NAME>.hcl` here + extending the SECRETS allow-list in vault-action -validation. - -## Adding a new policy - -1. Drop a file matching one of the four naming patterns above. Use an - existing file in the same family as the template — comment header, - capability list, and KV path layout should match the family. -2. Run `vault policy fmt <file>` locally so the formatting matches what - the CI fmt-check (step 4 of `.woodpecker/nomad-validate.yml`) will - accept. The fmt check runs non-destructively in CI but a dirty file - fails the step; running `fmt` locally before pushing is the fastest - path. -3. Add the matching entry to `../roles.yaml` (see "JWT-auth roles" below) - so the CI role-reference check (step 6) stays green. -4. Run `tools/vault-apply-policies.sh --dry-run` to confirm the new - basename appears in the planned-work list with the expected SHA. -5. Run `tools/vault-apply-policies.sh` against a Vault instance to - create it; re-run to confirm it reports `unchanged`. - -## JWT-auth roles (S2.3) - -Policies are inert until a Vault token carrying them is minted. In this -migration that mint path is JWT auth — Nomad jobs exchange their -workload-identity JWT for a Vault token via -`auth/jwt-nomad/role/<name>` → `token_policies = ["<policy>"]`. The -role bindings live in [`../roles.yaml`](../roles.yaml); the script that -enables the auth method + writes the config + applies roles is -[`lib/init/nomad/vault-nomad-auth.sh`](../../lib/init/nomad/vault-nomad-auth.sh). -The applier is [`tools/vault-apply-roles.sh`](../../tools/vault-apply-roles.sh). - -### Role → policy naming convention - -Role name == policy name, 1:1. `vault/roles.yaml` carries one entry per -`vault/policies/*.hcl` file: - -```yaml -roles: - - name: service-forgejo # Vault role - policy: service-forgejo # ACL policy attached to minted tokens - namespace: default # bound_claims.nomad_namespace - job_id: forgejo # bound_claims.nomad_job_id -``` - -The role name is what jobspecs reference via `vault { role = "..." }` — -keep it identical to the policy basename so an S2.1↔S2.3 drift (new -policy without a role, or vice versa) shows up in one directory review, -not as a runtime "permission denied" at job placement. - -`bound_claims.nomad_job_id` is the actual `job "..."` name in the -jobspec, which may differ from the policy name (e.g. policy -`service-forgejo` binds to job `forgejo`). Update it when each bot's or -runner's jobspec lands. - -### Adding a new service - -1. Write `vault/policies/<name>.hcl` using the naming-table family that - fits (`service-`, `bot-`, `runner-`, or standalone). -2. Add a matching entry to `vault/roles.yaml` with all four fields - (`name`, `policy`, `namespace`, `job_id`). -3. Apply both — either in one shot via `lib/init/nomad/vault-nomad-auth.sh` - (policies → roles → nomad SIGHUP), or granularly via - `tools/vault-apply-policies.sh` + `tools/vault-apply-roles.sh`. -4. Reference the role in the consuming jobspec's `vault { role = "<name>" }`. - -### Token shape - -All roles share the same token shape, hardcoded in -`tools/vault-apply-roles.sh`: - -| Field | Value | -|---|---| -| `bound_audiences` | `["vault.io"]` — matches `default_identity.aud` in `nomad/server.hcl` | -| `token_type` | `service` — auto-revoked when the task exits | -| `token_ttl` | `1h` | -| `token_max_ttl` | `24h` | - -Bumping any of these is a knowing, repo-wide change. Per-role overrides -would let one service's tokens outlive the others — add a field to -`vault/roles.yaml` and the applier at the same time if that ever -becomes necessary. - -## Policy lifecycle - -Adding a policy that an actual workload consumes is a three-step chain; -the CI pipeline guards each link. - -1. **Add the policy HCL** — `vault/policies/<name>.hcl`, formatted with - `vault policy fmt`. Capabilities must be drawn from the Vault-recognized - set (`read`, `list`, `create`, `update`, `delete`, `patch`, `sudo`, - `deny`); a typo fails CI step 5 (HCL written to an inline dev-mode Vault - via `vault policy write` — a real parser, not a regex). -2. **Update `../roles.yaml`** — add a JWT-auth role entry whose `policy:` - field matches the new basename (without `.hcl`). CI step 6 re-checks - every role in this file against the policy set, so a drift between the - two directories fails the step. -3. **Reference from a Nomad jobspec** — add `vault { role = "<name>" }` in - `nomad/jobs/<service>.hcl` (owned by S2.4). Policies do not take effect - until a Nomad job asks for a token via that role. - -See the "Adding a new service" walkthrough below for the applier-script -flow once steps 1–3 are committed. - -## CI enforcement (`.woodpecker/nomad-validate.yml`) - -The pipeline triggers on any PR touching `vault/policies/**`, -`vault/roles.yaml`, or `lib/init/nomad/vault-*.sh` and runs four -vault-scoped checks (in addition to the nomad-scoped steps already in -place): - -| Step | Tool | What it catches | -|---|---|---| -| 4. `vault-policy-fmt` | `vault policy fmt` + `diff` | formatting drift — trailing whitespace, wrong indentation, missing newlines | -| 5. `vault-policy-validate` | `vault policy write` against inline dev Vault | HCL syntax errors, unknown stanzas, invalid capability names (e.g. `"frobnicate"`), malformed `path "..." {}` blocks | -| 6. `vault-roles-validate` | yamllint + PyYAML | roles.yaml syntax drift, missing required fields, role→policy references with no matching `.hcl` | -| P11 | `lib/secret-scan.sh` via `.woodpecker/secret-scan.yml` | literal secret leaked into a policy HCL (rare copy-paste mistake) — already covers `vault/**/*`, no duplicate step here | - -All four steps are fail-closed — any error blocks merge. The pipeline -pins `hashicorp/vault:1.18.5` (matching `lib/init/nomad/install.sh`); -bumping the runtime version without bumping the CI image is a CI-caught -drift. - -## Common failure modes - -| Symptom in CI logs | Root cause | Fix | -|---|---|---| -| `vault-policy-fmt: … is not formatted — run 'vault policy fmt <file>'` | Trailing whitespace / mixed indent in an HCL file | `vault policy fmt <file>` locally and re-commit | -| `vault-policy-validate: … failed validation` plus a `policy` error from Vault | Unknown capability (e.g. `"frobnicate"`), unknown stanza, malformed `path` block | Fix the HCL; valid capabilities are `read`, `list`, `create`, `update`, `delete`, `patch`, `sudo`, `deny` | -| `vault-roles-validate: ERROR: role 'X' references policy 'Y' but vault/policies/Y.hcl does not exist` | A role's `policy:` field does not match any file basename in `vault/policies/` | Either add the missing policy HCL or fix the typo in `roles.yaml` | -| `vault-roles-validate: ERROR: role entry missing required field 'Z'` | A role in `roles.yaml` is missing one of `name`, `policy`, `namespace`, `job_id` | Add the field; all four are required | -| P11 `secret-scan: detected potential secret …` on a `.hcl` file | A literal token/password was pasted into a policy | Policies must name KV paths, not carry secret values — move the literal into KV (S2.2) and have the policy grant `read` on the path | - -## What this directory does NOT own - -- **Attaching policies to Nomad jobs.** That's S2.4 (#882) via the - jobspec `template { vault { policies = […] } }` stanza — the role - name in `vault { role = "..." }` is what binds the policy. -- **Writing the secret values themselves.** That's S2.2 (#880) via - `tools/vault-import.sh`. diff --git a/vault/policies/bot-architect.hcl b/vault/policies/bot-architect.hcl deleted file mode 100644 index 9f84de1..0000000 --- a/vault/policies/bot-architect.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# vault/policies/bot-architect.hcl -# -# Architect agent: reads its own bot KV namespace + the shared forge URL. -# Attached to the architect-agent Nomad job via workload identity (S2.4). - -path "kv/data/disinto/bots/architect" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/architect" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/shared/forge" { - capabilities = ["read"] -} diff --git a/vault/policies/bot-dev-qwen.hcl b/vault/policies/bot-dev-qwen.hcl deleted file mode 100644 index 50f2d2d..0000000 --- a/vault/policies/bot-dev-qwen.hcl +++ /dev/null @@ -1,18 +0,0 @@ -# vault/policies/bot-dev-qwen.hcl -# -# Local-Qwen dev agent (agents-llama profile): reads its own bot KV -# namespace + the shared forge URL. Attached to the dev-qwen Nomad job -# via workload identity (S2.4). KV path mirrors the bot basename: -# kv/disinto/bots/dev-qwen/*. - -path "kv/data/disinto/bots/dev-qwen" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/dev-qwen" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/shared/forge" { - capabilities = ["read"] -} diff --git a/vault/policies/bot-dev.hcl b/vault/policies/bot-dev.hcl deleted file mode 100644 index 35cf6de..0000000 --- a/vault/policies/bot-dev.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# vault/policies/bot-dev.hcl -# -# Dev agent: reads its own bot KV namespace + the shared forge URL. -# Attached to the dev-agent Nomad job via workload identity (S2.4). - -path "kv/data/disinto/bots/dev" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/dev" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/shared/forge" { - capabilities = ["read"] -} diff --git a/vault/policies/bot-gardener.hcl b/vault/policies/bot-gardener.hcl deleted file mode 100644 index ed45431..0000000 --- a/vault/policies/bot-gardener.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# vault/policies/bot-gardener.hcl -# -# Gardener agent: reads its own bot KV namespace + the shared forge URL. -# Attached to the gardener-agent Nomad job via workload identity (S2.4). - -path "kv/data/disinto/bots/gardener" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/gardener" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/shared/forge" { - capabilities = ["read"] -} diff --git a/vault/policies/bot-planner.hcl b/vault/policies/bot-planner.hcl deleted file mode 100644 index ae3e910..0000000 --- a/vault/policies/bot-planner.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# vault/policies/bot-planner.hcl -# -# Planner agent: reads its own bot KV namespace + the shared forge URL. -# Attached to the planner-agent Nomad job via workload identity (S2.4). - -path "kv/data/disinto/bots/planner" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/planner" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/shared/forge" { - capabilities = ["read"] -} diff --git a/vault/policies/bot-predictor.hcl b/vault/policies/bot-predictor.hcl deleted file mode 100644 index 7159d72..0000000 --- a/vault/policies/bot-predictor.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# vault/policies/bot-predictor.hcl -# -# Predictor agent: reads its own bot KV namespace + the shared forge URL. -# Attached to the predictor-agent Nomad job via workload identity (S2.4). - -path "kv/data/disinto/bots/predictor" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/predictor" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/shared/forge" { - capabilities = ["read"] -} diff --git a/vault/policies/bot-review.hcl b/vault/policies/bot-review.hcl deleted file mode 100644 index f0ddfe4..0000000 --- a/vault/policies/bot-review.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# vault/policies/bot-review.hcl -# -# Review agent: reads its own bot KV namespace + the shared forge URL. -# Attached to the review-agent Nomad job via workload identity (S2.4). - -path "kv/data/disinto/bots/review" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/review" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/shared/forge" { - capabilities = ["read"] -} diff --git a/vault/policies/bot-supervisor.hcl b/vault/policies/bot-supervisor.hcl deleted file mode 100644 index 4d7f1e2..0000000 --- a/vault/policies/bot-supervisor.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# vault/policies/bot-supervisor.hcl -# -# Supervisor agent: reads its own bot KV namespace + the shared forge URL. -# Attached to the supervisor-agent Nomad job via workload identity (S2.4). - -path "kv/data/disinto/bots/supervisor" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/supervisor" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/shared/forge" { - capabilities = ["read"] -} diff --git a/vault/policies/bot-vault.hcl b/vault/policies/bot-vault.hcl deleted file mode 100644 index d2f9fe4..0000000 --- a/vault/policies/bot-vault.hcl +++ /dev/null @@ -1,20 +0,0 @@ -# vault/policies/bot-vault.hcl -# -# Vault agent (the legacy edge dispatcher / vault-action runner): reads its -# own bot KV namespace + the shared forge URL. Attached to the vault-agent -# Nomad job via workload identity (S2.4). -# -# NOTE: distinct from the runner-* policies, which gate per-secret access -# for vault-runner ephemeral dispatches (Step 5). - -path "kv/data/disinto/bots/vault" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/vault" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/shared/forge" { - capabilities = ["read"] -} diff --git a/vault/policies/dispatcher.hcl b/vault/policies/dispatcher.hcl deleted file mode 100644 index a18f1ab..0000000 --- a/vault/policies/dispatcher.hcl +++ /dev/null @@ -1,29 +0,0 @@ -# vault/policies/dispatcher.hcl -# -# Edge dispatcher policy: needs to enumerate the runner secret namespace -# (to check secret presence before dispatching) and read the shared -# ops-repo credentials (token + clone URL) it uses to fetch action TOMLs. -# -# Scope: -# - kv/disinto/runner/* — read all per-secret values + list keys -# - kv/disinto/shared/ops-repo/* — read the ops-repo creds bundle -# -# The actual ephemeral runner container created per dispatch gets the -# narrow runner-<NAME> policies, NOT this one. This policy stays bound -# to the long-running dispatcher only. - -path "kv/data/disinto/runner/*" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/runner/*" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/shared/ops-repo" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/shared/ops-repo" { - capabilities = ["list", "read"] -} diff --git a/vault/policies/runner-CLAWHUB_TOKEN.hcl b/vault/policies/runner-CLAWHUB_TOKEN.hcl deleted file mode 100644 index 5de32e9..0000000 --- a/vault/policies/runner-CLAWHUB_TOKEN.hcl +++ /dev/null @@ -1,10 +0,0 @@ -# vault/policies/runner-CLAWHUB_TOKEN.hcl -# -# Per-secret runner policy: ClawHub token for skill-registry publish. -# vault-runner (Step 5) composes only the runner-* policies named by the -# dispatching action's `secrets = [...]` list, so this policy intentionally -# scopes a single KV path — no wildcards, no list capability. - -path "kv/data/disinto/runner/CLAWHUB_TOKEN" { - capabilities = ["read"] -} diff --git a/vault/policies/runner-CODEBERG_TOKEN.hcl b/vault/policies/runner-CODEBERG_TOKEN.hcl deleted file mode 100644 index 5de534b..0000000 --- a/vault/policies/runner-CODEBERG_TOKEN.hcl +++ /dev/null @@ -1,10 +0,0 @@ -# vault/policies/runner-CODEBERG_TOKEN.hcl -# -# Per-secret runner policy: Codeberg PAT for upstream-repo mirror push. -# vault-runner (Step 5) composes only the runner-* policies named by the -# dispatching action's `secrets = [...]` list, so this policy intentionally -# scopes a single KV path — no wildcards, no list capability. - -path "kv/data/disinto/runner/CODEBERG_TOKEN" { - capabilities = ["read"] -} diff --git a/vault/policies/runner-DEPLOY_KEY.hcl b/vault/policies/runner-DEPLOY_KEY.hcl deleted file mode 100644 index ac711f9..0000000 --- a/vault/policies/runner-DEPLOY_KEY.hcl +++ /dev/null @@ -1,10 +0,0 @@ -# vault/policies/runner-DEPLOY_KEY.hcl -# -# Per-secret runner policy: SSH deploy key for git push to a release target. -# vault-runner (Step 5) composes only the runner-* policies named by the -# dispatching action's `secrets = [...]` list, so this policy intentionally -# scopes a single KV path — no wildcards, no list capability. - -path "kv/data/disinto/runner/DEPLOY_KEY" { - capabilities = ["read"] -} diff --git a/vault/policies/runner-DOCKER_HUB_TOKEN.hcl b/vault/policies/runner-DOCKER_HUB_TOKEN.hcl deleted file mode 100644 index 7d93a65..0000000 --- a/vault/policies/runner-DOCKER_HUB_TOKEN.hcl +++ /dev/null @@ -1,10 +0,0 @@ -# vault/policies/runner-DOCKER_HUB_TOKEN.hcl -# -# Per-secret runner policy: Docker Hub access token for image push. -# vault-runner (Step 5) composes only the runner-* policies named by the -# dispatching action's `secrets = [...]` list, so this policy intentionally -# scopes a single KV path — no wildcards, no list capability. - -path "kv/data/disinto/runner/DOCKER_HUB_TOKEN" { - capabilities = ["read"] -} diff --git a/vault/policies/runner-GITHUB_TOKEN.hcl b/vault/policies/runner-GITHUB_TOKEN.hcl deleted file mode 100644 index 7914c92..0000000 --- a/vault/policies/runner-GITHUB_TOKEN.hcl +++ /dev/null @@ -1,10 +0,0 @@ -# vault/policies/runner-GITHUB_TOKEN.hcl -# -# Per-secret runner policy: GitHub PAT for cross-mirror push / API calls. -# vault-runner (Step 5) composes only the runner-* policies named by the -# dispatching action's `secrets = [...]` list, so this policy intentionally -# scopes a single KV path — no wildcards, no list capability. - -path "kv/data/disinto/runner/GITHUB_TOKEN" { - capabilities = ["read"] -} diff --git a/vault/policies/runner-NPM_TOKEN.hcl b/vault/policies/runner-NPM_TOKEN.hcl deleted file mode 100644 index 27c77ee..0000000 --- a/vault/policies/runner-NPM_TOKEN.hcl +++ /dev/null @@ -1,10 +0,0 @@ -# vault/policies/runner-NPM_TOKEN.hcl -# -# Per-secret runner policy: npm registry auth token for package publish. -# vault-runner (Step 5) composes only the runner-* policies named by the -# dispatching action's `secrets = [...]` list, so this policy intentionally -# scopes a single KV path — no wildcards, no list capability. - -path "kv/data/disinto/runner/NPM_TOKEN" { - capabilities = ["read"] -} diff --git a/vault/policies/service-agents.hcl b/vault/policies/service-agents.hcl deleted file mode 100644 index 4c65a13..0000000 --- a/vault/policies/service-agents.hcl +++ /dev/null @@ -1,76 +0,0 @@ -# vault/policies/service-agents.hcl -# -# Composite policy for the `agents` Nomad job (S4.1, issue #955). -# Grants read access to all 7 bot KV namespaces + shared forge config, -# so a single job running all agent roles can pull per-bot tokens from -# Vault via workload identity. - -# ── Per-bot KV paths (token + pass per role) ───────────────────────────────── -path "kv/data/disinto/bots/dev" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/dev" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/bots/review" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/review" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/bots/gardener" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/gardener" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/bots/architect" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/architect" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/bots/planner" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/planner" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/bots/predictor" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/predictor" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/bots/supervisor" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/supervisor" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/bots/vault" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/bots/vault" { - capabilities = ["list", "read"] -} - -# ── Shared forge config (URL, bot usernames) ───────────────────────────────── -path "kv/data/disinto/shared/forge" { - capabilities = ["read"] -} diff --git a/vault/policies/service-chat.hcl b/vault/policies/service-chat.hcl deleted file mode 100644 index a021006..0000000 --- a/vault/policies/service-chat.hcl +++ /dev/null @@ -1,15 +0,0 @@ -# vault/policies/service-chat.hcl -# -# Read-only access to shared Chat secrets (OAuth client config, forward auth -# secret). Attached to the Chat Nomad job via workload identity (S5.2). -# -# Scope: kv/disinto/shared/chat — entries owned by the operator and -# shared between the chat service and edge proxy. - -path "kv/data/disinto/shared/chat" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/shared/chat" { - capabilities = ["list", "read"] -} diff --git a/vault/policies/service-dispatcher.hcl b/vault/policies/service-dispatcher.hcl deleted file mode 100644 index bdc7ddb..0000000 --- a/vault/policies/service-dispatcher.hcl +++ /dev/null @@ -1,29 +0,0 @@ -# vault/policies/service-dispatcher.hcl -# -# Edge dispatcher policy: needs to enumerate the runner secret namespace -# (to check secret presence before dispatching) and read the shared -# ops-repo credentials (token + clone URL) it uses to fetch action TOMLs. -# -# Scope: -# - kv/disinto/runner/* — read all per-secret values + list keys -# - kv/disinto/shared/ops-repo/* — read the ops-repo creds bundle -# -# The actual ephemeral runner container created per dispatch gets the -# narrow runner-<NAME> policies, NOT this one. This policy stays bound -# to the long-running dispatcher only. - -path "kv/data/disinto/runner/*" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/runner/*" { - capabilities = ["list", "read"] -} - -path "kv/data/disinto/shared/ops-repo" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/shared/ops-repo" { - capabilities = ["list", "read"] -} diff --git a/vault/policies/service-forgejo.hcl b/vault/policies/service-forgejo.hcl deleted file mode 100644 index 1724fc5..0000000 --- a/vault/policies/service-forgejo.hcl +++ /dev/null @@ -1,15 +0,0 @@ -# vault/policies/service-forgejo.hcl -# -# Read-only access to shared Forgejo secrets (admin password, OAuth client -# config). Attached to the Forgejo Nomad job via workload identity (S2.4). -# -# Scope: kv/disinto/shared/forgejo — entries owned by the operator and -# shared between forgejo + the chat OAuth client (issue #855 lineage). - -path "kv/data/disinto/shared/forgejo" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/shared/forgejo" { - capabilities = ["list", "read"] -} diff --git a/vault/policies/service-woodpecker.hcl b/vault/policies/service-woodpecker.hcl deleted file mode 100644 index 34b3795..0000000 --- a/vault/policies/service-woodpecker.hcl +++ /dev/null @@ -1,15 +0,0 @@ -# vault/policies/service-woodpecker.hcl -# -# Read-only access to shared Woodpecker secrets (agent secret, forge OAuth -# client). Attached to the Woodpecker Nomad job via workload identity (S2.4). -# -# Scope: kv/disinto/shared/woodpecker/* — entries owned by the operator -# and consumed by woodpecker-server + woodpecker-agent. - -path "kv/data/disinto/shared/woodpecker" { - capabilities = ["read"] -} - -path "kv/metadata/disinto/shared/woodpecker" { - capabilities = ["list", "read"] -} diff --git a/action-vault/policy.toml b/vault/policy.toml similarity index 100% rename from action-vault/policy.toml rename to vault/policy.toml diff --git a/vault/roles.yaml b/vault/roles.yaml deleted file mode 100644 index c058a30..0000000 --- a/vault/roles.yaml +++ /dev/null @@ -1,170 +0,0 @@ -# ============================================================================= -# vault/roles.yaml — Vault JWT-auth role bindings for Nomad workload identity -# -# Part of the Nomad+Vault migration (S2.3, issue #881). One entry per -# vault/policies/*.hcl policy. Each entry pairs: -# -# - the Vault role name (what a Nomad job references via -# `vault { role = "..." }` in its jobspec), with -# - the ACL policy attached to tokens it mints, and -# - the bound claims that gate which Nomad workloads may authenticate -# through that role (prevents a jobspec named "woodpecker" from -# asking for role "service-forgejo"). -# -# The source of truth for *what* secrets each role's token can read is -# vault/policies/<policy>.hcl. This file only wires role→policy→claims. -# Keeping the two side-by-side in the repo means an S2.1↔S2.3 drift -# (new policy without a role, or vice versa) shows up in one directory -# review, not as a runtime "permission denied" at job placement. -# -# All roles share the same constants (hardcoded in tools/vault-apply-roles.sh): -# - bound_audiences = ["vault.io"] — Nomad's default workload-identity aud -# - token_type = "service" — revoked when task exits -# - token_ttl = "1h" — token lifetime -# - token_max_ttl = "24h" — hard cap across renewals -# -# Format (strict — parsed line-by-line by tools/vault-apply-roles.sh with -# awk; keep the "- name:" prefix + two-space nested indent exactly as -# shown below): -# -# roles: -# - name: <vault-role-name> # path: auth/jwt-nomad/role/<name> -# policy: <acl-policy-name> # must match vault/policies/<name>.hcl -# namespace: <nomad-namespace> # bound_claims.nomad_namespace -# job_id: <nomad-job-id> # bound_claims.nomad_job_id -# -# All four fields are required. Comments (#) and blank lines are ignored. -# -# Adding a new role: -# 1. Land the companion vault/policies/<name>.hcl in S2.1 style. -# 2. Add a block here with all four fields. -# 3. Run tools/vault-apply-roles.sh to upsert it. -# 4. Re-run to confirm "role <name> unchanged". -# ============================================================================= -roles: - # ── Long-running services (nomad/jobs/<name>.hcl) ────────────────────────── - # The jobspec's nomad job name is the bound job_id, e.g. `job "forgejo"` - # in nomad/jobs/forgejo.hcl → job_id: forgejo. The policy name stays - # `service-<name>` so the directory layout under vault/policies/ groups - # platform services under a single prefix. - - name: service-forgejo - policy: service-forgejo - namespace: default - job_id: forgejo - - - name: service-woodpecker - policy: service-woodpecker - namespace: default - job_id: woodpecker-server - - - name: service-woodpecker-agent - policy: service-woodpecker - namespace: default - job_id: woodpecker-agent - - # ── Agents composite (nomad/jobs/agents.hcl — S4.1) ────────────────────── - # Single job running all 7 agent roles. Uses a composite policy - # (vault/policies/service-agents.hcl) that unions all bot KV paths. - - name: service-agents - policy: service-agents - namespace: default - job_id: agents - - # ── Chat UI (nomad/jobs/chat.hcl — S5.2) ───────────────────────────────── - # Claude chat UI service with OAuth secrets. Uses vault/policies/service-chat.hcl. - - name: service-chat - policy: service-chat - namespace: default - job_id: chat - - # ── Per-agent bots (nomad/jobs/bot-<role>.hcl — land in later steps) ─────── - # job_id placeholders match the policy name 1:1 until each bot's jobspec - # lands. When a bot's jobspec is added under nomad/jobs/, update the - # corresponding job_id here to match the jobspec's `job "<name>"` — and - # CI's S2.6 roles.yaml check will confirm the pairing. - - name: bot-dev - policy: bot-dev - namespace: default - job_id: bot-dev - - - name: bot-dev-qwen - policy: bot-dev-qwen - namespace: default - job_id: bot-dev-qwen - - - name: bot-review - policy: bot-review - namespace: default - job_id: bot-review - - - name: bot-gardener - policy: bot-gardener - namespace: default - job_id: bot-gardener - - - name: bot-planner - policy: bot-planner - namespace: default - job_id: bot-planner - - - name: bot-predictor - policy: bot-predictor - namespace: default - job_id: bot-predictor - - - name: bot-supervisor - policy: bot-supervisor - namespace: default - job_id: bot-supervisor - - - name: bot-architect - policy: bot-architect - namespace: default - job_id: bot-architect - - - name: bot-vault - policy: bot-vault - namespace: default - job_id: bot-vault - - # ── Edge dispatcher ──────────────────────────────────────────────────────── - - name: service-dispatcher - policy: service-dispatcher - namespace: default - job_id: edge - - # ── Per-secret runner roles ──────────────────────────────────────────────── - # vault-runner (Step 5) composes runner-<NAME> policies onto each - # ephemeral dispatch token based on the action TOML's `secrets = [...]`. - # The per-dispatch runner jobspec job_id follows the same `runner-<NAME>` - # convention (one jobspec per secret, minted per dispatch) so the bound - # claim matches the role name directly. - - name: runner-GITHUB_TOKEN - policy: runner-GITHUB_TOKEN - namespace: default - job_id: runner-GITHUB_TOKEN - - - name: runner-CODEBERG_TOKEN - policy: runner-CODEBERG_TOKEN - namespace: default - job_id: runner-CODEBERG_TOKEN - - - name: runner-CLAWHUB_TOKEN - policy: runner-CLAWHUB_TOKEN - namespace: default - job_id: runner-CLAWHUB_TOKEN - - - name: runner-DEPLOY_KEY - policy: runner-DEPLOY_KEY - namespace: default - job_id: runner-DEPLOY_KEY - - - name: runner-NPM_TOKEN - policy: runner-NPM_TOKEN - namespace: default - job_id: runner-NPM_TOKEN - - - name: runner-DOCKER_HUB_TOKEN - policy: runner-DOCKER_HUB_TOKEN - namespace: default - job_id: runner-DOCKER_HUB_TOKEN diff --git a/action-vault/validate.sh b/vault/validate.sh similarity index 100% rename from action-vault/validate.sh rename to vault/validate.sh diff --git a/action-vault/vault-env.sh b/vault/vault-env.sh similarity index 99% rename from action-vault/vault-env.sh rename to vault/vault-env.sh index ec4c83b..4234774 100644 --- a/action-vault/vault-env.sh +++ b/vault/vault-env.sh @@ -28,7 +28,7 @@ fi # VAULT ACTION VALIDATION # ============================================================================= -# Allowed secret names - must match files in secrets/<NAME>.enc +# Allowed secret names - must match keys in .env.vault.enc VAULT_ALLOWED_SECRETS="CLAWHUB_TOKEN GITHUB_TOKEN CODEBERG_TOKEN DEPLOY_KEY NPM_TOKEN DOCKER_HUB_TOKEN" # Allowed mount aliases — well-known file-based credential directories