Initial SFERA platform baseline

This commit is contained in:
2026-05-16 19:03:49 +03:00
commit 3b845c8fce
282 changed files with 55045 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
[project]
name = "sfera-runtime-adapter"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"pydantic>=2",
"sfera-one-c-normalizer",
"uvicorn>=0.30",
]
[tool.uv]
package = true
@@ -0,0 +1 @@
"""SFERA runtime adapter service."""
@@ -0,0 +1,350 @@
from __future__ import annotations
import os
from enum import Enum
from pathlib import Path
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from one_c_normalizer import NormalizedProject, normalize_one_c_project
class RuntimeMode(str, Enum):
MOCK = "mock"
LOCAL_1C = "local_1c"
REMOTE_WORKER = "remote_worker"
class RuntimeImportRequest(BaseModel):
source_kind: str
path: str | None = None
project_id: str | None = None
credentials_ref: str | None = None
metadata: dict = Field(default_factory=dict)
class RuntimeImportResponse(BaseModel):
status: str
mode: str
platform_found: bool
normalized_project: NormalizedProject | None = None
diagnostics: list[str] = Field(default_factory=list)
dump_plan: list[str] = Field(default_factory=list)
class RuntimePlatformResponse(BaseModel):
mode: RuntimeMode
platform_found: bool
designer_path: str | None = None
discovered_paths: list[str] = Field(default_factory=list)
execution_enabled: bool = False
capabilities: list[str] = Field(default_factory=list)
diagnostics: list[str] = Field(default_factory=list)
app = FastAPI(title="SFERA Runtime Adapter", version="0.1.0")
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "mode": _mode()}
@app.get("/api/health")
async def api_health() -> dict[str, str]:
return {"status": "ok", "mode": _mode()}
@app.get("/runtime/platform")
async def platform() -> RuntimePlatformResponse:
return _platform_status()
@app.get("/runtime/modes")
async def runtime_modes() -> dict:
return {
"active": _mode(),
"modes": [
{
"mode": RuntimeMode.MOCK.value,
"description": "No 1C platform required; returns deterministic NormalizedProject fixtures.",
},
{
"mode": RuntimeMode.LOCAL_1C.value,
"description": "Uses local Designer CLI when execution is explicitly enabled.",
},
{
"mode": RuntimeMode.REMOTE_WORKER.value,
"description": "Queues work for an external Windows/1C worker.",
},
],
}
@app.post("/runtime/import", response_model=RuntimeImportResponse)
async def runtime_import(request: RuntimeImportRequest) -> RuntimeImportResponse:
mode = _mode()
if mode == RuntimeMode.MOCK:
return RuntimeImportResponse(
status="mock_imported",
mode=mode.value,
platform_found=False,
normalized_project=_mock_project(request.project_id),
diagnostics=["1C platform is not required in mock runtime-adapter mode."],
)
if mode == RuntimeMode.REMOTE_WORKER:
return RuntimeImportResponse(
status="queued_for_remote_worker",
mode=mode.value,
platform_found=False,
diagnostics=[f"Remote worker dispatch reserved: {_request_fingerprint(request)}"],
)
if mode != RuntimeMode.LOCAL_1C:
raise HTTPException(status_code=400, detail=f"Unsupported runtime adapter mode: {mode}")
platform_status = _platform_status()
source_kind = request.source_kind.upper()
if source_kind in {"EDT_PROJECT", "XML_DUMP", "FILE_TREE"}:
if not request.path:
raise HTTPException(status_code=400, detail="path is required for local metadata import")
path = Path(request.path)
if not path.exists():
raise HTTPException(status_code=404, detail=f"Path not found: {request.path}")
return RuntimeImportResponse(
status="normalized",
mode=mode.value,
platform_found=platform_status.platform_found,
normalized_project=normalize_one_c_project(path, project_id=request.project_id),
diagnostics=platform_status.diagnostics,
)
if not platform_status.platform_found:
raise HTTPException(status_code=503, detail="1C Designer CLI was not found")
dump_plan = _designer_dump_plan(request)
if not platform_status.execution_enabled:
return RuntimeImportResponse(
status="designer_dump_planned",
mode=mode.value,
platform_found=True,
diagnostics=[
"Designer execution is disabled. Set ONEC_ENABLE_DESIGNER_EXECUTION=true on a trusted worker to run dumps.",
"Credentials must be supplied by a runtime secret or prompt, not stored in project files.",
],
dump_plan=dump_plan,
)
raise HTTPException(status_code=501, detail="Designer execution runner is not implemented yet")
def _mode() -> RuntimeMode:
value = os.environ.get("RUNTIME_ADAPTER_MODE", RuntimeMode.MOCK.value)
try:
return RuntimeMode(value)
except ValueError:
return RuntimeMode.MOCK
def _designer_path() -> str:
configured = os.environ.get("ONEC_DESIGNER_PATH", "")
if configured:
return configured
candidates = _designer_candidates()
return candidates[0] if candidates else ""
def _designer_candidates() -> list[str]:
if os.environ.get("ONEC_DISABLE_AUTO_DISCOVERY", "").casefold() == "true":
return []
roots = [
Path("C:/Program Files/1cv8"),
Path("C:/Program Files (x86)/1cv8"),
Path("/opt/1cv8"),
]
candidates: list[Path] = []
for root in roots:
if not root.exists():
continue
candidates.extend(root.glob("*/bin/1cv8.exe"))
candidates.extend(root.glob("*/bin/1cv8"))
return [str(path) for path in sorted(candidates, reverse=True) if path.exists()]
def _platform_status() -> RuntimePlatformResponse:
mode = _mode()
discovered_paths = _designer_candidates()
designer_path = _designer_path() or None
platform_found = bool(designer_path and Path(designer_path).exists())
execution_enabled = os.environ.get("ONEC_ENABLE_DESIGNER_EXECUTION", "").casefold() == "true"
capabilities = ["mock_normalized_project"]
diagnostics: list[str] = []
if mode == RuntimeMode.LOCAL_1C:
capabilities.extend(["edt_project_normalize", "xml_dump_normalize", "file_tree_normalize"])
if platform_found:
capabilities.extend(["cf_dump_plan", "cfe_dump_plan", "live_infobase_dump_plan"])
else:
diagnostics.append("ONEC_DESIGNER_PATH is empty, does not exist, and auto-discovery found no Designer CLI.")
if mode == RuntimeMode.REMOTE_WORKER:
capabilities.append("remote_worker_queue")
return RuntimePlatformResponse(
mode=mode,
platform_found=platform_found,
designer_path=designer_path,
discovered_paths=discovered_paths,
execution_enabled=execution_enabled,
capabilities=capabilities,
diagnostics=diagnostics,
)
def _designer_dump_plan(request: RuntimeImportRequest) -> list[str]:
source_kind = request.source_kind.upper()
temp_root = request.metadata.get("temp_root", "%TEMP%/sfera-runtime")
dump_root = request.metadata.get("dump_root", f"{temp_root}/{request.project_id or 'project'}")
plan = [
f"designer={_designer_path()}",
f"source={source_kind}",
f"dump_root={dump_root}",
"create temporary file infobase",
]
if source_kind in {"CF_FILE", "CFE_FILE", "ARCHIVE_DUMP"}:
plan.append(f"load file={request.path or '<required at runtime>'}")
if source_kind == "LIVE_INFOBASE":
connection = request.metadata.get("connection_string") or request.metadata.get("infobase_connection") or "<runtime connection string>"
plan.append(f"connect infobase={_redact_connection_string(str(connection))}")
plan.append(f"credentials_ref={request.credentials_ref or request.metadata.get('credentials_ref') or '<runtime secret>'}")
plan.extend(["run DumpConfigToFiles", "normalize dumped XML/BSL", "return NormalizedProject"])
return plan
def _redact_connection_string(value: str) -> str:
sensitive_keys = {"pwd", "password", "пароль"}
chunks: list[str] = []
for raw_part in value.split(";"):
part = raw_part.strip()
if not part:
continue
if "=" not in part:
chunks.append(part)
continue
key, raw_value = part.split("=", 1)
clean_key = key.strip().strip('"').casefold()
if clean_key in sensitive_keys:
chunks.append(f"{key}=<redacted>")
else:
chunks.append(f"{key}={raw_value}")
return ";".join(chunks)
def _request_fingerprint(request: RuntimeImportRequest) -> str:
import hashlib
value = f"{request.project_id}:{request.source_kind}:{request.path}:{request.metadata.get('connection_string', '')}"
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:12]
def _mock_project(project_id: str | None) -> NormalizedProject:
from one_c_normalizer import (
Command,
ConfigurationRoot,
Extension,
Form,
MetadataGroup,
MetadataObject,
ObjectPart,
Rights,
)
return NormalizedProject(
project_id=project_id,
source_path="mock://runtime-adapter",
configuration=ConfigurationRoot(
groups=[
MetadataGroup(
name="Справочники",
object_kinds=["CATALOG"],
objects=[
MetadataObject(
name="Контрагенты",
qualified_name="Справочник.Контрагенты",
object_kind="CATALOG",
attributes=[ObjectPart(name="ИНН", kind="ATTRIBUTE")],
forms=[Form(name="ФормаЭлемента")],
commands=[Command(name="ПроверитьИНН")],
)
],
),
MetadataGroup(
name="Документы",
object_kinds=["DOCUMENT"],
objects=[
MetadataObject(
name="ЗаказПокупателя",
qualified_name="Документ.ЗаказПокупателя",
object_kind="DOCUMENT",
attributes=[ObjectPart(name="Контрагент", kind="ATTRIBUTE")],
tabular_sections=[ObjectPart(name="Товары", kind="TABULAR_SECTION")],
forms=[Form(name="ФормаДокумента")],
commands=[Command(name="Провести")],
rights=[
Rights(
name="Документ.ЗаказПокупателя",
target="Документ.ЗаказПокупателя",
permissions={"read": "true", "write": "true", "post": "true"},
)
],
)
],
),
MetadataGroup(
name="Регистры",
object_kinds=["REGISTER"],
objects=[
MetadataObject(
name="ОстаткиТоваров",
qualified_name="РегистрНакопления.ОстаткиТоваров",
object_kind="REGISTER",
attributes=[
ObjectPart(name="Номенклатура", kind="ATTRIBUTE", attributes={"attribute_role": "DIMENSION"}),
ObjectPart(name="Количество", kind="ATTRIBUTE", attributes={"attribute_role": "RESOURCE"}),
],
)
],
),
MetadataGroup(
name="Подсистемы",
object_kinds=["SUBSYSTEM"],
objects=[
MetadataObject(
name="Продажи",
qualified_name="Подсистема.Продажи",
object_kind="SUBSYSTEM",
)
],
),
MetadataGroup(
name="HTTP-сервисы",
object_kinds=["HTTP_SERVICE"],
objects=[
MetadataObject(
name="ПубличныйAPI",
qualified_name="HTTPСервис.ПубличныйAPI",
object_kind="HTTP_SERVICE",
)
],
),
MetadataGroup(
name="XDTO",
object_kinds=["XDTO_PACKAGE"],
objects=[
MetadataObject(
name="ИнтеграцияCRM",
qualified_name="XDTO.ИнтеграцияCRM",
object_kind="XDTO_PACKAGE",
)
],
),
],
extensions=[Extension(name="CRM", version="mock")],
),
)
@@ -0,0 +1,113 @@
from fastapi.testclient import TestClient
from runtime_adapter.main import app
def test_runtime_adapter_mock_import_returns_normalized_project(monkeypatch):
monkeypatch.setenv("RUNTIME_ADAPTER_MODE", "mock")
client = TestClient(app)
response = client.post("/runtime/import", json={"source_kind": "CF_FILE", "project_id": "demo"})
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "mock_imported"
assert payload["mode"] == "mock"
assert payload["normalized_project"]["project_id"] == "demo"
def test_runtime_adapter_local_1c_requires_designer(monkeypatch):
monkeypatch.setenv("RUNTIME_ADAPTER_MODE", "local_1c")
monkeypatch.delenv("ONEC_DESIGNER_PATH", raising=False)
monkeypatch.setenv("ONEC_DISABLE_AUTO_DISCOVERY", "true")
client = TestClient(app)
response = client.post("/runtime/import", json={"source_kind": "CF_FILE", "path": "/tmp/demo.cf"})
assert response.status_code == 503
def test_runtime_adapter_local_1c_normalizes_edt_without_designer_execution(monkeypatch, tmp_path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты" />
</Configuration>
""",
encoding="utf-8",
)
monkeypatch.setenv("RUNTIME_ADAPTER_MODE", "local_1c")
monkeypatch.delenv("ONEC_DESIGNER_PATH", raising=False)
monkeypatch.setenv("ONEC_DISABLE_AUTO_DISCOVERY", "true")
client = TestClient(app)
response = client.post(
"/runtime/import",
json={"source_kind": "EDT_PROJECT", "project_id": "edt", "path": str(tmp_path)},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "normalized"
assert payload["normalized_project"]["configuration"]["groups"][0]["name"] == "Справочники"
def test_runtime_adapter_local_1c_returns_cf_dump_plan_without_execution(monkeypatch, tmp_path):
designer = tmp_path / "1cv8.exe"
designer.write_text("", encoding="utf-8")
cf_file = tmp_path / "demo.cf"
cf_file.write_text("binary-placeholder", encoding="utf-8")
monkeypatch.setenv("RUNTIME_ADAPTER_MODE", "local_1c")
monkeypatch.setenv("ONEC_DESIGNER_PATH", str(designer))
monkeypatch.delenv("ONEC_ENABLE_DESIGNER_EXECUTION", raising=False)
client = TestClient(app)
response = client.post(
"/runtime/import",
json={"source_kind": "CF_FILE", "project_id": "cf", "path": str(cf_file)},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "designer_dump_planned"
assert payload["platform_found"] is True
assert any("DumpConfigToFiles" in item for item in payload["dump_plan"])
def test_runtime_adapter_live_infobase_dump_plan_redacts_password(monkeypatch, tmp_path):
designer = tmp_path / "1cv8.exe"
designer.write_text("", encoding="utf-8")
monkeypatch.setenv("RUNTIME_ADAPTER_MODE", "local_1c")
monkeypatch.setenv("ONEC_DESIGNER_PATH", str(designer))
client = TestClient(app)
response = client.post(
"/runtime/import",
json={
"source_kind": "LIVE_INFOBASE",
"project_id": "live",
"credentials_ref": "runtime://prompt/live",
"metadata": {"connection_string": 'Srvr="192.168.200.95";Ref="upo";Pwd="secret"'},
},
)
assert response.status_code == 200
payload = response.json()
plan = "\n".join(payload["dump_plan"])
assert "secret" not in plan
assert "Pwd=<redacted>" in plan
def test_runtime_platform_reports_capabilities(monkeypatch, tmp_path):
designer = tmp_path / "1cv8.exe"
designer.write_text("", encoding="utf-8")
monkeypatch.setenv("RUNTIME_ADAPTER_MODE", "local_1c")
monkeypatch.setenv("ONEC_DESIGNER_PATH", str(designer))
client = TestClient(app)
response = client.get("/runtime/platform")
assert response.status_code == 200
payload = response.json()
assert payload["platform_found"] is True
assert "cf_dump_plan" in payload["capabilities"]