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