Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,562 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011"
|
||||
DEFAULT_BASE_ID = "upo_test"
|
||||
|
||||
# Metadata kinds that either own application data or expose values through the
|
||||
# public data facade. Kinds absent from a concrete base remain in the audit so
|
||||
# that coverage cannot be declared only from a convenient test configuration.
|
||||
DATA_KINDS = {
|
||||
"AccountingRegister",
|
||||
"AccumulationRegister",
|
||||
"BusinessProcess",
|
||||
"CalculationRegister",
|
||||
"Catalog",
|
||||
"ChartOfAccounts",
|
||||
"ChartOfCalculationTypes",
|
||||
"ChartOfCharacteristicTypes",
|
||||
"Constant",
|
||||
"Document",
|
||||
"Enum",
|
||||
"ExchangePlan",
|
||||
"InformationRegister",
|
||||
"Sequence",
|
||||
"Task",
|
||||
}
|
||||
|
||||
Rpc = Callable[[str, str, str, dict[str, Any], float], dict[str, Any]]
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def write_json_atomic(path: Path, value: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + ".tmp")
|
||||
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def rpc(base_url: str, token: str, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||
request = urllib.request.Request(
|
||||
base_url.rstrip("/") + "/rpc",
|
||||
data=json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8"),
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json; charset=utf-8"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
value = json.loads(response.read().decode("utf-8"))
|
||||
return value if isinstance(value, dict) else {"status": "error", "error": "response_not_object"}
|
||||
|
||||
|
||||
def safe_rpc(
|
||||
rpc_call: Rpc,
|
||||
base_url: str,
|
||||
token: str,
|
||||
method: str,
|
||||
payload: dict[str, Any],
|
||||
timeout: float,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
result = rpc_call(base_url, token, method, payload, timeout)
|
||||
except (TimeoutError, urllib.error.URLError, OSError, ValueError) as exc:
|
||||
result = {
|
||||
"status": "transport_error",
|
||||
"error": type(exc).__name__,
|
||||
"diagnostics": {"message": str(exc)[:500]},
|
||||
}
|
||||
return result, round((time.monotonic() - started) * 1000)
|
||||
|
||||
|
||||
def first_object(response: dict[str, Any]) -> dict[str, Any] | None:
|
||||
for key in ("objects", "items"):
|
||||
values = response.get(key)
|
||||
if isinstance(values, list) and values and isinstance(values[0], dict):
|
||||
return values[0]
|
||||
return None
|
||||
|
||||
|
||||
def public_selector(sample: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: sample[key]
|
||||
for key in ("ref", "kind", "name", "guid")
|
||||
if sample.get(key) not in {None, ""}
|
||||
}
|
||||
|
||||
|
||||
def operation_summary(result: dict[str, Any], duration_ms: int, **values: Any) -> dict[str, Any]:
|
||||
summary = {"status": result.get("status") or "unknown", "duration_ms": duration_ms, **values}
|
||||
diagnostics = result.get("diagnostics")
|
||||
if summary["status"] != "ok" and isinstance(diagnostics, dict) and diagnostics.get("message"):
|
||||
summary["message"] = str(diagnostics["message"])[:500]
|
||||
if result.get("error"):
|
||||
summary["error"] = str(result["error"])[:200]
|
||||
return summary
|
||||
|
||||
|
||||
def record_ref_from_row(row: Any) -> str | None:
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
value = row.get("ref")
|
||||
if isinstance(value, dict):
|
||||
value = value.get("hex")
|
||||
compact = str(value or "").replace("-", "").strip()
|
||||
return compact if re.fullmatch(r"[0-9a-fA-F]{32}", compact) else None
|
||||
|
||||
|
||||
def audit_data_kind(
|
||||
base_url: str,
|
||||
base_id: str,
|
||||
token: str,
|
||||
kind: str,
|
||||
timeout: float,
|
||||
include_reads: bool,
|
||||
rpc_call: Rpc = rpc,
|
||||
existing: dict[str, Any] | None = None,
|
||||
progress: Callable[[dict[str, Any]], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
common = {"base_id": base_id, "timeout_seconds": max(1, int(timeout))}
|
||||
result = copy.deepcopy(existing) if isinstance(existing, dict) else {}
|
||||
result.update({"kind": kind, "status": "degraded"})
|
||||
result.setdefault("operations", {})
|
||||
|
||||
def save_progress() -> None:
|
||||
if progress is not None:
|
||||
progress(copy.deepcopy(result))
|
||||
|
||||
prior_list = result["operations"].get("metadata.objects.list") or {}
|
||||
if prior_list.get("status") == "ok" and isinstance(result.get("sample"), dict):
|
||||
listed = {"status": "ok"}
|
||||
sample = {**(result["sample"].get("selector") or {}), "name": result["sample"].get("name")}
|
||||
else:
|
||||
listed, list_ms = safe_rpc(
|
||||
rpc_call,
|
||||
base_url,
|
||||
token,
|
||||
"metadata.objects.list",
|
||||
{**common, "kind": kind, "limit": 1},
|
||||
timeout,
|
||||
)
|
||||
sample = first_object(listed)
|
||||
result["operations"]["metadata.objects.list"] = operation_summary(
|
||||
listed,
|
||||
list_ms,
|
||||
objects=len(listed.get("objects") or listed.get("items") or []),
|
||||
)
|
||||
if sample:
|
||||
result["sample"] = {"selector": public_selector(sample), "name": sample.get("name")}
|
||||
save_progress()
|
||||
if not sample:
|
||||
result["status"] = "absent" if listed.get("status") == "ok" else "degraded"
|
||||
result["reason"] = "no_sample_object"
|
||||
save_progress()
|
||||
return result
|
||||
|
||||
selector = public_selector(sample)
|
||||
result["sample"] = {"selector": selector, "name": sample.get("name")}
|
||||
prior_schema = result["operations"].get("data.schema") or {}
|
||||
if prior_schema.get("status") == "ok":
|
||||
schema_status = "ok"
|
||||
else:
|
||||
schema, schema_ms = safe_rpc(
|
||||
rpc_call,
|
||||
base_url,
|
||||
token,
|
||||
"data.schema",
|
||||
{**common, **selector},
|
||||
timeout,
|
||||
)
|
||||
table = schema.get("table") if isinstance(schema.get("table"), dict) else {}
|
||||
result["operations"]["data.schema"] = operation_summary(
|
||||
schema,
|
||||
schema_ms,
|
||||
fields=len(schema.get("fields") or []),
|
||||
table=table.get("name"),
|
||||
cache=(schema.get("cache") or {}).get("status") if isinstance(schema.get("cache"), dict) else None,
|
||||
)
|
||||
schema_status = str(schema.get("status") or "unknown")
|
||||
save_progress()
|
||||
if schema_status != "ok" or not include_reads:
|
||||
result["status"] = "ok" if schema_status == "ok" else "degraded"
|
||||
save_progress()
|
||||
return result
|
||||
|
||||
prior_data_list = result["operations"].get("data.list") or {}
|
||||
must_repeat_list = prior_data_list.get("status") != "ok" or (
|
||||
"sample_record_ref" not in result and "sample_record_ref_status" not in result
|
||||
)
|
||||
if must_repeat_list:
|
||||
data_list, data_list_ms = safe_rpc(
|
||||
rpc_call,
|
||||
base_url,
|
||||
token,
|
||||
"data.list",
|
||||
{**common, **selector, "limit": 1},
|
||||
timeout,
|
||||
)
|
||||
rows = data_list.get("rows") if isinstance(data_list.get("rows"), list) else []
|
||||
list_summary = operation_summary(data_list, data_list_ms, rows=len(rows))
|
||||
if data_list.get("status") == "ok":
|
||||
result["operations"]["data.list"] = list_summary
|
||||
result["operations"].pop("data.list_retry", None)
|
||||
ref = record_ref_from_row(rows[0]) if rows else None
|
||||
if ref:
|
||||
result["sample_record_ref"] = ref
|
||||
result.pop("sample_record_ref_status", None)
|
||||
else:
|
||||
result.pop("sample_record_ref", None)
|
||||
result["sample_record_ref_status"] = "empty_object" if not rows else "object_has_no_reference_key"
|
||||
elif prior_data_list.get("status") == "ok":
|
||||
# A successful operation is evidence. Do not downgrade it only
|
||||
# because a later attempt to recover the sample ref timed out.
|
||||
result["operations"]["data.list_retry"] = list_summary
|
||||
ref = result.get("sample_record_ref")
|
||||
else:
|
||||
result["operations"]["data.list"] = list_summary
|
||||
result.pop("sample_record_ref", None)
|
||||
result["sample_record_ref_status"] = "data_list_failed"
|
||||
ref = None
|
||||
save_progress()
|
||||
else:
|
||||
ref = result.get("sample_record_ref")
|
||||
|
||||
if (result["operations"].get("data.count") or {}).get("status") != "ok":
|
||||
counted, count_ms = safe_rpc(
|
||||
rpc_call,
|
||||
base_url,
|
||||
token,
|
||||
"data.count",
|
||||
{**common, **selector},
|
||||
timeout,
|
||||
)
|
||||
result["operations"]["data.count"] = operation_summary(counted, count_ms, count=counted.get("count"))
|
||||
save_progress()
|
||||
|
||||
prior_get_status = (result["operations"].get("data.get") or {}).get("status")
|
||||
if prior_get_status not in {"ok", "not_applicable"}:
|
||||
if ref:
|
||||
fetched, get_ms = safe_rpc(
|
||||
rpc_call,
|
||||
base_url,
|
||||
token,
|
||||
"data.get",
|
||||
{**common, **selector, "record_ref": ref},
|
||||
timeout,
|
||||
)
|
||||
fetched_rows = fetched.get("rows") if isinstance(fetched.get("rows"), list) else []
|
||||
result["operations"]["data.get"] = operation_summary(fetched, get_ms, rows=len(fetched_rows))
|
||||
elif (result["operations"].get("data.list") or {}).get("status") == "ok":
|
||||
reason = str(result.get("sample_record_ref_status") or "object_has_no_reference_key")
|
||||
result["operations"]["data.get"] = {"status": "not_applicable", "reason": reason, "duration_ms": 0}
|
||||
else:
|
||||
result["operations"]["data.get"] = {"status": "blocked", "reason": "data_list_failed", "duration_ms": 0}
|
||||
save_progress()
|
||||
|
||||
required = ("data.schema", "data.list", "data.count")
|
||||
failures = [name for name in required if result["operations"].get(name, {}).get("status") != "ok"]
|
||||
get_status = result["operations"]["data.get"]["status"]
|
||||
if get_status not in {"ok", "not_applicable"}:
|
||||
failures.append("data.get")
|
||||
result["status"] = "ok" if not failures else "degraded"
|
||||
if failures:
|
||||
result["failed_operations"] = failures
|
||||
else:
|
||||
result.pop("failed_operations", None)
|
||||
save_progress()
|
||||
return result
|
||||
|
||||
|
||||
def load_checkpoint(
|
||||
path: Path | None,
|
||||
base_url: str,
|
||||
base_id: str,
|
||||
resume: bool,
|
||||
include_reads: bool,
|
||||
) -> dict[str, Any]:
|
||||
fresh = {
|
||||
"schema": "onec_adapter_data_audit_checkpoint.v1",
|
||||
"base_url": base_url,
|
||||
"base_id": base_id,
|
||||
"include_reads": include_reads,
|
||||
"started_at": utc_now(),
|
||||
"updated_at": utc_now(),
|
||||
"checks": {},
|
||||
}
|
||||
if not resume or path is None or not path.exists():
|
||||
return fresh
|
||||
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if value.get("schema") != fresh["schema"]:
|
||||
raise ValueError(f"unsupported checkpoint schema in {path}")
|
||||
if value.get("base_url") != base_url or value.get("base_id") != base_id:
|
||||
raise ValueError(f"checkpoint {path} belongs to another adapter or base")
|
||||
if bool(value.get("include_reads")) != include_reads:
|
||||
raise ValueError(f"checkpoint {path} was created for another data audit mode")
|
||||
if not isinstance(value.get("checks"), dict):
|
||||
raise ValueError(f"checkpoint {path} has no checks object")
|
||||
return value
|
||||
|
||||
|
||||
def run_data_checks(
|
||||
base_url: str,
|
||||
base_id: str,
|
||||
token: str,
|
||||
kinds: list[str],
|
||||
timeout: float,
|
||||
include_reads: bool,
|
||||
workers: int,
|
||||
checkpoint_path: Path | None,
|
||||
resume: bool,
|
||||
retry_degraded: bool = False,
|
||||
rpc_call: Rpc = rpc,
|
||||
) -> tuple[dict[str, dict[str, Any]], int]:
|
||||
checkpoint = load_checkpoint(checkpoint_path, base_url, base_id, resume, include_reads)
|
||||
checks = checkpoint["checks"]
|
||||
checkpoint_lock = threading.Lock()
|
||||
reusable = {
|
||||
kind
|
||||
for kind in kinds
|
||||
if kind in checks and (not retry_degraded or checks[kind].get("status") == "ok")
|
||||
}
|
||||
resumed = len(reusable)
|
||||
pending = [kind for kind in kinds if kind not in reusable]
|
||||
|
||||
def execute(kind: str) -> dict[str, Any]:
|
||||
def save_partial(value: dict[str, Any]) -> None:
|
||||
with checkpoint_lock:
|
||||
checks[kind] = value
|
||||
checkpoint["updated_at"] = utc_now()
|
||||
if checkpoint_path is not None:
|
||||
write_json_atomic(checkpoint_path, checkpoint)
|
||||
|
||||
return audit_data_kind(
|
||||
base_url,
|
||||
base_id,
|
||||
token,
|
||||
kind,
|
||||
timeout,
|
||||
include_reads,
|
||||
rpc_call,
|
||||
existing=checks.get(kind),
|
||||
progress=save_partial,
|
||||
)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, workers)) as executor:
|
||||
futures = {executor.submit(execute, kind): kind for kind in pending}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
kind = futures[future]
|
||||
try:
|
||||
checks[kind] = future.result()
|
||||
except Exception as exc: # a single kind must not discard completed evidence
|
||||
checks[kind] = {
|
||||
"kind": kind,
|
||||
"status": "degraded",
|
||||
"error": type(exc).__name__,
|
||||
"message": str(exc)[:500],
|
||||
}
|
||||
with checkpoint_lock:
|
||||
checkpoint["updated_at"] = utc_now()
|
||||
if checkpoint_path is not None:
|
||||
write_json_atomic(checkpoint_path, checkpoint)
|
||||
return {kind: checks[kind] for kind in kinds if kind in checks}, resumed
|
||||
|
||||
|
||||
def build_report(
|
||||
base_url: str,
|
||||
base_id: str,
|
||||
token: str,
|
||||
timeout: float,
|
||||
sample_objects: bool,
|
||||
sample_schemas: bool,
|
||||
*,
|
||||
sample_reads: bool = False,
|
||||
workers: int = 1,
|
||||
checkpoint_path: Path | None = None,
|
||||
resume: bool = False,
|
||||
retry_degraded: bool = False,
|
||||
rpc_call: Rpc = rpc,
|
||||
) -> dict[str, Any]:
|
||||
audit, _ = safe_rpc(
|
||||
rpc_call,
|
||||
base_url,
|
||||
token,
|
||||
"metadata.adapter.audit",
|
||||
{"base_id": base_id, "include_missing": True, "include_unmapped": True, "timeout_seconds": int(timeout)},
|
||||
timeout,
|
||||
)
|
||||
if audit.get("status") != "ok":
|
||||
return {"schema": "onec_adapter_coverage_audit.v1", "status": "error", "audit": audit}
|
||||
|
||||
matrix: list[dict[str, Any]] = []
|
||||
for support in audit.get("metadata_kinds") or []:
|
||||
if not isinstance(support, dict):
|
||||
continue
|
||||
kind = str(support.get("kind") or "")
|
||||
count = int(support.get("count") or 0)
|
||||
matrix.append({
|
||||
"kind": kind,
|
||||
"kind_ru": support.get("kind_ru"),
|
||||
"objects": count,
|
||||
"capabilities": support.get("capabilities") or [],
|
||||
"discovery": "present" if count else "absent_in_base",
|
||||
})
|
||||
|
||||
data_checks: dict[str, dict[str, Any]] = {}
|
||||
resumed_checks = 0
|
||||
if sample_schemas or sample_reads:
|
||||
present_data_kinds = sorted(row["kind"] for row in matrix if row["objects"] and row["kind"] in DATA_KINDS)
|
||||
data_checks, resumed_checks = run_data_checks(
|
||||
base_url,
|
||||
base_id,
|
||||
token,
|
||||
present_data_kinds,
|
||||
timeout,
|
||||
sample_reads,
|
||||
workers,
|
||||
checkpoint_path,
|
||||
resume,
|
||||
retry_degraded,
|
||||
rpc_call,
|
||||
)
|
||||
for row in matrix:
|
||||
check = data_checks.get(row["kind"])
|
||||
if check:
|
||||
row["data_check"] = check
|
||||
row["list_status"] = check.get("operations", {}).get("metadata.objects.list", {}).get("status")
|
||||
schema = check.get("operations", {}).get("data.schema")
|
||||
if schema:
|
||||
row["data_schema"] = schema
|
||||
elif sample_objects:
|
||||
for row in matrix:
|
||||
if not row["objects"]:
|
||||
continue
|
||||
listed, _ = safe_rpc(
|
||||
rpc_call,
|
||||
base_url,
|
||||
token,
|
||||
"metadata.objects.list",
|
||||
{"base_id": base_id, "kind": row["kind"], "limit": 1, "timeout_seconds": int(timeout)},
|
||||
timeout,
|
||||
)
|
||||
row["list_status"] = listed.get("status")
|
||||
sample = first_object(listed)
|
||||
if sample:
|
||||
row["sample_selector"] = public_selector(sample)
|
||||
|
||||
missing = [row["kind"] for row in matrix if row["discovery"] == "absent_in_base"]
|
||||
failures = [
|
||||
row["kind"]
|
||||
for row in matrix
|
||||
if sample_objects and row.get("objects") and "list_status" in row and row.get("list_status") != "ok"
|
||||
]
|
||||
data_failures = sorted(kind for kind, check in data_checks.items() if check.get("status") != "ok")
|
||||
status = "ok" if not failures and not data_failures else "degraded"
|
||||
return {
|
||||
"schema": "onec_adapter_coverage_audit.v1",
|
||||
"status": status,
|
||||
"generated_at": utc_now(),
|
||||
"base_url": base_url,
|
||||
"base_id": base_id,
|
||||
"sampling": {
|
||||
"objects": sample_objects,
|
||||
"data_schemas": sample_schemas or sample_reads,
|
||||
"data_reads": sample_reads,
|
||||
"workers": workers,
|
||||
"resumed_checks": resumed_checks,
|
||||
},
|
||||
"policy": {
|
||||
"application_data": "read_only",
|
||||
"metadata_structure": "read_only",
|
||||
"sql_identity": "configured_base_credentials_only",
|
||||
"writes": ["ConfigSave", "ConfigCASSave"],
|
||||
},
|
||||
"counts": {
|
||||
"kinds": len(matrix),
|
||||
"present_kinds": sum(1 for row in matrix if row["objects"]),
|
||||
"absent_kinds": len(missing),
|
||||
"list_failures": len(failures),
|
||||
"data_kinds_declared": len(DATA_KINDS),
|
||||
"data_kinds_checked": len(data_checks),
|
||||
"data_check_failures": len(data_failures),
|
||||
},
|
||||
"absent_in_base": missing,
|
||||
"list_failures": failures,
|
||||
"data_check_failures": data_failures,
|
||||
"matrix": matrix,
|
||||
"data_checks": data_checks,
|
||||
"child_objects": audit.get("child_objects") or {},
|
||||
"not_yet_decoded": audit.get("not_yet_decoded") or [],
|
||||
"optional_deep_reads": audit.get("optional_deep_reads") or [],
|
||||
"unmapped_source_roles": audit.get("unmapped_source_roles") or audit.get("unknown_source_roles") or {},
|
||||
"write_capabilities": audit.get("write_capabilities") or {},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Audit live 1C adapter coverage without exposing SQL credentials.")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--base-id", default=DEFAULT_BASE_ID)
|
||||
parser.add_argument("--token-env", default="ONEC_ADAPTER_TOKEN")
|
||||
parser.add_argument("--timeout", type=float, default=120.0, help="Timeout for each adapter call, in seconds.")
|
||||
parser.add_argument("--workers", type=int, default=1, help="Concurrent data-kind checks (default: 1).")
|
||||
parser.add_argument("--sample-objects", action="store_true", help="List one object for each present metadata kind.")
|
||||
parser.add_argument("--sample-data-schemas", action="store_true", help="Decode one logical data schema for every present data kind.")
|
||||
parser.add_argument("--sample-data-reads", action="store_true", help="Run schema, list, get (when applicable), and count for every present data kind.")
|
||||
parser.add_argument("--checkpoint", type=Path, help="Atomically save progress after every completed data kind.")
|
||||
parser.add_argument("--resume", action="store_true", help="Reuse completed kinds from --checkpoint.")
|
||||
parser.add_argument("--retry-degraded", action="store_true", help="With --resume, rerun checkpoint entries whose status is not ok.")
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.workers < 1:
|
||||
parser.error("--workers must be >= 1")
|
||||
if args.resume and args.checkpoint is None:
|
||||
parser.error("--resume requires --checkpoint")
|
||||
if args.retry_degraded and not args.resume:
|
||||
parser.error("--retry-degraded requires --resume")
|
||||
token = os.environ.get(args.token_env, "").strip()
|
||||
if not token:
|
||||
parser.error(f"adapter token is required in environment variable {args.token_env}")
|
||||
sample_objects = args.sample_objects or args.sample_data_schemas or args.sample_data_reads
|
||||
try:
|
||||
report = build_report(
|
||||
args.base_url,
|
||||
args.base_id,
|
||||
token,
|
||||
args.timeout,
|
||||
sample_objects,
|
||||
args.sample_data_schemas,
|
||||
sample_reads=args.sample_data_reads,
|
||||
workers=args.workers,
|
||||
checkpoint_path=args.checkpoint,
|
||||
resume=args.resume,
|
||||
retry_degraded=args.retry_degraded,
|
||||
)
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
rendered = json.dumps(report, ensure_ascii=False, indent=2)
|
||||
if args.output:
|
||||
write_json_atomic(args.output, report)
|
||||
print(rendered)
|
||||
return 0 if report.get("status") == "ok" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user