diff --git a/.gitignore b/.gitignore index 21c6fbc..a29450c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,6 @@ metrics/supervisor-metrics.jsonl # OS .DS_Store dev/ci-fixes-*.json -gardener/dust.jsonl # Individual encrypted secrets (managed by disinto secrets add) secrets/ diff --git a/.woodpecker/detect-duplicates.py b/.woodpecker/detect-duplicates.py index 0485833..f3bf5b1 100644 --- a/.woodpecker/detect-duplicates.py +++ b/.woodpecker/detect-duplicates.py @@ -294,6 +294,35 @@ 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", + # Common vault-seed script flag parsing patterns + # Shared across tools/vault-seed-{forgejo,ops-repo}.sh + "6906b7787796c2ccb8dd622e2ad4e7bf": "vault-seed DRY_RUN init + case pattern (forgejo + ops-repo)", + "a0df5283b616b964f8bc32fd99ec1b5a": "vault-seed case pattern start (forgejo + ops-repo)", + "e15e3272fdd9f0f46ce9e726aea9f853": "vault-seed case pattern dry-run handler (forgejo + ops-repo)", + "c9f22385cc49a3dac1d336bc14c6315b": "vault-seed DRY_RUN assignment (forgejo + ops-repo)", + "106f4071e88f841b3208b01144cd1c39": "vault-seed case pattern dry-run end (forgejo + ops-repo)", + "c15506dcb6bb340b25d1c39d442dd2e6": "vault-seed help text + invalid arg handler (forgejo + ops-repo)", + "1feecd3b3caf00045fae938ddf2811de": "vault-seed invalid arg handler (forgejo + ops-repo)", + "919780d5e7182715344f5aa02b191294": "vault-seed invalid arg + esac pattern (forgejo + ops-repo)", + "8dce1d292bce8e60ef4c0665b62945b0": "vault-seed esac + binary check loop (forgejo + ops-repo)", + "ca043687143a5b47bd54e65a99ce8ee8": "vault-seed binary check loop start (forgejo + ops-repo)", + "aefd9f655411a955395e6e5995ddbe6f": "vault-seed binary check pattern (forgejo + ops-repo)", + "60f0c46deb5491599457efb4048918e5": "vault-seed VAULT_ADDR + hvault_token_lookup check (forgejo + ops-repo)", + "f6838f581ef6b4d82b55268389032769": "vault-seed VAULT_ADDR + hvault_token_lookup die (forgejo + ops-repo)", } if not sh_files: diff --git a/AGENTS.md b/AGENTS.md index ad3867b..9c42667 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ - + # Disinto — Agent Instructions ## What this repo is @@ -37,19 +37,20 @@ disinto/ (code repo) │ 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 │ 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) -├── nomad/ server.hcl, client.hcl, 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 reads Vault secrets via template stanza, S2.4) +│ 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, dynamic port — edge discovers via service registration, S5.2); chat.hcl (Claude chat UI, tmpfs via mount block, 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-seed-forgejo.sh — Vault provisioning (S2.1/S2.2) +│ 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, disinto-init-nomad.bats) +├── tests/ Test files (mock-forgejo.py, smoke-init.sh, lib-hvault.bats, lib-generators.bats, vault-import.bats, disinto-init-nomad.bats) ├── templates/ Issue templates -├── bin/ The `disinto` CLI script +├── bin/ The `disinto` CLI script (`--with ` deploys services + runs their Vault seeders) ├── disinto-factory/ Setup documentation and skill ├── state/ Runtime state ├── .woodpecker/ Woodpecker CI pipeline configs diff --git a/architect/AGENTS.md b/architect/AGENTS.md index 7f8b1f4..7286ee3 100644 --- a/architect/AGENTS.md +++ b/architect/AGENTS.md @@ -1,4 +1,4 @@ - + # Architect — Agent Instructions ## What this agent is diff --git a/bin/disinto b/bin/disinto index 2b676a3..7f6379d 100755 --- a/bin/disinto +++ b/bin/disinto @@ -82,7 +82,7 @@ Init options: --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[,...] (S1.3) + --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) @@ -670,6 +670,7 @@ _disinto_init_nomad() { 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" @@ -690,15 +691,22 @@ _disinto_init_nomad() { # --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-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. + # 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 @@ -737,10 +745,15 @@ _disinto_init_nomad() { exit 0 fi - # Vault policies + auth are invoked on every nomad real-run path - # regardless of --import-* flags (they're idempotent; S2.1 + S2.3). - # Mirror that ordering in the dry-run plan so the operator sees the - # full sequence Step 2 will execute. + # 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 "" @@ -770,19 +783,37 @@ _disinto_init_nomad() { fi if [ -n "$with_services" ]; then - echo "── Deploy services dry-run ────────────────────────────" - echo "[deploy] services to deploy: ${with_services}" - local IFS=',' - for svc in $with_services; do - svc=$(echo "$svc" | xargs) # trim whitespace - # Validate known services first + # 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 - forgejo) ;; - *) - echo "Error: unknown service '${svc}' — known: forgejo" >&2 - exit 1 - ;; + woodpecker-server|woodpecker-agent) seed_name="woodpecker" ;; + agents) seed_name="agents" ;; + chat) seed_name="chat" ;; + edge) seed_name="ops-repo" ;; 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 @@ -793,6 +824,32 @@ _disinto_init_nomad() { 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 @@ -814,6 +871,22 @@ _disinto_init_nomad() { 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 ────────────────────────────" @@ -864,44 +937,127 @@ _disinto_init_nomad() { echo "[import] no --import-env/--import-sops — skipping; set them or seed kv/disinto/* manually before deploying secret-dependent services" fi - # Deploy services if requested - if [ -n "$with_services" ]; then + # 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 "── Deploying services ─────────────────────────────────" - local -a deploy_cmd=("$deploy_sh") - # Split comma-separated service list into positional args - local IFS=',' - for svc in $with_services; do - svc=$(echo "$svc" | xargs) # trim whitespace - if ! echo "$svc" | grep -qE '^[a-zA-Z0-9_-]+$'; then - echo "Error: invalid service name '${svc}' — must match ^[a-zA-Z0-9_-]+$" >&2 - exit 1 + 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 - # Validate known services FIRST (before jobspec check) + done + + local IFS=' ' + for svc in $DEPLOY_ORDER; do + # Seed this service (if seed script exists) + local seed_name="$svc" case "$svc" in - forgejo) ;; - *) - echo "Error: unknown service '${svc}' — known: forgejo" >&2 - exit 1 + woodpecker-server|woodpecker-agent) seed_name="woodpecker" ;; + agents) seed_name="agents" ;; + chat) seed_name="chat" ;; + edge) seed_name="ops-repo" ;; + 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 - # Check jobspec exists + 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 - deploy_cmd+=("$svc") + + 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 - 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 + # 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 - sudo -n -- "${deploy_cmd[@]}" || exit $? + else + echo "[deploy] vault-runner: jobspec not found, skipping" fi # Print final summary @@ -919,9 +1075,24 @@ _disinto_init_nomad() { 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 + 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 @@ -1007,6 +1178,70 @@ disinto_init() { 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 diff --git a/dev/AGENTS.md b/dev/AGENTS.md index 13d9736..c64551f 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/docker-compose.yml b/docker-compose.yml index ba8c77c..c4676f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,6 @@ services: - 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 - - ${CLAUDE_BIN_DIR}:/usr/local/bin/claude: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 @@ -78,7 +77,6 @@ services: - 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 - - ${CLAUDE_BIN_DIR}:/usr/local/bin/claude: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 @@ -139,7 +137,6 @@ services: - 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 - - ${CLAUDE_BIN_DIR}:/usr/local/bin/claude: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 diff --git a/docker/agents/Dockerfile b/docker/agents/Dockerfile index 1bcba89..fa3b2d8 100644 --- a/docker/agents/Dockerfile +++ b/docker/agents/Dockerfile @@ -1,21 +1,26 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ - bash curl git jq tmux python3 python3-pip openssh-client ca-certificates age shellcheck procps gosu \ + 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 \ && rm -rf /var/lib/apt/lists/* # Pre-built binaries (copied from docker/agents/bin/) # SOPS — encrypted data decryption tool -COPY docker/agents/bin/sops /usr/local/bin/sops -RUN chmod +x /usr/local/bin/sops +# 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 # tea CLI — official Gitea/Forgejo CLI for issue/label/comment operations -COPY docker/agents/bin/tea /usr/local/bin/tea -RUN chmod +x /usr/local/bin/tea +# 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 -# Claude CLI is mounted from the host via docker-compose volume. -# No internet access to cli.anthropic.com required at build time. +# 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 # Non-root user RUN useradd -m -u 1000 -s /bin/bash agent diff --git a/docker/chat/Dockerfile b/docker/chat/Dockerfile index 3d89863..c4cb28b 100644 --- a/docker/chat/Dockerfile +++ b/docker/chat/Dockerfile @@ -1,20 +1,22 @@ # disinto-chat — minimal HTTP backend for Claude chat UI # -# Small Debian slim base with Python runtime. +# Small Debian slim base with Python runtime and Node.js. # Chosen for simplicity and small image size (~100MB). # # Image size: ~100MB (well under the 200MB ceiling) # -# The claude binary is mounted from the host at runtime via docker-compose, -# not baked into the image — same pattern as the agents container. +# Claude CLI is baked into the image — same pattern as the agents container. FROM debian:bookworm-slim -# Install Python (no build-time network access needed) +# Install Node.js (required for Claude CLI) and Python RUN apt-get update && apt-get install -y --no-install-recommends \ - python3 \ + nodejs npm python3 \ && rm -rf /var/lib/apt/lists/* +# Install Claude Code CLI — chat backend runtime +RUN npm install -g @anthropic-ai/claude-code@2.1.84 + # Non-root user — fixed UID 10001 for sandbox hardening (#706) RUN useradd -m -u 10001 -s /bin/bash chat diff --git a/docker/edge/dispatcher.sh b/docker/edge/dispatcher.sh index a48abf2..282342a 100755 --- a/docker/edge/dispatcher.sh +++ b/docker/edge/dispatcher.sh @@ -560,10 +560,168 @@ _launch_runner_docker() { # _launch_runner_nomad ACTION_ID SECRETS_CSV MOUNTS_CSV # -# Nomad backend stub — will be implemented in migration Step 5. +# 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() { - echo "nomad backend not yet implemented" >&2 - return 1 + 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) @@ -1051,11 +1209,8 @@ main() { # Validate backend selection at startup case "$DISPATCHER_BACKEND" in - docker) ;; - nomad) - log "ERROR: nomad backend not yet implemented" - echo "nomad backend not yet implemented" >&2 - exit 1 + docker|nomad) + log "Using ${DISPATCHER_BACKEND} backend for vault-runner dispatch" ;; *) log "ERROR: unknown DISPATCHER_BACKEND=${DISPATCHER_BACKEND}" diff --git a/docker/edge/entrypoint-edge.sh b/docker/edge/entrypoint-edge.sh index 1b5f94f..83131fb 100755 --- a/docker/edge/entrypoint-edge.sh +++ b/docker/edge/entrypoint-edge.sh @@ -173,11 +173,15 @@ 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. +# ── Load optional secrets from secrets/*.enc (#777) ──────────────────── +# Engagement collection (collect-engagement.sh) requires CADDY_ secrets to +# SCP access logs from a remote edge host. When age key or secrets dir is +# missing, or any secret fails to decrypt, log a warning and skip the cron. +# Caddy itself does not depend on these secrets. _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_ENGAGEMENT_READY=0 # Assume not ready until proven otherwise _edge_decrypt_secret() { local enc_path="${_SECRETS_DIR}/${1}.enc" @@ -192,47 +196,60 @@ if [ -f "$_AGE_KEY_FILE" ] && [ -d "$_SECRETS_DIR" ]; then 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 + echo "WARN: required engagement secrets missing from secrets/*.enc:${_missing}" >&2 + echo " collect-engagement cron will be skipped. Run 'disinto secrets add ' to enable." >&2 + EDGE_ENGAGEMENT_READY=0 + else + echo "edge: loaded required engagement secrets: ${EDGE_REQUIRED_SECRETS}" >&2 + EDGE_ENGAGEMENT_READY=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 + echo "WARN: age key (${_AGE_KEY_FILE}) or secrets dir (${_SECRETS_DIR}) not found — engagement secrets unavailable" >&2 + echo " collect-engagement cron will be skipped. Run 'disinto secrets add ' to enable." >&2 + EDGE_ENGAGEMENT_READY=0 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). -(while true; do - # Calculate seconds until next 23:50 UTC - _now=$(date -u +%s) - _target=$(date -u -d "today 23:50" +%s 2>/dev/null || date -u -d "23:50" +%s 2>/dev/null || echo 0) - if [ "$_target" -le "$_now" ]; then - _target=$(( _target + 86400 )) - fi - _sleep_secs=$(( _target - _now )) - echo "edge: collect-engagement scheduled in ${_sleep_secs}s (next 23:50 UTC)" >&2 - sleep "$_sleep_secs" - _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 - else - echo "edge: collect-engagement: fetched log is empty, skipping parse" >&2 - fi - rm -f "$_fetch_log" -done) & +# Guarded: only start if EDGE_ENGAGEMENT_READY=1. +if [ "$EDGE_ENGAGEMENT_READY" -eq 1 ]; then + (while true; do + # Calculate seconds until next 23:50 UTC + _now=$(date -u +%s) + _target=$(date -u -d "today 23:50" +%s 2>/dev/null || date -u -d "23:50" +%s 2>/dev/null || echo 0) + if [ "$_target" -le "$_now" ]; then + _target=$(( _target + 86400 )) + fi + _sleep_secs=$(( _target - _now )) + echo "edge: collect-engagement scheduled in ${_sleep_secs}s (next 23:50 UTC)" >&2 + sleep "$_sleep_secs" + _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 + else + echo "edge: collect-engagement: fetched log is empty, skipping parse" >&2 + fi + rm -f "$_fetch_log" + done) & +else + echo "edge: collect-engagement cron skipped (EDGE_ENGAGEMENT_READY=0)" >&2 +fi + +# Nomad template renders Caddyfile to /local/Caddyfile via service discovery; +# copy it into the expected location if present (compose uses the mounted path). +if [ -f /local/Caddyfile ]; then + cp /local/Caddyfile /etc/caddy/Caddyfile + echo "edge: using Nomad-rendered Caddyfile from /local/Caddyfile" >&2 +fi # Caddy as main process — run in foreground via wait so background jobs survive # (exec replaces the shell, which can orphan backgrounded subshells) diff --git a/formulas/run-supervisor.toml b/formulas/run-supervisor.toml index f31e6bc..4101252 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). + CI Pipelines, Open PRs, Issue Status, Stale Worktrees, **Woodpecker Agent Health**). 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,6 +75,10 @@ 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 @@ -100,6 +104,15 @@ 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 @@ -248,6 +261,11 @@ Format: - (or "No actions needed") + ### WP Agent Recovery (if applicable) + - WP agent restart: