feat(delivery): the Application page + owner consoles (v2 phase A3-6)

The user-visible payoff of the whole delivery workstream.

- (app)/app: four tabs — pipeline (runs + the eight steps + a live console),
  releases (with demo rollback), production (target summary, confirmed deploy,
  history), Git. The header answers "is it live": demo URL, live release,
  health state.
- Live console: batches onto an animation frame, stops following the tail the
  moment the user scrolls away from the bottom, strips ANSI colour codes and
  carriage-return redraws. A console that yanks the view back is worse than none.
- Production panel: the target is shown as the operator recognises their server
  plus has_secret/ثبت شده. No field anywhere could render a credential.
- contracts: the v2 delivery types and seven new WsEventType members.
- /admin/sandbox (owner): per-role container counts, running containers, per-org
  limits, image mapping, stop with the id typed back (audited). A provider without
  the capability reports an empty list rather than an error.
- /admin/domains (owner): every route with its owning app, attach/detach (host
  typed back), idempotent re-sync.
- /projects removed entirely, with a permanent redirect to /app.

Tests: 502 API (+3), 78 web (+11); ruff, mypy, tsc and lint clean.

Report: docs/plan/reports/agent-3-phase-6.md
This commit is contained in:
mjalmoudy94
2026-10-14 02:03:38 +03:30
parent 72408a0a4f
commit 3c8296e354
15 changed files with 2462 additions and 659 deletions
+134 -1
View File
@@ -23,23 +23,33 @@ from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.sandbox_service import SandboxService
from app.core.database import get_session
from app.core.errors import PermissionDeniedError
from app.core.errors import ConflictError, PermissionDeniedError, ValidationError
from app.domain.audit.models import AuditAction
from app.domain.identity.models import User
from app.domain.sandbox.models import NetworkPolicy
from app.infrastructure.adapters.docker_sandbox import LANGUAGE_IMAGES
from app.infrastructure.db.models import SandboxResourceLimitRow
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
get_sandbox_service,
require_platform_owner,
)
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
#: The platform owner's view of every container on the host. Mounted at
#: ``/api/admin/sandbox`` — a *separate* router object, because the tenant router
#: above is org-scoped and this one must never be.
admin_router = APIRouter(prefix="/admin/sandbox", tags=["admin-sandbox"])
def _require_platform_owner(context: OrgContext) -> None:
"""Infrastructure belongs to the platform owner (PLAN-v2 §7.7).
@@ -469,3 +479,126 @@ async def get_preview_url(
organization_id=context.org_id, session_id=session_id, port=port
)
return PreviewUrlOut(port=preview.port, url=preview.url)
# ---------------------------------------------------------------------------
# Platform-owner sandbox console (/admin/sandbox)
# ---------------------------------------------------------------------------
class ContainerRow(BaseModel):
id: str
name: str
status: str
organization_id: str
project_id: str
role: str
class SandboxListOut(BaseModel):
containers: list[ContainerRow]
limits: list[SandboxLimitRow]
images: dict[str, str]
class SandboxLimitRow(BaseModel):
organization_id: str
cpu: float
memory_mb: int
disk_mb: int
timeout_seconds: int
network_policy: str
class StopContainerRequest(BaseModel):
#: The container id, typed back — stopping one is destructive.
confirm: str = Field(min_length=1, max_length=200)
@admin_router.get("/containers", response_model=SandboxListOut)
async def list_containers(
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> SandboxListOut:
"""Every container carrying our labels, plus the per-org limits.
A provider that cannot list containers (the mock, a remote adapter without
the capability) returns an empty list rather than an error: the console is
diagnostic, and a provider outage should not look like "no containers".
"""
from app.infrastructure.adapters.registry import get_sandbox_provider
provider = get_sandbox_provider()
lister = getattr(provider, "list_managed_containers", None)
containers: list[ContainerRow] = []
if lister is not None:
try:
for item in await lister():
containers.append(
ContainerRow(
id=item.container_id,
name=item.name,
status=item.status,
organization_id=item.organization_id,
project_id=item.project_id,
role=item.role,
)
)
except Exception: # noqa: BLE001 - a CLI outage is not a 500
containers = []
limits = (
await session.execute(
select(SandboxResourceLimitRow).where(
SandboxResourceLimitRow.scope == "organization"
)
)
).scalars()
return SandboxListOut(
containers=containers,
limits=[
SandboxLimitRow(
organization_id=row.scope_id or "",
cpu=row.cpu,
memory_mb=row.memory_mb,
disk_mb=row.disk_mb,
timeout_seconds=row.timeout_seconds,
network_policy=str(row.network_policy),
)
for row in limits
if row.scope_id
],
images=dict(LANGUAGE_IMAGES),
)
@admin_router.post("/containers/{container_id}/stop", status_code=status.HTTP_204_NO_CONTENT)
async def stop_container(
container_id: str,
payload: StopContainerRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> None:
"""Stop and remove a container. Owner-only, confirmed and audited."""
if payload.confirm.strip() != container_id.strip():
raise ValidationError("تأییدیه با شناسهٔ کانتینر مطابقت ندارد.")
from app.infrastructure.adapters.registry import get_sandbox_provider
provider = get_sandbox_provider()
remover = getattr(provider, "remove_container", None)
if remover is None:
raise ValidationError("این ارائه‌دهندهٔ سندباکس توقف کانتینر را پشتیبانی نمی‌کند.")
removed = await remover(container_id)
if not removed:
raise ConflictError("توقف کانتینر ناموفق بود.")
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.SANDBOX_DESTROY, actor_user_id=owner.id,
target_type="sandbox_container", target_id=container_id, ip=ip, user_agent=ua,
)
@@ -84,6 +84,7 @@ def build_rpc_app() -> FastAPI:
application.include_router(admin_grants.router, prefix="/api")
application.include_router(delivery.router, prefix="/api")
application.include_router(admin_domains.router, prefix="/api")
application.include_router(sandbox.admin_router, prefix="/api")
application.include_router(realtime_router, prefix="/api")
return application
+62 -5
View File
@@ -488,11 +488,6 @@ async def test_run_process_streams_output_to_the_callback():
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture(autouse=True)
async def _platform_seed(seeded):
return seeded
async def _owner_org(client: AsyncClient) -> tuple[str, dict]:
from app.domain.identity.models import PlatformRole
from tests.conftest import signup
@@ -586,3 +581,65 @@ async def test_the_owner_check_helper_rejects_a_manager():
_require_platform_owner(_Ctx(owner)) # must not raise
_ = (Membership, Role)
# ---------------------------------------------------------------------------
# HTTP surface: the owner's sandbox console
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture(autouse=True)
async def _platform_seed(seeded):
return seeded
async def _owner_org(client: AsyncClient) -> tuple[str, dict]:
from app.domain.identity.models import PlatformRole
from tests.conftest import signup
token = (
await signup(
client,
"sandbox-owner@example.com",
name="مالک سامانه",
platform_role=PlatformRole.OWNER,
)
)["access_token"]
headers = auth_header(token)
org_id = (
await client.post("/api/organizations", json={"name": "سازمان سندباکس"}, headers=headers)
).json()["id"]
return org_id, headers
async def test_a_non_owner_cannot_list_containers(client: AsyncClient):
from tests.conftest import signup
token = (await signup(client, "not-owner-sandbox@example.com"))["access_token"]
response = await client.get("/api/admin/sandbox/containers", headers=auth_header(token))
assert response.status_code == 403, response.text
async def test_the_owner_sees_an_empty_list_with_a_mock_provider(
client: AsyncClient,
):
"""The console must not invent containers it cannot see."""
_org, headers = await _owner_org(client)
response = await client.get("/api/admin/sandbox/containers", headers=headers)
assert response.status_code == 200, response.text
body = response.json()
assert body["containers"] == []
# The image mapping is always available: it is configuration, not state.
assert "python" in body["images"]
async def test_stopping_a_container_requires_the_id_typed_back(client: AsyncClient):
_org, headers = await _owner_org(client)
response = await client.post(
"/api/admin/sandbox/containers/abc123/stop",
json={"confirm": "something-else"},
headers=headers,
)
assert response.status_code == 422, response.text
assert "مطابقت" in response.json()["error"]["message"]