1487 lines
79 KiB
Python
1487 lines
79 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
METHOD_STATUS = "repository.status"
|
|
SQL_LOCK_EVIDENCE = {
|
|
"native_lock_state": "unknown",
|
|
"native_lock_verification": "not_available_via_infobase_sql",
|
|
"excluded_sources": {
|
|
"_ConfigChngR": "exchange_plan_change_registration",
|
|
"_ConfigChngR_ExtProps": "exchange_plan_changed_file_details",
|
|
},
|
|
}
|
|
METHOD_LOCK_PLAN = "repository.lock.plan"
|
|
METHOD_LOCK_REQUEST = "repository.lock.request"
|
|
METHOD_LOCK_REQUEST_STATUS = "repository.lock.request.status"
|
|
METHOD_LOCK_REQUEST_CANCEL = "repository.lock.request.cancel"
|
|
METHOD_LOCK = "repository.lock"
|
|
METHOD_CONFIRM = "repository.lock.confirm"
|
|
METHOD_VERIFY = "repository.lock.verify"
|
|
METHOD_CLOSE = "repository.lock.close"
|
|
METHOD_UNLOCK = "repository.unlock"
|
|
METHOD_COMMIT_PLAN = "repository.commit.plan"
|
|
METHOD_COMMIT = "repository.commit"
|
|
METHODS = {METHOD_STATUS, METHOD_LOCK_PLAN, METHOD_LOCK_REQUEST, METHOD_LOCK_REQUEST_STATUS, METHOD_LOCK_REQUEST_CANCEL, METHOD_LOCK, METHOD_CONFIRM, METHOD_VERIFY, METHOD_CLOSE, METHOD_UNLOCK, METHOD_COMMIT_PLAN, METHOD_COMMIT}
|
|
SUPPORTED_BACKENDS = {"direct", "karman_bridge"}
|
|
SUPPORTED_LOCK_MODES = {"automatic", "manual"}
|
|
SUPPORTED_REPOSITORY_MODES = {"none", "manual", "automatic", "unknown"}
|
|
SUPPORTED_REPOSITORY_CONNECTION_STATES = {"not_configured", "configured", "unavailable", "unknown"}
|
|
SUPPORTED_SUPPORT_MODES = {"none", "editable", "locked", "rules", "unknown"}
|
|
_BASE_LOCKS: dict[str, threading.Lock] = {}
|
|
_BASE_LOCKS_GUARD = threading.Lock()
|
|
_STATE_LOCK = threading.RLock()
|
|
REPOSITORY_STATE_SCHEMA_VERSION = 2
|
|
|
|
|
|
def external_1c_enabled() -> bool:
|
|
return str(os.environ.get("ONEC_ADAPTER_ENABLE_EXTERNAL_1C") or "").strip().casefold() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _base_lock(base_id: str, layer: str) -> threading.Lock:
|
|
key = f"{base_id}:{layer}"
|
|
with _BASE_LOCKS_GUARD:
|
|
return _BASE_LOCKS.setdefault(key, threading.Lock())
|
|
|
|
|
|
def _load_json_map(env_name: str, file_env_name: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
raw = os.environ.get(env_name)
|
|
path = os.environ.get(file_env_name)
|
|
if not raw and path:
|
|
try:
|
|
raw = Path(path).read_text(encoding="utf-8-sig")
|
|
except Exception as exc:
|
|
return None, {"status": "invalid_config", "message": f"Cannot read {file_env_name}: {exc}"}
|
|
if not raw:
|
|
return None, {
|
|
"status": "not_configured",
|
|
"message": f"Set {env_name} or {file_env_name} with an explicit entry for this base_id.",
|
|
}
|
|
try:
|
|
value = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
return None, {"status": "invalid_config", "message": f"{env_name} is not valid JSON: {exc}"}
|
|
if not isinstance(value, dict):
|
|
return None, {"status": "invalid_config", "message": f"{env_name} must be an object keyed by base_id."}
|
|
return value, None
|
|
|
|
|
|
def development_layer_id(payload: dict[str, Any] | None = None) -> str:
|
|
value = payload if isinstance(payload, dict) else {}
|
|
target = value.get("target") if isinstance(value.get("target"), dict) else {}
|
|
origin = target.get("origin") if isinstance(target.get("origin"), dict) else {}
|
|
origin_extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {}
|
|
extension_guid = str(value.get("extension_guid") or target.get("extension_guid") or origin_extension.get("guid") or "").strip().lower()
|
|
layer_id = str(value.get("layer_id") or target.get("layer_id") or "").strip().lower()
|
|
if extension_guid:
|
|
return f"extension:{extension_guid}"
|
|
if layer_id.startswith("extension:"):
|
|
return layer_id
|
|
return "base"
|
|
|
|
|
|
def _base_settings(base_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
values, error = _load_json_map("ONEC_SQL_BASES_JSON", "ONEC_SQL_BASES_JSON_FILE")
|
|
base_item = values.get(base_id) if values and isinstance(values.get(base_id), dict) else None
|
|
if base_item is not None:
|
|
return base_item, None
|
|
return None, error or {"status": "not_configured", "message": f"No SQL configuration for base_id '{base_id}'."}
|
|
|
|
|
|
def development_layer_config(base_id: str, layer_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
base_item, base_error = _base_settings(base_id)
|
|
if base_error:
|
|
return None, base_error
|
|
layers = base_item.get("development_layers") if isinstance(base_item.get("development_layers"), dict) else None
|
|
if layers is not None:
|
|
item = layers.get(layer_id)
|
|
if not isinstance(item, dict):
|
|
return None, {"status": "layer_not_configured", "message": f"No development layer configuration for '{layer_id}' in base_id '{base_id}'."}
|
|
return dict(item), None
|
|
if layer_id == "base" and isinstance(base_item.get("repository"), dict):
|
|
return {"repository": dict(base_item["repository"]), "legacy": True}, None
|
|
return None, {"status": "layer_not_configured", "message": f"No development layer configuration for '{layer_id}' in base_id '{base_id}'."}
|
|
|
|
|
|
def repository_config(base_id: str, layer_id: str = "base") -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
base_item, _ = _base_settings(base_id)
|
|
layer, layer_error = development_layer_config(base_id, layer_id)
|
|
if layer_error is None:
|
|
item = layer.get("repository")
|
|
if item is None:
|
|
return {"mode": "unknown", "lock_mode": "manual", "layer_id": layer_id, "layer": layer_id}, None
|
|
else:
|
|
item = None
|
|
if isinstance(base_item, dict) and isinstance(base_item.get("development_layers"), dict):
|
|
# A disposable base can explicitly declare that its base layer has
|
|
# no repository at all. Extensions in such a base are not new
|
|
# repository layers merely because the adapter has discovered them
|
|
# after the configuration file was written. Inherit only this
|
|
# unambiguous no-repository fact; never inherit a manual/automatic
|
|
# repository policy to an extension.
|
|
base_layer = base_item["development_layers"].get("base")
|
|
base_repository = base_layer.get("repository") if isinstance(base_layer, dict) and isinstance(base_layer.get("repository"), dict) else None
|
|
base_mode = str((base_repository or {}).get("mode") or (base_repository or {}).get("lock_mode") or "").strip().casefold()
|
|
base_connection = str((base_repository or {}).get("connection_state") or "").strip().casefold()
|
|
if layer_id != "base" and (base_mode == "none" or base_connection == "not_configured"):
|
|
item = {"mode": "none", "connection_state": "not_configured", "inherited_from_layer": "base"}
|
|
else:
|
|
return None, layer_error
|
|
# Legacy repository-only configuration remains readable for the base layer.
|
|
if item is None and layer_id == "base" and isinstance(base_item, dict):
|
|
item = base_item.get("repository")
|
|
if item is None:
|
|
values, repository_error = _load_json_map("ONEC_REPOSITORY_BASES_JSON", "ONEC_REPOSITORY_BASES_JSON_FILE")
|
|
if repository_error and layer_error:
|
|
return None, repository_error
|
|
item = values.get(base_id) if values else None
|
|
if item is None:
|
|
return None, {"status": "not_configured", "message": f"No repository configuration for base_id '{base_id}'."}
|
|
if not isinstance(item, dict):
|
|
return None, {"status": "invalid_config", "message": f"Repository configuration for '{base_id}' must be an object."}
|
|
configured = dict(item)
|
|
configured["mode"] = str(configured.get("mode") or configured.get("lock_mode") or "automatic").strip().casefold()
|
|
if configured["mode"] not in SUPPORTED_REPOSITORY_MODES:
|
|
return None, {"status": "invalid_config", "message": "repository mode must be none, manual, automatic, or unknown."}
|
|
default_connection_state = "unknown" if configured["mode"] == "unknown" else ("not_configured" if configured["mode"] == "none" else "configured")
|
|
configured["connection_state"] = str(configured.get("connection_state") or configured.get("repository_connection") or configured.get("connection") or default_connection_state).strip().casefold()
|
|
if configured["connection_state"] not in SUPPORTED_REPOSITORY_CONNECTION_STATES:
|
|
return None, {"status": "invalid_config", "message": "repository connection_state must be not_configured, configured, unavailable, or unknown."}
|
|
if configured["mode"] == "unknown":
|
|
return {"mode": "unknown", "lock_mode": "manual", "connection_state": configured["connection_state"], "layer_id": layer_id, "layer": layer_id}, None
|
|
if configured["mode"] == "none":
|
|
return {
|
|
"mode": "none", "lock_mode": "manual", "connection_state": configured["connection_state"],
|
|
"layer_id": layer_id, "layer": layer_id,
|
|
**({"inherited_from_layer": configured["inherited_from_layer"]} if configured.get("inherited_from_layer") else {}),
|
|
}, None
|
|
configured["backend"] = str(configured.get("backend") or "direct").strip().casefold()
|
|
configured["layer_id"] = layer_id
|
|
configured["layer"] = layer_id
|
|
configured["lock_mode"] = configured["mode"]
|
|
if configured["backend"] not in SUPPORTED_BACKENDS:
|
|
return None, {"status": "invalid_config", "message": "repository backend must be direct or karman_bridge."}
|
|
if configured["lock_mode"] not in SUPPORTED_LOCK_MODES:
|
|
return None, {"status": "invalid_config", "message": "repository lock_mode must be automatic or manual."}
|
|
runner = configured.get("runner") if isinstance(configured.get("runner"), dict) else {}
|
|
configured["runner"] = runner
|
|
runner_kind = str(runner.get("kind") or "local").strip().casefold()
|
|
if runner_kind not in {"local", "http"}:
|
|
return None, {"status": "invalid_config", "message": "repository runner.kind must be local or http."}
|
|
runner["kind"] = runner_kind
|
|
required = ("endpoint", "designer_path") if runner_kind == "local" and configured["lock_mode"] == "automatic" else ()
|
|
for key in required:
|
|
if not str(configured.get(key) or "").strip():
|
|
return None, {"status": "invalid_config", "message": f"Repository configuration requires {key}."}
|
|
if runner_kind == "http" and configured["lock_mode"] == "automatic" and not str(runner.get("url") or "").strip():
|
|
return None, {"status": "invalid_config", "message": "repository runner.url is required for runner.kind=http."}
|
|
infobase = configured.get("infobase")
|
|
if runner_kind == "local" and configured["lock_mode"] == "automatic" and (not isinstance(infobase, dict) or sum(bool(str(infobase.get(key) or "").strip()) for key in ("file", "server", "name")) != 1):
|
|
return None, {"status": "invalid_config", "message": "infobase must contain exactly one of file, server, or name for runner.kind=local."}
|
|
return configured, None
|
|
|
|
|
|
def manual_confirmation_required(config: dict[str, Any]) -> bool:
|
|
"""Whether SQL-only operation needs the user's exact Configurator capture confirmation."""
|
|
if config.get("mode") in {"none", "unknown"} or config.get("connection_state") == "not_configured":
|
|
return False
|
|
return config.get("connection_state") == "unavailable" or config.get("lock_mode") == "manual" or not external_1c_enabled()
|
|
|
|
|
|
def manual_confirmation_next_call(base_id: str, request_id: str) -> dict[str, Any]:
|
|
"""Machine-readable continuation; clients must not infer confirmation keys."""
|
|
return {
|
|
"method": METHOD_CONFIRM,
|
|
"params": {
|
|
"base_id": base_id,
|
|
"request_id": request_id,
|
|
"user_confirmed_locked": True,
|
|
},
|
|
}
|
|
|
|
|
|
def _secret(config: dict[str, Any], field: str) -> str:
|
|
env_name = str(config.get(f"{field}_env") or "").strip()
|
|
return os.environ.get(env_name, "") if env_name else ""
|
|
|
|
|
|
def _public_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"mode": config.get("mode") or config.get("lock_mode"),
|
|
"layer_id": config.get("layer_id") or config.get("layer"),
|
|
"backend": config.get("backend"),
|
|
"layer": config.get("layer"),
|
|
"lock_mode": config.get("lock_mode"),
|
|
"connection_state": config.get("connection_state"),
|
|
"inherited_from_layer": config.get("inherited_from_layer"),
|
|
"adapter_access_mode": "sql_only" if not external_1c_enabled() else "sql_and_external_1c",
|
|
"automatic_repository_operations_available": external_1c_enabled(),
|
|
"endpoint": config.get("endpoint"),
|
|
"bridge_id": config.get("bridge_id") if config.get("backend") == "karman_bridge" else None,
|
|
"runtime_version": config.get("runtime_version"),
|
|
"runner_kind": (config.get("runner") or {}).get("kind"),
|
|
"runner_url": (config.get("runner") or {}).get("url"),
|
|
"runner_token_env": (config.get("runner") or {}).get("token_env"),
|
|
"repository_user": str(config.get("repository_user") or ""),
|
|
"repository_password_env": str(config.get("repository_password_env") or ""),
|
|
"repository_user_configured": bool(str(config.get("repository_user") or "").strip()),
|
|
"repository_password_configured": bool(_secret(config, "repository_password")),
|
|
"infobase_user": str(config.get("infobase_user") or ""),
|
|
"infobase_password_env": str(config.get("infobase_password_env") or ""),
|
|
"infobase_user_configured": bool(str(config.get("infobase_user") or "").strip()),
|
|
"infobase_password_configured": bool(_secret(config, "infobase_password")),
|
|
}
|
|
|
|
|
|
def public_development_layers(base_id: str) -> dict[str, Any]:
|
|
base_item, error = _base_settings(base_id)
|
|
if error or not isinstance(base_item, dict):
|
|
return {}
|
|
layers = base_item.get("development_layers") if isinstance(base_item.get("development_layers"), dict) else {}
|
|
result: dict[str, Any] = {}
|
|
for layer_id, layer in layers.items():
|
|
if not isinstance(layer, dict):
|
|
continue
|
|
repository, repository_error = repository_config(base_id, str(layer_id))
|
|
support = layer.get("support") if isinstance(layer.get("support"), dict) else {"mode": "unknown"}
|
|
result[str(layer_id)] = {
|
|
"repository": _public_config(repository) if repository and not repository_error else {"status": "invalid", "problem": repository_error},
|
|
"support": {"mode": str(support.get("mode") or "unknown"), "rules": dict(support.get("rules") or {}) if isinstance(support.get("rules"), dict) else {}},
|
|
}
|
|
return result
|
|
|
|
|
|
def _infobase_args(config: dict[str, Any]) -> list[str]:
|
|
infobase = config["infobase"]
|
|
if infobase.get("file"):
|
|
args = ["/F", str(infobase["file"])]
|
|
elif infobase.get("server"):
|
|
args = ["/S", str(infobase["server"])]
|
|
else:
|
|
args = ["/IBName", str(infobase["name"])]
|
|
user = str(config.get("infobase_user") or "").strip()
|
|
if user:
|
|
args += ["/N", user]
|
|
password = _secret(config, "infobase_password")
|
|
if password:
|
|
args += ["/P", password]
|
|
return args
|
|
|
|
|
|
def _repository_args(config: dict[str, Any]) -> list[str]:
|
|
args = ["/ConfigurationRepositoryF", str(config["endpoint"])]
|
|
user = str(config.get("repository_user") or "").strip()
|
|
if user:
|
|
args += ["/ConfigurationRepositoryN", user]
|
|
password = _secret(config, "repository_password")
|
|
if password:
|
|
args += ["/ConfigurationRepositoryP", password]
|
|
extension = str(config.get("extension") or "").strip()
|
|
if extension:
|
|
args += ["-Extension", extension]
|
|
return args
|
|
|
|
|
|
def _safe_excerpt(value: str, config: dict[str, Any], limit: int = 4000) -> str:
|
|
safe = value
|
|
for secret in (_secret(config, "repository_password"), _secret(config, "infobase_password")):
|
|
if secret:
|
|
safe = safe.replace(secret, "[REDACTED]")
|
|
return safe[-limit:]
|
|
|
|
|
|
def _run_designer(config: dict[str, Any], operation: list[str], timeout_seconds: int) -> dict[str, Any]:
|
|
started = time.monotonic()
|
|
with tempfile.TemporaryDirectory(prefix="onec-repository-") as directory:
|
|
log_path = Path(directory) / "designer.log"
|
|
args = [str(config["designer_path"]), "DESIGNER"]
|
|
args += _infobase_args(config)
|
|
args += ["/DisableStartupMessages", "/DisableStartupDialogs", "/Out", str(log_path)]
|
|
args += _repository_args(config)
|
|
args += operation
|
|
try:
|
|
completed = subprocess.run(args, capture_output=True, text=True, timeout=timeout_seconds, check=False)
|
|
except subprocess.TimeoutExpired as exc:
|
|
return {
|
|
"status": "timeout",
|
|
"exit_code": None,
|
|
"duration_ms": round((time.monotonic() - started) * 1000),
|
|
"output": _safe_excerpt(str(exc.stdout or "") + str(exc.stderr or ""), config),
|
|
}
|
|
except OSError as exc:
|
|
return {"status": "runner_error", "exit_code": None, "message": str(exc), "duration_ms": round((time.monotonic() - started) * 1000)}
|
|
log = ""
|
|
try:
|
|
log = log_path.read_text(encoding="utf-8-sig", errors="replace")
|
|
except OSError:
|
|
pass
|
|
output = "\n".join(part for part in (completed.stdout, completed.stderr, log) if part)
|
|
return {
|
|
"status": "ok" if completed.returncode == 0 else "failed",
|
|
"exit_code": completed.returncode,
|
|
"duration_ms": round((time.monotonic() - started) * 1000),
|
|
"output": _safe_excerpt(output, config),
|
|
}
|
|
|
|
|
|
def activation_debug_probe(
|
|
base_id: str,
|
|
config: dict[str, Any],
|
|
*,
|
|
layer: str,
|
|
timeout_seconds: int,
|
|
request_id: str = "",
|
|
fingerprint: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Probe runner readiness without starting Designer or reading configured credentials."""
|
|
|
|
runner = config.get("runner") if isinstance(config.get("runner"), dict) else {"kind": "local"}
|
|
runner_kind = str(runner.get("kind") or "local").strip().casefold()
|
|
if runner_kind == "http":
|
|
url = str(runner.get("url") or "").rstrip("/") + "/configuration/activation/debug"
|
|
request_body = {
|
|
"base_id": base_id,
|
|
"layer": layer,
|
|
"mode": "debug",
|
|
}
|
|
if request_id and fingerprint:
|
|
request_body["request_id"] = request_id
|
|
request_body["fingerprint"] = fingerprint
|
|
body = json.dumps(
|
|
request_body,
|
|
ensure_ascii=False,
|
|
).encode("utf-8")
|
|
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
token_env = str(runner.get("token_env") or "").strip()
|
|
token = os.environ.get(token_env, "") if token_env else ""
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
try:
|
|
with urllib.request.urlopen(
|
|
urllib.request.Request(url, data=body, headers=headers, method="POST"),
|
|
timeout=timeout_seconds,
|
|
) as response:
|
|
result = json.loads(response.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
result = json.loads(exc.read().decode("utf-8"))
|
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
result = {
|
|
"status": "runner_error",
|
|
"message": f"Activation debug runner returned HTTP {exc.code}.",
|
|
}
|
|
return result if isinstance(result, dict) else {
|
|
"status": "runner_error",
|
|
"message": f"Activation debug runner returned HTTP {exc.code}.",
|
|
}
|
|
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
|
return {"status": "runner_error", "message": str(exc)}
|
|
return result if isinstance(result, dict) else {
|
|
"status": "runner_error",
|
|
"message": "Activation debug runner returned a non-object response.",
|
|
}
|
|
infobase = config.get("infobase") if isinstance(config.get("infobase"), dict) else {}
|
|
selector_configured = (
|
|
sum(bool(str(infobase.get(key) or "").strip()) for key in ("file", "server", "name")) == 1
|
|
)
|
|
designer_path = str(config.get("designer_path") or "").strip()
|
|
try:
|
|
designer_available = bool(designer_path and Path(designer_path).is_file())
|
|
except OSError:
|
|
designer_available = False
|
|
ready = bool(selector_configured and designer_available)
|
|
debug_acceptance = None
|
|
if request_id and fingerprint:
|
|
receipt_source = json.dumps(
|
|
{
|
|
"base_id": base_id,
|
|
"layer": layer,
|
|
"request_id": request_id,
|
|
"fingerprint": fingerprint,
|
|
"mode": "debug",
|
|
},
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
debug_acceptance = {
|
|
"accepted": ready,
|
|
"request_id": request_id,
|
|
"fingerprint": fingerprint,
|
|
"receipt": hashlib.sha256(receipt_source).hexdigest() if ready else None,
|
|
}
|
|
return {
|
|
"schema": "onec_configuration_activation_runner_probe.v1",
|
|
"status": "ready" if ready else "not_ready",
|
|
"base_id": base_id,
|
|
"layer": layer,
|
|
"runner": {
|
|
"kind": "local",
|
|
"reachable": True,
|
|
"designer_path_configured": bool(designer_path),
|
|
"designer_available": designer_available,
|
|
"infobase_selector_configured": selector_configured,
|
|
},
|
|
"operation": {
|
|
"kind": "/UpdateDBCfg" if layer == "base_saved_state" else None,
|
|
"execution_supported": False,
|
|
"extension_manual_only": layer in {"all", "extension_saved_state"},
|
|
},
|
|
"debug_acceptance": debug_acceptance,
|
|
"execution": {
|
|
"mode": "debug",
|
|
"performed": False,
|
|
"designer_started": False,
|
|
"active_configuration_changed": False,
|
|
},
|
|
}
|
|
|
|
|
|
def _execute_repository(
|
|
base_id: str,
|
|
config: dict[str, Any],
|
|
action: str,
|
|
timeout_seconds: int,
|
|
*,
|
|
objects: list[str] | None = None,
|
|
comment: str = "",
|
|
keep_locked: bool = False,
|
|
) -> dict[str, Any]:
|
|
if not external_1c_enabled():
|
|
return {
|
|
"status": "external_1c_disabled",
|
|
"message": "This adapter version is SQL-only. Use repository.lock.plan and the manual confirmation workflow.",
|
|
}
|
|
runner = config.get("runner") if isinstance(config.get("runner"), dict) else {"kind": "local"}
|
|
if runner.get("kind") == "http":
|
|
url = str(runner.get("url") or "").rstrip("/") + "/repository/execute"
|
|
body = json.dumps(
|
|
{"base_id": base_id, "action": action, "objects": objects or [], "comment": comment, "keep_locked": keep_locked},
|
|
ensure_ascii=False,
|
|
).encode("utf-8")
|
|
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
token_env = str(runner.get("token_env") or "").strip()
|
|
token = os.environ.get(token_env, "") if token_env else ""
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
try:
|
|
with urllib.request.urlopen(urllib.request.Request(url, data=body, headers=headers, method="POST"), timeout=timeout_seconds) as response:
|
|
result = json.loads(response.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
result = json.loads(exc.read().decode("utf-8"))
|
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
result = {"status": "runner_error", "message": f"Repository runner returned HTTP {exc.code}."}
|
|
return result if isinstance(result, dict) else {"status": "runner_error", "message": f"Repository runner returned HTTP {exc.code}."}
|
|
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
|
return {"status": "runner_error", "message": str(exc)}
|
|
return result if isinstance(result, dict) else {"status": "runner_error", "message": "Repository runner returned a non-object response."}
|
|
if action == "report":
|
|
with tempfile.TemporaryDirectory(prefix="onec-repository-report-") as directory:
|
|
report = Path(directory) / "report.txt"
|
|
return _run_designer(config, ["/ConfigurationRepositoryReport", str(report), "-NBegin", "-1", "-ReportFormat", "txt"], timeout_seconds)
|
|
with tempfile.TemporaryDirectory(prefix="onec-repository-objects-") as directory:
|
|
objects_path = Path(directory) / "objects.txt"
|
|
objects_path.write_text("\n".join(objects or []) + "\n", encoding="utf-8")
|
|
if action == "lock":
|
|
operation = ["/ConfigurationRepositoryLock", "-Objects", str(objects_path)]
|
|
elif action == "unlock":
|
|
operation = ["/ConfigurationRepositoryUnlock", "-Objects", str(objects_path)]
|
|
elif action == "commit":
|
|
operation = ["/ConfigurationRepositoryCommit", "-Objects", str(objects_path), "-Comment", comment]
|
|
if keep_locked:
|
|
operation.append("-KeepLocked")
|
|
else:
|
|
return {"status": "runner_error", "message": f"Unsupported repository action: {action}"}
|
|
return _run_designer(config, operation, timeout_seconds)
|
|
|
|
|
|
_CHILD_MARKERS = re.compile(
|
|
r"\.(?:Реквизит|Attribute|ТабличнаяЧасть|TabularSection|Измерение|Dimension|Ресурс|Resource)\.",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def development_object(ref: str) -> str:
|
|
value = ref.strip().strip(".")
|
|
match = _CHILD_MARKERS.search(value)
|
|
if match:
|
|
return value[: match.start()]
|
|
for marker in (".МодульОбъекта", ".ObjectModule", ".МодульМенеджера", ".ManagerModule"):
|
|
if value.casefold().endswith(marker.casefold()):
|
|
return value[: -len(marker)]
|
|
return value
|
|
|
|
|
|
def _requested_objects(payload: dict[str, Any]) -> tuple[list[str] | None, dict[str, Any] | None]:
|
|
raw = payload.get("objects")
|
|
if raw is None:
|
|
raw = [payload.get("object") or payload.get("ref") or payload.get("path")]
|
|
if not isinstance(raw, list) or not raw:
|
|
return None, {"status": "invalid_argument", "argument": "objects", "message": "Pass object/ref/path or a non-empty objects array."}
|
|
values: list[str] = []
|
|
for index, item in enumerate(raw):
|
|
if not isinstance(item, str) or not item.strip():
|
|
return None, {"status": "invalid_argument", "argument": f"objects[{index}]", "message": "Repository object must be a non-empty public 1C reference."}
|
|
resolved = development_object(item)
|
|
if resolved not in values:
|
|
values.append(resolved)
|
|
return values, None
|
|
|
|
|
|
def lock_plan(payload: dict[str, Any]) -> dict[str, Any]:
|
|
objects, error = _requested_objects(payload)
|
|
if error:
|
|
return {"schema": "onec_repository_lock_plan.v1", "method": METHOD_LOCK_PLAN, **error}
|
|
operation = str(payload.get("operation") or "modify").strip().casefold()
|
|
warnings: list[dict[str, str]] = []
|
|
if operation in {"add", "delete", "rename"}:
|
|
warnings.append({"code": "parent_scope_requires_confirmation", "message": "Structural operations can require the parent/root and referenced objects; confirm the complete set before lock/apply."})
|
|
result = {
|
|
"schema": "onec_repository_lock_plan.v1",
|
|
"method": METHOD_LOCK_PLAN,
|
|
"status": "ready" if not warnings else "needs_confirmation",
|
|
"scope_status": "resolved",
|
|
"operation": operation,
|
|
"requested_objects": [str(x) for x in (payload.get("objects") or [payload.get("object") or payload.get("ref") or payload.get("path")])],
|
|
"lock_objects": objects,
|
|
"warnings": warnings,
|
|
"native_lock_state": "unknown",
|
|
"native_lock_verification": "not_available_via_infobase_sql" if not external_1c_enabled() else "not_checked",
|
|
"capture_required": None,
|
|
"sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [],
|
|
}
|
|
base_id = str(payload.get("base_id") or "").strip()
|
|
if base_id:
|
|
layer_id = development_layer_id(payload)
|
|
result["layer_id"] = layer_id
|
|
config, config_error = repository_config(base_id, layer_id)
|
|
if config_error:
|
|
result["repository_problem"] = config_error
|
|
elif config.get("mode") == "unknown" or config.get("connection_state") == "unknown":
|
|
result["workflow"] = "blocked"
|
|
result["status"] = "blocked_repository_connection_unknown"
|
|
result["next_method"] = None
|
|
elif config.get("mode") == "none" or config.get("connection_state") == "not_configured":
|
|
result["workflow"] = "not_required"
|
|
result["next_method"] = None
|
|
elif manual_confirmation_required(config):
|
|
result["workflow"] = "manual"
|
|
result["next_method"] = METHOD_LOCK_REQUEST if not external_1c_enabled() else METHOD_CONFIRM
|
|
result["user_action"] = {
|
|
"action": "confirm_lock_in_configurator",
|
|
"base_id": base_id,
|
|
"objects": objects,
|
|
"message": "SQL информационной базы не показывает нативное состояние захвата. Убедитесь в Конфигураторе, что перечисленные объекты уже захвачены указанным пользователем хранилища, и явно подтвердите тот же список через repository.lock.confirm.",
|
|
}
|
|
else:
|
|
result["workflow"] = "automatic"
|
|
result["next_method"] = METHOD_LOCK
|
|
return result
|
|
|
|
|
|
def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
|
base_id = str(payload.get("base_id") or "").strip()
|
|
layer_id = development_layer_id(payload)
|
|
config, error = repository_config(base_id, layer_id)
|
|
if error:
|
|
return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, **error}
|
|
plan = lock_plan(payload)
|
|
if plan.get("workflow") == "not_required":
|
|
return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, "layer_id": layer_id, "status": "not_required", "plan": plan}
|
|
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
|
|
plan["status"] = "ready"
|
|
if plan.get("status") != "ready":
|
|
return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, "status": "blocked", "plan": plan}
|
|
configured_repository_user = str(config.get("repository_user") or "").strip()
|
|
requested_repository_user = str(payload.get("confirmed_repository_user") or payload.get("repository_user") or "").strip()
|
|
if requested_repository_user and configured_repository_user and requested_repository_user.casefold() != configured_repository_user.casefold():
|
|
return {
|
|
"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST,
|
|
"base_id": base_id, "layer_id": layer_id, "status": "blocked",
|
|
"error": "repository_user_mismatch", "expected_repository_user": configured_repository_user,
|
|
}
|
|
repository_user = requested_repository_user or configured_repository_user
|
|
request_id = "rreq-" + uuid.uuid4().hex
|
|
with _state_transaction() as state:
|
|
state.setdefault("requests", {})[request_id] = {
|
|
"base_id": base_id,
|
|
"layer": layer_id,
|
|
"layer_id": layer_id,
|
|
"backend": config.get("backend"),
|
|
"operation": plan.get("operation"),
|
|
"objects": plan["lock_objects"],
|
|
"created_at": time.time(),
|
|
"status": "pending_user_lock",
|
|
"execution": "manual",
|
|
"repository_user": repository_user,
|
|
"sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [],
|
|
}
|
|
_audit(state, "lock_request_created", request_id=request_id, base_id=base_id, objects=plan["lock_objects"])
|
|
return {
|
|
"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST,
|
|
"base_id": base_id, "layer_id": layer_id, "status": "pending_user_lock", "request_id": request_id,
|
|
"objects": plan["lock_objects"], "automatically_locked": False,
|
|
"sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [],
|
|
"native_lock_state": "unknown",
|
|
"native_lock_verification": "not_available_via_infobase_sql",
|
|
"capture_required": None,
|
|
"repository_user": repository_user or None,
|
|
"repository_user_source": "layer_connection_setting" if configured_repository_user and not requested_repository_user else ("request" if requested_repository_user else "not_configured"),
|
|
"user_action": "Убедитесь в Конфигураторе, что перечисленные объекты захвачены вами под настроенным пользователем хранилища, и явно подтвердите захват через repository.lock.confirm.",
|
|
"next_method": METHOD_CONFIRM,
|
|
"next_call": manual_confirmation_next_call(base_id, request_id),
|
|
}
|
|
|
|
|
|
def lock_request_status(payload: dict[str, Any]) -> dict[str, Any]:
|
|
request_id = str(payload.get("request_id") or "").strip()
|
|
request = (_read_state().get("requests") or {}).get(request_id)
|
|
if not isinstance(request, dict):
|
|
return {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": "not_found", "request_id": request_id}
|
|
result = {
|
|
"schema": "onec_repository_lock_request_status.v1",
|
|
"method": METHOD_LOCK_REQUEST_STATUS,
|
|
"status": request.get("status"),
|
|
"request_id": request_id,
|
|
# Surface the manual-confirmation scope at top level. Requiring
|
|
# callers to inspect an opaque persisted request made a pending lock
|
|
# look context-free and encouraged unsafe confirmation guesses.
|
|
"base_id": request.get("base_id"),
|
|
"layer_id": request.get("layer_id") or request.get("layer"),
|
|
"objects": list(request.get("objects") or []),
|
|
"repository_user": request.get("repository_user") or None,
|
|
"native_lock_state": "unknown",
|
|
"request": request,
|
|
}
|
|
if request.get("status") == "pending_user_lock":
|
|
result["next_method"] = METHOD_CONFIRM
|
|
result["next_call"] = manual_confirmation_next_call(str(request.get("base_id") or ""), request_id)
|
|
return result
|
|
|
|
|
|
def cancel_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
|
request_id = str(payload.get("request_id") or "").strip()
|
|
if payload.get("confirm_cancel") is not True:
|
|
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "confirmation_required", "request_id": request_id}
|
|
with _state_transaction() as state:
|
|
request = (state.get("requests") or {}).get(request_id)
|
|
if not isinstance(request, dict):
|
|
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "not_found", "request_id": request_id}
|
|
if request.get("status") != "pending_user_lock":
|
|
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "blocked", "error": "lock_request_not_pending", "request_id": request_id}
|
|
request["status"] = "cancelled"
|
|
request["cancelled_at"] = time.time()
|
|
_audit(state, "lock_request_cancelled", request_id=request_id, base_id=request.get("base_id"), objects=request.get("objects"))
|
|
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "cancelled", "request_id": request_id}
|
|
|
|
|
|
def _state_path() -> Path:
|
|
"""Legacy JSON path used only for one-time migration to local SQLite."""
|
|
|
|
return Path(os.environ.get("ONEC_REPOSITORY_STATE_FILE") or "/data/onec-repository-locks.json")
|
|
|
|
|
|
def _state_db_path() -> Path:
|
|
"""Adapter-local state database; never points at a configured 1C database."""
|
|
|
|
configured = os.environ.get("ONEC_ADAPTER_STATE_DB") or os.environ.get("ONEC_ADAPTER_CACHE_DB")
|
|
if configured:
|
|
return Path(configured)
|
|
legacy_override = os.environ.get("ONEC_REPOSITORY_STATE_FILE")
|
|
if legacy_override:
|
|
return Path(legacy_override).with_suffix(".sqlite")
|
|
return Path("/data/adapter-cache.sqlite")
|
|
|
|
|
|
def _empty_state() -> dict[str, Any]:
|
|
return {"sessions": {}, "requests": {}, "audit": []}
|
|
|
|
|
|
def _read_legacy_state() -> dict[str, Any]:
|
|
try:
|
|
value = json.loads(_state_path().read_text(encoding="utf-8-sig"))
|
|
return value if isinstance(value, dict) else _empty_state()
|
|
except (OSError, json.JSONDecodeError):
|
|
return _empty_state()
|
|
|
|
|
|
def _state_connection() -> sqlite3.Connection:
|
|
path = _state_db_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(path, timeout=30)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA synchronous=NORMAL")
|
|
conn.execute("PRAGMA busy_timeout=30000")
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS adapter_state_meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT,
|
|
updated_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS repository_lock_requests (
|
|
request_id TEXT PRIMARY KEY,
|
|
base_id TEXT NOT NULL,
|
|
layer_id TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
payload_json TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS repository_lock_sessions (
|
|
lock_session_id TEXT PRIMARY KEY,
|
|
base_id TEXT NOT NULL,
|
|
layer_id TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
payload_json TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS repository_lock_events (
|
|
event_id TEXT PRIMARY KEY,
|
|
event TEXT NOT NULL,
|
|
occurred_at REAL NOT NULL,
|
|
base_id TEXT,
|
|
request_id TEXT,
|
|
lock_session_id TEXT,
|
|
details_json TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_repository_lock_requests_base_status "
|
|
"ON repository_lock_requests(base_id, status, created_at)"
|
|
)
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_repository_lock_sessions_base_status "
|
|
"ON repository_lock_sessions(base_id, status, created_at)"
|
|
)
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_repository_lock_events_base_time "
|
|
"ON repository_lock_events(base_id, occurred_at)"
|
|
)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO adapter_state_meta(key, value, updated_at)
|
|
VALUES('adapter_state_schema_version', ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET
|
|
value=CASE
|
|
WHEN CAST(adapter_state_meta.value AS INTEGER) < CAST(excluded.value AS INTEGER)
|
|
THEN excluded.value
|
|
ELSE adapter_state_meta.value
|
|
END,
|
|
updated_at=excluded.updated_at
|
|
""",
|
|
(str(REPOSITORY_STATE_SCHEMA_VERSION), time.time()),
|
|
)
|
|
migration = conn.execute(
|
|
"SELECT value FROM adapter_state_meta WHERE key='legacy_repository_state_imported'"
|
|
).fetchone()
|
|
if not migration:
|
|
legacy = _read_legacy_state()
|
|
_sync_state_to_connection(conn, legacy)
|
|
conn.execute(
|
|
"INSERT INTO adapter_state_meta(key, value, updated_at) VALUES(?, ?, ?)",
|
|
("legacy_repository_state_imported", "1", time.time()),
|
|
)
|
|
conn.commit()
|
|
return conn
|
|
|
|
|
|
def _event_id(row: dict[str, Any], index: int) -> str:
|
|
explicit = str(row.get("event_id") or "").strip()
|
|
if explicit:
|
|
return explicit
|
|
source = json.dumps(
|
|
{
|
|
"index": index,
|
|
"event": row.get("event"),
|
|
"time": row.get("time"),
|
|
"request_id": row.get("request_id"),
|
|
"lock_session_id": row.get("lock_session_id"),
|
|
"base_id": row.get("base_id"),
|
|
},
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
return "legacy-" + hashlib.sha1(source.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _load_state_from_connection(conn: sqlite3.Connection) -> dict[str, Any]:
|
|
state = _empty_state()
|
|
for row in conn.execute("SELECT request_id, payload_json FROM repository_lock_requests"):
|
|
try:
|
|
payload = json.loads(row["payload_json"])
|
|
except (TypeError, json.JSONDecodeError):
|
|
continue
|
|
if isinstance(payload, dict):
|
|
state["requests"][str(row["request_id"])] = payload
|
|
for row in conn.execute("SELECT lock_session_id, payload_json FROM repository_lock_sessions"):
|
|
try:
|
|
payload = json.loads(row["payload_json"])
|
|
except (TypeError, json.JSONDecodeError):
|
|
continue
|
|
if isinstance(payload, dict):
|
|
state["sessions"][str(row["lock_session_id"])] = payload
|
|
for row in conn.execute(
|
|
"SELECT event_id, event, occurred_at, details_json FROM repository_lock_events "
|
|
"ORDER BY occurred_at, event_id"
|
|
):
|
|
try:
|
|
payload = json.loads(row["details_json"])
|
|
except (TypeError, json.JSONDecodeError):
|
|
payload = {}
|
|
if not isinstance(payload, dict):
|
|
payload = {}
|
|
payload.setdefault("event_id", str(row["event_id"]))
|
|
payload.setdefault("event", str(row["event"]))
|
|
payload.setdefault("time", float(row["occurred_at"]))
|
|
state["audit"].append(payload)
|
|
return state
|
|
|
|
|
|
def _sync_state_to_connection(conn: sqlite3.Connection, value: dict[str, Any]) -> None:
|
|
now = time.time()
|
|
for request_id, raw in (value.get("requests") or {}).items():
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
request = dict(raw)
|
|
created_at = float(request.get("created_at") or now)
|
|
updated_at = float(
|
|
request.get("cancelled_at")
|
|
or request.get("confirmed_at")
|
|
or request.get("closed_at")
|
|
or request.get("expired_at")
|
|
or created_at
|
|
)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO repository_lock_requests(
|
|
request_id, base_id, layer_id, status, created_at, updated_at, payload_json
|
|
) VALUES(?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(request_id) DO UPDATE SET
|
|
base_id=excluded.base_id,
|
|
layer_id=excluded.layer_id,
|
|
status=excluded.status,
|
|
updated_at=excluded.updated_at,
|
|
payload_json=excluded.payload_json
|
|
""",
|
|
(
|
|
str(request_id),
|
|
str(request.get("base_id") or ""),
|
|
str(request.get("layer_id") or request.get("layer") or "base"),
|
|
str(request.get("status") or "unknown"),
|
|
created_at,
|
|
updated_at,
|
|
json.dumps(request, ensure_ascii=False, separators=(",", ":")),
|
|
),
|
|
)
|
|
for session_id, raw in (value.get("sessions") or {}).items():
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
session = dict(raw)
|
|
created_at = float(session.get("created_at") or now)
|
|
updated_at = float(
|
|
session.get("committed_at")
|
|
or session.get("released_at")
|
|
or session.get("closed_at")
|
|
or session.get("expired_at")
|
|
or created_at
|
|
)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO repository_lock_sessions(
|
|
lock_session_id, base_id, layer_id, status, created_at, updated_at, payload_json
|
|
) VALUES(?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(lock_session_id) DO UPDATE SET
|
|
base_id=excluded.base_id,
|
|
layer_id=excluded.layer_id,
|
|
status=excluded.status,
|
|
updated_at=excluded.updated_at,
|
|
payload_json=excluded.payload_json
|
|
""",
|
|
(
|
|
str(session_id),
|
|
str(session.get("base_id") or ""),
|
|
str(session.get("layer_id") or session.get("layer") or "base"),
|
|
str(session.get("status") or "unknown"),
|
|
created_at,
|
|
updated_at,
|
|
json.dumps(session, ensure_ascii=False, separators=(",", ":")),
|
|
),
|
|
)
|
|
audit = [row for row in (value.get("audit") or []) if isinstance(row, dict)][-5000:]
|
|
for index, raw in enumerate(audit):
|
|
event = dict(raw)
|
|
event_id = _event_id(event, index)
|
|
event["event_id"] = event_id
|
|
occurred_at = float(event.get("time") or now)
|
|
conn.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO repository_lock_events(
|
|
event_id, event, occurred_at, base_id, request_id, lock_session_id, details_json
|
|
) VALUES(?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
event_id,
|
|
str(event.get("event") or "unknown"),
|
|
occurred_at,
|
|
str(event.get("base_id") or "") or None,
|
|
str(event.get("request_id") or "") or None,
|
|
str(event.get("lock_session_id") or "") or None,
|
|
json.dumps(event, ensure_ascii=False, separators=(",", ":")),
|
|
),
|
|
)
|
|
|
|
|
|
def _expire_state(state: dict[str, Any]) -> bool:
|
|
changed = False
|
|
now = time.time()
|
|
request_ttl = max(60, int(os.environ.get("ONEC_REPOSITORY_REQUEST_TTL_SECONDS") or 86400))
|
|
session_ttl = max(60, int(os.environ.get("ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS") or 7200))
|
|
for request in (state.get("requests") or {}).values():
|
|
if isinstance(request, dict) and request.get("status") == "pending_user_lock" and now - float(request.get("created_at") if request.get("created_at") is not None else now) > request_ttl:
|
|
request["status"] = "expired"
|
|
request["expired_at"] = now
|
|
changed = True
|
|
for session in (state.get("sessions") or {}).values():
|
|
if isinstance(session, dict) and session.get("status") == "manual_confirmed" and now - float(session.get("created_at") if session.get("created_at") is not None else now) > session_ttl:
|
|
session["status"] = "expired"
|
|
session["expired_at"] = now
|
|
changed = True
|
|
return changed
|
|
|
|
|
|
def _read_state() -> dict[str, Any]:
|
|
with _STATE_LOCK:
|
|
with _state_connection() as conn:
|
|
state = _load_state_from_connection(conn)
|
|
if _expire_state(state):
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
_sync_state_to_connection(conn, state)
|
|
conn.commit()
|
|
return state
|
|
|
|
|
|
def _write_state(value: dict[str, Any]) -> None:
|
|
with _STATE_LOCK:
|
|
with _state_connection() as conn:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
_sync_state_to_connection(conn, value)
|
|
conn.commit()
|
|
|
|
|
|
@contextmanager
|
|
def _state_transaction() -> Any:
|
|
"""Serialize a repository state mutation across adapter processes."""
|
|
|
|
with _STATE_LOCK:
|
|
conn = _state_connection()
|
|
try:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
state = _load_state_from_connection(conn)
|
|
_expire_state(state)
|
|
yield state
|
|
_sync_state_to_connection(conn, state)
|
|
conn.commit()
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _audit(state: dict[str, Any], event: str, **details: Any) -> None:
|
|
rows = state.setdefault("audit", [])
|
|
rows.append({"event_id": "revt-" + uuid.uuid4().hex, "event": event, "time": time.time(), **details})
|
|
if len(rows) > 5000:
|
|
del rows[:-5000]
|
|
|
|
|
|
def admin_state(base_id: str = "") -> dict[str, Any]:
|
|
state = _read_state()
|
|
requests = [{"request_id": key, **row} for key, row in (state.get("requests") or {}).items() if isinstance(row, dict) and (not base_id or row.get("base_id") == base_id)]
|
|
sessions = [{"lock_session_id": key, **row} for key, row in (state.get("sessions") or {}).items() if isinstance(row, dict) and (not base_id or row.get("base_id") == base_id)]
|
|
audit = [row for row in (state.get("audit") or []) if isinstance(row, dict) and (not base_id or row.get("base_id") == base_id)]
|
|
requests.sort(key=lambda row: float(row.get("created_at") or 0), reverse=True)
|
|
sessions.sort(key=lambda row: float(row.get("created_at") or 0), reverse=True)
|
|
audit.sort(key=lambda row: float(row.get("time") or 0), reverse=True)
|
|
return {"schema": "onec_repository_admin_state.v1", "base_id": base_id or None, "requests": requests, "sessions": sessions, "audit": audit[:200], "counts": {"requests": len(requests), "sessions": len(sessions), "audit": len(audit)}}
|
|
|
|
|
|
def status(payload: dict[str, Any]) -> dict[str, Any]:
|
|
base_id = str(payload.get("base_id") or "").strip()
|
|
layer_id = development_layer_id(payload)
|
|
config, error = repository_config(base_id, layer_id)
|
|
if error:
|
|
return {"schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id, "connected": False, **error}
|
|
result: dict[str, Any] = {
|
|
"schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id, "layer_id": layer_id,
|
|
"status": "configured", "connected": True, "available": None, "repository": _public_config(config),
|
|
"sql_lock_evidence": SQL_LOCK_EVIDENCE,
|
|
}
|
|
if config.get("mode") == "none" or config.get("connection_state") == "not_configured":
|
|
result.update({"status": "repository_not_connected", "connected": False, "available": None})
|
|
return result
|
|
if config.get("mode") == "unknown" or config.get("connection_state") == "unknown":
|
|
result.update({"status": "repository_connection_unknown", "connected": False, "available": None})
|
|
return result
|
|
if manual_confirmation_required(config):
|
|
result.update({"status": "manual_confirmation_required", "connected": False, "available": False if config.get("connection_state") == "unavailable" else None})
|
|
if bool(payload.get("probe")):
|
|
result["probe"] = {"status": "not_applicable", "message": "Manual SQL-only workflow does not establish a native repository connection."}
|
|
return result
|
|
if not bool(payload.get("probe")):
|
|
return result
|
|
if not external_1c_enabled():
|
|
result["status"] = "sql_only"
|
|
result["connected"] = False
|
|
result["available"] = None
|
|
result["probe"] = {"status": "not_supported", "message": "External 1C access is disabled in this SQL-only adapter version."}
|
|
return result
|
|
timeout_seconds = int(payload.get("timeout_seconds") or 60)
|
|
executed = _execute_repository(base_id, config, "report", timeout_seconds)
|
|
result["probe"] = executed
|
|
result["available"] = executed.get("status") == "ok"
|
|
result["status"] = "ready" if result["available"] else "blocked_repository_unavailable"
|
|
return result
|
|
|
|
|
|
def lock(payload: dict[str, Any]) -> dict[str, Any]:
|
|
base_id = str(payload.get("base_id") or "").strip()
|
|
layer_id = development_layer_id(payload)
|
|
config, error = repository_config(base_id, layer_id)
|
|
if error:
|
|
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, **error}
|
|
if manual_confirmation_required(config):
|
|
return {
|
|
"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id,
|
|
"status": "manual_action_required", "plan": lock_plan(payload), "next_method": METHOD_CONFIRM,
|
|
}
|
|
plan = lock_plan(payload)
|
|
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
|
|
plan["status"] = "ready"
|
|
plan["scope_confirmed"] = True
|
|
if plan.get("status") != "ready":
|
|
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "plan": plan}
|
|
if not payload.get("allow_repository_lock") is True:
|
|
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "error": "explicit_repository_lock_required", "plan": plan}
|
|
layer = str(config.get("layer") or "base")
|
|
with _base_lock(base_id, layer):
|
|
executed = _execute_repository(base_id, config, "lock", int(payload.get("timeout_seconds") or 120), objects=plan["lock_objects"])
|
|
if executed.get("status") != "ok":
|
|
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "error": "repository_lock_failed", "plan": plan, "execution": executed}
|
|
session_id = "rlock-" + uuid.uuid4().hex
|
|
with _state_transaction() as state:
|
|
sessions = state.setdefault("sessions", {})
|
|
sessions[session_id] = {"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
|
|
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "acquired", "lock_session_id": session_id, "acquired": plan["lock_objects"], "backend": config.get("backend"), "execution": executed}
|
|
|
|
|
|
def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
|
|
base_id = str(payload.get("base_id") or "").strip()
|
|
layer_id = development_layer_id(payload)
|
|
config, error = repository_config(base_id, layer_id)
|
|
if error:
|
|
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, **error}
|
|
if not manual_confirmation_required(config):
|
|
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "manual_lock_mode_required"}
|
|
if not external_1c_enabled() and not str(payload.get("request_id") or "").strip():
|
|
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_required", "next_method": METHOD_LOCK_REQUEST}
|
|
state = _read_state()
|
|
request_id = str(payload.get("request_id") or "").strip()
|
|
request = (state.get("requests") or {}).get(request_id) if request_id else None
|
|
if request_id and (not isinstance(request, dict) or request.get("base_id") != base_id):
|
|
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_not_found", "request_id": request_id}
|
|
if isinstance(request, dict) and request.get("status") != "pending_user_lock":
|
|
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_not_pending", "request_id": request_id}
|
|
effective_payload = dict(payload)
|
|
if isinstance(request, dict):
|
|
layer_id = str(request.get("layer_id") or request.get("layer") or "base")
|
|
config, error = repository_config(base_id, layer_id)
|
|
if error:
|
|
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, **error}
|
|
if not manual_confirmation_required(config):
|
|
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "manual_lock_mode_required"}
|
|
effective_payload["objects"] = [str(item) for item in request.get("objects") or []]
|
|
effective_payload["layer_id"] = layer_id
|
|
effective_payload["sql_resolution"] = request.get("sql_resolution") if isinstance(request.get("sql_resolution"), list) else []
|
|
if payload.get("user_confirmed_locked") is not True:
|
|
response: dict[str, Any] = {
|
|
"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM,
|
|
"base_id": base_id, "layer_id": layer_id, "status": "confirmation_required",
|
|
"error": "user_confirmed_locked_required",
|
|
"required_fields": ["base_id", "request_id", "user_confirmed_locked"],
|
|
"next_call": manual_confirmation_next_call(base_id, request_id),
|
|
}
|
|
if isinstance(request, dict):
|
|
response.update({
|
|
"request_id": request_id,
|
|
"objects": list(request.get("objects") or []),
|
|
"repository_user": str(request.get("repository_user") or config.get("repository_user") or "") or None,
|
|
"sql_resolution": effective_payload["sql_resolution"],
|
|
})
|
|
return response
|
|
plan = lock_plan(effective_payload)
|
|
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
|
|
plan["status"] = "ready"
|
|
if plan.get("status") != "ready":
|
|
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "plan": plan}
|
|
requested_repository_user = str(payload.get("confirmed_repository_user") or payload.get("repository_user") or "").strip()
|
|
expected_repository_user = str(config.get("repository_user") or "").strip()
|
|
request_repository_user = str(request.get("repository_user") or "").strip() if isinstance(request, dict) else ""
|
|
confirmed_repository_user = requested_repository_user or request_repository_user or expected_repository_user
|
|
if not confirmed_repository_user:
|
|
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "confirmation_required", "error": "repository_user_confirmation_required", "plan": plan}
|
|
if expected_repository_user and confirmed_repository_user.casefold() != expected_repository_user.casefold():
|
|
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "repository_user_mismatch", "expected_repository_user": expected_repository_user}
|
|
session_id = "rlock-" + uuid.uuid4().hex
|
|
with _state_transaction() as current_state:
|
|
current_request = (current_state.get("requests") or {}).get(request_id) if request_id else None
|
|
if request_id and (
|
|
not isinstance(current_request, dict)
|
|
or current_request.get("base_id") != base_id
|
|
or current_request.get("status") != "pending_user_lock"
|
|
):
|
|
return {
|
|
"schema": "onec_repository_manual_lock.v1",
|
|
"method": METHOD_CONFIRM,
|
|
"base_id": base_id,
|
|
"status": "blocked",
|
|
"error": "lock_request_not_pending",
|
|
"request_id": request_id,
|
|
}
|
|
current_state.setdefault("sessions", {})[session_id] = {
|
|
"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"),
|
|
"objects": plan["lock_objects"], "created_at": time.time(), "status": "manual_confirmed",
|
|
"verification": "user_confirmation_only", "automatically_verified": False,
|
|
"repository_user": confirmed_repository_user,
|
|
**({"request_id": request_id} if request_id else {}),
|
|
}
|
|
if isinstance(current_request, dict):
|
|
current_request["status"] = "confirmed_by_user"
|
|
current_request["confirmed_at"] = time.time()
|
|
current_request["lock_session_id"] = session_id
|
|
_audit(current_state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"], repository_user=confirmed_repository_user)
|
|
return {
|
|
"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id,
|
|
"status": "manual_confirmed", "layer_id": layer_id, "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"],
|
|
"capture_state": "manually_confirmed",
|
|
"native_lock_state": "unknown",
|
|
"native_lock_verification": "not_available_via_infobase_sql",
|
|
"automatically_verified": False,
|
|
"repository_user": confirmed_repository_user,
|
|
"write_context": {
|
|
"lock_session_id": session_id,
|
|
"repository_object": plan["lock_objects"][0] if len(plan["lock_objects"]) == 1 else None,
|
|
"layer_id": layer_id,
|
|
"locked_objects": plan["lock_objects"],
|
|
"instruction": "Copy lock_session_id into metadata.write.preflight and code.write. For one object, repository_object is supplied for clients that do not resolve module ownership themselves.",
|
|
},
|
|
"warning": "Адаптер принял явное подтверждение пользователя, но не проверял захват через API хранилища.",
|
|
}
|
|
|
|
|
|
def verify(payload: dict[str, Any]) -> dict[str, Any]:
|
|
session_id = str(payload.get("lock_session_id") or "").strip()
|
|
session = (_read_state().get("sessions") or {}).get(session_id)
|
|
if not isinstance(session, dict):
|
|
return {"schema": "onec_repository_lock_verify.v1", "method": METHOD_VERIFY, "status": "not_found", "lock_session_id": session_id}
|
|
verify_status = "owned_by_adapter" if session.get("status") == "acquired" else ("manual_confirmation_unverified" if session.get("status") == "manual_confirmed" else session.get("status"))
|
|
return {"schema": "onec_repository_lock_verify.v1", "method": METHOD_VERIFY, "status": verify_status, "lock_session_id": session_id, "session": session}
|
|
|
|
|
|
def close_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
|
|
session_id = str(payload.get("lock_session_id") or "").strip()
|
|
if payload.get("user_confirmed_released") is not True:
|
|
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "confirmation_required", "lock_session_id": session_id}
|
|
with _state_transaction() as state:
|
|
session = (state.get("sessions") or {}).get(session_id)
|
|
if not isinstance(session, dict):
|
|
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "not_found", "lock_session_id": session_id}
|
|
if session.get("status") not in {"manual_confirmed", "closed"}:
|
|
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "blocked", "error": "manual_confirmation_not_active", "lock_session_id": session_id}
|
|
already_closed = session.get("status") == "closed"
|
|
session["status"] = "closed"
|
|
closed_at = float(session.get("closed_at") or time.time())
|
|
session["closed_at"] = closed_at
|
|
request_id = str(session.get("request_id") or "").strip()
|
|
request = (state.get("requests") or {}).get(request_id) if request_id else None
|
|
if not isinstance(request, dict):
|
|
request_id = next(
|
|
(
|
|
str(candidate_id)
|
|
for candidate_id, candidate in (state.get("requests") or {}).items()
|
|
if isinstance(candidate, dict) and candidate.get("lock_session_id") == session_id
|
|
),
|
|
"",
|
|
)
|
|
request = (state.get("requests") or {}).get(request_id) if request_id else None
|
|
if isinstance(request, dict):
|
|
request["status"] = "closed"
|
|
request["closed_at"] = closed_at
|
|
if not already_closed:
|
|
_audit(state, "manual_lock_closed", request_id=request_id or None, lock_session_id=session_id, base_id=session.get("base_id"), objects=session.get("objects"))
|
|
return {
|
|
"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "closed",
|
|
"lock_session_id": session_id, "request_id": request_id or None,
|
|
}
|
|
|
|
|
|
def write_gate(payload: dict[str, Any]) -> dict[str, Any]:
|
|
base_id = str(payload.get("base_id") or "").strip()
|
|
layer_id = development_layer_id(payload)
|
|
config, error = repository_config(base_id, layer_id)
|
|
if error and error.get("status") == "not_configured":
|
|
return {"required": False, "allowed": True, "status": "not_configured", "layer_id": layer_id}
|
|
if error:
|
|
return {"required": True, "allowed": False, "status": "blocked_repository_configuration", "layer_id": layer_id, "problem": error}
|
|
if config.get("mode") == "none" or config.get("connection_state") == "not_configured":
|
|
return {"required": False, "allowed": True, "status": "repository_not_connected", "layer_id": layer_id}
|
|
if config.get("mode") == "unknown" or config.get("connection_state") == "unknown":
|
|
return {"required": True, "allowed": False, "status": "blocked_repository_connection_unknown", "layer_id": layer_id}
|
|
session_id = str(payload.get("lock_session_id") or "").strip()
|
|
if not session_id:
|
|
return {
|
|
"required": True, "allowed": False, "status": "needs_repository_lock",
|
|
"backend": config.get("backend"), "layer_id": layer_id, "next_method": METHOD_LOCK_PLAN,
|
|
}
|
|
session = (_read_state().get("sessions") or {}).get(session_id)
|
|
if not isinstance(session, dict):
|
|
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_session_not_found", "layer_id": layer_id, "lock_session_id": session_id}
|
|
if session.get("base_id") != base_id:
|
|
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_session_base_mismatch", "layer_id": layer_id, "lock_session_id": session_id, "session_base_id": session.get("base_id")}
|
|
session_layer_id = str(session.get("layer_id") or session.get("layer") or "base")
|
|
if session_layer_id != layer_id:
|
|
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_layer_mismatch", "layer_id": layer_id, "session_layer_id": session_layer_id, "lock_session_id": session_id}
|
|
if session.get("status") not in {"acquired", "manual_confirmed"}:
|
|
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_session_inactive", "layer_id": layer_id, "lock_session_id": session_id, "session_status": session.get("status")}
|
|
target = payload.get("target") if isinstance(payload.get("target"), dict) else {}
|
|
requested = ""
|
|
for value in (
|
|
payload.get("repository_object"), target.get("repository_object"), target.get("canonical_path"),
|
|
payload.get("canonical_path"), target.get("path"), payload.get("path"), payload.get("ref"), payload.get("object"),
|
|
):
|
|
if isinstance(value, str) and value.strip():
|
|
requested = development_object(value)
|
|
break
|
|
if not requested:
|
|
kind = str(payload.get("object_type") or payload.get("kind") or target.get("object_type") or target.get("kind") or "").strip()
|
|
name = str(payload.get("object_name") or payload.get("name") or target.get("object_name") or target.get("name") or "").strip()
|
|
if kind and name:
|
|
requested = development_object(f"{kind}.{name}")
|
|
if not requested:
|
|
return {
|
|
"required": True, "allowed": False, "status": "blocked_repository_scope_unresolved",
|
|
"lock_session_id": session_id, "message": "Pass repository_object with the public 1C development-object reference for this low-level write route.",
|
|
}
|
|
locked = [str(item) for item in session.get("objects") or []]
|
|
if requested.casefold() not in {item.casefold() for item in locked}:
|
|
return {
|
|
"required": True, "allowed": False, "status": "blocked_repository_scope_mismatch",
|
|
"error": "lock_object_mismatch", "lock_session_id": session_id, "requested_object": requested, "locked_objects": locked,
|
|
}
|
|
return {
|
|
"required": True, "allowed": True, "status": "ready", "backend": config.get("backend"), "layer_id": layer_id,
|
|
"lock_session_id": session_id, "requested_object": requested, "objects": locked,
|
|
"verification": "automatic" if session.get("status") == "acquired" else "user_confirmation_only",
|
|
}
|
|
|
|
|
|
def support_gate(payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Evaluate the independently configured support policy for one exact target.
|
|
|
|
Repository scope may collapse a child to its development owner. Support scope
|
|
intentionally does not: a form or another child can have its own support rule.
|
|
"""
|
|
base_id = str(payload.get("base_id") or "").strip()
|
|
layer_id = development_layer_id(payload)
|
|
base_item, base_error = _base_settings(base_id)
|
|
if base_error:
|
|
if base_error.get("status") == "not_configured":
|
|
return {"required": False, "allowed": True, "status": "support_not_configured_legacy", "layer_id": layer_id, "source": "legacy_configuration"}
|
|
return {"required": True, "allowed": False, "status": "blocked_support_configuration", "layer_id": layer_id, "problem": base_error}
|
|
layers = base_item.get("development_layers") if isinstance(base_item.get("development_layers"), dict) else None
|
|
if layers is None:
|
|
return {"required": False, "allowed": True, "status": "support_not_configured_legacy", "layer_id": layer_id, "source": "legacy_configuration"}
|
|
layer = layers.get(layer_id)
|
|
if not isinstance(layer, dict):
|
|
# See repository_config(): an explicit repository-less base is the
|
|
# disposable-test profile. It applies to newly discovered extensions
|
|
# as well, so a missing per-extension policy cannot turn a permitted
|
|
# test write into a false "unknown support" block.
|
|
repository, repository_error = repository_config(base_id, layer_id)
|
|
if repository_error is None and isinstance(repository, dict) and repository.get("mode") == "none":
|
|
return {
|
|
"required": False, "allowed": True,
|
|
"status": "not_on_support_inherited_no_repository",
|
|
"layer_id": layer_id,
|
|
"inherited_from_layer": repository.get("inherited_from_layer"),
|
|
}
|
|
return {"required": True, "allowed": False, "status": "blocked_support_layer_unknown", "layer_id": layer_id}
|
|
support = layer.get("support")
|
|
if not isinstance(support, dict):
|
|
return {"required": True, "allowed": False, "status": "blocked_support_unknown", "layer_id": layer_id, "source": "development_layer_configuration"}
|
|
mode = str(support.get("mode") or "unknown").strip().casefold()
|
|
if mode not in SUPPORTED_SUPPORT_MODES:
|
|
return {"required": True, "allowed": False, "status": "blocked_support_configuration", "layer_id": layer_id, "problem": {"message": "support.mode must be none, editable, locked, rules, or unknown."}}
|
|
if mode == "none":
|
|
return {"required": False, "allowed": True, "status": "not_on_support", "layer_id": layer_id, "mode": mode}
|
|
if mode == "editable":
|
|
return {"required": True, "allowed": True, "status": "support_editable", "layer_id": layer_id, "mode": mode}
|
|
if mode == "locked":
|
|
return {"required": True, "allowed": False, "status": "blocked_by_support", "layer_id": layer_id, "mode": mode}
|
|
target = payload.get("target") if isinstance(payload.get("target"), dict) else {}
|
|
scope = str(target.get("support_scope") or target.get("canonical_path") or target.get("path") or payload.get("support_scope") or payload.get("canonical_path") or payload.get("path") or payload.get("ref") or payload.get("object") or "").strip()
|
|
if mode == "rules" and scope:
|
|
rules = support.get("rules") if isinstance(support.get("rules"), dict) else {}
|
|
candidates = []
|
|
folded = scope.casefold()
|
|
for rule_scope, rule_value in rules.items():
|
|
key = str(rule_scope).strip()
|
|
if key and (folded == key.casefold() or folded.startswith(key.casefold() + ".")):
|
|
candidates.append((len(key), key, str(rule_value or "unknown").strip().casefold()))
|
|
if candidates:
|
|
_, matched_scope, rule = max(candidates)
|
|
allowed = rule in {"editable", "vendor_editable_support_preserved", "not_supported"}
|
|
return {
|
|
"required": True, "allowed": allowed,
|
|
"status": "support_rule_editable" if allowed else ("blocked_by_support_rule" if rule in {"locked", "vendor_not_editable"} else "blocked_support_unknown"),
|
|
"layer_id": layer_id, "mode": mode, "scope": scope, "matched_scope": matched_scope, "rule": rule,
|
|
}
|
|
return {"required": True, "allowed": False, "status": "blocked_support_unknown", "layer_id": layer_id, "mode": mode, "scope": scope or None}
|
|
|
|
|
|
def commit_plan(payload: dict[str, Any]) -> dict[str, Any]:
|
|
session_id = str(payload.get("lock_session_id") or "").strip()
|
|
session = (_read_state().get("sessions") or {}).get(session_id)
|
|
if not isinstance(session, dict):
|
|
return {"schema": "onec_repository_commit_plan.v1", "method": METHOD_COMMIT_PLAN, "status": "not_found", "lock_session_id": session_id}
|
|
comment = str(payload.get("comment") or "").strip()
|
|
problems: list[dict[str, str]] = []
|
|
if session.get("status") != "acquired":
|
|
problems.append({"code": "lock_session_not_acquired", "message": "Commit requires an active adapter lock session."})
|
|
if not comment:
|
|
problems.append({"code": "commit_comment_required", "message": "A non-empty repository version comment is required."})
|
|
return {
|
|
"schema": "onec_repository_commit_plan.v1", "method": METHOD_COMMIT_PLAN,
|
|
"status": "ready" if not problems else "blocked", "allowed": not problems,
|
|
"lock_session_id": session_id, "base_id": session.get("base_id"),
|
|
"objects": session.get("objects") or [], "comment": comment, "problems": problems,
|
|
}
|
|
|
|
|
|
def commit(payload: dict[str, Any]) -> dict[str, Any]:
|
|
plan = commit_plan(payload)
|
|
session_id = str(payload.get("lock_session_id") or "").strip()
|
|
if not plan.get("allowed"):
|
|
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "lock_session_id": session_id, "plan": plan}
|
|
if payload.get("allow_repository_commit") is not True:
|
|
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "explicit_repository_commit_required", "lock_session_id": session_id, "plan": plan}
|
|
state = _read_state()
|
|
session = (state.get("sessions") or {}).get(session_id)
|
|
base_id = str(session.get("base_id") or "")
|
|
config, error = repository_config(base_id, str(session.get("layer_id") or session.get("layer") or "base"))
|
|
if error:
|
|
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "lock_session_id": session_id, **error}
|
|
with _base_lock(base_id, str(session.get("layer") or "base")):
|
|
executed = _execute_repository(base_id, config, "commit", int(payload.get("timeout_seconds") or 180), objects=[str(item) for item in session.get("objects") or []], comment=str(plan["comment"]), keep_locked=payload.get("keep_locked") is True)
|
|
if executed.get("status") != "ok":
|
|
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "repository_commit_failed", "lock_session_id": session_id, "execution": executed}
|
|
with _state_transaction() as current_state:
|
|
current_session = (current_state.get("sessions") or {}).get(session_id)
|
|
if not isinstance(current_session, dict) or current_session.get("status") != "acquired":
|
|
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "lock_session_not_acquired", "lock_session_id": session_id}
|
|
current_session["status"] = "acquired" if payload.get("keep_locked") is True else "committed"
|
|
current_session["committed_at"] = time.time()
|
|
current_session["commit_comment"] = str(plan["comment"])
|
|
final_status = str(current_session["status"])
|
|
committed_objects = current_session.get("objects")
|
|
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": final_status, "lock_session_id": session_id, "committed": committed_objects, "keep_locked": payload.get("keep_locked") is True, "execution": executed}
|
|
|
|
|
|
def unlock(payload: dict[str, Any]) -> dict[str, Any]:
|
|
session_id = str(payload.get("lock_session_id") or "").strip()
|
|
state = _read_state()
|
|
session = (state.get("sessions") or {}).get(session_id)
|
|
if not isinstance(session, dict):
|
|
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "not_found", "lock_session_id": session_id}
|
|
if session.get("status") != "acquired":
|
|
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": str(session.get("status")), "lock_session_id": session_id}
|
|
if not payload.get("allow_repository_unlock") is True:
|
|
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "explicit_repository_unlock_required", "lock_session_id": session_id}
|
|
base_id = str(session.get("base_id") or "")
|
|
config, error = repository_config(base_id, str(session.get("layer_id") or session.get("layer") or "base"))
|
|
if error:
|
|
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "lock_session_id": session_id, **error}
|
|
with _base_lock(base_id, str(session.get("layer") or "base")):
|
|
executed = _execute_repository(base_id, config, "unlock", int(payload.get("timeout_seconds") or 120), objects=[str(item) for item in session.get("objects") or []])
|
|
if executed.get("status") != "ok":
|
|
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "repository_unlock_failed", "lock_session_id": session_id, "execution": executed}
|
|
with _state_transaction() as current_state:
|
|
current_session = (current_state.get("sessions") or {}).get(session_id)
|
|
if not isinstance(current_session, dict) or current_session.get("status") != "acquired":
|
|
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "lock_session_not_acquired", "lock_session_id": session_id}
|
|
current_session["status"] = "released"
|
|
current_session["released_at"] = time.time()
|
|
released_objects = current_session.get("objects")
|
|
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "released", "lock_session_id": session_id, "released": released_objects, "execution": executed}
|
|
|
|
|
|
def call(method: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
if method == METHOD_STATUS:
|
|
return status(payload)
|
|
if method == METHOD_LOCK_PLAN:
|
|
return lock_plan(payload)
|
|
if method == METHOD_LOCK_REQUEST:
|
|
return create_lock_request(payload)
|
|
if method == METHOD_LOCK_REQUEST_STATUS:
|
|
return lock_request_status(payload)
|
|
if method == METHOD_LOCK_REQUEST_CANCEL:
|
|
return cancel_lock_request(payload)
|
|
if method == METHOD_LOCK:
|
|
return lock(payload)
|
|
if method == METHOD_CONFIRM:
|
|
return confirm_manual_lock(payload)
|
|
if method == METHOD_VERIFY:
|
|
return verify(payload)
|
|
if method == METHOD_CLOSE:
|
|
return close_manual_lock(payload)
|
|
if method == METHOD_UNLOCK:
|
|
return unlock(payload)
|
|
if method == METHOD_COMMIT_PLAN:
|
|
return commit_plan(payload)
|
|
if method == METHOD_COMMIT:
|
|
return commit(payload)
|
|
return {"status": "method_not_found", "method": method}
|