feat(delivery): docker sandbox adapter + owner-only infrastructure (v2 phase A3-1)
The execution boundary for v2. Adds the local docker/podman sandbox adapter and the hardened host subprocess runner every other delivery adapter will use. - adapters/process.py: the one place the platform shells out. argv-only (no shell), start_new_session + killpg on timeout, concurrent stdout/stderr drains with a byte cap, explicit minimal env (never os.environ), and a missing binary reported as spawn_failed rather than an exception. - adapters/docker_sandbox.py: DockerSandboxAdapter over SandboxProviderPort. --user 1000:1000, --cap-drop ALL, no-new-privileges, pids/cpu/memory limits, --read-only with tmpfs scratch, one bind mount (the work tree, never the git dir), labels, no published ports. Path-safe file access; secrets refused in `docker exec -e`. Extras for later phases: start_demo_container (loopback publish only), list_managed_containers, remove_container. - registry: SANDBOX_PROVIDER=docker, cached singleton so the workspace registry survives create_workspace -> start_session. - sandbox_gateway worker: reap_orphans() removes labelled containers no live session points at, audited as sandbox.reap; run_pass() does both passes. - sandbox router: every mutating route now requires the platform owner (PLAN-v2 §7.7); read-only status routes stay open to org members. - sandbox-panel: canManageInfra prop (fails closed) hides the create form, the session panel and the action buttons from non-owners. Tests: 346 API (+37), 67 web (+3); ruff, mypy, tsc and lint clean. Three existing tests now drive the sandbox as the platform owner, since that is who may touch it. Report: docs/plan/reports/agent-3-phase-1.md
This commit is contained in:
@@ -23,10 +23,19 @@ from datetime import timedelta
|
||||
from app.application.container import build_sandbox_service
|
||||
from app.application.ports.realtime import emit_sandbox_reaped
|
||||
from app.core.config import settings
|
||||
from app.domain.audit.models import AuditAction, AuditEvent
|
||||
from app.domain.audit.models import (
|
||||
AuditAction,
|
||||
AuditEvent,
|
||||
ErrorSeverity,
|
||||
ErrorSource,
|
||||
)
|
||||
from app.domain.base import utcnow
|
||||
from app.infrastructure.error_log import record_error
|
||||
from app.infrastructure.repositories.audit import AuditRepository
|
||||
from app.infrastructure.repositories.sandbox import WorkspaceRepository
|
||||
from app.infrastructure.repositories.sandbox import (
|
||||
SandboxSessionRepository,
|
||||
WorkspaceRepository,
|
||||
)
|
||||
from app.workers.base import WorkerOutcome, worker_session
|
||||
|
||||
#: Sessions are considered abandoned after this long with no activity.
|
||||
@@ -38,6 +47,22 @@ class SandboxGatewayWorker:
|
||||
|
||||
name = "sandbox-gateway"
|
||||
|
||||
async def run_pass(
|
||||
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
|
||||
) -> WorkerOutcome:
|
||||
"""One housekeeping pass: idle sessions first, then orphan containers.
|
||||
|
||||
Combined into one pass so a cron entry needs a single invocation and an
|
||||
operator sees one summary line per run.
|
||||
"""
|
||||
outcome = await self.reap(idle_minutes=idle_minutes, limit=limit)
|
||||
orphans = await self.reap_orphans(limit=limit)
|
||||
outcome.processed += orphans.processed
|
||||
outcome.failed += orphans.failed
|
||||
for note in orphans.notes:
|
||||
outcome.note(note)
|
||||
return outcome
|
||||
|
||||
async def reap(
|
||||
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
|
||||
) -> WorkerOutcome:
|
||||
@@ -98,5 +123,86 @@ class SandboxGatewayWorker:
|
||||
outcome.note("egress policy default = deny_all (set per resource limit)")
|
||||
return outcome
|
||||
|
||||
async def reap_orphans(self, *, limit: int = 200) -> WorkerOutcome:
|
||||
"""Remove labelled containers that no live session row points at.
|
||||
|
||||
``reap`` above stops *sessions*; this stops *containers*. The two are
|
||||
not the same: a session row can be stopped (or the database restored
|
||||
from a backup) while the container keeps running, and a container can be
|
||||
started by a process that then died before writing its row. Without this
|
||||
pass those containers hold CPU, memory and disk forever, because nothing
|
||||
in the application knows they exist.
|
||||
|
||||
Only the container runtime is inspected. A provider without
|
||||
``list_managed_containers`` (the mock, the remote adapters) has nothing
|
||||
to reap and is skipped silently — this is not an error condition.
|
||||
"""
|
||||
outcome = WorkerOutcome(name=self.name)
|
||||
|
||||
from app.infrastructure.adapters.registry import get_sandbox_provider
|
||||
|
||||
provider = get_sandbox_provider()
|
||||
lister = getattr(provider, "list_managed_containers", None)
|
||||
if lister is None:
|
||||
outcome.skipped += 1
|
||||
outcome.note("provider does not expose managed containers; nothing to reap")
|
||||
return outcome
|
||||
|
||||
try:
|
||||
containers = await lister()
|
||||
except Exception as exc: # noqa: BLE001 - a CLI outage must not kill the worker
|
||||
await record_error(
|
||||
kind="sandbox_orphan_list_failed",
|
||||
message=str(exc),
|
||||
exc=exc,
|
||||
source=ErrorSource.WORKER,
|
||||
severity=ErrorSeverity.WARNING,
|
||||
metadata={"worker": self.name},
|
||||
)
|
||||
outcome.failed += 1
|
||||
return outcome
|
||||
|
||||
async with worker_session() as session:
|
||||
live = await SandboxSessionRepository(session).list_active_external_ids()
|
||||
audit = AuditRepository(session)
|
||||
|
||||
reaped = 0
|
||||
for container in containers:
|
||||
if reaped >= limit:
|
||||
break
|
||||
if container.container_id in live:
|
||||
continue
|
||||
# A demo/build container is owned by the delivery pipeline, which
|
||||
# tracks it in its own table. Until that table exists it is left
|
||||
# alone rather than guessed at.
|
||||
if container.role and container.role != "workspace":
|
||||
outcome.skipped += 1
|
||||
continue
|
||||
removed = await provider.remove_container(container.container_id)
|
||||
if not removed:
|
||||
outcome.failed += 1
|
||||
continue
|
||||
reaped += 1
|
||||
outcome.processed += 1
|
||||
outcome.note(f"removed orphan container {container.container_id}")
|
||||
await audit.record(
|
||||
event=AuditEvent(
|
||||
action=AuditAction.SANDBOX_REAP,
|
||||
organization_id=container.organization_id or None,
|
||||
target_type="sandbox_container",
|
||||
target_id=container.container_id,
|
||||
metadata_json={
|
||||
"reason": "orphan_container",
|
||||
"project_id": container.project_id,
|
||||
"role": container.role,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
if not reaped:
|
||||
outcome.skipped += 1
|
||||
|
||||
return outcome
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_IDLE_MINUTES", "SandboxGatewayWorker"]
|
||||
|
||||
Reference in New Issue
Block a user