debate/backend/app/services/sandbox.py
Mikkel Georgsen cd94d99c62 feat(01-05): add systemd-nspawn sandbox for isolated ISO builds
- Create scripts/setup-sandbox.sh to bootstrap Arch base environment
- Add BuildSandbox class for container management and build execution
- Configure sandbox with network isolation, read-only root, 8GB/4core limits
- Add sandbox_root and iso_output_root settings to config
2026-01-25 20:19:02 +00:00

129 lines
4 KiB
Python

"""
systemd-nspawn sandbox for isolated ISO builds.
Security measures:
- --private-network: No network access (packages pre-cached in base)
- --read-only: Immutable root filesystem
- --tmpfs: Writable temp directories only
- --capability: Minimal capabilities for mkarchiso
- Resource limits: 8GB RAM, 4 cores (from CONTEXT.md)
"""
import asyncio
import shutil
from dataclasses import dataclass
from pathlib import Path
from backend.app.core.config import settings
@dataclass
class SandboxConfig:
"""Configuration for sandbox execution."""
memory_limit: str = "8G"
cpu_quota: str = "400%" # 4 cores
timeout_seconds: int = 1200 # 20 minutes (with 15min warning)
warning_seconds: int = 900 # 15 minutes
class BuildSandbox:
"""Manages systemd-nspawn sandboxed build environments."""
def __init__(
self,
sandbox_root: Path | None = None,
config: SandboxConfig | None = None,
):
self.sandbox_root = sandbox_root or Path(settings.sandbox_root)
self.base_path = self.sandbox_root / "base"
self.builds_path = self.sandbox_root / "builds"
self.config = config or SandboxConfig()
async def create_build_container(self, build_id: str) -> Path:
"""
Create isolated container for a specific build.
Uses overlay filesystem on base for efficiency.
"""
container_path = self.builds_path / build_id
if container_path.exists():
shutil.rmtree(container_path)
container_path.mkdir(parents=True)
# Copy base (in production, use overlayfs for efficiency)
# For now, simple copy is acceptable
proc = await asyncio.create_subprocess_exec(
"cp",
"-a",
str(self.base_path) + "/.",
str(container_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.wait()
return container_path
async def run_build(
self,
container_path: Path,
profile_path: Path,
output_path: Path,
source_date_epoch: int,
) -> tuple[int, str, str]:
"""
Execute archiso build in sandboxed container.
Returns:
Tuple of (return_code, stdout, stderr)
"""
output_path.mkdir(parents=True, exist_ok=True)
nspawn_cmd = [
"systemd-nspawn",
f"--directory={container_path}",
"--private-network", # No network access
"--read-only", # Immutable root
"--tmpfs=/tmp:mode=1777",
"--tmpfs=/var/tmp:mode=1777",
f"--bind={profile_path}:/build/profile:ro",
f"--bind={output_path}:/build/output",
f"--setenv=SOURCE_DATE_EPOCH={source_date_epoch}",
"--setenv=LC_ALL=C",
"--setenv=TZ=UTC",
"--capability=CAP_SYS_ADMIN", # Required for mkarchiso
"--console=pipe",
"--quiet",
"--",
"mkarchiso",
"-v",
"-r", # Remove work directory after build
"-w",
"/tmp/archiso-work",
"-o",
"/build/output",
"/build/profile",
]
proc = await asyncio.create_subprocess_exec(
*nspawn_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(),
timeout=self.config.timeout_seconds,
)
return_code = proc.returncode if proc.returncode is not None else -1
return return_code, stdout.decode(), stderr.decode()
except TimeoutError:
proc.kill()
timeout_msg = f"Build timed out after {self.config.timeout_seconds} seconds"
return -1, "", timeout_msg
async def cleanup_container(self, container_path: Path) -> None:
"""Remove container after build."""
if container_path.exists():
shutil.rmtree(container_path)