From 39eaee573b0652e5d750bba9e33176fd4a7d0224 Mon Sep 17 00:00:00 2001 From: Griffen Fargo <3642037+gfargo@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:10:29 -0400 Subject: [PATCH] fix(mcp): confirmation gate, migration guards, drift fallback (#516, #517, #518) - #516: Deploy and stop default to --dry-run in MCP context. A new 'confirm' boolean parameter must be set to true to execute the real operation, preventing accidental production changes from agent calls. - #517: Add fallback container enumeration in drift_images (remote and local). When the project-name query finds nothing, retry without --project-name to catch containers deployed under a different name. - #518: Guard migration steps against missing DB env vars (POSTGRES_USER, NEO4J_URI, etc). Stacks without databases now skip gracefully instead of crashing with 'unbound variable'. Also adds a compose-file-only fallback to health_check_containers as a third-level net after #504's working-dir label fallback. --- lib/cmd_db.sh | 18 ++++++++++++++++++ lib/cmd_drift_images.sh | 9 +++++++++ lib/health.sh | 13 +++++++++++++ lib/mcp/tools.sh | 26 ++++++++++++++++++-------- 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/lib/cmd_db.sh b/lib/cmd_db.sh index d83ed78..43cfe2c 100644 --- a/lib/cmd_db.sh +++ b/lib/cmd_db.sh @@ -133,6 +133,15 @@ cmd_migrate_schema() { case "$migrate_target" in neo4j) + # Guard: skip if required Neo4j vars are not configured (strut#518) + if [ -z "${NEO4J_URI:-}" ] || [ -z "${NEO4J_PASSWORD:-}" ]; then + log "Skipping Neo4j migration — NEO4J_URI/NEO4J_PASSWORD not configured" + return 0 + fi + if [ -z "${MIGRATION_IMAGE:-}" ]; then + log "Skipping Neo4j migration — MIGRATION_IMAGE not set" + return 0 + fi log "Running Neo4j schema migration ($migrate_action)..." local migrate_cmd if [ "$migrate_action" = "--down" ]; then @@ -155,6 +164,15 @@ cmd_migrate_schema() { $migrate_cmd ;; postgres) + # Guard: skip if required Postgres vars are not configured (strut#518) + if [ -z "${POSTGRES_USER:-}" ] || [ -z "${POSTGRES_PASSWORD:-}" ] || [ -z "${POSTGRES_DB:-}" ]; then + log "Skipping Postgres migration — POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB not configured" + return 0 + fi + if [ -z "${MIGRATION_IMAGE:-}" ]; then + log "Skipping Postgres migration — MIGRATION_IMAGE not set" + return 0 + fi local pg_action="${migrate_action:---up}" log "Running Postgres schema migration ($pg_action)..." local postgres_migrate_cmd diff --git a/lib/cmd_drift_images.sh b/lib/cmd_drift_images.sh index 4a8cb1d..c16fb0d 100644 --- a/lib/cmd_drift_images.sh +++ b/lib/cmd_drift_images.sh @@ -94,6 +94,11 @@ drift_images_remote() { cd '$deploy_dir' || exit 90 containers=\$(docker compose -f 'stacks/$stack/docker-compose.yml' --project-name '$project_name' ps -q 2>/dev/null) + # Fallback: if project-name query finds nothing, retry without it to + # catch containers deployed under a different project name (strut#517) + if [ -z \"\$containers\" ]; then + containers=\$(docker compose -f 'stacks/$stack/docker-compose.yml' ps -q 2>/dev/null) + fi [ -n \"\$containers\" ] || exit 0 while IFS= read -r cid; do @@ -180,6 +185,10 @@ _drift_images_detect() { local containers containers=$(docker compose -f "$compose_file" --project-name "$project_name" ps -q 2>/dev/null) || true + # Fallback: if project-name query finds nothing, retry without it (strut#517) + if [ -z "$containers" ]; then + containers=$(docker compose -f "$compose_file" ps -q 2>/dev/null) || true + fi [ -n "$containers" ] || { log "No running containers for $stack"; return 0; } local output="" diff --git a/lib/health.sh b/lib/health.sh index 2b9a153..de87e01 100644 --- a/lib/health.sh +++ b/lib/health.sh @@ -142,6 +142,19 @@ health_check_containers() { fi fi + if [ -z "$rows" ]; then + # Fallback: the project-name query may not match if containers were + # deployed under a different project name (e.g., just the stack name + # or the directory name). Try querying by compose file only, which + # matches any container associated with that file. (strut#502) + local fallback_cmd + fallback_cmd="$(_docker_sudo)docker compose -f $compose_file ps --format json" + raw=$($fallback_cmd 2>/dev/null || true) + rows=$(printf '%s' "$raw" | jq -rs ' + (if (length == 1 and (.[0] | type) == "array") then .[0] else . end) + | .[] | "\(.Name)|\(.State)|\(.Health // "")"' 2>/dev/null || true) + fi + if [ -z "$rows" ]; then _health_record fail "Containers" "No containers running" return 1 diff --git a/lib/mcp/tools.sh b/lib/mcp/tools.sh index d112a5d..531ef6c 100644 --- a/lib/mcp/tools.sh +++ b/lib/mcp/tools.sh @@ -20,10 +20,10 @@ _mcp_tools_list() { {"name":"strut_backup_health","description":"Show backup health scores for a stack","inputSchema":{"type":"object","properties":{"stack":{"type":"string","description":"Stack name"}},"required":["stack"]}}, {"name":"strut_briefing","description":"One-call operational situation report: aggregates health, config drift, image staleness, pending diff, and backup health into an overall posture plus prioritized actions","inputSchema":{"type":"object","properties":{"stack":{"type":"string","description":"Stack name"},"env":{"type":"string","description":"Environment name (default: prod)"}},"required":["stack"]}}, {"name":"strut_preflight","description":"Deploy go/no-go verdict (GO/CAUTION/NO-GO): fuses pending diff, config drift, current health, and backup freshness into a release-safety decision with reasons","inputSchema":{"type":"object","properties":{"stack":{"type":"string","description":"Stack name"},"env":{"type":"string","description":"Environment name (default: prod)"}},"required":["stack"]}}, - {"name":"strut_deploy","description":"Deploy a stack to its VPS (requires approval). Runs the full pipeline on the host the stack maps to: sync repo, run migrations, pull images, restart services, health-check, and auto-roll-back if unhealthy. Fails if the stack does not resolve to a remote host — it will never fall back to a local deploy.","inputSchema":{"type":"object","properties":{"stack":{"type":"string","description":"Stack name"},"env":{"type":"string","description":"Environment name (default: prod)"}},"required":["stack"]}}, + {"name":"strut_deploy","description":"Deploy a stack to its VPS. Without confirm:true, runs in dry-run mode showing the execution plan. Pass confirm:true to execute the full pipeline: sync repo, run migrations, pull images, restart services, health-check, and auto-roll-back if unhealthy. Fails if the stack does not resolve to a remote host.","inputSchema":{"type":"object","properties":{"stack":{"type":"string","description":"Stack name"},"env":{"type":"string","description":"Environment name (default: prod)"},"confirm":{"type":"boolean","description":"Set to true to actually execute the deploy. Omit or false for a dry-run preview."}},"required":["stack"]}}, {"name":"strut_sync","description":"Bring a host checkout in sync with origin","inputSchema":{"type":"object","properties":{"host":{"type":"string","description":"Host alias from topology"}},"required":["host"]}}, {"name":"strut_backup","description":"Create a backup for a stack","inputSchema":{"type":"object","properties":{"stack":{"type":"string","description":"Stack name"},"target":{"type":"string","description":"Backup target (postgres, neo4j, mysql, sqlite, all). Default: all"}},"required":["stack"]}}, - {"name":"strut_stop","description":"Stop containers for a stack (requires approval)","inputSchema":{"type":"object","properties":{"stack":{"type":"string","description":"Stack name"}},"required":["stack"]}} + {"name":"strut_stop","description":"Stop containers for a stack. Without confirm:true, runs in dry-run mode showing what would be stopped. Pass confirm:true to actually stop and remove containers.","inputSchema":{"type":"object","properties":{"stack":{"type":"string","description":"Stack name"},"confirm":{"type":"boolean","description":"Set to true to actually stop the stack. Omit or false for a dry-run preview."}},"required":["stack"]}} ]} EOF } @@ -179,12 +179,16 @@ _mcp_tools_call() { output=$("$strut_bin" "$stack" preflight --env "$env" --json 2>&1) || rc=$? ;; strut_deploy) - local stack env + local stack env confirm stack=$(_mcp_arg "$args" stack) || { _mcp_reject "invalid 'stack' argument"; return 0; } env=$(_mcp_arg "$args" env prod) || { _mcp_reject "invalid 'env' argument"; return 0; } - # --require-remote, not a bare `deploy`: an agent calling this must never - # get a silent local deploy because the stack's VPS_HOST didn't resolve. - output=$("$strut_bin" "$stack" deploy --require-remote --env "$env" 2>&1) || rc=$? + confirm=$(printf '%s' "$args" | jq -r '.confirm // false') + # Default to dry-run unless explicitly confirmed (strut#516) + if [ "$confirm" = "true" ]; then + output=$("$strut_bin" "$stack" deploy --require-remote --env "$env" 2>&1) || rc=$? + else + output=$("$strut_bin" "$stack" deploy --require-remote --env "$env" --dry-run 2>&1) || rc=$? + fi ;; strut_sync) local host @@ -198,9 +202,15 @@ _mcp_tools_call() { output=$("$strut_bin" "$stack" backup "$target" --env prod 2>&1) || rc=$? ;; strut_stop) - local stack + local stack confirm stack=$(_mcp_arg "$args" stack) || { _mcp_reject "invalid 'stack' argument"; return 0; } - output=$("$strut_bin" "$stack" stop --env prod 2>&1) || rc=$? + confirm=$(printf '%s' "$args" | jq -r '.confirm // false') + # Default to dry-run unless explicitly confirmed (strut#516) + if [ "$confirm" = "true" ]; then + output=$("$strut_bin" "$stack" stop --env prod 2>&1) || rc=$? + else + output=$("$strut_bin" "$stack" stop --env prod --dry-run 2>&1) || rc=$? + fi ;; *) printf '{"content":[{"type":"text","text":"Unknown tool: %s"}],"isError":true}' "$tool"