Keep backup subprocess output out of workflow evidence
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:
parent
1f9897addc
commit
dcb5f7ddef
5 changed files with 158 additions and 30 deletions
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue