Initial SFERA platform baseline
This commit is contained in:
@@ -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"]
|
||||
Reference in New Issue
Block a user