diff --git a/bin/disinto b/bin/disinto index 08adb8d..98cb2fe 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,woodpecker,agents[,...] (S1.3, S3.4, S4.2) + --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) @@ -787,7 +787,7 @@ _disinto_init_nomad() { # 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; do + 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 @@ -824,8 +824,19 @@ _disinto_init_nomad() { echo "[deploy] dry-run complete" fi - # Build custom images dry-run (if agents or chat services are included) - if echo ",$with_services," | grep -qE ",(agents|chat),"; then + # 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 @@ -834,6 +845,9 @@ _disinto_init_nomad() { 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}" 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}" + fi fi exit 0 fi @@ -922,10 +936,10 @@ _disinto_init_nomad() { 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) — before deploy. + # 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),"; then + if echo ",$with_services," | grep -qE ",(agents|chat|edge),"; then echo "" echo "── Building custom images ─────────────────────────────" if echo ",$with_services," | grep -q ",agents,"; then @@ -938,6 +952,11 @@ _disinto_init_nomad() { echo "── Building $tag ─────────────────────────────" docker build -t "$tag" -f "${FACTORY_ROOT}/docker/chat/Dockerfile" "${FACTORY_ROOT}" 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}" 2>&1 | tail -5 + fi fi # Interleaved seed/deploy per service (S2.6, #928, #948). @@ -948,9 +967,9 @@ _disinto_init_nomad() { 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): forgejo → woodpecker-server → woodpecker-agent → agents → staging → chat + # 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; do + 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 @@ -1001,6 +1020,27 @@ _disinto_init_nomad() { 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 ────────────────────────────────────────────" @@ -1157,14 +1197,25 @@ disinto_init() { 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) ;; + forgejo|woodpecker-server|woodpecker-agent|agents|staging|chat|edge) ;; *) - echo "Error: unknown service '${_svc}' — known: forgejo, woodpecker-server, woodpecker-agent, agents, staging, chat" >&2 + echo "Error: unknown service '${_svc}' — known: forgejo, woodpecker-server, woodpecker-agent, agents, staging, chat, edge" >&2 exit 1 ;; esac diff --git a/lib/hvault.sh b/lib/hvault.sh index b0d1635..d283330 100644 --- a/lib/hvault.sh +++ b/lib/hvault.sh @@ -405,3 +405,36 @@ hvault_token_lookup() { 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/tools/vault-seed-chat.sh b/tools/vault-seed-chat.sh new file mode 100755 index 0000000..f27ea0a --- /dev/null +++ b/tools/vault-seed-chat.sh @@ -0,0 +1,114 @@ +#!/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 Print planned actions without writing.\n' + exit 0 + ;; + *) die "invalid argument: ${1} (try --help)" ;; + esac +fi + +# ── Preconditions ──────────────────────────────────────────────────────────── +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/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 + if _hvault_seed_key "$KV_LOGICAL_PATH" "$key"; then + generated+=("$key") + log "${key} generated" + else + log "${key} unchanged" + fi + 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