#!/usr/bin/env python3 from __future__ import annotations import argparse import json import sys import urllib.request from pathlib import Path from typing import Any DEFAULT_BASE_URL = "http://docker.cin.su:8011" DEFAULT_TABLES = ("ConfigCASSave", "ConfigSave") ALLOWED_TABLES = {"ConfigCASSave", "ConfigSave"} def request_json(method: str, url: str, *, payload: dict[str, Any] | None, timeout: float) -> tuple[int, dict[str, Any] | str]: data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None request = urllib.request.Request( url, data=data, method=method, headers={"Content-Type": "application/json; charset=utf-8"}, ) with urllib.request.urlopen(request, timeout=timeout) as response: raw = response.read().decode("utf-8") return response.status, json.loads(raw) if raw else {} def rpc(base_url: str, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]: status, response = request_json( "POST", base_url.rstrip("/") + "/rpc", payload={"method": method, "payload": payload}, timeout=timeout, ) if status != 200 or not isinstance(response, dict): raise RuntimeError(f"{method} failed: status={status}, response={response!r}") return response def health_summary(base_url: str, base_id: str, timeout: float) -> dict[str, Any]: status, response = request_json("GET", f"{base_url.rstrip('/')}/health?base_id={base_id}", payload=None, timeout=timeout) if status != 200 or not isinstance(response, dict): return {"status": "error", "http_status": status, "response": response} live_sql = response.get("live_sql") if isinstance(response.get("live_sql"), dict) else {} return { "status": response.get("status"), "contract_version": response.get("contract_version"), "live_sql": { "configured": live_sql.get("configured"), "server": live_sql.get("server"), "database": live_sql.get("database"), }, } def saved_state_row_counts(base_url: str, base_id: str, tables: list[str], timeout: float) -> dict[str, Any]: selects = [f"SELECT '{table}' AS TableName, COUNT(*) AS RowsCount FROM {table}" for table in tables] result = rpc( base_url, "query.run", { "base_id": base_id, "diagnostic": True, "query": "\nUNION ALL\n".join(selects), "timeout_seconds": int(timeout), }, timeout, ) counts: dict[str, int] = {table: 0 for table in tables} for row in result.get("rows") or []: if not isinstance(row, dict): continue table = str(row.get("TableName") or "") if table in counts and isinstance(row.get("RowsCount"), int): counts[table] = int(row["RowsCount"]) return { "status": result.get("status"), "validation": result.get("validation"), "counts": counts, "raw_counts": result.get("counts"), } def search_saved_state(base_url: str, base_id: str, tables: list[str], timeout: float) -> dict[str, Any]: common = {"base_id": base_id, "tables": tables, "limit": 3, "timeout_seconds": int(timeout)} forms = rpc(base_url, "metadata.saved_state.forms.search", common, timeout) modules = rpc(base_url, "metadata.saved_state.modules.search", {**common, "scan_limit": 100}, timeout) return { "forms": { "status": forms.get("status"), "counts": forms.get("counts"), "sample": forms.get("forms") or [], }, "modules": { "status": modules.get("status"), "counts": modules.get("counts"), "sample": modules.get("modules") or [], }, } def build_report(base_url: str, base_id: str, tables: list[str], timeout: float, saved_state_table: str | None) -> dict[str, Any]: report: dict[str, Any] = { "schema": "onec_saved_state_strict_readiness.v1", "base_url": base_url, "base_id": base_id, "saved_state_table": saved_state_table, "tables": tables, "ready": False, "status": "error", "checks": {}, "recommendations": [], } report["checks"]["health"] = health_summary(base_url, base_id, timeout) report["checks"]["row_counts"] = saved_state_row_counts(base_url, base_id, tables, timeout) report["checks"]["saved_state_search"] = search_saved_state(base_url, base_id, tables, timeout) row_counts = (report["checks"]["row_counts"] or {}).get("counts") or {} forms_counts = (((report["checks"]["saved_state_search"] or {}).get("forms") or {}).get("counts") or {}) modules_counts = (((report["checks"]["saved_state_search"] or {}).get("modules") or {}).get("counts") or {}) total_rows = sum(value for value in row_counts.values() if isinstance(value, int)) forms = forms_counts.get("forms") if isinstance(forms_counts.get("forms"), int) else 0 modules = modules_counts.get("modules") if isinstance(modules_counts.get("modules"), int) else 0 report["summary"] = { "saved_state_rows": total_rows, "forms": forms, "modules": modules, } if forms > 0 and modules > 0: report["ready"] = True report["status"] = "ready" report["recommendations"].append("Strict saved-state smoke can be attempted: form and module saved-state candidates are present in the selected save layer.") elif total_rows == 0: report["status"] = "blocked_no_saved_state_rows" report["recommendations"].append( f"No unactivated Configurator changes are present in {', '.join(tables)}. To prepare a strict write test, copy the target object from Config/ConfigCAS into the selected save layer using the approved saved-state workflow, then rerun readiness." ) else: report["status"] = "blocked_no_strict_candidates" report["recommendations"].append( "Saved-state rows exist, but the adapter did not find both form and module candidates needed by strict write-and-rollback smoke." ) return report def main() -> int: parser = argparse.ArgumentParser(description="Read-only readiness check for strict 1C saved-state smoke tests.") parser.add_argument("--base-url", default=DEFAULT_BASE_URL) parser.add_argument("--base-id", default="upo_test") parser.add_argument("--table", action="append", choices=sorted(ALLOWED_TABLES), help="Saved-state table to inspect. Repeatable.") parser.add_argument("--saved-state-table", choices=sorted(ALLOWED_TABLES), help="Expected saved-state table for the strict smoke target.") parser.add_argument("--timeout", type=float, default=30.0) parser.add_argument("--report", type=Path) parser.add_argument("--require-ready", action="store_true", help="Exit non-zero when strict saved-state smoke is not ready.") parser.add_argument("--json", action="store_true", help="Print full JSON report.") args = parser.parse_args() if args.saved_state_table and args.table and any(table != args.saved_state_table for table in args.table): parser.error("--saved-state-table must match --table when both are provided") tables = args.table or ([args.saved_state_table] if args.saved_state_table else list(DEFAULT_TABLES)) report = build_report(args.base_url, args.base_id, tables, args.timeout, args.saved_state_table) if args.report: args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") if args.json or args.require_ready or not report["ready"]: print(json.dumps(report, ensure_ascii=True, indent=2), file=sys.stderr if args.require_ready and not report["ready"] else sys.stdout) else: print(f"OK: strict saved-state smoke readiness passed for {args.base_id}.") return 0 if report["ready"] or not args.require_ready else 1 if __name__ == "__main__": raise SystemExit(main())