Keep backup subprocess output out of workflow evidence
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 1m22s

Sanitize failures and returned summaries at the adapter boundary. Allowlist backup status values and preserve failure exit codes. Verify synthetic sensitive output cannot survive Python traceback or Temporal failure serialization.

Decision-Class: strengthen-existing-evidence-boundary

Delegation: explicit-user-task; Emergent-Security-A6

Evidence: full-suite-676-passed-1-skipped; final-focused-48-passed; no-new-lint-findings

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a100de-2e1c-7e23-9ae9-2da0e0499411
This commit is contained in:
tegwick 2026-10-03 14:57:31 +02:00
parent 1f9897addc
commit dcb5f7ddef
5 changed files with 158 additions and 30 deletions

View file

@ -119,14 +119,22 @@ def normalize_operation_result(operation_id: str, raw: Any) -> dict[str, Any]:
else 0,
}
if operation_id == "cnpg_option_a_backup":
overall = result.get("overall")
if not isinstance(overall, str) or overall not in {
"ok", "degraded", "error", "pass", "fail", "unknown"
}:
overall = "unknown"
exit_code = result.get("script_exit_code", 0)
if isinstance(exit_code, bool) or not isinstance(exit_code, int):
exit_code = 1
return {
"kind": operation_id,
"overall": str(result.get("overall") or "unknown")[:40],
"overall": overall,
"dry_run": bool(result.get("dry_run")),
"dumped": _safe_count(result.get("dumped")),
"uploaded": _safe_count(result.get("uploaded")),
"failed": _safe_count(result.get("failed")),
"script_exit_code": _safe_count(result.get("script_exit_code")),
"script_exit_code": exit_code,
}
raise ValueError(f"no result projection for bounded operation {operation_id!r}")

View file

@ -3,13 +3,12 @@
from __future__ import annotations
import json
import logging
import os
import subprocess
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
from activity_core.bounded_operations import normalize_operation_result
_DEFAULT_SCRIPT = Path("/opt/railiance-platform/tools/cmd/cnpg-option-a-backup")
_DEFAULT_TIMEOUT_SECONDS = 7200
@ -19,7 +18,7 @@ def cnpg_option_a_backup(params: dict[str, Any]) -> dict[str, Any]:
"""Run multi-host Option A backup CLI and return its JSON summary."""
script = Path(str(params.get("backup_script", _DEFAULT_SCRIPT))).expanduser()
if not script.is_file():
raise FileNotFoundError(f"cnpg_option_a_backup script not found: {script}")
raise FileNotFoundError("cnpg_option_a_backup script not found")
dry_run = bool(params.get("dry_run", False))
timeout = float(params.get("timeout_seconds", _DEFAULT_TIMEOUT_SECONDS))
@ -63,34 +62,37 @@ def cnpg_option_a_backup(params: dict[str, Any]) -> dict[str, Any]:
env["CNPG_BACKUP_TARGETS"] = str(targets)
cmd = [str(script)]
completed = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
env=env,
)
stdout = (completed.stdout or "").strip()
stderr = (completed.stderr or "").strip()
if completed.returncode not in (0, 2) and not stdout:
raise RuntimeError(
f"cnpg_option_a_backup failed (exit {completed.returncode}): {stderr[:500]}"
try:
completed = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
env=env,
)
except subprocess.TimeoutExpired:
raise RuntimeError("cnpg_option_a_backup timed out") from None
except OSError:
raise RuntimeError("cnpg_option_a_backup could not start") from None
except UnicodeError:
raise RuntimeError("cnpg_option_a_backup returned invalid output encoding") from None
stdout = (completed.stdout or "").strip()
if completed.returncode not in (0, 2) and not stdout:
raise RuntimeError(f"cnpg_option_a_backup failed (exit {completed.returncode})")
try:
summary = json.loads(stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(
f"cnpg_option_a_backup returned invalid JSON: {exc}; stderr={stderr[:300]}"
) from exc
except json.JSONDecodeError:
raise RuntimeError("cnpg_option_a_backup returned invalid JSON") from None
if not isinstance(summary, dict):
raise TypeError("cnpg_option_a_backup returned a non-object summary")
if completed.returncode not in (0, 2):
summary.setdefault("errors", []).append(
f"script_exit_code:{completed.returncode}"
)
summary["kind"] = "cnpg_option_a_backup"
# A failed process cannot report a successful backup, even with valid JSON.
summary["overall"] = "error"
summary["script_exit_code"] = completed.returncode
if stderr:
summary["log_tail"] = stderr[-1500:]
return summary
# Sanitize at the adapter boundary, before any caller can log or serialize it.
# Raw targets, errors, stderr and provider payloads never leave this function.
return normalize_operation_result("cnpg_option_a_backup", summary)