Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,722 @@
|
||||
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",
|
||||
}
|
||||
_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,
|
||||
}
|
||||
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") != "manual_confirmed":
|
||||
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "blocked", "error": "manual_confirmation_not_active", "lock_session_id": session_id}
|
||||
session["status"] = "closed"
|
||||
session["closed_at"] = time.time()
|
||||
_audit(state, "manual_lock_closed", 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}
|
||||
|
||||
|
||||
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}
|
||||
Reference in New Issue
Block a user