Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions src/odoo_forge_server/app.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,46 @@
"""FastAPI application factory for the read-only control-plane edge."""

from pathlib import Path
from typing import Any

from fastapi import FastAPI

from odoo_forge_server.routes.instances import create_instances_router
from odoo_forge.tenancy import ProjectScope
from odoo_forge_server.routes.instances import ManifestLoader, create_instances_router
from odoo_forge_server.runtime import UiRuntime


def create_app(*, reconciler: Any, ui_runtime: UiRuntime | None = None) -> FastAPI:
def create_app(
*,
reconciler: Any,
ui_runtime: UiRuntime | None = None,
manifest_scope: ProjectScope | None = None,
manifest_location: Path | None = None,
manifest_loader: ManifestLoader | None = None,
) -> FastAPI:
"""Create the framework boundary around one request-scoped reconciler."""
app = FastAPI(title="Odoo Forge Control Plane")
app.include_router(create_instances_router(reconciler))
app.include_router(
create_instances_router(
reconciler,
runtime=ui_runtime,
manifest_scope=manifest_scope,
manifest_location=manifest_location,
manifest_loader=manifest_loader,
)
)
if ui_runtime is not None:
from odoo_forge_server.views import create_ui_router

app.include_router(create_ui_router(reconciler, ui_runtime))
app.include_router(
create_ui_router(
reconciler,
ui_runtime,
manifest_scope=manifest_scope,
manifest_location=manifest_location,
manifest_loader=manifest_loader,
)
)
return app


Expand Down
13 changes: 12 additions & 1 deletion src/odoo_forge_server/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
ResolvedProviderAdapter,
)
from odoo_forge.resource_lifecycle.service import LifecycleService
from odoo_forge.tenancy import ProjectScope
from odoo_forge_docker.provider import DockerBackendProvider
from odoo_forge_instances_postgres.adapter import (
Connection,
Expand All @@ -42,6 +43,7 @@
)
from odoo_forge_postgres_docker.provider import DockerPostgresqlDatabaseProvider
from odoo_forge_server.app import UiRuntime, create_app
from odoo_forge_server.routes.instances import ManifestLoader

BackendAdapter = Any

