fix: chore: tear down old vault scripts — prepare for PR-based vault (#73)
This commit is contained in:
parent
bfce7a9a06
commit
aad21dc084
19 changed files with 31 additions and 907 deletions
|
|
@ -1,45 +0,0 @@
|
|||
<!-- last-reviewed: f32707ba659de278a3af434e3549fb8a8dce9d3a -->
|
||||
# Vault Agent
|
||||
|
||||
**Role**: Three-pipeline gate — action safety classification, resource procurement, and human-action drafting.
|
||||
|
||||
**Pipeline A — Action Gating (*.json)**: Actions enter a pending queue and are
|
||||
classified by Claude via `vault-agent.sh`, which can auto-approve (call
|
||||
`vault-fire.sh` directly), auto-reject (call `vault-reject.sh`), or escalate
|
||||
to a human by writing `PHASE:escalate` to a phase file — using the same
|
||||
unified escalation path as dev/action agents.
|
||||
|
||||
**Pipeline B — Procurement (*.md)**: The planner files resource requests as
|
||||
markdown files in `$OPS_REPO_ROOT/vault/pending/`. `vault-poll.sh` notifies the human via
|
||||
vault/forge. The human fulfills the request (creates accounts, provisions infra,
|
||||
adds secrets to `.env`) and moves the file to `$OPS_REPO_ROOT/vault/approved/`.
|
||||
`vault-fire.sh` then extracts the proposed entry and appends it to
|
||||
`$OPS_REPO_ROOT/RESOURCES.md`.
|
||||
|
||||
**Pipeline C — Rent-a-Human (outreach drafts)**: Any agent can dispatch the
|
||||
`run-rent-a-human` formula (via an `action` issue) when a task requires a human
|
||||
touch — posting on Reddit, commenting on HN, signing up for a service, etc.
|
||||
Claude drafts copy-paste-ready content to `vault/outreach/{platform}/drafts/`
|
||||
and notifies the human via vault/forge for one-click execution. No vault approval
|
||||
needed — the human reviews and publishes directly.
|
||||
|
||||
**Trigger**: `vault-poll.sh` runs every 30 min via cron.
|
||||
|
||||
**Key files**:
|
||||
- `vault/vault-poll.sh` — Processes pending items: retry approved, auto-reject after 48h timeout, invoke vault-agent for JSON actions, notify human for procurement requests
|
||||
- `vault/vault-agent.sh` — Classifies and routes pending JSON actions via `claude -p`: auto-approve, auto-reject, or escalate to human
|
||||
- `vault/vault-env.sh` — Shared env setup for vault sub-scripts: sources `lib/env.sh`, overrides `FORGE_TOKEN` with `FORGE_VAULT_TOKEN`, sets `VAULT_TOKEN` for runner container
|
||||
- `formulas/run-vault.toml` — Source-of-truth formula for the vault agent's classification and routing logic
|
||||
- `vault/vault-fire.sh` — Executes an approved action (JSON) in an **ephemeral Docker container** with vault-only secrets injected (GITHUB_TOKEN, CLAWHUB_TOKEN — never exposed to agents). For deployment actions, calls `lib/ci-helpers.sh:ci_promote()` to gate production promotes via Woodpecker environments. Writes `$OPS_REPO_ROOT/RESOURCES.md` entry for procurement MD approvals.
|
||||
- `vault/vault-reject.sh` — Marks a JSON action as rejected
|
||||
- `formulas/run-rent-a-human.toml` — Formula for human-action drafts: Claude researches target platform norms, drafts copy-paste content, writes to `vault/outreach/{platform}/drafts/`, notifies human via vault/forge
|
||||
|
||||
**Procurement flow** (all vault items live in `$OPS_REPO_ROOT/vault/`):
|
||||
1. Planner drops `$OPS_REPO_ROOT/vault/pending/<name>.md` with what/why/proposed RESOURCES.md entry
|
||||
2. `vault-poll.sh` notifies human via vault/forge
|
||||
3. Human fulfills: creates account, adds secrets to `.env`, moves file to `approved/`
|
||||
4. `vault-fire.sh` extracts proposed entry, appends to `$OPS_REPO_ROOT/RESOURCES.md`, moves to `fired/`
|
||||
5. Next planner run reads RESOURCES.md → new capability available → unblocks prerequisite tree
|
||||
|
||||
**Environment variables consumed**:
|
||||
- All from `lib/env.sh`
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# run-action.sh — Execute an action inside the ephemeral runner container
|
||||
#
|
||||
# This script is the entrypoint for the runner container. It runs with
|
||||
# vault secrets injected as environment variables (GITHUB_TOKEN, CLAWHUB_TOKEN,
|
||||
# deploy keys, etc.) and dispatches to the appropriate action handler.
|
||||
#
|
||||
# The runner container is ephemeral: it starts, runs the action, and is
|
||||
# destroyed. Secrets exist only in container memory, never on disk.
|
||||
#
|
||||
# Usage: run-action.sh <action-id>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VAULT_SCRIPT_DIR="${DISINTO_VAULT_DIR:-/home/agent/disinto/vault}"
|
||||
OPS_VAULT_DIR="${DISINTO_OPS_VAULT_DIR:-${VAULT_SCRIPT_DIR}}"
|
||||
LOGFILE="${VAULT_SCRIPT_DIR}/vault.log"
|
||||
ACTION_ID="${1:?Usage: run-action.sh <action-id>}"
|
||||
|
||||
log() {
|
||||
printf '[%s] runner: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE" 2>/dev/null || \
|
||||
printf '[%s] runner: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2
|
||||
}
|
||||
|
||||
# Find action file in approved/
|
||||
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
|
||||
if [ ! -f "$ACTION_FILE" ]; then
|
||||
log "ERROR: action file not found: ${ACTION_FILE}"
|
||||
echo "ERROR: action file not found: ${ACTION_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTION_TYPE=$(jq -r '.type // ""' < "$ACTION_FILE")
|
||||
ACTION_SOURCE=$(jq -r '.source // ""' < "$ACTION_FILE")
|
||||
PAYLOAD=$(jq -c '.payload // {}' < "$ACTION_FILE")
|
||||
|
||||
if [ -z "$ACTION_TYPE" ]; then
|
||||
log "ERROR: ${ACTION_ID} has no type field"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "${ACTION_ID}: executing type=${ACTION_TYPE} source=${ACTION_SOURCE}"
|
||||
|
||||
FIRE_EXIT=0
|
||||
|
||||
case "$ACTION_TYPE" in
|
||||
webhook-call)
|
||||
# HTTP call to endpoint with optional method/headers/body
|
||||
ENDPOINT=$(echo "$PAYLOAD" | jq -r '.endpoint // ""')
|
||||
METHOD=$(echo "$PAYLOAD" | jq -r '.method // "POST"')
|
||||
REQ_BODY=$(echo "$PAYLOAD" | jq -r '.body // ""')
|
||||
|
||||
if [ -z "$ENDPOINT" ]; then
|
||||
log "ERROR: ${ACTION_ID} webhook-call missing endpoint"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURL_ARGS=(-sf -X "$METHOD" -o /dev/null -w "%{http_code}")
|
||||
while IFS= read -r header; do
|
||||
[ -n "$header" ] && CURL_ARGS+=(-H "$header")
|
||||
done < <(echo "$PAYLOAD" | jq -r '.headers // {} | to_entries[] | "\(.key): \(.value)"' 2>/dev/null || true)
|
||||
if [ -n "$REQ_BODY" ] && [ "$REQ_BODY" != "null" ]; then
|
||||
CURL_ARGS+=(-d "$REQ_BODY")
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl "${CURL_ARGS[@]}" "$ENDPOINT" 2>/dev/null) || HTTP_CODE="000"
|
||||
if [[ "$HTTP_CODE" =~ ^2 ]]; then
|
||||
log "${ACTION_ID}: webhook-call -> HTTP ${HTTP_CODE} OK"
|
||||
else
|
||||
log "ERROR: ${ACTION_ID} webhook-call -> HTTP ${HTTP_CODE}"
|
||||
FIRE_EXIT=1
|
||||
fi
|
||||
;;
|
||||
|
||||
promote)
|
||||
# Promote a Woodpecker pipeline to a deployment environment (staging/production).
|
||||
# Payload: {"repo_id": N, "pipeline": N, "environment": "staging"|"production"}
|
||||
PROMOTE_REPO_ID=$(echo "$PAYLOAD" | jq -r '.repo_id // ""')
|
||||
PROMOTE_PIPELINE=$(echo "$PAYLOAD" | jq -r '.pipeline // ""')
|
||||
PROMOTE_ENV=$(echo "$PAYLOAD" | jq -r '.environment // ""')
|
||||
|
||||
if [ -z "$PROMOTE_REPO_ID" ] || [ -z "$PROMOTE_PIPELINE" ] || [ -z "$PROMOTE_ENV" ]; then
|
||||
log "ERROR: ${ACTION_ID} promote missing repo_id, pipeline, or environment"
|
||||
FIRE_EXIT=1
|
||||
else
|
||||
# Validate environment is staging or production
|
||||
case "$PROMOTE_ENV" in
|
||||
staging|production) ;;
|
||||
*)
|
||||
log "ERROR: ${ACTION_ID} promote invalid environment '${PROMOTE_ENV}' (must be staging or production)"
|
||||
FIRE_EXIT=1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$FIRE_EXIT" -eq 0 ]; then
|
||||
WP_SERVER="${WOODPECKER_SERVER:-http://woodpecker:8000}"
|
||||
WP_TOKEN="${WOODPECKER_TOKEN:-}"
|
||||
|
||||
if [ -z "$WP_TOKEN" ]; then
|
||||
log "ERROR: ${ACTION_ID} promote requires WOODPECKER_TOKEN"
|
||||
FIRE_EXIT=1
|
||||
else
|
||||
PROMOTE_RESP=$(curl -sf -X POST \
|
||||
-H "Authorization: Bearer ${WP_TOKEN}" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "event=deployment&deploy_to=${PROMOTE_ENV}" \
|
||||
"${WP_SERVER}/api/repos/${PROMOTE_REPO_ID}/pipelines/${PROMOTE_PIPELINE}" 2>/dev/null) || PROMOTE_RESP=""
|
||||
|
||||
NEW_PIPELINE=$(printf '%s' "$PROMOTE_RESP" | jq -r '.number // empty' 2>/dev/null)
|
||||
if [ -n "$NEW_PIPELINE" ]; then
|
||||
log "${ACTION_ID}: promoted pipeline ${PROMOTE_PIPELINE} to ${PROMOTE_ENV} -> new pipeline #${NEW_PIPELINE}"
|
||||
else
|
||||
log "ERROR: ${ACTION_ID} promote API failed (repo_id=${PROMOTE_REPO_ID} pipeline=${PROMOTE_PIPELINE} env=${PROMOTE_ENV})"
|
||||
FIRE_EXIT=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
blog-post|social-post|email-blast|pricing-change|dns-change|stripe-charge)
|
||||
HANDLER="${VAULT_SCRIPT_DIR}/handlers/${ACTION_TYPE}.sh"
|
||||
if [ -x "$HANDLER" ]; then
|
||||
bash "$HANDLER" "$ACTION_ID" "$PAYLOAD" 2>&1 || FIRE_EXIT=$?
|
||||
else
|
||||
log "ERROR: ${ACTION_ID} no handler for type '${ACTION_TYPE}' (${HANDLER} not found)"
|
||||
FIRE_EXIT=1
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
log "ERROR: ${ACTION_ID} unknown action type '${ACTION_TYPE}'"
|
||||
FIRE_EXIT=1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit "$FIRE_EXIT"
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# vault-agent.sh — Invoke claude -p to classify and route pending vault actions
|
||||
#
|
||||
# Called by vault-poll.sh when pending actions exist. Reads all pending/*.json,
|
||||
# builds a prompt with action summaries, and lets the LLM decide routing.
|
||||
#
|
||||
# The LLM can call vault-fire.sh (auto-approve) or vault-reject.sh (reject)
|
||||
# directly. For escalations, it writes a PHASE:escalate file and marks the
|
||||
# action as "escalated" in pending/ so vault-poll skips it on future runs.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
source "${SCRIPT_DIR}/vault-env.sh"
|
||||
|
||||
VAULT_SCRIPT_DIR="${FACTORY_ROOT}/vault"
|
||||
OPS_VAULT_DIR="${OPS_REPO_ROOT}/vault"
|
||||
PROMPT_FILE="${FACTORY_ROOT}/formulas/run-vault.toml"
|
||||
LOGFILE="${VAULT_SCRIPT_DIR}/vault.log"
|
||||
CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-3600}"
|
||||
|
||||
log() {
|
||||
printf '[%s] vault-agent: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
||||
}
|
||||
|
||||
# Collect all pending actions (skip already-escalated)
|
||||
ACTIONS_BATCH=""
|
||||
ACTION_COUNT=0
|
||||
|
||||
for action_file in "${OPS_VAULT_DIR}/pending/"*.json; do
|
||||
[ -f "$action_file" ] || continue
|
||||
|
||||
ACTION_STATUS=$(jq -r '.status // ""' < "$action_file" 2>/dev/null)
|
||||
[ "$ACTION_STATUS" = "escalated" ] && continue
|
||||
|
||||
# Validate JSON
|
||||
if ! jq empty < "$action_file" 2>/dev/null; then
|
||||
ACTION_ID=$(basename "$action_file" .json)
|
||||
log "malformed JSON: $action_file — rejecting"
|
||||
bash "${VAULT_SCRIPT_DIR}/vault-reject.sh" "$ACTION_ID" "malformed JSON" 2>/dev/null || true
|
||||
continue
|
||||
fi
|
||||
|
||||
ACTION_JSON=$(cat "$action_file")
|
||||
ACTIONS_BATCH="${ACTIONS_BATCH}
|
||||
--- ACTION ---
|
||||
$(echo "$ACTION_JSON" | jq '.')
|
||||
--- END ACTION ---
|
||||
"
|
||||
ACTION_COUNT=$((ACTION_COUNT + 1))
|
||||
done
|
||||
|
||||
if [ "$ACTION_COUNT" -eq 0 ]; then
|
||||
log "no actionable pending items"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "processing $ACTION_COUNT pending action(s) via claude -p"
|
||||
|
||||
# Build the prompt
|
||||
SYSTEM_PROMPT=$(cat "$PROMPT_FILE" 2>/dev/null || echo "You are a vault agent. Classify and route actions.")
|
||||
|
||||
PROMPT="${SYSTEM_PROMPT}
|
||||
|
||||
## Pending Actions (${ACTION_COUNT} total)
|
||||
${ACTIONS_BATCH}
|
||||
|
||||
## Environment
|
||||
- FACTORY_ROOT=${FACTORY_ROOT}
|
||||
- OPS_REPO_ROOT=${OPS_REPO_ROOT}
|
||||
- Vault data: ${OPS_VAULT_DIR}
|
||||
- vault-fire.sh: bash ${VAULT_SCRIPT_DIR}/vault-fire.sh <action-id>
|
||||
- vault-reject.sh: bash ${VAULT_SCRIPT_DIR}/vault-reject.sh <action-id> \"<reason>\"
|
||||
|
||||
Process each action now. For auto-approve, fire immediately. For reject, call vault-reject.sh.
|
||||
|
||||
For actions that need human approval (escalate), write a PHASE:escalate file
|
||||
to signal the unified escalation path:
|
||||
printf 'PHASE:escalate\nReason: vault procurement — %s\n' '<action summary>' \\
|
||||
> /tmp/vault-escalate-<action-id>.phase
|
||||
Then STOP and wait — a human will review via the forge."
|
||||
|
||||
CLAUDE_OUTPUT=$(timeout "$CLAUDE_TIMEOUT" claude -p "$PROMPT" \
|
||||
--model sonnet \
|
||||
--dangerously-skip-permissions \
|
||||
--max-turns 20 \
|
||||
2>/dev/null) || true
|
||||
|
||||
log "claude finished ($(echo "$CLAUDE_OUTPUT" | wc -c) bytes)"
|
||||
|
||||
# Log routing decisions
|
||||
ROUTES=$(echo "$CLAUDE_OUTPUT" | grep "^ROUTE:" || true)
|
||||
if [ -n "$ROUTES" ]; then
|
||||
echo "$ROUTES" | while read -r line; do
|
||||
log " $line"
|
||||
done
|
||||
fi
|
||||
|
|
@ -8,5 +8,5 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/env.sh"
|
|||
# Use vault-bot's own Forgejo identity
|
||||
FORGE_TOKEN="${FORGE_VAULT_TOKEN:-${FORGE_TOKEN}}"
|
||||
|
||||
# Set entrypoint for runner container
|
||||
export VAULT_RUNNER_ENTRYPOINT="run-action.sh"
|
||||
# Vault redesign in progress (PR-based approval workflow)
|
||||
# This file is kept for shared env setup; scripts being replaced by #73
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# vault-fire.sh — Execute an approved vault item by ID
|
||||
#
|
||||
# Handles two pipelines:
|
||||
# A. Action gating (*.json): pending/ → approved/ → fired/
|
||||
# Execution delegated to ephemeral runner container via disinto run.
|
||||
# The runner gets vault secrets (.env.vault.enc); this script does NOT.
|
||||
# B. Procurement (*.md): approved/ → fired/ (writes RESOURCES.md entry)
|
||||
#
|
||||
# If item is in pending/, moves to approved/ first.
|
||||
# If item is already in approved/, fires directly (crash recovery).
|
||||
#
|
||||
# Usage: bash vault-fire.sh <item-id>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
source "${SCRIPT_DIR}/vault-env.sh"
|
||||
|
||||
OPS_VAULT_DIR="${OPS_REPO_ROOT}/vault"
|
||||
LOCKS_DIR="${DISINTO_LOG_DIR}/vault/.locks"
|
||||
LOGFILE="${DISINTO_LOG_DIR}/vault/vault.log"
|
||||
RESOURCES_FILE="${OPS_REPO_ROOT}/RESOURCES.md"
|
||||
|
||||
log() {
|
||||
printf '[%s] vault-fire: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
||||
}
|
||||
|
||||
ACTION_ID="${1:?Usage: vault-fire.sh <item-id>}"
|
||||
|
||||
# =============================================================================
|
||||
# Detect pipeline: procurement (.md) or action gating (.json)
|
||||
# =============================================================================
|
||||
IS_PROCUREMENT=false
|
||||
ACTION_FILE=""
|
||||
|
||||
if [ -f "${OPS_VAULT_DIR}/approved/${ACTION_ID}.md" ]; then
|
||||
IS_PROCUREMENT=true
|
||||
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.md"
|
||||
elif [ -f "${OPS_VAULT_DIR}/pending/${ACTION_ID}.md" ]; then
|
||||
IS_PROCUREMENT=true
|
||||
mv "${OPS_VAULT_DIR}/pending/${ACTION_ID}.md" "${OPS_VAULT_DIR}/approved/${ACTION_ID}.md"
|
||||
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.md"
|
||||
log "$ACTION_ID: pending → approved (procurement)"
|
||||
elif [ -f "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json" ]; then
|
||||
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
|
||||
elif [ -f "${OPS_VAULT_DIR}/pending/${ACTION_ID}.json" ]; then
|
||||
mv "${OPS_VAULT_DIR}/pending/${ACTION_ID}.json" "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
|
||||
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
|
||||
TMP=$(mktemp)
|
||||
jq '.status = "approved"' "$ACTION_FILE" > "$TMP" && mv "$TMP" "$ACTION_FILE"
|
||||
log "$ACTION_ID: pending → approved"
|
||||
else
|
||||
log "ERROR: item $ACTION_ID not found in pending/ or approved/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Acquire lock
|
||||
mkdir -p "$LOCKS_DIR"
|
||||
LOCKFILE="${LOCKS_DIR}/${ACTION_ID}.lock"
|
||||
if [ -f "$LOCKFILE" ]; then
|
||||
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || true)
|
||||
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
|
||||
log "$ACTION_ID: already being fired by PID $LOCK_PID"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo $$ > "$LOCKFILE"
|
||||
trap 'rm -f "$LOCKFILE"' EXIT
|
||||
|
||||
# =============================================================================
|
||||
# Pipeline A: Procurement — extract RESOURCES.md entry and append
|
||||
# =============================================================================
|
||||
if [ "$IS_PROCUREMENT" = true ]; then
|
||||
log "$ACTION_ID: firing procurement request"
|
||||
|
||||
# Extract the proposed RESOURCES.md entry from the markdown file.
|
||||
# Everything after the "## Proposed RESOURCES.md Entry" heading to EOF.
|
||||
# Uses awk because the entry itself contains ## headings (## <resource-id>).
|
||||
ENTRY=""
|
||||
ENTRY=$(awk '/^## Proposed RESOURCES\.md Entry/{found=1; next} found{print}' "$ACTION_FILE" 2>/dev/null || true)
|
||||
|
||||
# Strip leading/trailing blank lines and markdown code fences
|
||||
ENTRY=$(echo "$ENTRY" | sed '/^```/d' | sed -e '/./,$!d' -e :a -e '/^\n*$/{$d;N;ba;}')
|
||||
|
||||
if [ -z "$ENTRY" ]; then
|
||||
log "ERROR: $ACTION_ID has no '## Proposed RESOURCES.md Entry' section"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Append entry to RESOURCES.md
|
||||
printf '\n%s\n' "$ENTRY" >> "$RESOURCES_FILE"
|
||||
log "$ACTION_ID: wrote RESOURCES.md entry"
|
||||
|
||||
# Move to fired/
|
||||
mv "$ACTION_FILE" "${OPS_VAULT_DIR}/fired/${ACTION_ID}.md"
|
||||
rm -f "${LOCKS_DIR}/${ACTION_ID}.notified"
|
||||
log "$ACTION_ID: approved → fired (procurement)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Pipeline B: Action gating — delegate to ephemeral runner container
|
||||
# =============================================================================
|
||||
ACTION_TYPE=$(jq -r '.type // ""' < "$ACTION_FILE")
|
||||
ACTION_SOURCE=$(jq -r '.source // ""' < "$ACTION_FILE")
|
||||
|
||||
if [ -z "$ACTION_TYPE" ]; then
|
||||
log "ERROR: $ACTION_ID has no type field"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "$ACTION_ID: firing type=$ACTION_TYPE source=$ACTION_SOURCE via runner"
|
||||
|
||||
FIRE_EXIT=0
|
||||
|
||||
# Delegate execution to the ephemeral runner container.
|
||||
# The runner gets vault secrets (.env.vault.enc) injected at runtime;
|
||||
# this host process never sees those secrets.
|
||||
if [ -f "${FACTORY_ROOT}/.env.vault.enc" ] && [ -f "${FACTORY_ROOT}/docker-compose.yml" ]; then
|
||||
bash "${FACTORY_ROOT}/bin/disinto" run "$ACTION_ID" >> "$LOGFILE" 2>&1 || FIRE_EXIT=$?
|
||||
else
|
||||
# Fallback for bare-metal or pre-migration setups: run action handler directly
|
||||
log "$ACTION_ID: no .env.vault.enc or docker-compose.yml — running action directly"
|
||||
bash "${SCRIPT_DIR}/run-action.sh" "$ACTION_ID" >> "$LOGFILE" 2>&1 || FIRE_EXIT=$?
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Move to fired/ or leave in approved/ on failure
|
||||
# =============================================================================
|
||||
if [ "$FIRE_EXIT" -eq 0 ]; then
|
||||
# Update with fired timestamp and move to fired/
|
||||
TMP=$(mktemp)
|
||||
jq --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '.status = "fired" | .fired_at = $ts' "$ACTION_FILE" > "$TMP" \
|
||||
&& mv "$TMP" "${OPS_VAULT_DIR}/fired/${ACTION_ID}.json"
|
||||
rm -f "$ACTION_FILE"
|
||||
log "$ACTION_ID: approved → fired"
|
||||
else
|
||||
log "ERROR: $ACTION_ID fire failed (exit $FIRE_EXIT) — stays in approved/ for retry"
|
||||
exit "$FIRE_EXIT"
|
||||
fi
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# vault-poll.sh — Vault: process pending actions + procurement requests
|
||||
#
|
||||
# Runs every 30min via cron. Two pipelines:
|
||||
# A. Action gating (*.json): auto-approve/escalate/reject via vault-agent.sh
|
||||
# B. Procurement (*.md): notify human, fire approved requests via vault-fire.sh
|
||||
#
|
||||
# Phases:
|
||||
# 1. Retry any approved/ items that weren't fired (crash recovery)
|
||||
# 2. Auto-reject escalations with no reply for 48h
|
||||
# 3. Invoke vault-agent.sh for new pending JSON actions
|
||||
# 4. Notify human about new pending procurement requests (.md)
|
||||
#
|
||||
# Cron: */30 * * * * /path/to/disinto/vault/vault-poll.sh
|
||||
#
|
||||
# Peek: cat /tmp/vault-status
|
||||
# Log: tail -f /path/to/disinto/vault/vault.log
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
source "${SCRIPT_DIR}/../lib/env.sh"
|
||||
# Use vault-bot's own Forgejo identity (#747)
|
||||
FORGE_TOKEN="${FORGE_VAULT_TOKEN:-${FORGE_TOKEN}}"
|
||||
|
||||
LOGFILE="${DISINTO_LOG_DIR}/vault/vault.log"
|
||||
STATUSFILE="/tmp/vault-status"
|
||||
LOCKFILE="/tmp/vault-poll.lock"
|
||||
VAULT_SCRIPT_DIR="${FACTORY_ROOT}/vault"
|
||||
OPS_VAULT_DIR="${OPS_REPO_ROOT}/vault"
|
||||
LOCKS_DIR="${DISINTO_LOG_DIR}/vault/.locks"
|
||||
|
||||
TIMEOUT_HOURS=48
|
||||
|
||||
# Prevent overlapping runs
|
||||
if [ -f "$LOCKFILE" ]; then
|
||||
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null)
|
||||
if kill -0 "$LOCK_PID" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$LOCKFILE"
|
||||
fi
|
||||
echo $$ > "$LOCKFILE"
|
||||
trap 'rm -f "$LOCKFILE" "$STATUSFILE"' EXIT
|
||||
|
||||
log() {
|
||||
printf '[%s] vault: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
||||
}
|
||||
|
||||
status() {
|
||||
printf '[%s] vault: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" > "$STATUSFILE"
|
||||
log "$*"
|
||||
}
|
||||
|
||||
# Acquire per-action lock (returns 0 if acquired, 1 if already locked)
|
||||
lock_action() {
|
||||
local action_id="$1"
|
||||
local lockfile="${LOCKS_DIR}/${action_id}.lock"
|
||||
mkdir -p "$LOCKS_DIR"
|
||||
if [ -f "$lockfile" ]; then
|
||||
local lock_pid
|
||||
lock_pid=$(cat "$lockfile" 2>/dev/null || true)
|
||||
if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
rm -f "$lockfile"
|
||||
fi
|
||||
echo $$ > "$lockfile"
|
||||
return 0
|
||||
}
|
||||
|
||||
unlock_action() {
|
||||
local action_id="$1"
|
||||
rm -f "${LOCKS_DIR}/${action_id}.lock"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# PHASE 1: Retry approved items (crash recovery — JSON actions + MD procurement)
|
||||
# =============================================================================
|
||||
status "phase 1: retrying approved items"
|
||||
|
||||
for action_file in "${OPS_VAULT_DIR}/approved/"*.json; do
|
||||
[ -f "$action_file" ] || continue
|
||||
ACTION_ID=$(jq -r '.id // ""' < "$action_file" 2>/dev/null)
|
||||
[ -z "$ACTION_ID" ] && continue
|
||||
|
||||
if ! lock_action "$ACTION_ID"; then
|
||||
log "skip $ACTION_ID — locked by another process"
|
||||
continue
|
||||
fi
|
||||
|
||||
log "retrying approved action: $ACTION_ID"
|
||||
if bash "${VAULT_SCRIPT_DIR}/vault-fire.sh" "$ACTION_ID" >> "$LOGFILE" 2>&1; then
|
||||
log "fired $ACTION_ID (retry)"
|
||||
else
|
||||
log "ERROR: fire failed for $ACTION_ID (retry)"
|
||||
fi
|
||||
|
||||
unlock_action "$ACTION_ID"
|
||||
done
|
||||
|
||||
# Retry approved procurement requests (.md)
|
||||
for req_file in "${OPS_VAULT_DIR}/approved/"*.md; do
|
||||
[ -f "$req_file" ] || continue
|
||||
REQ_ID=$(basename "$req_file" .md)
|
||||
|
||||
if ! lock_action "$REQ_ID"; then
|
||||
log "skip procurement $REQ_ID — locked by another process"
|
||||
continue
|
||||
fi
|
||||
|
||||
log "retrying approved procurement: $REQ_ID"
|
||||
if bash "${VAULT_SCRIPT_DIR}/vault-fire.sh" "$REQ_ID" >> "$LOGFILE" 2>&1; then
|
||||
log "fired procurement $REQ_ID (retry)"
|
||||
else
|
||||
log "ERROR: fire failed for procurement $REQ_ID (retry)"
|
||||
fi
|
||||
|
||||
unlock_action "$REQ_ID"
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
# PHASE 2: Timeout escalations (48h no reply → auto-reject)
|
||||
# =============================================================================
|
||||
status "phase 2: checking escalation timeouts"
|
||||
|
||||
NOW_EPOCH=$(date +%s)
|
||||
TIMEOUT_SECS=$((TIMEOUT_HOURS * 3600))
|
||||
|
||||
for action_file in "${OPS_VAULT_DIR}/pending/"*.json; do
|
||||
[ -f "$action_file" ] || continue
|
||||
|
||||
ACTION_STATUS=$(jq -r '.status // ""' < "$action_file" 2>/dev/null)
|
||||
[ "$ACTION_STATUS" != "escalated" ] && continue
|
||||
|
||||
ACTION_ID=$(jq -r '.id // ""' < "$action_file" 2>/dev/null)
|
||||
ESCALATED_AT=$(jq -r '.escalated_at // ""' < "$action_file" 2>/dev/null)
|
||||
[ -z "$ESCALATED_AT" ] && continue
|
||||
|
||||
ESCALATED_EPOCH=$(date -d "$ESCALATED_AT" +%s 2>/dev/null || echo 0)
|
||||
AGE_SECS=$((NOW_EPOCH - ESCALATED_EPOCH))
|
||||
|
||||
if [ "$AGE_SECS" -gt "$TIMEOUT_SECS" ]; then
|
||||
AGE_HOURS=$((AGE_SECS / 3600))
|
||||
log "timeout: $ACTION_ID escalated ${AGE_HOURS}h ago with no reply — auto-rejecting"
|
||||
bash "${VAULT_SCRIPT_DIR}/vault-reject.sh" "$ACTION_ID" "timeout (${AGE_HOURS}h, no human reply)" >> "$LOGFILE" 2>&1 || true
|
||||
fi
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
# PHASE 3: Process new pending actions (JSON — action gating)
|
||||
# =============================================================================
|
||||
status "phase 3: processing pending actions"
|
||||
|
||||
PENDING_COUNT=0
|
||||
PENDING_SUMMARY=""
|
||||
|
||||
for action_file in "${OPS_VAULT_DIR}/pending/"*.json; do
|
||||
[ -f "$action_file" ] || continue
|
||||
|
||||
ACTION_STATUS=$(jq -r '.status // ""' < "$action_file" 2>/dev/null)
|
||||
# Skip already-escalated actions (waiting for human reply)
|
||||
[ "$ACTION_STATUS" = "escalated" ] && continue
|
||||
|
||||
ACTION_ID=$(jq -r '.id // ""' < "$action_file" 2>/dev/null)
|
||||
[ -z "$ACTION_ID" ] && continue
|
||||
|
||||
if ! lock_action "$ACTION_ID"; then
|
||||
log "skip $ACTION_ID — locked"
|
||||
continue
|
||||
fi
|
||||
|
||||
PENDING_COUNT=$((PENDING_COUNT + 1))
|
||||
ACTION_TYPE=$(jq -r '.type // "unknown"' < "$action_file" 2>/dev/null)
|
||||
ACTION_SOURCE=$(jq -r '.source // "unknown"' < "$action_file" 2>/dev/null)
|
||||
PENDING_SUMMARY="${PENDING_SUMMARY} ${ACTION_ID} [${ACTION_TYPE}] from ${ACTION_SOURCE}\n"
|
||||
|
||||
unlock_action "$ACTION_ID"
|
||||
done
|
||||
|
||||
if [ "$PENDING_COUNT" -gt 0 ]; then
|
||||
log "found $PENDING_COUNT pending action(s), invoking vault-agent"
|
||||
status "invoking vault-agent for $PENDING_COUNT action(s)"
|
||||
|
||||
bash "${VAULT_SCRIPT_DIR}/vault-agent.sh" >> "$LOGFILE" 2>&1 || {
|
||||
log "ERROR: vault-agent failed"
|
||||
}
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# PHASE 4: Notify human about new pending procurement requests (.md)
|
||||
# =============================================================================
|
||||
status "phase 4: processing pending procurement requests"
|
||||
|
||||
PROCURE_COUNT=0
|
||||
|
||||
for req_file in "${OPS_VAULT_DIR}/pending/"*.md; do
|
||||
[ -f "$req_file" ] || continue
|
||||
REQ_ID=$(basename "$req_file" .md)
|
||||
|
||||
# Check if already notified (marker file)
|
||||
if [ -f "${LOCKS_DIR}/${REQ_ID}.notified" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! lock_action "$REQ_ID"; then
|
||||
log "skip procurement $REQ_ID — locked"
|
||||
continue
|
||||
fi
|
||||
|
||||
PROCURE_COUNT=$((PROCURE_COUNT + 1))
|
||||
|
||||
# Extract title from first heading
|
||||
REQ_TITLE=$(grep -m1 '^# ' "$req_file" | sed 's/^# //' || echo "$REQ_ID")
|
||||
|
||||
log "new procurement request: $REQ_ID — $REQ_TITLE"
|
||||
|
||||
# Mark as notified so we don't re-send
|
||||
mkdir -p "${LOCKS_DIR}"
|
||||
touch "${LOCKS_DIR}/${REQ_ID}.notified"
|
||||
|
||||
unlock_action "$REQ_ID"
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
# PHASE 5: Detect vault-bot authorized comments on issues
|
||||
# =============================================================================
|
||||
status "phase 5: scanning for vault-bot authorized comments"
|
||||
|
||||
COMMENT_COUNT=0
|
||||
|
||||
if [ -n "${FORGE_REPO:-}" ] && [ -n "${FORGE_TOKEN:-}" ]; then
|
||||
# Get open issues with action label
|
||||
ACTION_ISSUES=$(curl -sf \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_URL}/api/v1/repos/${FORGE_REPO}/issues?state=open&labels=action&limit=50" 2>/dev/null) || ACTION_ISSUES="[]"
|
||||
|
||||
ISSUE_COUNT=$(printf '%s' "$ACTION_ISSUES" | jq 'length')
|
||||
for idx in $(seq 0 $((ISSUE_COUNT - 1))); do
|
||||
ISSUE_NUM=$(printf '%s' "$ACTION_ISSUES" | jq -r ".[$idx].number")
|
||||
|
||||
# Skip if already processed
|
||||
if [ -f "${LOCKS_DIR}/issue-${ISSUE_NUM}.vault-fired" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get comments on this issue
|
||||
COMMENTS=$(curl -sf \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_URL}/api/v1/repos/${FORGE_REPO}/issues/${ISSUE_NUM}/comments?limit=50" 2>/dev/null) || continue
|
||||
|
||||
# Look for vault-bot comments containing VAULT:APPROVED with a JSON action spec
|
||||
APPROVED_BODY=$(printf '%s' "$COMMENTS" | jq -r '
|
||||
[.[] | select(.user.login == "vault-bot") | select(.body | test("VAULT:APPROVED"))] | last | .body // empty
|
||||
' 2>/dev/null) || continue
|
||||
|
||||
[ -z "$APPROVED_BODY" ] && continue
|
||||
|
||||
# Extract JSON action spec from fenced code block in the comment
|
||||
ACTION_JSON=$(printf '%s' "$APPROVED_BODY" | sed -n '/^```json$/,/^```$/p' | sed '1d;$d')
|
||||
[ -z "$ACTION_JSON" ] && continue
|
||||
|
||||
# Validate JSON
|
||||
if ! printf '%s' "$ACTION_JSON" | jq empty 2>/dev/null; then
|
||||
log "malformed action JSON in vault-bot comment on issue #${ISSUE_NUM}"
|
||||
continue
|
||||
fi
|
||||
|
||||
ACTION_ID=$(printf '%s' "$ACTION_JSON" | jq -r '.id // empty')
|
||||
if [ -z "$ACTION_ID" ]; then
|
||||
ACTION_ID="issue-${ISSUE_NUM}-$(date +%s)"
|
||||
ACTION_JSON=$(printf '%s' "$ACTION_JSON" | jq --arg id "$ACTION_ID" '.id = $id')
|
||||
fi
|
||||
|
||||
# Skip if this action already exists in any stage
|
||||
if [ -f "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json" ] || \
|
||||
[ -f "${OPS_VAULT_DIR}/fired/${ACTION_ID}.json" ] || \
|
||||
[ -f "${OPS_VAULT_DIR}/rejected/${ACTION_ID}.json" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
log "vault-bot authorized action on issue #${ISSUE_NUM}: ${ACTION_ID}"
|
||||
printf '%s' "$ACTION_JSON" | jq '.status = "approved"' > "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
|
||||
COMMENT_COUNT=$((COMMENT_COUNT + 1))
|
||||
|
||||
# Fire the action
|
||||
if bash "${VAULT_SCRIPT_DIR}/vault-fire.sh" "$ACTION_ID" >> "$LOGFILE" 2>&1; then
|
||||
log "fired ${ACTION_ID} from issue #${ISSUE_NUM}"
|
||||
# Mark issue as processed
|
||||
touch "${LOCKS_DIR}/issue-${ISSUE_NUM}.vault-fired"
|
||||
else
|
||||
log "ERROR: fire failed for ${ACTION_ID} from issue #${ISSUE_NUM}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$PENDING_COUNT" -eq 0 ] && [ "$PROCURE_COUNT" -eq 0 ] && [ "$COMMENT_COUNT" -eq 0 ]; then
|
||||
status "all clear — no pending items"
|
||||
else
|
||||
status "poll complete — ${PENDING_COUNT} action(s), ${PROCURE_COUNT} procurement(s), ${COMMENT_COUNT} comment-authorized"
|
||||
fi
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# vault-reject.sh — Move a vault action to rejected/ with reason
|
||||
#
|
||||
# Usage: bash vault-reject.sh <action-id> "<reason>"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
source "${SCRIPT_DIR}/vault-env.sh"
|
||||
|
||||
OPS_VAULT_DIR="${OPS_REPO_ROOT}/vault"
|
||||
LOGFILE="${DISINTO_LOG_DIR}/vault/vault.log"
|
||||
LOCKS_DIR="${DISINTO_LOG_DIR}/vault/.locks"
|
||||
|
||||
log() {
|
||||
printf '[%s] vault-reject: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
||||
}
|
||||
|
||||
ACTION_ID="${1:?Usage: vault-reject.sh <action-id> \"<reason>\"}"
|
||||
REASON="${2:-unspecified}"
|
||||
|
||||
# Find the action file
|
||||
ACTION_FILE=""
|
||||
if [ -f "${OPS_VAULT_DIR}/pending/${ACTION_ID}.json" ]; then
|
||||
ACTION_FILE="${OPS_VAULT_DIR}/pending/${ACTION_ID}.json"
|
||||
elif [ -f "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json" ]; then
|
||||
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
|
||||
else
|
||||
log "ERROR: action $ACTION_ID not found in pending/ or approved/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Update with rejection metadata and move to rejected/
|
||||
TMP=$(mktemp)
|
||||
jq --arg reason "$REASON" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'.status = "rejected" | .rejected_at = $ts | .reject_reason = $reason' \
|
||||
"$ACTION_FILE" > "$TMP" && mv "$TMP" "${OPS_VAULT_DIR}/rejected/${ACTION_ID}.json"
|
||||
rm -f "$ACTION_FILE"
|
||||
|
||||
# Clean up lock if present
|
||||
rm -f "${LOCKS_DIR}/${ACTION_ID}.lock"
|
||||
|
||||
log "$ACTION_ID: rejected — $REASON"
|
||||
Loading…
Add table
Add a link
Reference in a new issue