Initial project import
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -10,6 +12,7 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -43,6 +46,7 @@ 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:
|
||||
@@ -124,7 +128,20 @@ def repository_config(base_id: str, layer_id: str = "base") -> tuple[dict[str, A
|
||||
else:
|
||||
item = None
|
||||
if isinstance(base_item, dict) and isinstance(base_item.get("development_layers"), dict):
|
||||
return None, layer_error
|
||||
# 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")
|
||||
@@ -148,7 +165,11 @@ def repository_config(base_id: str, layer_id: str = "base") -> tuple[dict[str, A
|
||||
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}, 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
|
||||
@@ -207,6 +228,7 @@ def _public_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"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"),
|
||||
@@ -317,6 +339,119 @@ def _run_designer(config: dict[str, Any], operation: list[str], timeout_seconds:
|
||||
}
|
||||
|
||||
|
||||
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],
|
||||
@@ -483,8 +618,7 @@ def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
repository_user = requested_repository_user or configured_repository_user
|
||||
request_id = "rreq-" + uuid.uuid4().hex
|
||||
with _STATE_LOCK:
|
||||
state = _read_state()
|
||||
with _state_transaction() as state:
|
||||
state.setdefault("requests", {})[request_id] = {
|
||||
"base_id": base_id,
|
||||
"layer": layer_id,
|
||||
@@ -499,7 +633,6 @@ def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"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, "layer_id": layer_id, "status": "pending_user_lock", "request_id": request_id,
|
||||
@@ -521,7 +654,21 @@ def lock_request_status(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
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, "request": request}
|
||||
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)
|
||||
@@ -532,8 +679,7 @@ 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()
|
||||
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}
|
||||
@@ -542,20 +688,284 @@ def cancel_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
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:
|
||||
"""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 _read_state() -> dict[str, Any]:
|
||||
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"))
|
||||
state = value if isinstance(value, dict) else {"sessions": {}, "requests": {}, "audit": []}
|
||||
return value if isinstance(value, dict) else _empty_state()
|
||||
except (OSError, json.JSONDecodeError):
|
||||
state = {"sessions": {}, "requests": {}, "audit": []}
|
||||
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))
|
||||
@@ -563,24 +973,57 @@ def _read_state() -> dict[str, Any]:
|
||||
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:
|
||||
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)
|
||||
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": event, "time": time.time(), **details})
|
||||
rows.append({"event_id": "revt-" + uuid.uuid4().hex, "event": event, "time": time.time(), **details})
|
||||
if len(rows) > 5000:
|
||||
del rows[:-5000]
|
||||
|
||||
@@ -659,10 +1102,9 @@ def lock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
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_id, "layer_id": layer_id, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
|
||||
_write_state(state)
|
||||
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}
|
||||
|
||||
|
||||
@@ -724,19 +1166,33 @@ def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
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
|
||||
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(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"], repository_user=confirmed_repository_user)
|
||||
_write_state(state)
|
||||
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"],
|
||||
@@ -769,8 +1225,7 @@ 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()
|
||||
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}
|
||||
@@ -797,7 +1252,6 @@ def close_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
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,
|
||||
@@ -882,6 +1336,18 @@ def support_gate(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
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):
|
||||
@@ -952,11 +1418,16 @@ def commit(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
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}
|
||||
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]:
|
||||
@@ -977,10 +1448,14 @@ def unlock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
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}
|
||||
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]:
|
||||
|
||||
Reference in New Issue
Block a user