746 lines
42 KiB
Python
746 lines
42 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
METHOD_STATUS = "repository.status"
|
|
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"}
|
|
_BASE_LOCKS: dict[str, threading.Lock] = {}
|
|
_BASE_LOCKS_GUARD = threading.Lock()
|
|
_STATE_LOCK = threading.RLock()
|
|
|
|
|
|
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 repository_config(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
|
|
item = base_item.get("repository") if isinstance(base_item, dict) else None
|
|
if item is None:
|
|
values, repository_error = _load_json_map("ONEC_REPOSITORY_BASES_JSON", "ONEC_REPOSITORY_BASES_JSON_FILE")
|
|
if repository_error and 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["backend"] = str(configured.get("backend") or "direct").strip().casefold()
|
|
configured["layer"] = str(configured.get("layer") or "base").strip().casefold()
|
|
configured["lock_mode"] = str(configured.get("lock_mode") or "automatic").strip().casefold()
|
|
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 _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 {
|
|
"backend": config.get("backend"),
|
|
"layer": config.get("layer"),
|
|
"lock_mode": config.get("lock_mode"),
|
|
"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 _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 _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",
|
|
"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,
|
|
}
|
|
base_id = str(payload.get("base_id") or "").strip()
|
|
if base_id:
|
|
config, config_error = repository_config(base_id)
|
|
if config_error:
|
|
result["repository_problem"] = config_error
|
|
elif config.get("lock_mode") == "manual":
|
|
result["workflow"] = "manual"
|
|
result["next_method"] = METHOD_CONFIRM
|
|
result["user_action"] = {
|
|
"action": "lock_in_configurator",
|
|
"base_id": base_id,
|
|
"objects": objects,
|
|
"message": "Захватите перечисленные объекты в Конфигураторе, затем явно подтвердите тот же список через 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()
|
|
config, error = repository_config(base_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("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}
|
|
request_id = "rreq-" + uuid.uuid4().hex
|
|
with _STATE_LOCK:
|
|
state = _read_state()
|
|
state.setdefault("requests", {})[request_id] = {
|
|
"base_id": base_id,
|
|
"layer": str(config.get("layer") or "base"),
|
|
"backend": config.get("backend"),
|
|
"operation": plan.get("operation"),
|
|
"objects": plan["lock_objects"],
|
|
"created_at": time.time(),
|
|
"status": "pending_user_lock",
|
|
"execution": "manual",
|
|
"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"])
|
|
_write_state(state)
|
|
return {
|
|
"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST,
|
|
"base_id": base_id, "status": "pending_user_lock", "request_id": request_id,
|
|
"objects": plan["lock_objects"], "automatically_locked": False,
|
|
"user_action": "Захватите перечисленные объекты в Конфигураторе и подтвердите заявку через repository.lock.confirm.",
|
|
"next_method": METHOD_CONFIRM,
|
|
}
|
|
|
|
|
|
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}
|
|
return {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": request.get("status"), "request_id": request_id, "request": request}
|
|
|
|
|
|
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_LOCK:
|
|
state = _read_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"))
|
|
_write_state(state)
|
|
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "cancelled", "request_id": request_id}
|
|
|
|
|
|
def _state_path() -> Path:
|
|
return Path(os.environ.get("ONEC_REPOSITORY_STATE_FILE") or "/data/onec-repository-locks.json")
|
|
|
|
|
|
def _read_state() -> dict[str, Any]:
|
|
try:
|
|
value = json.loads(_state_path().read_text(encoding="utf-8-sig"))
|
|
state = value if isinstance(value, dict) else {"sessions": {}, "requests": {}, "audit": []}
|
|
except (OSError, json.JSONDecodeError):
|
|
state = {"sessions": {}, "requests": {}, "audit": []}
|
|
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
|
|
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
|
|
return state
|
|
|
|
|
|
def _write_state(value: dict[str, Any]) -> None:
|
|
path = _state_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_name(path.name + f".{uuid.uuid4().hex}.tmp")
|
|
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
os.replace(temporary, path)
|
|
|
|
|
|
def _audit(state: dict[str, Any], event: str, **details: Any) -> None:
|
|
rows = state.setdefault("audit", [])
|
|
rows.append({"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()
|
|
config, error = repository_config(base_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,
|
|
"status": "configured", "connected": True, "available": None, "repository": _public_config(config),
|
|
}
|
|
if not bool(payload.get("probe")):
|
|
return result
|
|
if not external_1c_enabled():
|
|
result["status"] = "sql_only"
|
|
result["available"] = None
|
|
result["probe"] = {"status": "not_supported", "message": "External 1C access is disabled in this SQL-only adapter version."}
|
|
return result
|
|
if config.get("lock_mode") == "manual":
|
|
result["status"] = "manual_workflow"
|
|
result["available"] = None
|
|
result["probe"] = {"status": "not_applicable", "message": "Manual lock mode does not require Designer or a repository runner. Use repository.lock.plan."}
|
|
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()
|
|
config, error = repository_config(base_id)
|
|
if error:
|
|
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, **error}
|
|
if config.get("lock_mode") == "manual":
|
|
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
|
|
state = _read_state()
|
|
sessions = state.setdefault("sessions", {})
|
|
sessions[session_id] = {"base_id": base_id, "layer": layer, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
|
|
_write_state(state)
|
|
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()
|
|
config, error = repository_config(base_id)
|
|
if error:
|
|
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, **error}
|
|
if config.get("lock_mode") != "manual":
|
|
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):
|
|
effective_payload["objects"] = [str(item) for item in request.get("objects") or []]
|
|
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}
|
|
if payload.get("user_confirmed_locked") is not True:
|
|
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "confirmation_required", "plan": plan}
|
|
session_id = "rlock-" + uuid.uuid4().hex
|
|
state.setdefault("sessions", {})[session_id] = {
|
|
"base_id": base_id, "layer": str(config.get("layer") or "base"), "backend": config.get("backend"),
|
|
"objects": plan["lock_objects"], "created_at": time.time(), "status": "manual_confirmed",
|
|
"verification": "user_confirmation_only", "automatically_verified": False,
|
|
**({"request_id": request_id} if request_id else {}),
|
|
}
|
|
if isinstance(request, dict):
|
|
request["status"] = "confirmed_by_user"
|
|
request["confirmed_at"] = time.time()
|
|
request["lock_session_id"] = session_id
|
|
_audit(state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"])
|
|
_write_state(state)
|
|
return {
|
|
"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id,
|
|
"status": "manual_confirmed", "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"],
|
|
"automatically_verified": False,
|
|
"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_LOCK:
|
|
state = _read_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"))
|
|
_write_state(state)
|
|
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()
|
|
config, error = repository_config(base_id)
|
|
if error and error.get("status") == "not_configured":
|
|
return {"required": False, "allowed": True, "status": "not_configured"}
|
|
if error:
|
|
return {"required": True, "allowed": False, "status": "blocked_repository_configuration", "problem": error}
|
|
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"), "next_method": METHOD_LOCK_PLAN,
|
|
}
|
|
session = (_read_state().get("sessions") or {}).get(session_id)
|
|
if not isinstance(session, dict) or session.get("base_id") != base_id or session.get("status") not in {"acquired", "manual_confirmed"}:
|
|
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "lock_session_id": session_id}
|
|
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",
|
|
"lock_session_id": session_id, "requested_object": requested, "locked_objects": locked,
|
|
}
|
|
return {
|
|
"required": True, "allowed": True, "status": "ready", "backend": config.get("backend"),
|
|
"lock_session_id": session_id, "requested_object": requested, "objects": locked,
|
|
"verification": "automatic" if session.get("status") == "acquired" else "user_confirmation_only",
|
|
}
|
|
|
|
|
|
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)
|
|
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}
|
|
session["status"] = "acquired" if payload.get("keep_locked") is True else "committed"
|
|
session["committed_at"] = time.time()
|
|
session["commit_comment"] = str(plan["comment"])
|
|
_write_state(state)
|
|
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": session["status"], "lock_session_id": session_id, "committed": session.get("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)
|
|
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}
|
|
session["status"] = "released"
|
|
session["released_at"] = time.time()
|
|
_write_state(state)
|
|
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "released", "lock_session_id": session_id, "released": session.get("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}
|