Expand Down Expand Up @@ -100,6 +102,9 @@ def create_production_app(
backend_adapters: Mapping[str, BackendAdapter] | None = None,
acquire_connection: Callable[[], AbstractContextManager[Connection]] | None = None,
ui_runtime: UiRuntime | None = None,
manifest_scope: ProjectScope | None = None,
manifest_location: Path | None = None,
manifest_loader: ManifestLoader | None = None,
custody_adapter: ResourceCustodyAdapter | None = None,
data_environment_service: DataEnvironmentService | None = None,
lifecycle_service: LifecycleService | None = None,
Expand Down Expand Up @@ -135,7 +140,13 @@ def create_production_app(
registry: InstanceRegistry = PostgresInstanceRegistry(acquirer)
reconciler = Reconciler(registry, backend.status)
authority = _default_resource_authority()
app = create_app(reconciler=reconciler, ui_runtime=ui_runtime)
app = create_app(
reconciler=reconciler,
ui_runtime=ui_runtime,
manifest_scope=manifest_scope,
manifest_location=manifest_location,
manifest_loader=manifest_loader,
)
app.state.registry = registry
app.state.backend_status = backend.status
app.state.reconciler = reconciler
Expand Down
88 changes: 83 additions & 5 deletions src/odoo_forge_server/routes/instances.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
"""Read-only instance reconciliation routes."""

from typing import Any
from collections.abc import Callable
from pathlib import Path
from typing import Any, Literal

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, ConfigDict, ValidationError

from odoo_forge.backend.status import InstanceStatus
from odoo_forge.control_plane.models import (
ReconciliationOutcome,
ReconciliationResult,
)
from odoo_forge.instance_registry import InstanceId, InstancePointer, InstanceRecord
from odoo_forge.manifest.schema import Manifest
from odoo_forge.tenancy import ProjectScope, TenantId
from odoo_forge_server.runtime import UiRuntime, guard_loopback_request

ManifestLoader = Callable[[Path], object]


class ReconciliationRowResponse(BaseModel):
Expand All @@ -31,7 +37,26 @@ class ReconciliationResponse(BaseModel):
detail: str | None = None


class ManifestSummary(BaseModel):
model_config = ConfigDict(extra="forbid")

project_name: str
odoo_version: str
edition: str
layer_names: tuple[str, ...]
backend_bind_host: str | None
backend_http_port: int | None


class ManifestContextResponse(BaseModel):
model_config = ConfigDict(extra="forbid")

status: Literal["valid", "unavailable", "invalid"]
summary: ManifestSummary | None = None


_PREFIX = "/api/v1/tenants/{tenant_id}/projects/{project_id}/instances"
_MANIFEST_PREFIX = "/api/v1/tenants/{tenant_id}/projects/{project_id}/manifest"


def _scope(tenant_id: str, project_id: str) -> ProjectScope:
Expand All @@ -46,7 +71,37 @@ def _response(result: ReconciliationResult, *, single: bool = False) -> Reconcil
return ReconciliationResponse.model_validate(result.model_dump())


def create_instances_router(reconciler: Any) -> APIRouter:
def _manifest_context(location: Path, loader: ManifestLoader) -> ManifestContextResponse:
try:
raw = loader(location)
except Exception:
return ManifestContextResponse(status="unavailable")
try:
manifest = Manifest.model_validate(raw)
except ValidationError:
return ManifestContextResponse(status="invalid")
backend = manifest.backend.odoo if manifest.backend is not None else None
return ManifestContextResponse(
status="valid",
summary=ManifestSummary(
project_name=manifest.name,
odoo_version=manifest.odoo_version,
edition=manifest.edition,
layer_names=tuple(layer.name for layer in manifest.layers),
backend_bind_host=backend.bind_host if backend is not None else None,
backend_http_port=backend.http_port if backend is not None else None,
),
)


def create_instances_router(
reconciler: Any,
*,
runtime: UiRuntime | None = None,
manifest_scope: ProjectScope | None = None,
manifest_location: Path | None = None,
manifest_loader: ManifestLoader | None = None,
) -> APIRouter:
router = APIRouter()

@router.get(_PREFIX, response_model=ReconciliationResponse)
Expand All @@ -60,7 +115,30 @@ def get_instance(tenant_id: str, project_id: str, instance_id: str) -> Reconcili
)
return _response(reconciler.get(pointer), single=True)

if (
runtime is not None
and manifest_scope is not None
and manifest_location is not None
and manifest_loader is not None
):

@router.get(_MANIFEST_PREFIX, response_model=ManifestContextResponse)
def get_manifest_context(
request: Request, tenant_id: str, project_id: str
) -> ManifestContextResponse:
guard_loopback_request(request, runtime)
if _scope(tenant_id, project_id) != manifest_scope:
raise HTTPException(status_code=404, detail="manifest not found")
return _manifest_context(manifest_location, manifest_loader)

return router


__all__ = ["ReconciliationResponse", "ReconciliationRowResponse", "create_instances_router"]
__all__ = [
"ManifestContextResponse",
"ManifestLoader",
"ManifestSummary",
"ReconciliationResponse",
"ReconciliationRowResponse",
"create_instances_router",
]
11 changes: 10 additions & 1 deletion src/odoo_forge_server/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from dataclasses import dataclass
from ipaddress import ip_address

from fastapi import HTTPException, Request


@dataclass(frozen=True)
class UiRuntime:
Expand All @@ -22,4 +24,11 @@ def __post_init__(self) -> None:
raise ValueError("read-only UI requires a loopback bind host")


__all__ = ["UiRuntime"]
def guard_loopback_request(request: Request, runtime: UiRuntime) -> None:
"""Reject requests whose socket origin is not the configured literal bind."""
server = request.scope.get("server")
if not server or server[0] != runtime.bind_host:
raise HTTPException(status_code=403, detail="read-only UI is loopback-only")


__all__ = ["UiRuntime", "guard_loopback_request"]
16 changes: 16 additions & 0 deletions src/odoo_forge_server/templates/instances.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@
{% block title %}Instances — {{ label }}{% endblock %}
{% block content %}<h1>Read-only operations</h1>
<p>Aggregate outcome: <strong>{{ label }}</strong></p>
{% if manifest %}
<section aria-label="Project manifest">
<h2>Project manifest</h2>
<p>Manifest status: <strong>{{ manifest.status }}</strong></p>
{% if manifest.summary %}
<dl>
<dt>Project</dt><dd>{{ manifest.summary.project_name }}</dd>
<dt>Odoo version</dt><dd>{{ manifest.summary.odoo_version }}</dd>
<dt>Edition</dt><dd>{{ manifest.summary.edition }}</dd>
<dt>Layers</dt><dd>{{ manifest.summary.layer_names|join(", ") }}</dd>
<dt>Backend bind host</dt><dd>{{ manifest.summary.backend_bind_host or "not configured" }}</dd>
<dt>Backend HTTP port</dt><dd>{{ manifest.summary.backend_http_port or "not configured" }}</dd>
</dl>
{% endif %}
</section>
{% endif %}
{% if is_persistence_error %}
<p role="alert">The instance registry is unavailable.</p>
{% elif rows %}
Expand Down
41 changes: 28 additions & 13 deletions src/odoo_forge_server/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
from odoo_forge.control_plane.models import ReconciliationOutcome, ReconciliationResult
from odoo_forge.instance_registry import InstanceId, InstancePointer
from odoo_forge.tenancy import ProjectScope, TenantId
from odoo_forge_server.runtime import UiRuntime
from odoo_forge_server.routes.instances import (
ManifestContextResponse,
ManifestLoader,
_manifest_context,
)
from odoo_forge_server.runtime import UiRuntime, guard_loopback_request

_PREFIX = "/ui/tenants/{tenant_id}/projects/{project_id}/instances"
_TEMPLATES = Jinja2Templates(directory=Path(__file__).parent / "templates")
Expand All @@ -32,14 +37,6 @@ def _label(outcome: ReconciliationOutcome) -> str:
return _LABELS[outcome]


def _guard(request: Request, runtime: UiRuntime) -> None:
# A local reverse proxy defeats this socket-origin check; access control must
# then be enforced at the proxy or network boundary.
server = request.scope.get("server")
if not server or server[0] != runtime.bind_host:
raise HTTPException(status_code=403, detail="read-only UI is loopback-only")


def _render(
request: Request, name: str, result: ReconciliationResult, *, status: int = 200, **extra: Any
) -> HTMLResponse:
Expand All @@ -63,24 +60,42 @@ def _render(
)


def create_ui_router(reconciler: Any, runtime: UiRuntime) -> APIRouter:
def create_ui_router(
reconciler: Any,
runtime: UiRuntime,
*,
manifest_scope: ProjectScope | None = None,
manifest_location: Path | None = None,
manifest_loader: ManifestLoader | None = None,
) -> APIRouter:
"""Create loopback-guarded, read-only HTML reconciliation routes."""
router = APIRouter()

@router.get(_PREFIX, response_class=HTMLResponse)
def dashboard(request: Request, tenant_id: str, project_id: str) -> HTMLResponse:
_guard(request, runtime)
result = reconciler.list(_scope(tenant_id, project_id))
guard_loopback_request(request, runtime)
scope = _scope(tenant_id, project_id)
manifest: ManifestContextResponse | None = None
if (
manifest_scope is not None
and manifest_location is not None
and manifest_loader is not None
):
if scope != manifest_scope:
raise HTTPException(status_code=404, detail="manifest not found")
manifest = _manifest_context(manifest_location, manifest_loader)
result = reconciler.list(scope)
return _render(
request,
"instances.html",
result,
status=503 if result.outcome is ReconciliationOutcome.PERSISTENCE_ERROR else 200,
manifest=manifest,
)

@router.get(f"{_PREFIX}/{{instance_id}}", response_class=HTMLResponse)
def detail(request: Request, tenant_id: str, project_id: str, instance_id: str) -> HTMLResponse:
_guard(request, runtime)
guard_loopback_request(request, runtime)
result = reconciler.get(
InstancePointer(
scope=_scope(tenant_id, project_id), instance_id=InstanceId(value=instance_id)
Expand Down
Loading
Loading