336 lines
15 KiB
Python
336 lines
15 KiB
Python
#!/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-gpu.cin.su:8011"
|
|
DISCOVERY_KINDS = ("Catalog", "Document", "DataProcessor", "Report")
|
|
SOURCE_TABLES = {"Config", "ConfigCAS"}
|
|
TARGET_TABLES = {"ConfigSave", "ConfigCASSave"}
|
|
SOURCE_BY_TARGET = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"}
|
|
|
|
|
|
def rpc(base_url: str, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
body = json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8")
|
|
request = urllib.request.Request(
|
|
base_url.rstrip("/") + "/rpc",
|
|
data=body,
|
|
method="POST",
|
|
headers={"Content-Type": "application/json; charset=utf-8"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
data = json.loads(response.read().decode("utf-8"))
|
|
if not isinstance(data, dict):
|
|
raise RuntimeError(f"{method} returned non-object response: {data!r}")
|
|
return data
|
|
|
|
|
|
def sql_literal(value: str) -> str:
|
|
return "'" + value.replace("'", "''") + "'"
|
|
|
|
|
|
def selector_from_args(args: argparse.Namespace) -> dict[str, Any]:
|
|
selector: dict[str, Any] = {"base_id": args.base_id, "include_storage": True, "table": SOURCE_BY_TARGET[args.target_table]}
|
|
for key in ("ref", "kind", "name", "guid"):
|
|
value = getattr(args, key)
|
|
if value:
|
|
selector[key] = value
|
|
return selector
|
|
|
|
|
|
def discover_object(base_url: str, base_id: str, source_table: str, timeout: float) -> dict[str, Any]:
|
|
fallback: dict[str, Any] | None = None
|
|
best: tuple[int, dict[str, Any]] | None = None
|
|
for kind in DISCOVERY_KINDS:
|
|
listed = rpc(
|
|
base_url,
|
|
"metadata.objects.list",
|
|
{"base_id": base_id, "kind": kind, "table": source_table, "limit": 20, "include_storage": True},
|
|
timeout,
|
|
)
|
|
for item in listed.get("objects") or []:
|
|
if not isinstance(item, dict) or not item.get("guid") or not item.get("name"):
|
|
continue
|
|
if fallback is None:
|
|
fallback = item
|
|
selector = object_selector(base_id, item, default_table=source_table)
|
|
try:
|
|
forms = rpc(base_url, "metadata.object.forms", selector, timeout)
|
|
modules = rpc(base_url, "metadata.object.modules", selector, timeout)
|
|
except Exception:
|
|
continue
|
|
form_count = ((forms.get("counts") or {}).get("forms") or 0) if isinstance(forms.get("counts"), dict) else 0
|
|
module_count = ((modules.get("counts") or {}).get("modules") or 0) if isinstance(modules.get("counts"), dict) else 0
|
|
score = (10 if form_count > 0 else 0) + (10 if module_count > 0 else 0) + min(int(form_count), 5) + min(int(module_count), 5)
|
|
if best is None or score > best[0]:
|
|
best = (score, item)
|
|
if form_count > 0 and module_count > 0:
|
|
return item
|
|
if best is not None and best[0] > 0:
|
|
return best[1]
|
|
if fallback is not None:
|
|
return fallback
|
|
raise RuntimeError(f"No {source_table} metadata object candidate found for saved-state copy planning.")
|
|
|
|
|
|
def resolve_object(base_url: str, selector: dict[str, Any], source_table: str, timeout: float) -> dict[str, Any]:
|
|
if not any(selector.get(key) for key in ("ref", "kind", "name", "guid")):
|
|
return discover_object(base_url, str(selector["base_id"]), source_table, timeout)
|
|
if selector.get("kind") or selector.get("name") or selector.get("guid"):
|
|
listed = rpc(base_url, "metadata.objects.list", selector | {"limit": 5, "table": source_table}, timeout)
|
|
objects = [item for item in listed.get("objects") or [] if isinstance(item, dict)]
|
|
if objects:
|
|
return objects[0]
|
|
found = rpc(base_url, "metadata.definition.find", selector | {"areas": ["metadata"], "include_storage": True}, timeout)
|
|
matches = [item for item in found.get("matches") or found.get("items") or [] if isinstance(item, dict)]
|
|
for item in matches:
|
|
if item.get("guid") or (item.get("object") or {}).get("guid"):
|
|
return item.get("object") if isinstance(item.get("object"), dict) else item
|
|
raise RuntimeError(f"Object selector did not resolve: {selector}")
|
|
|
|
|
|
def object_selector(base_id: str, obj: dict[str, Any], *, default_table: str | None = None) -> dict[str, Any]:
|
|
selector = {"base_id": base_id, "include_storage": True}
|
|
storage = obj.get("storage") if isinstance(obj.get("storage"), dict) else {}
|
|
table = storage.get("table") or default_table
|
|
if table in SOURCE_TABLES:
|
|
selector["table"] = table
|
|
for key in ("kind", "name", "guid"):
|
|
if obj.get(key):
|
|
selector[key] = obj[key]
|
|
return selector
|
|
|
|
|
|
def add_source_row(rows: list[dict[str, Any]], *, role: str, table: str | None, file_name: str | None, extra: dict[str, Any] | None = None) -> None:
|
|
if table not in SOURCE_TABLES or not file_name:
|
|
return
|
|
row = {"role": role, "table": table, "file_name": file_name}
|
|
if extra:
|
|
row.update(extra)
|
|
if not any(existing.get("table") == table and existing.get("file_name") == file_name and existing.get("role") == role for existing in rows):
|
|
rows.append(row)
|
|
|
|
|
|
def collect_source_rows(base_url: str, base_id: str, obj: dict[str, Any], timeout: float) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
storage = obj.get("storage") if isinstance(obj.get("storage"), dict) else {}
|
|
selector = object_selector(base_id, obj, default_table=storage.get("table") or "Config")
|
|
rows: list[dict[str, Any]] = []
|
|
evidence: dict[str, Any] = {"forms": None, "modules": None}
|
|
|
|
add_source_row(rows, role="object", table=storage.get("table") or "Config", file_name=obj.get("guid"))
|
|
|
|
forms = rpc(base_url, "metadata.object.forms", selector, timeout)
|
|
evidence["forms"] = {"status": forms.get("status"), "counts": forms.get("counts")}
|
|
for form in forms.get("forms") or []:
|
|
if not isinstance(form, dict):
|
|
continue
|
|
source = form.get("source") if isinstance(form.get("source"), dict) else {}
|
|
add_source_row(
|
|
rows,
|
|
role="form",
|
|
table=source.get("table"),
|
|
file_name=source.get("file_name"),
|
|
extra={"name": form.get("name"), "guid": form.get("guid")},
|
|
)
|
|
add_source_row(
|
|
rows,
|
|
role="form_payload",
|
|
table=source.get("table"),
|
|
file_name=f"{source.get('file_name')}.0" if source.get("file_name") else None,
|
|
extra={"name": form.get("name"), "guid": form.get("guid"), "suffix": ".0"},
|
|
)
|
|
|
|
modules = rpc(base_url, "metadata.object.modules", selector, timeout)
|
|
evidence["modules"] = {"status": modules.get("status"), "counts": modules.get("counts")}
|
|
for module in modules.get("modules") or []:
|
|
if not isinstance(module, dict):
|
|
continue
|
|
add_source_row(
|
|
rows,
|
|
role="module",
|
|
table=module.get("table"),
|
|
file_name=module.get("file_name"),
|
|
extra={
|
|
"module_id": module.get("module_id"),
|
|
"stream_index": module.get("stream_index"),
|
|
"sha1": module.get("sha1"),
|
|
"bytes": module.get("bytes"),
|
|
},
|
|
)
|
|
return rows, evidence
|
|
|
|
|
|
def read_row_details(base_url: str, base_id: str, rows: list[dict[str, Any]], timeout: float) -> dict[str, Any]:
|
|
details: dict[str, Any] = {}
|
|
by_table: dict[str, list[str]] = {}
|
|
for row in rows:
|
|
table = str(row.get("table") or "")
|
|
file_name = str(row.get("file_name") or "")
|
|
if table in SOURCE_TABLES and file_name:
|
|
by_table.setdefault(table, []).append(file_name)
|
|
for table, names in sorted(by_table.items()):
|
|
unique_names = sorted(set(names))
|
|
query = (
|
|
"SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes, "
|
|
"CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1 "
|
|
f"FROM {table} WHERE FileName IN ({', '.join(sql_literal(name) for name in unique_names)})"
|
|
)
|
|
result = rpc(
|
|
base_url,
|
|
"query.run",
|
|
{"base_id": base_id, "diagnostic": True, "query": query, "timeout_seconds": int(timeout)},
|
|
timeout,
|
|
)
|
|
details[table] = {
|
|
"status": result.get("status"),
|
|
"rows": result.get("rows") or [],
|
|
"counts": result.get("counts"),
|
|
}
|
|
return details
|
|
|
|
|
|
def read_target_collisions(base_url: str, base_id: str, rows: list[dict[str, Any]], target_table: str, timeout: float) -> dict[str, Any]:
|
|
file_names = sorted({str(row.get("file_name") or "") for row in rows if row.get("file_name")})
|
|
if not file_names:
|
|
return {"status": "skipped_no_source_rows", "rows": [], "counts": {"rows": 0}}
|
|
query = (
|
|
"SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes, "
|
|
"CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1 "
|
|
f"FROM {target_table} WHERE FileName IN ({', '.join(sql_literal(name) for name in file_names)})"
|
|
)
|
|
result = rpc(
|
|
base_url,
|
|
"query.run",
|
|
{"base_id": base_id, "diagnostic": True, "query": query, "timeout_seconds": int(timeout)},
|
|
timeout,
|
|
)
|
|
collisions = result.get("rows") or []
|
|
return {
|
|
"status": "collision" if collisions else "clear",
|
|
"table": target_table,
|
|
"rows": collisions,
|
|
"counts": result.get("counts"),
|
|
}
|
|
|
|
|
|
def build_report(args: argparse.Namespace) -> dict[str, Any]:
|
|
expected_source_table = SOURCE_BY_TARGET[args.target_table]
|
|
selector = selector_from_args(args)
|
|
obj = resolve_object(args.base_url, selector, expected_source_table, args.timeout)
|
|
rows, evidence = collect_source_rows(args.base_url, args.base_id, obj, args.timeout)
|
|
row_details = read_row_details(args.base_url, args.base_id, rows, args.timeout)
|
|
target_collisions = read_target_collisions(args.base_url, args.base_id, rows, args.target_table, args.timeout)
|
|
found_rows = sum(len((table_details.get("rows") or [])) for table_details in row_details.values() if isinstance(table_details, dict))
|
|
active_source_tables = sorted({str(row.get("table")) for row in rows if row.get("table") in SOURCE_TABLES})
|
|
mismatched_source_tables = sorted(table for table in active_source_tables if table != expected_source_table)
|
|
collision_rows = target_collisions.get("rows") if isinstance(target_collisions.get("rows"), list) else []
|
|
|
|
if mismatched_source_tables:
|
|
status = "blocked_source_target_family_mismatch"
|
|
elif collision_rows:
|
|
status = "blocked_target_collision"
|
|
elif rows and found_rows:
|
|
status = "plan_ready"
|
|
else:
|
|
status = "blocked_no_active_source_rows"
|
|
recommendations = [
|
|
"Do not edit Config/ConfigCAS directly for the smoke. Prepare the save layer first, then run strict readiness.",
|
|
f"Copy the target object's required active rows from {expected_source_table} into {args.target_table} using the approved saved-state workflow.",
|
|
"After the copy, rerun check_1c_saved_state_strict_readiness.py and only then enable strict saved-state smoke.",
|
|
]
|
|
if mismatched_source_tables:
|
|
recommendations.insert(1, f"{args.target_table} must be prepared only from {expected_source_table}; found source table(s): {', '.join(mismatched_source_tables)}.")
|
|
if collision_rows:
|
|
recommendations.insert(1, f"{args.target_table} already contains one or more planned FileName values; inspect/clear the existing unactivated change before copying.")
|
|
return {
|
|
"schema": "onec_saved_state_copy_plan.v1",
|
|
"base_url": args.base_url,
|
|
"base_id": args.base_id,
|
|
"status": status,
|
|
"ready_to_copy": status == "plan_ready",
|
|
"selector": selector,
|
|
"object": {
|
|
"guid": obj.get("guid"),
|
|
"kind": obj.get("kind"),
|
|
"name": obj.get("name"),
|
|
"synonym": obj.get("synonym"),
|
|
"source": obj.get("source"),
|
|
},
|
|
"target": {
|
|
"table": args.target_table,
|
|
"mode": "prepare_saved_state_working_copy",
|
|
},
|
|
"source_family": {
|
|
"target_table": args.target_table,
|
|
"expected_source_table": expected_source_table,
|
|
"source_tables": active_source_tables,
|
|
"mismatched_source_tables": mismatched_source_tables,
|
|
"valid": not mismatched_source_tables,
|
|
},
|
|
"source_rows": rows,
|
|
"source_row_details": row_details,
|
|
"target_collisions": target_collisions,
|
|
"evidence": evidence,
|
|
"summary": {
|
|
"planned_source_rows": len(rows),
|
|
"found_source_storage_rows": found_rows,
|
|
"target_collision_rows": len(collision_rows),
|
|
"active_source_tables": active_source_tables,
|
|
},
|
|
"recommendations": recommendations,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Read-only plan for preparing a 1C saved-state copy from an active object.")
|
|
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
|
parser.add_argument("--base-id", default="upo_test")
|
|
parser.add_argument("--ref")
|
|
parser.add_argument("--kind")
|
|
parser.add_argument("--name")
|
|
parser.add_argument("--guid")
|
|
parser.add_argument("--target-table", choices=sorted(TARGET_TABLES), default="ConfigSave")
|
|
parser.add_argument("--timeout", type=float, default=60.0)
|
|
parser.add_argument("--report", type=Path)
|
|
parser.add_argument("--json", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
report = build_report(args)
|
|
except Exception as exc:
|
|
report = {
|
|
"schema": "onec_saved_state_copy_plan.v1",
|
|
"base_url": args.base_url,
|
|
"base_id": args.base_id,
|
|
"status": "error",
|
|
"ready_to_copy": False,
|
|
"error": str(exc),
|
|
}
|
|
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")
|
|
print(json.dumps(report, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
return 1
|
|
|
|
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 report["status"] != "plan_ready":
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
else:
|
|
print(
|
|
"OK: saved-state copy plan ready for "
|
|
f"{report['object'].get('kind')}.{report['object'].get('name')} -> {report['target'].get('table')}."
|
|
)
|
|
return 0 if report["status"] == "plan_ready" else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|