Complete name-first 1C adapter saved-state support
This commit is contained in:
@@ -50,6 +50,8 @@ def run_checks() -> dict[str, Any]:
|
||||
PARSER / "__init__.py",
|
||||
PARSER / "payload.py",
|
||||
PARSER / "cas_payload.py",
|
||||
PARSER / "common_command.py",
|
||||
PARSER / "scheduled_job.py",
|
||||
]
|
||||
missing = [str(path.relative_to(ROOT)) for path in required if not path.exists()]
|
||||
require(not missing, f"missing standalone connector files: {missing}", failures)
|
||||
@@ -96,7 +98,35 @@ def run_checks() -> dict[str, Any]:
|
||||
require(openapi.get("openapi") == "3.1.0", "connector OpenAPI must parse as 3.1.0", failures)
|
||||
paths = openapi.get("paths") if isinstance(openapi.get("paths"), dict) else {}
|
||||
require("/health" in paths, "connector OpenAPI must expose /health", failures)
|
||||
require("/methods" in paths, "connector OpenAPI must expose runtime /methods registry", failures)
|
||||
require("/rpc" in paths, "connector OpenAPI must expose universal /rpc", failures)
|
||||
require("/metadata/write-plan" in paths, "connector OpenAPI must expose /metadata/write-plan", failures)
|
||||
schemas = ((openapi.get("components") or {}).get("schemas") or {}) if isinstance(openapi.get("components"), dict) else {}
|
||||
require("AdapterRpcRequest" in schemas, "connector OpenAPI must define AdapterRpcRequest", failures)
|
||||
require("AdapterMethodsResponse" in schemas, "connector OpenAPI must define AdapterMethodsResponse", failures)
|
||||
try:
|
||||
sys.path.insert(0, str(CONNECTOR))
|
||||
sys.path.insert(0, str(CONNECTOR.parent))
|
||||
import adapter_1c_server as adapter_server
|
||||
|
||||
runtime_paths = set(adapter_server.HTTP_GET_METHOD_ROUTES) | set(adapter_server.HTTP_POST_METHOD_ROUTES) | {"/rpc"}
|
||||
require(
|
||||
set(paths) == runtime_paths,
|
||||
f"OpenAPI/runtime HTTP route drift: missing_in_openapi={sorted(runtime_paths - set(paths))}, missing_in_runtime={sorted(set(paths) - runtime_paths)}",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
all("get" in (paths.get(path) or {}) for path in adapter_server.HTTP_GET_METHOD_ROUTES),
|
||||
"every runtime GET route must be declared as GET in OpenAPI",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
all("post" in (paths.get(path) or {}) for path in adapter_server.HTTP_POST_METHOD_ROUTES),
|
||||
"every runtime POST route must be declared as POST in OpenAPI",
|
||||
failures,
|
||||
)
|
||||
except Exception as exc:
|
||||
failures.append(f"could not verify runtime HTTP route registry: {exc}")
|
||||
|
||||
return {
|
||||
"schema": "onec_connector_standalone_check.v1",
|
||||
|
||||
@@ -92,13 +92,14 @@ def run_saved_state_search_with_object_name(params: dict[str, Any] | None = None
|
||||
adapter_server.read_storage_file_bytes = lambda base_id, table, file_name, timeout_seconds=30: (stored, {"database": base_id}, None)
|
||||
request_payload = {
|
||||
"base_id": "upo_test",
|
||||
"tables": ["ConfigCASSave"],
|
||||
"layer": "extension_saved_state",
|
||||
"object_type": "Catalog",
|
||||
"object_name": "Номенклатура",
|
||||
"query": "ПередЗаписью",
|
||||
"limit": 10,
|
||||
}
|
||||
request_payload.update(params or {})
|
||||
request_payload["include_storage"] = True
|
||||
return adapter_server.call_method(
|
||||
adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD,
|
||||
request_payload,
|
||||
@@ -126,7 +127,9 @@ def run_checks() -> dict[str, Any]:
|
||||
require((unknown_evidence.get("target") or {}).get("extension_action") == unknown_action, "unknown action must be carried into write_plan_evidence target", failures)
|
||||
require("intent" not in unknown_evidence, "unknown action must not infer write intent", failures)
|
||||
require(unknown_next.get("method") == adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD, "write_plan_evidence must expose saved-state module resolver", failures)
|
||||
require(unknown_next_params.get("tables") == ["ConfigCASSave"], "extension write_plan_evidence must search ConfigCASSave", failures)
|
||||
require(unknown_next_params.get("layer") == "extension_saved_state", "extension write_plan_evidence must use extension_saved_state", failures)
|
||||
require(unknown_next_params.get("ref") == "Catalog.Номенклатура", "extension write_plan_evidence must use a public object ref", failures)
|
||||
require("tables" not in unknown_next_params and "owner_guid" not in unknown_next_params, "public extension evidence must hide storage selectors", failures)
|
||||
|
||||
controlled = run_override({"operation_class": "replace_with_control"})
|
||||
controlled_action = first_action(controlled)
|
||||
@@ -152,7 +155,8 @@ def run_checks() -> dict[str, Any]:
|
||||
base_next_params = base_next.get("params") if isinstance(base_next.get("params"), dict) else {}
|
||||
require(base_action.get("operation_class") == "base_definition", "base routine must be marked as base_definition", failures)
|
||||
require(base_action.get("requires_control_fragment") is False, "base routine must not require control fragment", failures)
|
||||
require(base_next_params.get("tables") == ["ConfigSave"], "base write_plan_evidence must search ConfigSave", failures)
|
||||
require(base_next_params.get("layer") == "base_saved_state", "base write_plan_evidence must use base_saved_state", failures)
|
||||
require(base_next_params.get("ref") == "Catalog.Номенклатура", "base write_plan_evidence must use a public object ref", failures)
|
||||
|
||||
saved_state = run_saved_state_search_with_object_name(controlled_next_params)
|
||||
saved_state_owner = saved_state.get("owner_resolution") if isinstance(saved_state.get("owner_resolution"), dict) else {}
|
||||
|
||||
@@ -87,6 +87,7 @@ def contract_checks(issues: list[dict[str, Any]], *, onec_request_present: bool)
|
||||
for issue in issues
|
||||
),
|
||||
"mcp_tool_selector_guidance": not any(issue["code"] in {"mcp_tool_selector_guidance_missing", "mcp_examples_public_ref_placeholder_missing"} for issue in issues),
|
||||
"mcp_saved_state_examples_name_first": not any(issue["code"].startswith("mcp_saved_state_example_") for issue in issues),
|
||||
"all_adapter_methods_forward_over_rpc": not any(issue["code"].startswith("mcp_method") or issue["code"] == "mcp_rpc_body_method_mismatch" for issue in issues),
|
||||
"no_unified_shadowing_adapter_methods": not any(issue["code"] == "mcp_unified_shadows_adapter_methods" for issue in issues),
|
||||
"adapter_help_selector_guidance": not any(issue["code"] == "adapter_help_selector_guidance_missing" for issue in issues),
|
||||
@@ -103,6 +104,7 @@ def contract_checks(issues: list[dict[str, Any]], *, onec_request_present: bool)
|
||||
),
|
||||
"adapter_definition_read_selector_public_ref": not any(issue["code"] == "adapter_definition_read_selector_public_ref_missing" for issue in issues),
|
||||
"adapter_module_read_selector_public_ref": not any(issue["code"] == "adapter_module_read_selector_public_ref_missing" for issue in issues),
|
||||
"agent_facing_object_methods_name_first": not any(issue["code"] == "agent_facing_object_method_not_name_first" for issue in issues),
|
||||
"adapter_parse_ordinal_unpacked": not any(issue["code"] == "adapter_parse_ordinal_not_unpacked" for issue in issues),
|
||||
"adapter_contract_version": not any(
|
||||
issue["code"] in {"adapter_contract_version_missing", "mcp_contract_version_mismatch", "adapter_help_contract_version_missing"}
|
||||
@@ -393,6 +395,23 @@ def check_contract() -> dict[str, Any]:
|
||||
"actual": help_result.get("contract_version"),
|
||||
}
|
||||
)
|
||||
agent_facing_object_terms = ("object", "module", "form", "template", "code")
|
||||
for row in adapter_server.METHODS:
|
||||
method = str(row.get("name") or "")
|
||||
description = str(row.get("description") or "")
|
||||
normalized_description = description.casefold()
|
||||
if "agent-facing" not in normalized_description:
|
||||
continue
|
||||
if not any(term in normalized_description for term in agent_facing_object_terms):
|
||||
continue
|
||||
if method not in selector_alias_methods:
|
||||
issues.append(
|
||||
{
|
||||
"code": "agent_facing_object_method_not_name_first",
|
||||
"method": method,
|
||||
"description": description,
|
||||
}
|
||||
)
|
||||
for method in sorted(getattr(adapter_server, "OBJECT_SELECTOR_ALIAS_METHODS", set())):
|
||||
capabilities = (public_methods.get(method) or {}).get("selector_capabilities")
|
||||
if not isinstance(capabilities, dict) or not capabilities.get("accepts_ref") or not capabilities.get("accepts_object_aliases"):
|
||||
@@ -448,6 +467,28 @@ def check_contract() -> dict[str, Any]:
|
||||
examples_json = json.dumps(schema.get("examples") or [], ensure_ascii=False)
|
||||
if "<metadata-kind>.<metadata-object-name>" not in examples_json:
|
||||
issues.append({"code": "mcp_examples_public_ref_placeholder_missing"})
|
||||
examples_by_method = {
|
||||
str(example.get("method") or ""): example.get("payload")
|
||||
for example in schema.get("examples") or []
|
||||
if isinstance(example, dict) and isinstance(example.get("payload"), dict)
|
||||
}
|
||||
saved_state_example_contract = {
|
||||
"metadata.saved_state.prepare": {"required": {"layer", "ref"}, "forbidden": {"target_table", "table", "file_name", "module_ref"}},
|
||||
"metadata.saved_state.diff": {"required": {"ref", "module_ordinal"}, "forbidden": {"target_table", "table", "file_name", "module_ref"}},
|
||||
"metadata.saved_state.status": {"required": {"layer"}, "forbidden": {"target_table", "table"}},
|
||||
"metadata.saved_state.changes.list": {"required": {"layer"}, "forbidden": {"target_table", "table"}},
|
||||
}
|
||||
for method, requirements in saved_state_example_contract.items():
|
||||
example_payload = examples_by_method.get(method)
|
||||
if not isinstance(example_payload, dict):
|
||||
issues.append({"code": "mcp_saved_state_example_missing", "method": method})
|
||||
continue
|
||||
missing = sorted(requirements["required"] - set(example_payload))
|
||||
forbidden = sorted(requirements["forbidden"].intersection(example_payload))
|
||||
if missing:
|
||||
issues.append({"code": "mcp_saved_state_example_public_selector_missing", "method": method, "fields": missing})
|
||||
if forbidden:
|
||||
issues.append({"code": "mcp_saved_state_example_storage_selector_present", "method": method, "fields": forbidden})
|
||||
if schema.get("required") != ["method"]:
|
||||
issues.append({"code": "mcp_onec_request_required_not_generic", "required": schema.get("required")})
|
||||
if "oneOf" in schema:
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate and optionally probe rare 1C metadata-kind fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
SCHEMA = "onec_metadata_kind_fixture_manifest.v1"
|
||||
REPORT_SCHEMA = "onec_metadata_kind_fixture_check.v1"
|
||||
SUPPORTED_MODES = {"extension_saved_state", "dedicated_base_active"}
|
||||
REFERENCE_USAGE = "external_reference_only_not_vendored"
|
||||
DESIGNER_ACCESS_MODE = "operating_system_integrated_only"
|
||||
SECRET_KEY_RE = re.compile(r"(password|passwd|pwd|secret|token|парол|секрет)", re.IGNORECASE)
|
||||
Rpc = Callable[[str, str, dict[str, Any], float, str], dict[str, Any]]
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("manifest root must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def write_json(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(adapter_url: str, method: str, payload: dict[str, Any], timeout: float, service_token: str) -> dict[str, Any]:
|
||||
headers = {"Content-Type": "application/json; charset=utf-8"}
|
||||
if service_token:
|
||||
headers["Authorization"] = f"Bearer {service_token}"
|
||||
request = urllib.request.Request(
|
||||
adapter_url.rstrip("/") + "/rpc",
|
||||
data=json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8"),
|
||||
headers=headers,
|
||||
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 finding(code: str, message: str, *, fixture_id: str | None = None) -> dict[str, Any]:
|
||||
value = {"severity": "error", "code": code, "message": message}
|
||||
if fixture_id:
|
||||
value["fixture_id"] = fixture_id
|
||||
return value
|
||||
|
||||
|
||||
def secret_key_paths(value: Any, prefix: str = "") -> list[str]:
|
||||
hits: list[str] = []
|
||||
if isinstance(value, dict):
|
||||
for key, nested in value.items():
|
||||
path = f"{prefix}.{key}" if prefix else str(key)
|
||||
if SECRET_KEY_RE.search(str(key)):
|
||||
hits.append(path)
|
||||
hits.extend(secret_key_paths(nested, path))
|
||||
elif isinstance(value, list):
|
||||
for index, nested in enumerate(value):
|
||||
hits.extend(secret_key_paths(nested, f"{prefix}[{index}]"))
|
||||
return hits
|
||||
|
||||
|
||||
def validate_manifest(manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
findings: list[dict[str, Any]] = []
|
||||
if manifest.get("schema") != SCHEMA:
|
||||
findings.append(finding("invalid_schema", f"schema must be {SCHEMA}"))
|
||||
|
||||
policy = manifest.get("policy")
|
||||
if not isinstance(policy, dict):
|
||||
findings.append(finding("missing_policy", "policy must be an object"))
|
||||
else:
|
||||
if policy.get("provisioning_tool") != "1c_designer":
|
||||
findings.append(finding("unsafe_provisioning_tool", "fixtures must be provisioned by 1C Designer"))
|
||||
if policy.get("direct_sql_write_allowed") is not False:
|
||||
findings.append(finding("direct_sql_write_not_forbidden", "direct SQL fixture writes must be forbidden"))
|
||||
if policy.get("credentials_in_manifest_allowed") is not False:
|
||||
findings.append(finding("credentials_not_forbidden", "credentials must be forbidden in the manifest"))
|
||||
|
||||
for path in secret_key_paths(manifest):
|
||||
findings.append(finding("credential_key_in_manifest", f"credential-like key is forbidden: {path}"))
|
||||
|
||||
designer = manifest.get("designer")
|
||||
if not isinstance(designer, dict):
|
||||
findings.append(finding("missing_designer_requirements", "designer requirements must be an object"))
|
||||
designer = {}
|
||||
required_version = str(designer.get("required_version") or "")
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+\.\d+", required_version):
|
||||
findings.append(finding("invalid_designer_version", "designer.required_version must be an exact four-part version"))
|
||||
if designer.get("access_mode") != DESIGNER_ACCESS_MODE:
|
||||
findings.append(finding("unsafe_designer_access_mode", f"designer.access_mode must be {DESIGNER_ACCESS_MODE}"))
|
||||
if designer.get("credential_arguments_allowed") is not False:
|
||||
findings.append(finding("designer_credential_arguments_not_forbidden", "Designer credential arguments must be forbidden"))
|
||||
export_script = str(designer.get("export_script") or "")
|
||||
if not export_script.endswith(".ps1") or Path(export_script).is_absolute():
|
||||
findings.append(finding("invalid_designer_export_script", "designer.export_script must be a repository-relative PowerShell path"))
|
||||
|
||||
external_reference = manifest.get("external_reference")
|
||||
if not isinstance(external_reference, dict):
|
||||
findings.append(finding("missing_external_reference", "external_reference must be an object"))
|
||||
external_reference = {}
|
||||
if external_reference.get("usage") != REFERENCE_USAGE:
|
||||
findings.append(finding("unsafe_external_reference_usage", f"external_reference.usage must be {REFERENCE_USAGE}"))
|
||||
if not str(external_reference.get("repository") or "").startswith("https://"):
|
||||
findings.append(finding("invalid_external_reference_repository", "external_reference.repository must be an HTTPS URL"))
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", str(external_reference.get("commit") or "")):
|
||||
findings.append(finding("invalid_external_reference_commit", "external_reference.commit must be a full Git commit"))
|
||||
for key in ("source_path", "license"):
|
||||
if not str(external_reference.get(key) or "").strip():
|
||||
findings.append(finding("incomplete_external_reference", f"external_reference.{key} is required"))
|
||||
|
||||
required_kinds = manifest.get("required_kinds")
|
||||
if not isinstance(required_kinds, list) or not required_kinds or any(not isinstance(item, str) or not item for item in required_kinds):
|
||||
findings.append(finding("invalid_required_kinds", "required_kinds must be a non-empty string array"))
|
||||
required_kinds = []
|
||||
|
||||
fixtures = manifest.get("fixtures")
|
||||
if not isinstance(fixtures, list):
|
||||
findings.append(finding("invalid_fixtures", "fixtures must be an array"))
|
||||
return findings
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
seen_kinds: set[str] = set()
|
||||
for fixture in fixtures:
|
||||
if not isinstance(fixture, dict):
|
||||
findings.append(finding("invalid_fixture", "each fixture must be an object"))
|
||||
continue
|
||||
fixture_id = str(fixture.get("id") or "")
|
||||
kind = str(fixture.get("kind") or "")
|
||||
if not fixture_id:
|
||||
findings.append(finding("missing_fixture_id", "fixture id is required"))
|
||||
elif fixture_id in seen_ids:
|
||||
findings.append(finding("duplicate_fixture_id", f"duplicate fixture id: {fixture_id}", fixture_id=fixture_id))
|
||||
seen_ids.add(fixture_id)
|
||||
if not kind:
|
||||
findings.append(finding("missing_fixture_kind", "fixture kind is required", fixture_id=fixture_id))
|
||||
elif kind in seen_kinds:
|
||||
findings.append(finding("duplicate_fixture_kind", f"duplicate fixture kind: {kind}", fixture_id=fixture_id))
|
||||
seen_kinds.add(kind)
|
||||
|
||||
target = fixture.get("target")
|
||||
mode = target.get("mode") if isinstance(target, dict) else None
|
||||
if mode not in SUPPORTED_MODES:
|
||||
findings.append(finding("invalid_target_mode", f"unsupported target mode: {mode}", fixture_id=fixture_id))
|
||||
if mode == "extension_saved_state":
|
||||
for key in ("base_id", "extension", "state"):
|
||||
if not isinstance(target.get(key), str) or not target.get(key):
|
||||
findings.append(finding("missing_extension_target", f"target.{key} is required", fixture_id=fixture_id))
|
||||
if required_version and target.get("platform_version") != required_version:
|
||||
findings.append(
|
||||
finding(
|
||||
"fixture_designer_version_mismatch",
|
||||
f"target.platform_version must equal designer.required_version ({required_version})",
|
||||
fixture_id=fixture_id,
|
||||
)
|
||||
)
|
||||
if mode == "dedicated_base_active" and target.get("base_override_required") is not True:
|
||||
findings.append(finding("unsafe_shared_legacy_target", "dedicated legacy fixture must require an explicit base override", fixture_id=fixture_id))
|
||||
|
||||
selector = fixture.get("selector")
|
||||
names = selector.get("names") if isinstance(selector, dict) else None
|
||||
if not isinstance(names, list) or not names or any(not isinstance(name, str) or not name.strip() for name in names):
|
||||
findings.append(finding("invalid_selector_names", "selector.names must be a non-empty string array", fixture_id=fixture_id))
|
||||
methods = fixture.get("smoke_methods")
|
||||
if not isinstance(methods, list) or not methods or any(not isinstance(method, str) or not method for method in methods):
|
||||
findings.append(finding("invalid_smoke_methods", "smoke_methods must be a non-empty string array", fixture_id=fixture_id))
|
||||
|
||||
missing = sorted(set(required_kinds) - seen_kinds)
|
||||
extra = sorted(seen_kinds - set(required_kinds))
|
||||
if missing:
|
||||
findings.append(finding("required_fixture_missing", f"fixtures missing for kinds: {', '.join(missing)}"))
|
||||
if extra:
|
||||
findings.append(finding("unexpected_fixture_kind", f"unexpected fixture kinds: {', '.join(extra)}"))
|
||||
kind_paths = external_reference.get("kind_paths")
|
||||
if not isinstance(kind_paths, dict):
|
||||
findings.append(finding("missing_external_reference_kind_paths", "external_reference.kind_paths must be an object"))
|
||||
else:
|
||||
missing_reference_kinds = sorted(set(required_kinds) - set(kind_paths))
|
||||
if missing_reference_kinds:
|
||||
findings.append(
|
||||
finding(
|
||||
"external_reference_kind_missing",
|
||||
f"external reference paths missing for kinds: {', '.join(missing_reference_kinds)}",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def normalize_guid(value: Any) -> str:
|
||||
return str(value or "").strip().strip("{}").casefold()
|
||||
|
||||
|
||||
def matches_selector(obj: dict[str, Any], selector: dict[str, Any]) -> bool:
|
||||
names = {str(value).strip().casefold() for value in selector.get("names") or []}
|
||||
guid = normalize_guid(selector.get("guid"))
|
||||
object_name = str(obj.get("name") or "").strip().casefold()
|
||||
object_guid = normalize_guid(obj.get("guid"))
|
||||
if guid and object_guid == guid:
|
||||
return True
|
||||
return bool(object_name and object_name in names)
|
||||
|
||||
|
||||
def object_public_selector(base_id: str, fixture: dict[str, Any], obj: dict[str, Any]) -> dict[str, Any]:
|
||||
selector: dict[str, Any] = {"base_id": base_id, "kind": fixture["kind"]}
|
||||
if obj.get("name"):
|
||||
selector["name"] = obj["name"]
|
||||
if obj.get("guid"):
|
||||
selector["guid"] = obj["guid"]
|
||||
target = fixture.get("target") or {}
|
||||
route = obj.get("route") if isinstance(obj.get("route"), dict) else {}
|
||||
extension_guid = obj.get("extension_guid") or route.get("extension_guid")
|
||||
if extension_guid:
|
||||
selector["extension_guid"] = extension_guid
|
||||
elif target.get("extension"):
|
||||
selector["extension"] = target["extension"]
|
||||
return selector
|
||||
|
||||
|
||||
def probe_smoke_methods(
|
||||
adapter_url: str,
|
||||
service_token: str,
|
||||
timeout: float,
|
||||
fixture: dict[str, Any],
|
||||
base_id: str,
|
||||
obj: dict[str, Any],
|
||||
rpc_call: Rpc,
|
||||
) -> list[dict[str, Any]]:
|
||||
selector = object_public_selector(base_id, fixture, obj)
|
||||
results: list[dict[str, Any]] = []
|
||||
for method in fixture.get("smoke_methods") or []:
|
||||
payload = {**selector, "timeout_seconds": max(1, int(timeout))}
|
||||
if method == "metadata.object.attributes":
|
||||
payload["only"] = "all"
|
||||
try:
|
||||
response = rpc_call(adapter_url, method, payload, timeout, service_token)
|
||||
status = str(response.get("status") or "unknown")
|
||||
results.append({"method": method, "status": status, **({"error": response.get("error")} if response.get("error") else {})})
|
||||
except (TimeoutError, urllib.error.URLError, OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
results.append({"method": method, "status": "transport_error", "error": type(exc).__name__, "message": str(exc)[:300]})
|
||||
return results
|
||||
|
||||
|
||||
def check_fixture_live(
|
||||
fixture: dict[str, Any],
|
||||
adapter_url: str,
|
||||
service_token: str,
|
||||
timeout: float,
|
||||
base_overrides: dict[str, str],
|
||||
rpc_call: Rpc,
|
||||
probe_cache: dict[tuple[str, str, str, str], dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
fixture_id = str(fixture["id"])
|
||||
target = fixture.get("target") or {}
|
||||
base_id = base_overrides.get(fixture_id) or target.get("base_id")
|
||||
if target.get("base_override_required") and fixture_id not in base_overrides:
|
||||
return {
|
||||
"id": fixture_id,
|
||||
"kind": fixture["kind"],
|
||||
"status": "target_not_configured",
|
||||
"message": f"Pass --target-base {fixture_id}=BASE_ID for the dedicated legacy fixture.",
|
||||
}
|
||||
if not base_id:
|
||||
return {"id": fixture_id, "kind": fixture["kind"], "status": "target_not_configured"}
|
||||
|
||||
payload: dict[str, Any]
|
||||
method: str
|
||||
if target.get("mode") == "extension_saved_state":
|
||||
method = "extension.objects.find"
|
||||
payload = {
|
||||
"base_id": base_id,
|
||||
"extension": target["extension"],
|
||||
"state": target.get("state") or "working",
|
||||
"limit": 100,
|
||||
"use_cache": True,
|
||||
"full_scan": False,
|
||||
"refresh_cache": False,
|
||||
"timeout_seconds": max(1, int(timeout)),
|
||||
}
|
||||
else:
|
||||
method = "metadata.objects.list"
|
||||
payload = {
|
||||
"base_id": base_id,
|
||||
"kind": fixture["kind"],
|
||||
"limit": 100,
|
||||
"refresh_cache": True,
|
||||
"timeout_seconds": max(1, int(timeout)),
|
||||
}
|
||||
|
||||
cache_key = (
|
||||
method,
|
||||
str(base_id),
|
||||
str(target.get("extension") or ""),
|
||||
str(target.get("state") or ""),
|
||||
)
|
||||
cached_probe_error: dict[str, Any] | None = None
|
||||
try:
|
||||
if probe_cache is not None and cache_key in probe_cache:
|
||||
cached = probe_cache[cache_key]
|
||||
cached_probe_error = cached.get("__probe_error__") if isinstance(cached.get("__probe_error__"), dict) else None
|
||||
if cached_probe_error is not None:
|
||||
raise TimeoutError(str(cached_probe_error.get("message") or "cached adapter probe failed"))
|
||||
response = cached
|
||||
else:
|
||||
response = rpc_call(adapter_url, method, payload, timeout, service_token)
|
||||
if probe_cache is not None:
|
||||
probe_cache[cache_key] = response
|
||||
except (TimeoutError, urllib.error.URLError, OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
error_name = str(cached_probe_error.get("error")) if cached_probe_error else type(exc).__name__
|
||||
error_message = str(cached_probe_error.get("message")) if cached_probe_error else str(exc)[:300]
|
||||
if probe_cache is not None and cached_probe_error is None:
|
||||
probe_cache[cache_key] = {
|
||||
"__probe_error__": {
|
||||
"error": error_name,
|
||||
"message": error_message,
|
||||
}
|
||||
}
|
||||
return {
|
||||
"id": fixture_id,
|
||||
"kind": fixture["kind"],
|
||||
"base_id": base_id,
|
||||
"status": "adapter_error",
|
||||
"probe_method": method,
|
||||
"error": error_name,
|
||||
"message": error_message,
|
||||
**({"probe_error_reused": True} if cached_probe_error is not None else {}),
|
||||
}
|
||||
|
||||
objects = response.get("objects") if isinstance(response.get("objects"), list) else response.get("items")
|
||||
objects = [obj for obj in (objects or []) if isinstance(obj, dict)]
|
||||
kind_objects = [obj for obj in objects if str(obj.get("kind") or "") == str(fixture["kind"])]
|
||||
matched = next((obj for obj in kind_objects if matches_selector(obj, fixture.get("selector") or {})), None)
|
||||
if not matched:
|
||||
return {
|
||||
"id": fixture_id,
|
||||
"kind": fixture["kind"],
|
||||
"base_id": base_id,
|
||||
"status": "fixture_missing" if response.get("status") in {"ok", "not_found"} else "adapter_error",
|
||||
"probe_method": method,
|
||||
"adapter_status": response.get("status"),
|
||||
"observed_names": sorted({str(obj.get("name")) for obj in kind_objects if obj.get("name")})[:20],
|
||||
}
|
||||
|
||||
expected_states = set(fixture.get("expected_activation_states") or [])
|
||||
activation_state = str(matched.get("activation_state") or "active")
|
||||
smoke = probe_smoke_methods(adapter_url, service_token, timeout, fixture, base_id, matched, rpc_call)
|
||||
failed_smoke = [item["method"] for item in smoke if item.get("status") != "ok"]
|
||||
state_ok = not expected_states or activation_state in expected_states
|
||||
return {
|
||||
"id": fixture_id,
|
||||
"kind": fixture["kind"],
|
||||
"base_id": base_id,
|
||||
"status": "fixture_ready" if state_ok and not failed_smoke else "fixture_degraded",
|
||||
"probe_method": method,
|
||||
"adapter_status": response.get("status"),
|
||||
"object": {
|
||||
"name": matched.get("name"),
|
||||
"guid": matched.get("guid"),
|
||||
"activation_state": activation_state,
|
||||
},
|
||||
"smoke": smoke,
|
||||
**({"failed_smoke_methods": failed_smoke} if failed_smoke else {}),
|
||||
**({"unexpected_activation_state": activation_state} if not state_ok else {}),
|
||||
}
|
||||
|
||||
|
||||
def parse_base_overrides(values: list[str]) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for value in values:
|
||||
fixture_id, separator, base_id = value.partition("=")
|
||||
if not separator or not fixture_id.strip() or not base_id.strip():
|
||||
raise ValueError(f"invalid --target-base value: {value}; expected FIXTURE_ID=BASE_ID")
|
||||
result[fixture_id.strip()] = base_id.strip()
|
||||
return result
|
||||
|
||||
|
||||
def check_manifest(
|
||||
manifest_path: Path,
|
||||
*,
|
||||
live: bool = False,
|
||||
adapter_url: str = "http://docker-gpu.cin.su:8011",
|
||||
service_token: str = "",
|
||||
timeout: float = 90,
|
||||
base_overrides: dict[str, str] | None = None,
|
||||
require_ready: bool = False,
|
||||
rpc_call: Rpc = rpc,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
manifest = load_json(manifest_path)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
findings = [finding("manifest_read_failed", str(exc))]
|
||||
manifest = {}
|
||||
else:
|
||||
findings = validate_manifest(manifest)
|
||||
|
||||
fixture_results: list[dict[str, Any]] = []
|
||||
if not findings:
|
||||
if live:
|
||||
probe_cache: dict[tuple[str, str, str, str], dict[str, Any]] = {}
|
||||
fixture_results = [
|
||||
check_fixture_live(
|
||||
fixture,
|
||||
adapter_url,
|
||||
service_token,
|
||||
timeout,
|
||||
base_overrides or {},
|
||||
rpc_call,
|
||||
probe_cache,
|
||||
)
|
||||
for fixture in manifest.get("fixtures") or []
|
||||
]
|
||||
else:
|
||||
fixture_results = [
|
||||
{"id": fixture["id"], "kind": fixture["kind"], "status": "not_checked"}
|
||||
for fixture in manifest.get("fixtures") or []
|
||||
]
|
||||
|
||||
ready = len([item for item in fixture_results if item.get("status") == "fixture_ready"])
|
||||
gaps = len([item for item in fixture_results if item.get("status") in {"fixture_missing", "fixture_degraded", "target_not_configured", "adapter_error"}])
|
||||
passed = not findings and (not require_ready or (bool(fixture_results) and ready == len(fixture_results)))
|
||||
return {
|
||||
"schema": REPORT_SCHEMA,
|
||||
"created_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "invalid_manifest" if findings else ("ready" if fixture_results and ready == len(fixture_results) else ("gaps_found" if live else "manifest_valid")),
|
||||
"passed": passed,
|
||||
"manifest_path": str(manifest_path),
|
||||
"live": live,
|
||||
"require_ready": require_ready,
|
||||
"adapter_url": adapter_url if live else None,
|
||||
"findings": findings,
|
||||
"fixtures": fixture_results,
|
||||
"counts": {
|
||||
"findings": len(findings),
|
||||
"fixtures": len(fixture_results),
|
||||
"ready": ready,
|
||||
"gaps": gaps,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate and optionally probe rare 1C metadata-kind fixtures.")
|
||||
parser.add_argument("--manifest", type=Path, default=Path("config/1c_metadata_kind_fixtures.json"))
|
||||
parser.add_argument("--live", action="store_true")
|
||||
parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011")
|
||||
parser.add_argument("--service-token-env", default="ONEC_ADAPTER_SERVICE_TOKEN")
|
||||
parser.add_argument("--timeout", type=float, default=90)
|
||||
parser.add_argument("--target-base", action="append", default=[], metavar="FIXTURE_ID=BASE_ID")
|
||||
parser.add_argument("--require-ready", action="store_true")
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
base_overrides = parse_base_overrides(args.target_base)
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
result = check_manifest(
|
||||
args.manifest,
|
||||
live=args.live,
|
||||
adapter_url=args.adapter_url,
|
||||
service_token=os.environ.get(args.service_token_env, ""),
|
||||
timeout=args.timeout,
|
||||
base_overrides=base_overrides,
|
||||
require_ready=args.require_ready,
|
||||
)
|
||||
if args.output:
|
||||
write_json(args.output, result)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result["passed"] else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -334,6 +334,8 @@ def check_adapter_verify_wiring(scripts: list[Path], executable: str) -> list[st
|
||||
failures.append("deploy_1c_adapter_stack.ps1 must declare -SkipWriteRollbackSafetySmoke.")
|
||||
if "[switch]$SkipSavedStateDiffSmoke" not in deploy_text:
|
||||
failures.append("deploy_1c_adapter_stack.ps1 must declare -SkipSavedStateDiffSmoke.")
|
||||
if "[switch]$SkipCodeWriteSavedStateSmoke" not in deploy_text:
|
||||
failures.append("deploy_1c_adapter_stack.ps1 must declare -SkipCodeWriteSavedStateSmoke.")
|
||||
if "[string]$SavedStateTable" not in deploy_text or '[ValidateSet("ConfigSave", "ConfigCASSave")]' not in deploy_text:
|
||||
failures.append("deploy_1c_adapter_stack.ps1 must declare -SavedStateTable with ConfigSave/ConfigCASSave validation.")
|
||||
if '"-RequireSelectorChainWritePlanComposition"' not in deploy_text:
|
||||
@@ -346,6 +348,8 @@ def check_adapter_verify_wiring(scripts: list[Path], executable: str) -> list[st
|
||||
failures.append("deploy_1c_adapter_stack.ps1 must forward -SkipWriteRollbackSafetySmoke to verify_1c_adapter_deployment.ps1.")
|
||||
if '"-SkipSavedStateDiffSmoke"' not in deploy_text:
|
||||
failures.append("deploy_1c_adapter_stack.ps1 must forward -SkipSavedStateDiffSmoke to verify_1c_adapter_deployment.ps1.")
|
||||
if '"-SkipCodeWriteSavedStateSmoke"' not in deploy_text:
|
||||
failures.append("deploy_1c_adapter_stack.ps1 must forward -SkipCodeWriteSavedStateSmoke to verify_1c_adapter_deployment.ps1.")
|
||||
if "function Get-DuplicateValues" not in deploy_text or "Duplicate BaseId value(s)" not in deploy_text:
|
||||
failures.append("deploy_1c_adapter_stack.ps1 must reject duplicate -BaseId values before invoking verification.")
|
||||
if "function Normalize-BaseIds" not in deploy_text or '-split ","' not in deploy_text:
|
||||
|
||||
@@ -23,6 +23,7 @@ param(
|
||||
[switch]$SkipWriteRollbackSafetySmoke,
|
||||
[switch]$SkipSavedStateDiffSmoke,
|
||||
[switch]$SkipSavedStateWriteSmoke,
|
||||
[switch]$SkipCodeWriteSavedStateSmoke,
|
||||
[switch]$RequireSavedStateWriteSmoke,
|
||||
[switch]$RequireSelectorChainWritePlanComposition
|
||||
)
|
||||
@@ -216,6 +217,9 @@ try {
|
||||
if ($SkipSavedStateWriteSmoke) {
|
||||
$verifyCommand += "-SkipSavedStateWriteSmoke"
|
||||
}
|
||||
if ($SkipCodeWriteSavedStateSmoke) {
|
||||
$verifyCommand += "-SkipCodeWriteSavedStateSmoke"
|
||||
}
|
||||
if ($RequireSavedStateWriteSmoke) {
|
||||
$verifyCommand += "-RequireSavedStateWriteSmoke"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$DesignerPath = "C:\Program Files\1cv8\8.5.1.1236\bin\1cv8.exe",
|
||||
[string]$ExpectedVersion = "8.5.1.1236",
|
||||
[string]$ServerConnection = "wsr\upo_test",
|
||||
[string]$Extension = "test2",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputDirectory
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
if (-not (Test-Path -LiteralPath $DesignerPath -PathType Leaf)) {
|
||||
throw "1C Designer executable not found: $DesignerPath"
|
||||
}
|
||||
|
||||
$actualVersion = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($DesignerPath).FileVersion
|
||||
if ($actualVersion -ne $ExpectedVersion) {
|
||||
throw "1C Designer version mismatch: expected $ExpectedVersion, found $actualVersion"
|
||||
}
|
||||
|
||||
$resolvedOutput = [System.IO.Path]::GetFullPath($OutputDirectory)
|
||||
if (Test-Path -LiteralPath $resolvedOutput) {
|
||||
$existingItems = @(Get-ChildItem -LiteralPath $resolvedOutput -Force)
|
||||
if ($existingItems.Count -gt 0) {
|
||||
throw "Output directory must be empty: $resolvedOutput"
|
||||
}
|
||||
}
|
||||
else {
|
||||
New-Item -ItemType Directory -Path $resolvedOutput | Out-Null
|
||||
}
|
||||
|
||||
$logPath = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$designerArguments = @(
|
||||
"DESIGNER",
|
||||
"/S", $ServerConnection,
|
||||
"/WA+",
|
||||
"/DisableStartupMessages",
|
||||
"/DisableStartupDialogs",
|
||||
"/Out", $logPath,
|
||||
"/DumpConfigToFiles", $resolvedOutput,
|
||||
"-Extension", $Extension
|
||||
)
|
||||
|
||||
& $DesignerPath @designerArguments
|
||||
$designerExitCode = $LASTEXITCODE
|
||||
$designerLog = Get-Content -LiteralPath $logPath -Raw -ErrorAction SilentlyContinue
|
||||
if ($designerExitCode -ne 0) {
|
||||
$details = if ($designerLog) { $designerLog.Trim() } else { "Designer did not write a diagnostic log." }
|
||||
throw "1C Designer export failed with exit code ${designerExitCode}: $details"
|
||||
}
|
||||
|
||||
$exportedFiles = @(Get-ChildItem -LiteralPath $resolvedOutput -File -Recurse)
|
||||
if ($exportedFiles.Count -eq 0) {
|
||||
$details = if ($designerLog) { $designerLog.Trim() } else { "No diagnostic log was written." }
|
||||
throw "1C Designer reported success but exported no files: $details"
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
status = "exported"
|
||||
designer_version = $actualVersion
|
||||
server_connection = $ServerConnection
|
||||
extension = $Extension
|
||||
output_directory = $resolvedOutput
|
||||
exported_files = $exportedFiles.Count
|
||||
access_mode = "operating_system_integrated"
|
||||
} | ConvertTo-Json -Depth 3
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $logPath -PathType Leaf) {
|
||||
Remove-Item -LiteralPath $logPath -Force
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
|
||||
changes = rpc_call(
|
||||
endpoint_url,
|
||||
"metadata.saved_state.changes.list",
|
||||
{"base_id": base_id, "limit": 50, "timeout_seconds": int(timeout)},
|
||||
{"base_id": base_id, "limit": 50, "timeout_seconds": int(timeout), "include_storage": True},
|
||||
timeout,
|
||||
transport=transport,
|
||||
session_id=session_id,
|
||||
@@ -169,7 +169,15 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
|
||||
contextual = rpc_call(
|
||||
endpoint_url,
|
||||
"metadata.saved_state.changes.list",
|
||||
{"base_id": base_id, "limit": 10, "include_context": True, "group_by_context": True, "context_limit": 5, "timeout_seconds": int(timeout)},
|
||||
{
|
||||
"base_id": base_id,
|
||||
"limit": 10,
|
||||
"include_context": True,
|
||||
"group_by_context": True,
|
||||
"context_limit": 5,
|
||||
"timeout_seconds": int(timeout),
|
||||
"include_storage": True,
|
||||
},
|
||||
timeout,
|
||||
transport=transport,
|
||||
session_id=session_id,
|
||||
|
||||
@@ -120,7 +120,14 @@ def first_saved_form(endpoint_url: str, base_id: str, timeout: float, *, transpo
|
||||
result = rpc_call(
|
||||
endpoint_url,
|
||||
"metadata.saved_state.forms.search",
|
||||
{"base_id": base_id, "tables": [table], "limit": 1, "scan_limit": 200, "timeout_seconds": int(timeout)},
|
||||
{
|
||||
"base_id": base_id,
|
||||
"tables": [table],
|
||||
"limit": 1,
|
||||
"scan_limit": 200,
|
||||
"timeout_seconds": int(timeout),
|
||||
"include_storage": True,
|
||||
},
|
||||
timeout,
|
||||
transport=transport,
|
||||
session_id=session_id,
|
||||
@@ -171,6 +178,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
|
||||
"max_changes": 20,
|
||||
"max_text_diff_lines": 20,
|
||||
"timeout_seconds": int(timeout),
|
||||
"include_storage": True,
|
||||
}
|
||||
diff = rpc_call(endpoint_url, "metadata.saved_state.diff", diff_payload, timeout, transport=transport, session_id=session_id)
|
||||
checks["diff_existing_saved_state"] = {
|
||||
@@ -193,7 +201,13 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
|
||||
diff = rpc_call(
|
||||
endpoint_url,
|
||||
"metadata.saved_state.diff",
|
||||
{"base_id": base_id, "table": saved_state_table, "file_name": missing_file, "timeout_seconds": int(timeout)},
|
||||
{
|
||||
"base_id": base_id,
|
||||
"table": saved_state_table,
|
||||
"file_name": missing_file,
|
||||
"timeout_seconds": int(timeout),
|
||||
"include_storage": True,
|
||||
},
|
||||
timeout,
|
||||
transport=transport,
|
||||
session_id=session_id,
|
||||
|
||||
@@ -174,7 +174,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
|
||||
require(diagnostic_fallback.get("status") == "blocked", "MCP must block low-level diagnostic fallback methods", failures)
|
||||
require(diagnostic_fallback.get("reason") == "diagnostic_method", "MCP diagnostic fallback block must use diagnostic_method reason", failures)
|
||||
|
||||
blocked_effective = rpc_call(
|
||||
blocked_effective_apply = rpc_call(
|
||||
endpoint_url,
|
||||
"metadata.write",
|
||||
{
|
||||
@@ -189,11 +189,37 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
|
||||
transport=transport,
|
||||
session_id=session_id,
|
||||
)
|
||||
apply_gate = None
|
||||
if blocked_effective_apply.get("schema") == "onec_repository_write_gate.v1":
|
||||
apply_gate = {
|
||||
"status": blocked_effective_apply.get("status"),
|
||||
"error": blocked_effective_apply.get("error"),
|
||||
}
|
||||
# Repository/support coordination is allowed to block apply before the
|
||||
# metadata route is evaluated. Repeat in non-mutating plan mode so the
|
||||
# smoke still verifies the effective-path routing contract.
|
||||
blocked_effective = rpc_call(
|
||||
endpoint_url,
|
||||
"metadata.write",
|
||||
{
|
||||
"base_id": base_id,
|
||||
"target": {"canonical_path": "Документ.АвансовыйОтчет.Форма.ФормаДокумента.КнопкаЗаписать"},
|
||||
"mode": "plan",
|
||||
"edits": [{"property": "Заголовок", "value": "BLOCKED_SMOKE"}],
|
||||
"timeout_seconds": int(timeout),
|
||||
},
|
||||
timeout,
|
||||
transport=transport,
|
||||
session_id=session_id,
|
||||
)
|
||||
else:
|
||||
blocked_effective = blocked_effective_apply
|
||||
checks["blocked_effective_form_path"] = {
|
||||
"status": blocked_effective.get("status"),
|
||||
"error": blocked_effective.get("error"),
|
||||
"routed_method": blocked_effective.get("routed_method"),
|
||||
"target_kind": blocked_effective.get("target_kind"),
|
||||
**({"apply_gate": apply_gate} if apply_gate else {}),
|
||||
}
|
||||
require(blocked_effective.get("status") == "blocked", "effective canonical path write must be blocked", failures)
|
||||
require(blocked_effective.get("error") == "write_plan_required", "effective canonical path write must require write plan", failures)
|
||||
|
||||
@@ -12,6 +12,17 @@ from urllib.error import HTTPError, URLError
|
||||
DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011"
|
||||
DEFAULT_MCP_URL = "http://docker.cin.su:8021"
|
||||
EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1"
|
||||
PREFLIGHT_CLASSIFICATION_STATUSES = {
|
||||
"ready",
|
||||
"needs_prepare",
|
||||
"needs_resolution",
|
||||
"needs_repository_lock",
|
||||
"blocked",
|
||||
"blocked_by_support",
|
||||
"blocked_by_support_live_sql",
|
||||
"blocked_by_support_rule",
|
||||
"blocked_support_unknown",
|
||||
}
|
||||
|
||||
|
||||
def post_json(url: str, payload: dict[str, Any], *, timeout: float, headers: dict[str, str] | None = None) -> tuple[dict[str, str], dict[str, Any]]:
|
||||
@@ -106,6 +117,10 @@ def require(condition: bool, message: str, failures: list[str]) -> None:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def classified_preflight_status(value: Any) -> bool:
|
||||
return str(value or "") in PREFLIGHT_CLASSIFICATION_STATUSES
|
||||
|
||||
|
||||
def method_names(help_result: dict[str, Any]) -> set[str]:
|
||||
return {str(item.get("name") or "") for item in help_result.get("methods") or [] if isinstance(item, dict)}
|
||||
|
||||
@@ -120,13 +135,24 @@ def first_saved_module_target(endpoint_url: str, base_id: str, timeout: float, *
|
||||
session_id=session_id,
|
||||
)
|
||||
modules = result.get("modules") if isinstance(result.get("modules"), list) else []
|
||||
targets: list[dict[str, Any]] = []
|
||||
for module in modules:
|
||||
if not isinstance(module, dict):
|
||||
continue
|
||||
for stream in module.get("streams") or []:
|
||||
if isinstance(stream, dict) and isinstance(stream.get("write_plan_target"), dict):
|
||||
return stream["write_plan_target"]
|
||||
return None
|
||||
targets.append(stream["write_plan_target"])
|
||||
# A concrete stream target can be freshness-checked without decoding and
|
||||
# structurally diffing a whole saved form descriptor. Prefer it so this
|
||||
# deployment smoke remains bounded on large ConfigCASSave forms.
|
||||
return next(
|
||||
(
|
||||
target
|
||||
for target in targets
|
||||
if target.get("stream_index") is not None or "#stream:" in str(target.get("module_ref") or "")
|
||||
),
|
||||
targets[0] if targets else None,
|
||||
)
|
||||
|
||||
|
||||
def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str) -> dict[str, Any]:
|
||||
@@ -173,7 +199,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
|
||||
"plan_allowed": (blocked.get("plan") or {}).get("allowed") if isinstance(blocked.get("plan"), dict) else None,
|
||||
}
|
||||
require(blocked.get("schema") == "onec_metadata_write_preflight.v1", "preflight must return expected schema", failures)
|
||||
require(blocked.get("status") in {"blocked", "needs_resolution"}, "effective path preflight must be blocked or need resolution", failures)
|
||||
require(classified_preflight_status(blocked.get("status")), "effective path preflight must classify readiness or a safety gate", failures)
|
||||
require(blocked.get("allowed") is False, "effective path preflight must not be allowed", failures)
|
||||
|
||||
target = first_saved_module_target(endpoint_url, base_id, timeout, transport=transport, session_id=session_id)
|
||||
@@ -201,7 +227,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
|
||||
"writer": (concrete.get("route") or {}).get("writer") if isinstance(concrete.get("route"), dict) else None,
|
||||
}
|
||||
require(concrete.get("schema") == "onec_metadata_write_preflight.v1", "concrete preflight must return expected schema", failures)
|
||||
require(concrete.get("status") in {"ready", "needs_prepare", "blocked"}, "concrete preflight must classify readiness", failures)
|
||||
require(classified_preflight_status(concrete.get("status")), "concrete preflight must classify readiness or a safety gate", failures)
|
||||
if concrete.get("status") == "ready":
|
||||
require(freshness.get("status") == "live_sql_verified", "ready concrete preflight must be live SQL verified", failures)
|
||||
else:
|
||||
@@ -230,7 +256,18 @@ def main() -> int:
|
||||
args = parser.parse_args()
|
||||
|
||||
endpoint_url = args.mcp_url if args.transport == "mcp" else args.base_url
|
||||
report = run_smoke(endpoint_url, args.base_id, args.timeout, transport=args.transport)
|
||||
try:
|
||||
report = run_smoke(endpoint_url, args.base_id, args.timeout, transport=args.transport)
|
||||
except (TimeoutError, URLError, OSError) as exc:
|
||||
report = {
|
||||
"schema": "onec_write_preflight_smoke.v1",
|
||||
"status": "failed",
|
||||
"endpoint_url": endpoint_url,
|
||||
"transport": args.transport,
|
||||
"base_id": args.base_id,
|
||||
"checks": {},
|
||||
"failures": [f"transport timeout/error: {type(exc).__name__}: {str(exc)[:300]}"],
|
||||
}
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user