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
605 lines
20 KiB
Python
605 lines
20 KiB
Python
"""Sandbox Gateway endpoints.
|
|
|
|
Note what is **absent**: there is no route that accepts a shell command and runs
|
|
it on the host. Every execution goes to ``SandboxProviderPort``, is bounded by a
|
|
policy timeout and output cap, and is metered into the usage ledger.
|
|
|
|
**Who may call what (v2).** Containers, ports and command output are
|
|
*infrastructure*, and v2 gives infrastructure to the platform owner alone
|
|
(PLAN-v2 §7.7): a customer only ever gives orders to the AI. So every mutating
|
|
route here — create/start/stop a workspace or session, run a command, read or
|
|
write a file, snapshot, mint a preview URL, set limits — requires the platform
|
|
owner. The read-only status routes (templates, effective limits, workspace list,
|
|
the command log) stay open to org members, because the project page shows them.
|
|
|
|
The org-scoped prefix is kept even for owner-only routes: it is what scopes the
|
|
audit trail and the tenancy check, and it means the platform owner must
|
|
*deliberately* be a member of the org they are operating on.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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 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).
|
|
|
|
Deliberately *not* an org-capability check: an org admin running a command
|
|
in a container is exactly the "users touch infrastructure" behaviour v2
|
|
removes.
|
|
"""
|
|
if not context.user.is_platform_owner:
|
|
raise PermissionDeniedError(
|
|
"دسترسی به زیرساخت فقط برای مالک سامانه امکانپذیر است."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class SandboxTemplateOut(BaseModel):
|
|
id: str
|
|
name: str
|
|
provider: str
|
|
image: str
|
|
description: str
|
|
default_cpu: float
|
|
default_memory_mb: int
|
|
default_disk_mb: int
|
|
|
|
|
|
class WorkspaceOut(BaseModel):
|
|
id: str
|
|
project_id: str
|
|
template_id: str
|
|
provider: str
|
|
status: str
|
|
created_at: datetime
|
|
|
|
|
|
class CreateWorkspaceRequest(BaseModel):
|
|
project_id: str
|
|
template_id: str
|
|
|
|
|
|
class SessionOut(BaseModel):
|
|
id: str
|
|
workspace_id: str
|
|
status: str
|
|
started_at: datetime
|
|
last_active_at: datetime
|
|
|
|
|
|
class StartSessionRequest(BaseModel):
|
|
workspace_id: str
|
|
|
|
|
|
class CommandRunOut(BaseModel):
|
|
id: str
|
|
command: str
|
|
exit_code: int | None
|
|
stdout: str
|
|
stderr: str
|
|
duration_ms: int
|
|
truncated: bool
|
|
created_at: datetime
|
|
|
|
|
|
class RunCommandRequest(BaseModel):
|
|
command: str = Field(min_length=1, max_length=4_000)
|
|
|
|
|
|
class FileReadOut(BaseModel):
|
|
path: str
|
|
content: str
|
|
|
|
|
|
class ReadFileRequest(BaseModel):
|
|
path: str = Field(min_length=1, max_length=1_000)
|
|
|
|
|
|
class WriteFileRequest(BaseModel):
|
|
path: str = Field(min_length=1, max_length=1_000)
|
|
content: str = Field(max_length=1_000_000)
|
|
|
|
|
|
class SnapshotOut(BaseModel):
|
|
id: str
|
|
name: str
|
|
created_at: datetime
|
|
|
|
|
|
class CreateSnapshotRequest(BaseModel):
|
|
name: str = Field(min_length=1, max_length=120)
|
|
|
|
|
|
class PreviewUrlOut(BaseModel):
|
|
port: int
|
|
url: str
|
|
|
|
|
|
class ResourceLimitOut(BaseModel):
|
|
cpu: float
|
|
memory_mb: int
|
|
disk_mb: int
|
|
timeout_seconds: int
|
|
max_output_bytes: int
|
|
network_policy: str
|
|
|
|
|
|
class SetLimitsRequest(BaseModel):
|
|
scope: str = "organization"
|
|
scope_id: str | None = None
|
|
cpu: float = Field(default=1.0, gt=0, le=16)
|
|
memory_mb: int = Field(default=1024, ge=128, le=65_536)
|
|
disk_mb: int = Field(default=2048, ge=256, le=1_048_576)
|
|
timeout_seconds: int = Field(default=120, ge=1, le=900)
|
|
max_output_bytes: int = Field(default=200_000, ge=1024, le=2_000_000)
|
|
network_policy: NetworkPolicy = NetworkPolicy.DENY_ALL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Templates & limits
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/templates", response_model=list[SandboxTemplateOut])
|
|
async def list_templates(
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> list[SandboxTemplateOut]:
|
|
templates = await service.list_templates()
|
|
return [
|
|
SandboxTemplateOut(
|
|
id=t.id, name=t.name, provider=str(t.provider), image=t.image,
|
|
description=t.description, default_cpu=t.default_cpu,
|
|
default_memory_mb=t.default_memory_mb, default_disk_mb=t.default_disk_mb,
|
|
)
|
|
for t in templates
|
|
]
|
|
|
|
|
|
@router.get("/limits", response_model=ResourceLimitOut)
|
|
async def get_limits(
|
|
project_id: str | None = Query(default=None),
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> ResourceLimitOut:
|
|
limit = await service.effective_limits(
|
|
organization_id=context.org_id, project_id=project_id
|
|
)
|
|
return ResourceLimitOut(
|
|
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
|
|
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
|
|
network_policy=str(limit.network_policy),
|
|
)
|
|
|
|
|
|
@router.put("/limits", response_model=ResourceLimitOut)
|
|
async def set_limits(
|
|
payload: SetLimitsRequest,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> ResourceLimitOut:
|
|
_require_platform_owner(context)
|
|
limit = await service.set_limits(
|
|
scope=payload.scope, scope_id=payload.scope_id or context.org_id,
|
|
cpu=payload.cpu, memory_mb=payload.memory_mb, disk_mb=payload.disk_mb,
|
|
timeout_seconds=payload.timeout_seconds,
|
|
max_output_bytes=payload.max_output_bytes,
|
|
network_policy=payload.network_policy,
|
|
)
|
|
return ResourceLimitOut(
|
|
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
|
|
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
|
|
network_policy=str(limit.network_policy),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Workspaces
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post(
|
|
"/workspaces", response_model=WorkspaceOut, status_code=status.HTTP_201_CREATED
|
|
)
|
|
async def create_workspace(
|
|
payload: CreateWorkspaceRequest,
|
|
request: Request,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> WorkspaceOut:
|
|
_require_platform_owner(context)
|
|
|
|
workspace = await service.create_workspace(
|
|
organization_id=context.org_id,
|
|
project_id=payload.project_id,
|
|
template_id=payload.template_id,
|
|
)
|
|
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session, action="sandbox.workspace_create", organization_id=context.org_id,
|
|
actor_user_id=context.user.id, target_type="workspace", target_id=workspace.id,
|
|
metadata={"project_id": workspace.project_id, "provider": str(workspace.provider)},
|
|
ip=ip, user_agent=ua,
|
|
)
|
|
return WorkspaceOut(
|
|
id=workspace.id, project_id=workspace.project_id,
|
|
template_id=workspace.template_id, provider=str(workspace.provider),
|
|
status=str(workspace.status), created_at=workspace.created_at,
|
|
)
|
|
|
|
|
|
@router.get("/workspaces", response_model=list[WorkspaceOut])
|
|
async def list_workspaces(
|
|
project_id: str = Query(min_length=1),
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> list[WorkspaceOut]:
|
|
workspaces = await service.list_workspaces(
|
|
organization_id=context.org_id,
|
|
project_id=project_id,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
return [
|
|
WorkspaceOut(
|
|
id=w.id, project_id=w.project_id, template_id=w.template_id,
|
|
provider=str(w.provider), status=str(w.status), created_at=w.created_at,
|
|
)
|
|
for w in workspaces
|
|
]
|
|
|
|
|
|
@router.delete("/workspaces/{workspace_id}", response_model=WorkspaceOut)
|
|
async def destroy_workspace(
|
|
workspace_id: str,
|
|
request: Request,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> WorkspaceOut:
|
|
_require_platform_owner(context)
|
|
workspace = await service.destroy_workspace(
|
|
organization_id=context.org_id, workspace_id=workspace_id
|
|
)
|
|
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session, action=AuditAction.SANDBOX_DESTROY, organization_id=context.org_id,
|
|
actor_user_id=context.user.id, target_type="workspace",
|
|
target_id=workspace.id, ip=ip, user_agent=ua,
|
|
)
|
|
return WorkspaceOut(
|
|
id=workspace.id, project_id=workspace.project_id,
|
|
template_id=workspace.template_id, provider=str(workspace.provider),
|
|
status=str(workspace.status), created_at=workspace.created_at,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sessions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post(
|
|
"/sessions", response_model=SessionOut, status_code=status.HTTP_201_CREATED
|
|
)
|
|
async def start_session(
|
|
payload: StartSessionRequest,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> SessionOut:
|
|
_require_platform_owner(context)
|
|
sandbox_session = await service.start_session(
|
|
organization_id=context.org_id, workspace_id=payload.workspace_id
|
|
)
|
|
return SessionOut(
|
|
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
|
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
|
last_active_at=sandbox_session.last_active_at,
|
|
)
|
|
|
|
|
|
@router.post("/sessions/{session_id}/stop", response_model=SessionOut)
|
|
async def stop_session(
|
|
session_id: str,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> SessionOut:
|
|
_require_platform_owner(context)
|
|
sandbox_session = await service.stop_session(
|
|
organization_id=context.org_id, session_id=session_id
|
|
)
|
|
return SessionOut(
|
|
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
|
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
|
last_active_at=sandbox_session.last_active_at,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Commands & files
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/sessions/{session_id}/commands", response_model=CommandRunOut)
|
|
async def run_command(
|
|
session_id: str,
|
|
payload: RunCommandRequest,
|
|
request: Request,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> CommandRunOut:
|
|
_require_platform_owner(context)
|
|
|
|
run, _result = await service.run_command(
|
|
organization_id=context.org_id,
|
|
session_id=session_id,
|
|
command=payload.command,
|
|
user_id=context.user.id,
|
|
)
|
|
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session, action=AuditAction.SANDBOX_COMMAND, organization_id=context.org_id,
|
|
actor_user_id=context.user.id, target_type="sandbox_session",
|
|
target_id=session_id,
|
|
# The command itself is recorded, but never a secret from the environment.
|
|
metadata={"exit_code": run.exit_code, "duration_ms": run.duration_ms},
|
|
ip=ip, user_agent=ua,
|
|
)
|
|
return CommandRunOut(
|
|
id=run.id, command=run.command, exit_code=run.exit_code,
|
|
stdout=run.stdout_ref, stderr=run.stderr_ref, duration_ms=run.duration_ms,
|
|
truncated=run.truncated, created_at=run.created_at,
|
|
)
|
|
|
|
|
|
@router.get("/sessions/{session_id}/commands", response_model=list[CommandRunOut])
|
|
async def list_commands(
|
|
session_id: str,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> list[CommandRunOut]:
|
|
await service.get_session_or_404(
|
|
organization_id=context.org_id, session_id=session_id
|
|
)
|
|
runs = await service.list_commands(session_id=session_id)
|
|
return [
|
|
CommandRunOut(
|
|
id=r.id, command=r.command, exit_code=r.exit_code, stdout=r.stdout_ref,
|
|
stderr=r.stderr_ref, duration_ms=r.duration_ms, truncated=r.truncated,
|
|
created_at=r.created_at,
|
|
)
|
|
for r in runs
|
|
]
|
|
|
|
|
|
@router.post("/sessions/{session_id}/files/read", response_model=FileReadOut)
|
|
async def read_file(
|
|
session_id: str,
|
|
payload: ReadFileRequest,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> FileReadOut:
|
|
_require_platform_owner(context)
|
|
content = await service.read_file(
|
|
organization_id=context.org_id, session_id=session_id, path=payload.path
|
|
)
|
|
return FileReadOut(path=payload.path, content=content)
|
|
|
|
|
|
@router.post("/sessions/{session_id}/files/write", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def write_file(
|
|
session_id: str,
|
|
payload: WriteFileRequest,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> None:
|
|
_require_platform_owner(context)
|
|
await service.write_file(
|
|
organization_id=context.org_id, session_id=session_id,
|
|
path=payload.path, content=payload.content,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Snapshots & preview
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post(
|
|
"/sessions/{session_id}/snapshots",
|
|
response_model=SnapshotOut,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_snapshot(
|
|
session_id: str,
|
|
payload: CreateSnapshotRequest,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> SnapshotOut:
|
|
_require_platform_owner(context)
|
|
snapshot = await service.snapshot(
|
|
organization_id=context.org_id, session_id=session_id, name=payload.name
|
|
)
|
|
return SnapshotOut(
|
|
id=snapshot.id, name=snapshot.name, created_at=snapshot.created_at
|
|
)
|
|
|
|
|
|
@router.post("/sessions/{session_id}/preview-url", response_model=PreviewUrlOut)
|
|
async def get_preview_url(
|
|
session_id: str,
|
|
port: int = Query(ge=1, le=65535),
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> PreviewUrlOut:
|
|
_require_platform_owner(context)
|
|
preview = await service.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,
|
|
)
|