Initial import with GitLab CI/CD and registry deploy flow
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user