330 lines
11 KiB
Python
330 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
import shutil
|
|
from typing import Any
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_EVIDENCE_ROOT = ROOT / "docs" / "uat" / "evidence"
|
|
DEFAULT_TEMPLATE_ROOT = ROOT / "docs" / "uat"
|
|
|
|
|
|
def utc_now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def now_compact() -> str:
|
|
return utc_now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
|
|
def iso_now() -> str:
|
|
return utc_now().isoformat()
|
|
|
|
|
|
def find_latest_preflight(evidence_root: Path) -> Path:
|
|
candidates = sorted(
|
|
evidence_root.glob("preflight_*.md"),
|
|
key=lambda path: path.stat().st_mtime,
|
|
reverse=True,
|
|
)
|
|
if not candidates:
|
|
raise FileNotFoundError("No preflight report found in docs/uat/evidence")
|
|
return candidates[0]
|
|
|
|
|
|
def find_latest_dry_run_dir(evidence_root: Path) -> Path:
|
|
candidates = sorted(
|
|
[path for path in evidence_root.glob("dry_run_*") if path.is_dir()],
|
|
key=lambda path: path.stat().st_mtime,
|
|
reverse=True,
|
|
)
|
|
if not candidates:
|
|
raise FileNotFoundError("No dry-run directory found in docs/uat/evidence")
|
|
return candidates[0]
|
|
|
|
|
|
def _replace_line(content: str, prefix: str, value: str) -> str:
|
|
replacement = f"{prefix} {value}".rstrip()
|
|
return content.replace(prefix, replacement, 1)
|
|
|
|
|
|
def render_session_protocol(
|
|
template_text: str,
|
|
*,
|
|
session_id: str,
|
|
environment_url: str,
|
|
build_version: str,
|
|
deployment_date: str,
|
|
cluster_namespace: str,
|
|
participants: dict[str, str],
|
|
preflight_name: str,
|
|
defect_log_name: str,
|
|
) -> str:
|
|
content = template_text
|
|
today = utc_now().date().isoformat()
|
|
content = _replace_line(content, "- Session ID:", session_id)
|
|
content = _replace_line(content, "- Date:", today)
|
|
content = _replace_line(content, "- Start time:", "")
|
|
content = _replace_line(content, "- End time:", "")
|
|
content = _replace_line(content, "- Environment URL:", environment_url)
|
|
content = _replace_line(content, "- Build/version:", build_version)
|
|
content = _replace_line(content, "- Deployment date:", deployment_date)
|
|
content = _replace_line(content, "- Cluster/namespace:", cluster_namespace)
|
|
|
|
field_map = {
|
|
"- Operators:": participants["operators"],
|
|
"- Supervisor:": participants["supervisor"],
|
|
"- Analyst:": participants["analyst"],
|
|
"- Admin:": participants["admin"],
|
|
"- Business owner:": participants["business_owner"],
|
|
"- IT owner:": participants["it_owner"],
|
|
}
|
|
for prefix, value in field_map.items():
|
|
content = _replace_line(content, prefix, value)
|
|
|
|
content = content.replace(
|
|
"- [ ] Preflight report attached (`docs/uat/evidence/preflight_*.md`)",
|
|
f"- [x] Preflight report attached (`attachments/{preflight_name}`)",
|
|
1,
|
|
)
|
|
content = content.replace(
|
|
"- [ ] Defect log prepared from `docs/uat/defect-log-template.csv`",
|
|
f"- [x] Defect log prepared from `{defect_log_name}`",
|
|
1,
|
|
)
|
|
return content
|
|
|
|
|
|
def render_signoff_sheet(
|
|
template_text: str,
|
|
*,
|
|
session_id: str,
|
|
environment_url: str,
|
|
build_version: str,
|
|
) -> str:
|
|
lines = template_text.splitlines()
|
|
if not lines:
|
|
return template_text
|
|
|
|
header = [
|
|
lines[0],
|
|
"",
|
|
f"- Session ID: {session_id}",
|
|
f"- Environment URL: {environment_url}",
|
|
f"- Build/version: {build_version}",
|
|
f"- Prepared at (UTC): {iso_now()}",
|
|
"",
|
|
]
|
|
return "\n".join(header + lines[1:])
|
|
|
|
|
|
def build_manual_readme(
|
|
*,
|
|
session_id: str,
|
|
environment_url: str,
|
|
preflight_name: str,
|
|
dry_run_name: str,
|
|
) -> str:
|
|
lines = [
|
|
"# Manual UAT Session Bundle",
|
|
"",
|
|
f"- Session ID: {session_id}",
|
|
f"- Environment URL: {environment_url}",
|
|
f"- Prepared at (UTC): {iso_now()}",
|
|
"",
|
|
"## Included Files",
|
|
"",
|
|
"- `session-protocol.md`",
|
|
"- `scenario-checklist.md`",
|
|
"- `defect-log.csv`",
|
|
"- `signoff-sheet.md`",
|
|
"- `manifest.json`",
|
|
"",
|
|
"## Attached Evidence",
|
|
"",
|
|
f"- `attachments/{preflight_name}`",
|
|
f"- `attachments/{dry_run_name}/`",
|
|
"",
|
|
"## Next Step",
|
|
"",
|
|
"Run the live manual UAT on the pilot gateway, record any defects, and use the",
|
|
"`scripts/finalize_mvp_pilot.py` command only after signatures are captured and",
|
|
"all open P1/P2 defects are closed.",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def prepare_manual_bundle(
|
|
*,
|
|
session_id: str,
|
|
environment_url: str,
|
|
build_version: str,
|
|
deployment_date: str,
|
|
cluster_namespace: str,
|
|
participants: dict[str, str],
|
|
output_root: Path,
|
|
template_root: Path,
|
|
preflight_report: Path,
|
|
dry_run_dir: Path,
|
|
overwrite: bool = False,
|
|
) -> Path:
|
|
session_dir = output_root / f"manual_{session_id}"
|
|
if session_dir.exists():
|
|
if not overwrite:
|
|
raise FileExistsError(f"Session directory already exists: {session_dir}")
|
|
shutil.rmtree(session_dir)
|
|
|
|
attachments_dir = session_dir / "attachments"
|
|
attachments_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
copied_preflight = attachments_dir / preflight_report.name
|
|
shutil.copy2(preflight_report, copied_preflight)
|
|
|
|
copied_dry_run = attachments_dir / dry_run_dir.name
|
|
shutil.copytree(dry_run_dir, copied_dry_run)
|
|
|
|
session_template = (template_root / "session-template.md").read_text(encoding="utf-8")
|
|
signoff_template = (template_root / "signoff-template.md").read_text(encoding="utf-8")
|
|
checklist_template = (template_root / "scenario-checklist.md").read_text(encoding="utf-8")
|
|
defect_template = (template_root / "defect-log-template.csv").read_text(encoding="utf-8")
|
|
|
|
rendered_session = render_session_protocol(
|
|
session_template,
|
|
session_id=session_id,
|
|
environment_url=environment_url,
|
|
build_version=build_version,
|
|
deployment_date=deployment_date,
|
|
cluster_namespace=cluster_namespace,
|
|
participants=participants,
|
|
preflight_name=preflight_report.name,
|
|
defect_log_name="defect-log.csv",
|
|
)
|
|
rendered_signoff = render_signoff_sheet(
|
|
signoff_template,
|
|
session_id=session_id,
|
|
environment_url=environment_url,
|
|
build_version=build_version,
|
|
)
|
|
|
|
(session_dir / "session-protocol.md").write_text(rendered_session, encoding="utf-8")
|
|
(session_dir / "signoff-sheet.md").write_text(rendered_signoff, encoding="utf-8")
|
|
(session_dir / "scenario-checklist.md").write_text(checklist_template, encoding="utf-8")
|
|
(session_dir / "defect-log.csv").write_text(defect_template, encoding="utf-8")
|
|
|
|
manifest: dict[str, Any] = {
|
|
"prepared_at": iso_now(),
|
|
"session_id": session_id,
|
|
"environment_url": environment_url,
|
|
"build_version": build_version,
|
|
"deployment_date": deployment_date,
|
|
"cluster_namespace": cluster_namespace,
|
|
"participants": participants,
|
|
"artifacts": {
|
|
"preflight_report": f"attachments/{preflight_report.name}",
|
|
"dry_run_bundle": f"attachments/{dry_run_dir.name}",
|
|
"session_protocol": "session-protocol.md",
|
|
"scenario_checklist": "scenario-checklist.md",
|
|
"defect_log": "defect-log.csv",
|
|
"signoff_sheet": "signoff-sheet.md",
|
|
},
|
|
}
|
|
(session_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
(session_dir / "README.md").write_text(
|
|
build_manual_readme(
|
|
session_id=session_id,
|
|
environment_url=environment_url,
|
|
preflight_name=preflight_report.name,
|
|
dry_run_name=dry_run_dir.name,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
return session_dir
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Prepare a manual UAT bundle from the latest preflight and dry-run")
|
|
parser.add_argument("--session-id", default=f"UAT-MANUAL-{now_compact()}", help="Manual UAT session id")
|
|
parser.add_argument(
|
|
"--environment-url",
|
|
default="http://<pilot-gateway>:8080",
|
|
help="Pilot gateway URL recorded in the session pack",
|
|
)
|
|
parser.add_argument("--build-version", default="v1.0.0-mvp", help="Build/version under test")
|
|
parser.add_argument(
|
|
"--deployment-date",
|
|
default=utc_now().date().isoformat(),
|
|
help="Deployment date recorded in the session pack",
|
|
)
|
|
parser.add_argument(
|
|
"--cluster-namespace",
|
|
default="pilot/production-like",
|
|
help="Cluster or namespace label recorded in the session pack",
|
|
)
|
|
parser.add_argument("--operators", default="TBD", help="Operator participants")
|
|
parser.add_argument("--supervisor", default="TBD", help="Supervisor participant")
|
|
parser.add_argument("--analyst", default="TBD", help="Analyst participant")
|
|
parser.add_argument("--admin", default="TBD", help="Admin participant")
|
|
parser.add_argument("--business-owner", default="TBD", help="Business owner")
|
|
parser.add_argument("--it-owner", default="TBD", help="IT owner")
|
|
parser.add_argument(
|
|
"--preflight-report",
|
|
default="",
|
|
help="Optional explicit preflight report path (default: latest preflight report)",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run-dir",
|
|
default="",
|
|
help="Optional explicit dry-run directory path (default: latest dry-run evidence folder)",
|
|
)
|
|
parser.add_argument(
|
|
"--output-root",
|
|
default="",
|
|
help="Output root directory (default: docs/uat/evidence)",
|
|
)
|
|
parser.add_argument("--overwrite", action="store_true", help="Overwrite the session folder if it already exists")
|
|
args = parser.parse_args()
|
|
|
|
output_root = Path(args.output_root) if args.output_root else DEFAULT_EVIDENCE_ROOT
|
|
output_root = output_root if output_root.is_absolute() else (ROOT / output_root).resolve()
|
|
|
|
preflight_report = Path(args.preflight_report) if args.preflight_report else find_latest_preflight(DEFAULT_EVIDENCE_ROOT)
|
|
if not preflight_report.is_absolute():
|
|
preflight_report = (ROOT / preflight_report).resolve()
|
|
|
|
dry_run_dir = Path(args.dry_run_dir) if args.dry_run_dir else find_latest_dry_run_dir(DEFAULT_EVIDENCE_ROOT)
|
|
if not dry_run_dir.is_absolute():
|
|
dry_run_dir = (ROOT / dry_run_dir).resolve()
|
|
|
|
participants = {
|
|
"operators": args.operators,
|
|
"supervisor": args.supervisor,
|
|
"analyst": args.analyst,
|
|
"admin": args.admin,
|
|
"business_owner": args.business_owner,
|
|
"it_owner": args.it_owner,
|
|
}
|
|
|
|
session_dir = prepare_manual_bundle(
|
|
session_id=args.session_id,
|
|
environment_url=args.environment_url,
|
|
build_version=args.build_version,
|
|
deployment_date=args.deployment_date,
|
|
cluster_namespace=args.cluster_namespace,
|
|
participants=participants,
|
|
output_root=output_root,
|
|
template_root=DEFAULT_TEMPLATE_ROOT,
|
|
preflight_report=preflight_report,
|
|
dry_run_dir=dry_run_dir,
|
|
overwrite=args.overwrite,
|
|
)
|
|
print(f"Manual UAT bundle prepared: {session_dir}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|