260 lines
8.8 KiB
Python
260 lines
8.8 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
TERMINAL_STATUSES = {"COMPLETED", "FAILED", "SKIPPED"}
|
|
|
|
|
|
def _utcnow() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
|
|
|
|
def _load_json(path: str | Path) -> dict[str, Any]:
|
|
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
|
|
|
|
def _write_json(path: str | Path, payload: dict[str, Any]) -> None:
|
|
target = Path(path)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def _tail(text: str, limit: int = 4000) -> str:
|
|
if not text:
|
|
return ""
|
|
return text[-limit:]
|
|
|
|
|
|
def _normalize_step_ids(value: str | None) -> set[str]:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return set()
|
|
return {item.strip() for item in text.replace(";", ",").split(",") if item.strip()}
|
|
|
|
|
|
def _load_workflow_manifest(broker_manifest: dict[str, Any]) -> tuple[dict[str, Any], Path]:
|
|
payload = broker_manifest.get("payload") or {}
|
|
workflow_manifest_path = payload.get("workflow_manifest_wsl") or payload.get("workflow_manifest")
|
|
if not workflow_manifest_path:
|
|
raise ValueError("payload.workflow_manifest_wsl is required")
|
|
manifest_path = Path(str(workflow_manifest_path))
|
|
return _load_json(manifest_path), manifest_path
|
|
|
|
|
|
def _state_path(workflow_manifest: dict[str, Any]) -> Path:
|
|
state = workflow_manifest.get("state") or {}
|
|
explicit = state.get("step_status_path")
|
|
if explicit:
|
|
return Path(str(explicit))
|
|
run_root = Path(str(workflow_manifest.get("run_root_wsl") or workflow_manifest.get("run_root") or "."))
|
|
return run_root / "state" / "step_status.json"
|
|
|
|
|
|
def _script_env(workflow_manifest: dict[str, Any], step: dict[str, Any]) -> dict[str, str]:
|
|
env = os.environ.copy()
|
|
run_root = str(workflow_manifest.get("run_root_wsl") or workflow_manifest.get("run_root") or "")
|
|
params = workflow_manifest.get("params") or {}
|
|
env.update(
|
|
{
|
|
"GAMMA_SBAS_RUN_ROOT": run_root,
|
|
"GAMMA_SBAS_MANIFEST": str(workflow_manifest.get("manifest_path_wsl") or ""),
|
|
"GAMMA_SBAS_STEP_ID": str(step.get("id") or ""),
|
|
"GAMMA_SBAS_STEP_NAME": str(step.get("name") or step.get("id") or ""),
|
|
"GAMMA_SBAS_RLKS": str(params.get("rlks") or ""),
|
|
"GAMMA_SBAS_AZLKS": str(params.get("azlks") or ""),
|
|
"GAMMA_SBAS_MB_MODE": str(params.get("mb_mode") or ""),
|
|
"GAMMA_SBAS_REFERENCE_WINDOW": str(params.get("reference_window") or ""),
|
|
}
|
|
)
|
|
for key, value in (step.get("env") or {}).items():
|
|
env[str(key)] = str(value)
|
|
return env
|
|
|
|
|
|
def _selected_steps(steps: list[dict[str, Any]], only_steps: set[str], from_step: str | None, to_step: str | None) -> list[dict[str, Any]]:
|
|
if only_steps:
|
|
return [step for step in steps if str(step.get("id") or "") in only_steps]
|
|
if not from_step and not to_step:
|
|
return steps
|
|
|
|
selected: list[dict[str, Any]] = []
|
|
active = from_step is None
|
|
for step in steps:
|
|
step_id = str(step.get("id") or "")
|
|
if step_id == from_step:
|
|
active = True
|
|
if active:
|
|
selected.append(step)
|
|
if step_id == to_step:
|
|
break
|
|
return selected
|
|
|
|
|
|
def _run_step(
|
|
workflow_manifest: dict[str, Any],
|
|
step: dict[str, Any],
|
|
*,
|
|
state: dict[str, Any],
|
|
force: bool,
|
|
dry_run: bool,
|
|
timeout_seconds: int,
|
|
) -> dict[str, Any]:
|
|
step_id = str(step.get("id") or "").strip()
|
|
if not step_id:
|
|
raise ValueError("workflow step id must not be empty")
|
|
|
|
if step.get("enabled") is False:
|
|
return {
|
|
"id": step_id,
|
|
"name": step.get("name") or step_id,
|
|
"status": "SKIPPED",
|
|
"skipped_reason": "step disabled in workflow manifest",
|
|
"started_at": _utcnow(),
|
|
"ended_at": _utcnow(),
|
|
}
|
|
|
|
previous = (state.get("steps") or {}).get(step_id) or {}
|
|
if previous.get("status") == "COMPLETED" and not force:
|
|
return {**previous, "status": "SKIPPED", "skipped_reason": "already completed"}
|
|
|
|
script = Path(str(step.get("script_wsl") or step.get("script") or ""))
|
|
if not script.is_file():
|
|
raise FileNotFoundError(f"step script not found: {script}")
|
|
|
|
log_path = Path(str(step.get("log_wsl") or step.get("log") or ""))
|
|
if not log_path:
|
|
run_root = Path(str(workflow_manifest.get("run_root_wsl") or "."))
|
|
log_path = run_root / "logs" / f"{step_id}.log"
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
started_at = _utcnow()
|
|
if dry_run:
|
|
return {
|
|
"id": step_id,
|
|
"name": step.get("name") or step_id,
|
|
"status": "DRY_RUN",
|
|
"script": str(script),
|
|
"log": str(log_path),
|
|
"started_at": started_at,
|
|
"ended_at": _utcnow(),
|
|
"returncode": None,
|
|
}
|
|
|
|
proc = subprocess.run(
|
|
["bash", str(script)],
|
|
cwd=str(script.parent),
|
|
text=True,
|
|
capture_output=True,
|
|
timeout=timeout_seconds,
|
|
check=False,
|
|
env=_script_env(workflow_manifest, step),
|
|
)
|
|
log_path.write_text(
|
|
"\n".join(
|
|
[
|
|
f"# step={step_id}",
|
|
f"# started_at={started_at}",
|
|
f"# ended_at={_utcnow()}",
|
|
f"# returncode={proc.returncode}",
|
|
"",
|
|
"## stdout",
|
|
proc.stdout or "",
|
|
"",
|
|
"## stderr",
|
|
proc.stderr or "",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return {
|
|
"id": step_id,
|
|
"name": step.get("name") or step_id,
|
|
"status": "COMPLETED" if proc.returncode == 0 else "FAILED",
|
|
"script": str(script),
|
|
"log": str(log_path),
|
|
"started_at": started_at,
|
|
"ended_at": _utcnow(),
|
|
"returncode": proc.returncode,
|
|
"stdout_tail": _tail(proc.stdout),
|
|
"stderr_tail": _tail(proc.stderr),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Gamma SBAS manifest runner.")
|
|
parser.add_argument("--manifest", required=True, help="WSL path to broker manifest.")
|
|
parser.add_argument("--from-step", default="", help="First workflow step id to execute.")
|
|
parser.add_argument("--to-step", default="", help="Last workflow step id to execute.")
|
|
parser.add_argument("--only-steps", default="", help="Comma-separated step ids to execute.")
|
|
parser.add_argument("--force", action="store_true", help="Run completed steps again.")
|
|
parser.add_argument("--dry-run", action="store_true", help="Validate manifest and write dry-run state.")
|
|
parser.add_argument("--timeout-seconds", type=int, default=43200)
|
|
args = parser.parse_args()
|
|
|
|
broker_manifest = _load_json(args.manifest)
|
|
workflow_manifest, workflow_manifest_path = _load_workflow_manifest(broker_manifest)
|
|
workflow_manifest["manifest_path_wsl"] = str(workflow_manifest_path)
|
|
|
|
state_file = _state_path(workflow_manifest)
|
|
state = _load_json(state_file) if state_file.is_file() else {
|
|
"schema": "insar.gamma-sbas-step-status/v1",
|
|
"run_id": workflow_manifest.get("run_id"),
|
|
"steps": {},
|
|
}
|
|
state.setdefault("steps", {})
|
|
state["updated_at"] = _utcnow()
|
|
state["runner_manifest"] = args.manifest
|
|
|
|
all_steps = list(workflow_manifest.get("steps") or [])
|
|
selected = _selected_steps(
|
|
all_steps,
|
|
_normalize_step_ids(args.only_steps),
|
|
str(args.from_step or "").strip() or None,
|
|
str(args.to_step or "").strip() or None,
|
|
)
|
|
if not selected:
|
|
raise ValueError("no workflow steps selected")
|
|
|
|
overall_rc = 0
|
|
executed: list[str] = []
|
|
for step in selected:
|
|
step_id = str(step.get("id") or "")
|
|
result = _run_step(
|
|
workflow_manifest,
|
|
step,
|
|
state=state,
|
|
force=args.force,
|
|
dry_run=args.dry_run,
|
|
timeout_seconds=max(60, int(args.timeout_seconds or 43200)),
|
|
)
|
|
state["steps"][step_id] = result
|
|
state["updated_at"] = _utcnow()
|
|
_write_json(state_file, state)
|
|
executed.append(step_id)
|
|
if result.get("status") == "FAILED":
|
|
overall_rc = int(result.get("returncode") or 1)
|
|
break
|
|
|
|
summary = {
|
|
"runner": "gamma_sbas_runtime_v1",
|
|
"operation": broker_manifest.get("operation"),
|
|
"workflow_manifest": str(workflow_manifest_path),
|
|
"state_path": str(state_file),
|
|
"executed_steps": executed,
|
|
"returncode": overall_rc,
|
|
"dry_run": bool(args.dry_run),
|
|
}
|
|
print(json.dumps(summary, ensure_ascii=False))
|
|
return overall_rc
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|