351 lines
13 KiB
Python
351 lines
13 KiB
Python
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")],
|
|
),
|
|
)
|