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
642 lines
24 KiB
Python
642 lines
24 KiB
Python
"""Local Docker/podman sandbox adapter — the hardened execution boundary (D5).
|
|
|
|
One *workspace* container per application is where the AI employees edit files
|
|
and run commands; a separate *demo* container (created by the delivery service)
|
|
runs the latest green build. Both are created by this adapter.
|
|
|
|
The hardening is not decoration — it is the only thing standing between an LLM
|
|
that writes arbitrary shell and the host:
|
|
|
|
* non-root (``--user 1000:1000``) and ``--cap-drop ALL`` + ``no-new-privileges``
|
|
so a setuid binary cannot be used to escalate;
|
|
* ``--read-only`` root filesystem with two ``tmpfs`` scratch mounts, so a
|
|
compromised process cannot persist anything outside the bind-mounted work dir;
|
|
* ``--pids-limit`` / ``--cpus`` / ``--memory`` so a fork bomb or a runaway build
|
|
cannot take the host down;
|
|
* the **git dir is never mounted** — only the work tree. The platform runs git
|
|
on the host (see ``git_workspace.py``), so repository credentials never enter
|
|
the container;
|
|
* ports are published on ``127.0.0.1`` only, never ``0.0.0.0``.
|
|
|
|
The provider binary is configurable (``SANDBOX_DOCKER_BIN``) and the flags used
|
|
here are the common subset of ``docker`` and ``podman``, so podman works as a
|
|
drop-in.
|
|
|
|
``create_workspace``/``start_session`` are split by the port, but the workspace
|
|
identity is *encoded in the external id* (``ws-<org>-<project>``) so a restart
|
|
can rebuild the host paths without a database round-trip. Only the image is
|
|
kept in a process-local registry; a cold registry falls back to the profile
|
|
default for the language, which is what a fresh process would want anyway.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
from collections.abc import Awaitable, Callable, Sequence
|
|
from dataclasses import dataclass, field
|
|
|
|
from app.application.ports import (
|
|
CommandResult,
|
|
SandboxProviderPort, # noqa: F401 - documented as the implemented protocol
|
|
SessionInfo,
|
|
SnapshotInfo,
|
|
WorkspaceInfo,
|
|
)
|
|
from app.core.config import settings
|
|
from app.core.errors import ValidationError
|
|
from app.domain.platform.rules import assert_not_protected_port
|
|
from app.infrastructure.adapters.process import ProcessResult, run_process
|
|
|
|
logger = logging.getLogger("agy.sandbox.docker")
|
|
|
|
#: Language → base image. The plan fixes these names; the mapping lives here so
|
|
#: the delivery service can ask for an image without knowing the table.
|
|
LANGUAGE_IMAGES: dict[str, str] = {
|
|
"node": "node:20-bookworm",
|
|
"javascript": "node:20-bookworm",
|
|
"typescript": "node:20-bookworm",
|
|
"python": "python:3.13-bookworm",
|
|
"php": "php:8.3-cli",
|
|
"go": "golang:1.23",
|
|
"golang": "golang:1.23",
|
|
"static": "debian:bookworm",
|
|
"html": "debian:bookworm",
|
|
}
|
|
FALLBACK_IMAGE = "debian:bookworm"
|
|
|
|
#: Role → label value. The reaper and the admin console filter on these.
|
|
ROLE_WORKSPACE = "workspace"
|
|
ROLE_DEMO = "demo"
|
|
ROLE_BUILD = "build"
|
|
|
|
LABEL_ORG = "smart-org.org"
|
|
LABEL_PROJECT = "smart-org.project"
|
|
LABEL_ROLE = "smart-org.role"
|
|
|
|
#: In-container mount point of the working tree.
|
|
SANDBOX_WORKSPACE_PATH = "/workspace"
|
|
|
|
_SAFE_ID = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
_SNAPSHOT_NAME = re.compile(r"[^a-z0-9-]+")
|
|
|
|
#: Command used to keep a container alive. ``sleep infinity`` is present in
|
|
#: every image we use (coreutils in debian, busybox in alpine variants).
|
|
_KEEPALIVE = "sleep infinity"
|
|
|
|
|
|
class PathEscapeError(ValidationError):
|
|
"""A requested path resolves outside the sandbox working directory."""
|
|
|
|
|
|
def image_for_language(language: str | None) -> str:
|
|
"""Base image for an app profile language, with a safe fallback."""
|
|
return LANGUAGE_IMAGES.get((language or "").strip().lower(), FALLBACK_IMAGE)
|
|
|
|
|
|
def container_name(role: str, organization_id: str, project_id: str) -> str:
|
|
"""Deterministic container name so a restart can find its own containers."""
|
|
safe = f"smartorg-{role}-{organization_id}-{project_id}".lower()
|
|
return re.sub(r"[^a-z0-9_.-]", "-", safe)[:200]
|
|
|
|
|
|
def _check_id(value: str, label: str) -> str:
|
|
clean = (value or "").strip()
|
|
if not clean or not _SAFE_ID.match(clean):
|
|
raise ValidationError(f"شناسهٔ {label} نامعتبر است.")
|
|
return clean
|
|
|
|
|
|
def safe_join(root: str, path: str) -> str:
|
|
"""Resolve ``path`` under ``root``, refusing any escape.
|
|
|
|
``..`` segments, absolute paths and symlinks that point outside the root are
|
|
all rejected after ``realpath``, so a symlink created inside the sandbox
|
|
cannot be used to read ``/etc/shadow`` on the host.
|
|
"""
|
|
root_real = os.path.realpath(root)
|
|
relative = (path or "").lstrip("/")
|
|
candidate = os.path.realpath(os.path.join(root_real, relative))
|
|
if candidate != root_real and not candidate.startswith(root_real + os.sep):
|
|
raise PathEscapeError("مسیر درخواستی خارج از محیط اجرا است.")
|
|
return candidate
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ManagedContainer:
|
|
"""One container carrying our labels, as reported by the CLI."""
|
|
|
|
container_id: str
|
|
name: str = ""
|
|
organization_id: str = ""
|
|
project_id: str = ""
|
|
role: str = ""
|
|
status: str = ""
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _WorkspaceState:
|
|
organization_id: str
|
|
project_id: str
|
|
host_dir: str
|
|
image: str
|
|
session_ids: list[str] = field(default_factory=list)
|
|
|
|
|
|
class DockerSandboxAdapter:
|
|
"""``SandboxProviderPort`` over the docker/podman CLI.
|
|
|
|
``runner`` is injectable so unit tests assert on the exact argv without a
|
|
container runtime present.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
provider_bin: str | None = None,
|
|
workspaces_root: str | None = None,
|
|
default_cpus: float | None = None,
|
|
default_memory_mb: int | None = None,
|
|
max_output_bytes: int | None = None,
|
|
runner: Callable[..., Awaitable[ProcessResult]] = run_process,
|
|
) -> None:
|
|
self.bin = (provider_bin or settings.sandbox_docker_bin or "docker").strip()
|
|
self.workspaces_root = os.path.abspath(
|
|
workspaces_root or settings.sandbox_workspaces_root
|
|
)
|
|
self.default_cpus = float(
|
|
settings.sandbox_default_cpus if default_cpus is None else default_cpus
|
|
)
|
|
self.default_memory_mb = int(
|
|
settings.sandbox_default_memory_mb if default_memory_mb is None else default_memory_mb
|
|
)
|
|
self.max_output_bytes = int(
|
|
settings.sandbox_max_output_bytes if max_output_bytes is None else max_output_bytes
|
|
)
|
|
self._runner = runner
|
|
self._workspaces: dict[str, _WorkspaceState] = {}
|
|
#: container id → the workspace state it belongs to (filled by start_session).
|
|
self._sessions: dict[str, _WorkspaceState] = {}
|
|
|
|
# -- helpers -------------------------------------------------------------
|
|
|
|
def _workspace_dir(self, organization_id: str, project_id: str) -> str:
|
|
return os.path.join(self.workspaces_root, organization_id, project_id, "work")
|
|
|
|
def _external_id(self, organization_id: str, project_id: str) -> str:
|
|
return f"ws-{organization_id}-{project_id}"
|
|
|
|
@staticmethod
|
|
def _parse_external_id(external_id: str) -> tuple[str, str] | None:
|
|
if not external_id.startswith("ws-"):
|
|
return None
|
|
parts = external_id[3:].split("-", 1)
|
|
if len(parts) != 2:
|
|
return None
|
|
return parts[0], parts[1]
|
|
|
|
def _state_for(self, external_id: str, image: str | None = None) -> _WorkspaceState:
|
|
"""Look up (or rebuild) the workspace state behind an external id."""
|
|
state = self._workspaces.get(external_id)
|
|
if state is not None:
|
|
return state
|
|
parsed = self._parse_external_id(external_id)
|
|
if parsed is None:
|
|
raise ValidationError("شناسهٔ محیط اجرا نامعتبر است.")
|
|
organization_id, project_id = parsed
|
|
state = _WorkspaceState(
|
|
organization_id=organization_id,
|
|
project_id=project_id,
|
|
host_dir=self._workspace_dir(organization_id, project_id),
|
|
image=image or FALLBACK_IMAGE,
|
|
)
|
|
self._workspaces[external_id] = state
|
|
return state
|
|
|
|
def _state_for_session(self, session_external_id: str) -> _WorkspaceState | None:
|
|
return self._sessions.get(session_external_id)
|
|
|
|
async def _docker(
|
|
self,
|
|
args: Sequence[str],
|
|
*,
|
|
timeout_seconds: float = 120.0,
|
|
on_output: Callable[[str], Awaitable[None]] | None = None,
|
|
) -> ProcessResult:
|
|
return await self._runner(
|
|
[self.bin, *args],
|
|
timeout_seconds=timeout_seconds,
|
|
max_output_bytes=self.max_output_bytes,
|
|
on_output=on_output,
|
|
env={},
|
|
)
|
|
|
|
# -- workspaces ----------------------------------------------------------
|
|
|
|
async def create_workspace(
|
|
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
|
) -> WorkspaceInfo:
|
|
org = _check_id(organization_id, "سازمان")
|
|
project = _check_id(project_id, "پروژه")
|
|
|
|
host_dir = self._workspace_dir(org, project)
|
|
# 0700: the workspace holds the tenant's source code. The platform user
|
|
# owns it; nothing else on the host may read it.
|
|
os.makedirs(host_dir, exist_ok=True)
|
|
with contextlib.suppress(OSError):
|
|
os.chmod(self.workspaces_root, 0o700)
|
|
os.chmod(os.path.dirname(host_dir), 0o700)
|
|
os.chmod(host_dir, 0o700)
|
|
|
|
external_id = self._external_id(org, project)
|
|
self._workspaces[external_id] = _WorkspaceState(
|
|
organization_id=org,
|
|
project_id=project,
|
|
host_dir=host_dir,
|
|
image=image or FALLBACK_IMAGE,
|
|
)
|
|
return WorkspaceInfo(
|
|
external_id=external_id,
|
|
status="ready",
|
|
raw={"host_dir": host_dir, "image": image},
|
|
)
|
|
|
|
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
|
|
state = self._state_for(workspace_external_id)
|
|
name = container_name(ROLE_WORKSPACE, state.organization_id, state.project_id)
|
|
|
|
# A restart leaves the old container behind; reuse it rather than
|
|
# stacking duplicates, and start it if it was stopped.
|
|
existing = await self._container_id_by_name(name)
|
|
if existing:
|
|
await self._docker(["start", existing], timeout_seconds=60)
|
|
self._sessions[existing] = state
|
|
state.session_ids.append(existing)
|
|
return SessionInfo(external_id=existing, status="running")
|
|
|
|
result = await self._docker(
|
|
[
|
|
"run",
|
|
"-d",
|
|
"--name",
|
|
name,
|
|
# --- isolation -------------------------------------------------
|
|
"--user",
|
|
"1000:1000",
|
|
"--cap-drop",
|
|
"ALL",
|
|
"--security-opt",
|
|
"no-new-privileges",
|
|
"--pids-limit",
|
|
"512",
|
|
"--cpus",
|
|
str(self.default_cpus),
|
|
"--memory",
|
|
f"{self.default_memory_mb}m",
|
|
# --- immutable root, scratch in tmpfs --------------------------
|
|
"--read-only",
|
|
"--tmpfs",
|
|
"/tmp",
|
|
"--tmpfs",
|
|
"/home/app",
|
|
# --- only the work tree, never the git dir ---------------------
|
|
"-v",
|
|
f"{state.host_dir}:{SANDBOX_WORKSPACE_PATH}",
|
|
"-w",
|
|
SANDBOX_WORKSPACE_PATH,
|
|
"--label",
|
|
f"{LABEL_ORG}={state.organization_id}",
|
|
"--label",
|
|
f"{LABEL_PROJECT}={state.project_id}",
|
|
"--label",
|
|
f"{LABEL_ROLE}={ROLE_WORKSPACE}",
|
|
# Default bridge network: package installs need egress. No
|
|
# published ports — the container is reached with ``exec``.
|
|
state.image,
|
|
"sh",
|
|
"-lc",
|
|
_KEEPALIVE,
|
|
],
|
|
timeout_seconds=180,
|
|
)
|
|
if not result.ok:
|
|
raise ValidationError("راهاندازی محیط اجرا ناموفق بود.")
|
|
|
|
container_id = result.stdout.strip().splitlines()[-1].strip() if result.stdout else ""
|
|
if not container_id:
|
|
raise ValidationError("راهاندازی محیط اجرا ناموفق بود.")
|
|
self._sessions[container_id] = state
|
|
state.session_ids.append(container_id)
|
|
return SessionInfo(
|
|
external_id=container_id,
|
|
status="running",
|
|
raw={"name": name, "image": state.image},
|
|
)
|
|
|
|
async def _container_id_by_name(self, name: str) -> str | None:
|
|
result = await self._docker(
|
|
["ps", "-a", "--filter", f"name=^{name}$", "--format", "{{.ID}}"],
|
|
timeout_seconds=30,
|
|
)
|
|
if not result.ok or not result.stdout.strip():
|
|
return None
|
|
return result.stdout.strip().splitlines()[0].strip()
|
|
|
|
# -- commands ------------------------------------------------------------
|
|
|
|
async def run_command(
|
|
self,
|
|
*,
|
|
session_external_id: str,
|
|
command: str,
|
|
timeout_seconds: int,
|
|
max_output_bytes: int,
|
|
env: dict[str, str] | None = None,
|
|
cwd: str | None = None,
|
|
on_output: Callable[[str], Awaitable[None]] | None = None,
|
|
) -> CommandResult:
|
|
"""``docker exec`` the command inside the container.
|
|
|
|
``cwd``/``on_output`` are adapter-level extras used by the engine; the
|
|
port signature is unchanged.
|
|
|
|
``env`` is passed with ``-e``. Keys that *look* like secrets are refused
|
|
outright: argv is visible in ``ps`` and in the audit log, and the whole
|
|
point of the design is that credentials never travel that way.
|
|
"""
|
|
cleaned = (command or "").strip()
|
|
if not cleaned:
|
|
raise ValidationError("دستور نمیتواند خالی باشد.")
|
|
|
|
env_flags: list[str] = []
|
|
for key, value in (env or {}).items():
|
|
if _looks_like_secret(key):
|
|
raise ValidationError(
|
|
"متغیرهای محیطی حاوی اطلاعات محرمانه مجاز نیستند."
|
|
)
|
|
env_flags.extend(["-e", f"{key}={value}"])
|
|
|
|
workdir = cwd or SANDBOX_WORKSPACE_PATH
|
|
args = [
|
|
"exec",
|
|
"-w",
|
|
workdir,
|
|
*env_flags,
|
|
session_external_id,
|
|
"sh",
|
|
"-lc",
|
|
cleaned,
|
|
]
|
|
result = await self._runner(
|
|
[self.bin, *args],
|
|
timeout_seconds=float(timeout_seconds),
|
|
max_output_bytes=int(max_output_bytes),
|
|
on_output=on_output,
|
|
env={},
|
|
)
|
|
return CommandResult(
|
|
exit_code=result.exit_code,
|
|
stdout=result.stdout,
|
|
stderr=result.stderr,
|
|
duration_ms=result.duration_ms,
|
|
truncated=result.truncated,
|
|
)
|
|
|
|
# -- files ---------------------------------------------------------------
|
|
|
|
def _host_path(self, session_external_id: str, path: str) -> str:
|
|
state = self._state_for_session(session_external_id)
|
|
if state is None:
|
|
raise ValidationError("نشست اجرا یافت نشد.")
|
|
# Container paths under /workspace map 1:1 onto the bind-mounted host
|
|
# dir; anything else is not reachable through this adapter.
|
|
relative = (path or "").lstrip("/")
|
|
if relative.startswith("workspace"):
|
|
relative = relative[len("workspace") :].lstrip("/")
|
|
return safe_join(state.host_dir, relative)
|
|
|
|
async def read_file(self, *, session_external_id: str, path: str) -> str:
|
|
host_path = self._host_path(session_external_id, path)
|
|
if not os.path.isfile(host_path):
|
|
return ""
|
|
with open(host_path, encoding="utf-8", errors="replace") as handle:
|
|
return handle.read()
|
|
|
|
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
|
|
host_path = self._host_path(session_external_id, path)
|
|
os.makedirs(os.path.dirname(host_path) or ".", exist_ok=True)
|
|
with open(host_path, "w", encoding="utf-8") as handle:
|
|
handle.write(content)
|
|
|
|
# -- lifecycle -----------------------------------------------------------
|
|
|
|
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
|
|
"""``docker commit`` the container.
|
|
|
|
Supported, but documented as a heavy operation: it captures the whole
|
|
root filesystem, and the work tree lives on a bind mount that is *not*
|
|
included. Snapshots are therefore useful for the base image state only.
|
|
"""
|
|
state = self._state_for_session(session_external_id)
|
|
slug = _SNAPSHOT_NAME.sub("-", (name or "snapshot").lower()).strip("-") or "snapshot"
|
|
org = state.organization_id if state else "unknown"
|
|
tag = f"smartorg-snapshot-{org}-{slug}"[:120]
|
|
result = await self._docker(
|
|
["commit", session_external_id, tag], timeout_seconds=300
|
|
)
|
|
if not result.ok:
|
|
raise ValidationError("تهیهٔ اسنپشات ناموفق بود.")
|
|
return SnapshotInfo(external_id=tag, name=name)
|
|
|
|
async def pause(self, *, session_external_id: str) -> None:
|
|
await self._docker(["stop", session_external_id], timeout_seconds=60)
|
|
|
|
async def resume(self, *, session_external_id: str) -> SessionInfo:
|
|
result = await self._docker(["start", session_external_id], timeout_seconds=60)
|
|
return SessionInfo(
|
|
external_id=session_external_id,
|
|
status="running" if result.ok else "error",
|
|
)
|
|
|
|
async def destroy(self, *, session_external_id: str) -> None:
|
|
# ``-f`` covers running and stopped; ``-v`` removes anonymous volumes so
|
|
# a destroyed workspace does not leave data behind on the host.
|
|
await self._docker(["rm", "-f", "-v", session_external_id], timeout_seconds=60)
|
|
self._sessions.pop(session_external_id, None)
|
|
|
|
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
|
|
# Loopback only, and never an internal platform port. The public hostname
|
|
# is a domain-layer concern (Phase 4), not the sandbox's.
|
|
assert_not_protected_port(int(port))
|
|
return f"http://127.0.0.1:{int(port)}"
|
|
|
|
# -- demo / build containers (used by the delivery service) --------------
|
|
|
|
async def start_demo_container(
|
|
self,
|
|
*,
|
|
organization_id: str,
|
|
project_id: str,
|
|
image: str,
|
|
host_port: int,
|
|
app_port: int,
|
|
command: str,
|
|
work_dir: str,
|
|
cpus: float | None = None,
|
|
memory_mb: int | None = None,
|
|
name_suffix: str = "",
|
|
) -> str:
|
|
"""Start a demo container and return its id.
|
|
|
|
Blue/green is implemented by the caller: start on a temporary port, wait
|
|
for the healthcheck, switch the route, then stop the previous container.
|
|
Publishing on ``127.0.0.1`` means only Caddy on this host can reach it.
|
|
"""
|
|
org = _check_id(organization_id, "سازمان")
|
|
project = _check_id(project_id, "پروژه")
|
|
assert_not_protected_port(int(host_port))
|
|
name = container_name(ROLE_DEMO, org, project)
|
|
if name_suffix:
|
|
name = f"{name}-{_SNAPSHOT_NAME.sub('-', name_suffix.lower()).strip('-')}"[:200]
|
|
|
|
result = await self._docker(
|
|
[
|
|
"run",
|
|
"-d",
|
|
"--name",
|
|
name,
|
|
"--user",
|
|
"1000:1000",
|
|
"--cap-drop",
|
|
"ALL",
|
|
"--security-opt",
|
|
"no-new-privileges",
|
|
"--pids-limit",
|
|
"512",
|
|
"--cpus",
|
|
str(cpus if cpus is not None else self.default_cpus),
|
|
"--memory",
|
|
f"{int(memory_mb if memory_mb is not None else self.default_memory_mb)}m",
|
|
"--read-only",
|
|
"--tmpfs",
|
|
"/tmp",
|
|
"--tmpfs",
|
|
"/home/app",
|
|
"-v",
|
|
f"{work_dir}:{SANDBOX_WORKSPACE_PATH}",
|
|
"-w",
|
|
SANDBOX_WORKSPACE_PATH,
|
|
# Loopback publish only — never 0.0.0.0.
|
|
"-p",
|
|
f"127.0.0.1:{int(host_port)}:{int(app_port)}",
|
|
"--label",
|
|
f"{LABEL_ORG}={org}",
|
|
"--label",
|
|
f"{LABEL_PROJECT}={project}",
|
|
"--label",
|
|
f"{LABEL_ROLE}={ROLE_DEMO}",
|
|
image,
|
|
"sh",
|
|
"-lc",
|
|
command or _KEEPALIVE,
|
|
],
|
|
timeout_seconds=180,
|
|
)
|
|
if not result.ok or not result.stdout.strip():
|
|
raise ValidationError("راهاندازی نسخهٔ نمایشی ناموفق بود.")
|
|
return result.stdout.strip().splitlines()[-1].strip()
|
|
|
|
# -- maintenance ---------------------------------------------------------
|
|
|
|
async def list_managed_containers(self) -> list[ManagedContainer]:
|
|
"""Every container carrying our labels, running or not.
|
|
|
|
Used by the sandbox-gateway worker to find orphans: a container whose
|
|
session row is gone is still holding CPU and disk.
|
|
"""
|
|
result = await self._docker(
|
|
[
|
|
"ps",
|
|
"-a",
|
|
"--filter",
|
|
f"label={LABEL_ROLE}",
|
|
"--format",
|
|
"{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Labels}}",
|
|
],
|
|
timeout_seconds=30,
|
|
)
|
|
if not result.ok:
|
|
return []
|
|
|
|
containers: list[ManagedContainer] = []
|
|
for line in result.stdout.splitlines():
|
|
parts = line.split("\t")
|
|
if len(parts) < 4:
|
|
continue
|
|
labels = _parse_labels(parts[3])
|
|
containers.append(
|
|
ManagedContainer(
|
|
container_id=parts[0].strip(),
|
|
name=parts[1].strip(),
|
|
status=parts[2].strip(),
|
|
organization_id=labels.get(LABEL_ORG, ""),
|
|
project_id=labels.get(LABEL_PROJECT, ""),
|
|
role=labels.get(LABEL_ROLE, ""),
|
|
)
|
|
)
|
|
return containers
|
|
|
|
async def remove_container(self, container_id: str) -> bool:
|
|
result = await self._docker(["rm", "-f", "-v", container_id], timeout_seconds=60)
|
|
self._sessions.pop(container_id, None)
|
|
return result.ok
|
|
|
|
def host_dir_for(self, organization_id: str, project_id: str) -> str:
|
|
"""Absolute host path of a project's working tree."""
|
|
return self._workspace_dir(
|
|
_check_id(organization_id, "سازمان"), _check_id(project_id, "پروژه")
|
|
)
|
|
|
|
@staticmethod
|
|
def binary_available(binary: str | None = None) -> bool:
|
|
"""True when the container runtime CLI is on PATH."""
|
|
return shutil.which((binary or settings.sandbox_docker_bin or "docker").strip()) is not None
|
|
|
|
|
|
def _parse_labels(raw: str) -> dict[str, str]:
|
|
labels: dict[str, str] = {}
|
|
for item in (raw or "").split(","):
|
|
if "=" not in item:
|
|
continue
|
|
key, _, value = item.partition("=")
|
|
labels[key.strip()] = value.strip()
|
|
return labels
|
|
|
|
|
|
def _looks_like_secret(key: str) -> bool:
|
|
from app.core.redact import is_sensitive_key
|
|
|
|
return is_sensitive_key(key)
|
|
|
|
|
|
__all__ = [
|
|
"FALLBACK_IMAGE",
|
|
"LABEL_ORG",
|
|
"LABEL_PROJECT",
|
|
"LABEL_ROLE",
|
|
"LANGUAGE_IMAGES",
|
|
"ROLE_BUILD",
|
|
"ROLE_DEMO",
|
|
"ROLE_WORKSPACE",
|
|
"SANDBOX_WORKSPACE_PATH",
|
|
"DockerSandboxAdapter",
|
|
"ManagedContainer",
|
|
"PathEscapeError",
|
|
"container_name",
|
|
"image_for_language",
|
|
"safe_join",
|
|
]
|