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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user