Finish SAND-WP-0002: remote smoke, podman-compose, HTTP stub
- T10 smoke passed on CoulombCore (sand-boxer self-deploy, sandbox 4e542c51) - Add e2e smoke compose, scripts/smoke-compose-e2e.sh, make smoke-remote - Support SANDBOXER_COMPOSE_CMD for podman-compose hosts - FastAPI v0 stub at sandboxer.api.app; migration gaps doc - Mark workplan finished (all 10 tasks done)
This commit is contained in:
parent
380034c911
commit
8e0465865a
17 changed files with 610 additions and 30 deletions
40
src/sandboxer/api/app.py
Normal file
40
src/sandboxer/api/app.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""HTTP API v0 stub — CLI remains primary; run via uvicorn sandboxer.api.app:app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
|
||||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.models import SandboxCreateRequest, SandboxStatus
|
||||
|
||||
app = FastAPI(title="sand-boxer", version="0.0.0")
|
||||
_manager = SandboxManager()
|
||||
|
||||
|
||||
@app.post("/v1/sandboxes", response_model=SandboxStatus)
|
||||
def create_sandbox(request: SandboxCreateRequest, host: str | None = None) -> SandboxStatus:
|
||||
try:
|
||||
return _manager.create(request, host=host)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/v1/sandboxes/{sandbox_id}", response_model=SandboxStatus)
|
||||
def get_sandbox(sandbox_id: str) -> SandboxStatus:
|
||||
status = _manager.get(sandbox_id)
|
||||
if not status:
|
||||
raise HTTPException(status_code=404, detail="sandbox not found")
|
||||
return status
|
||||
|
||||
|
||||
@app.get("/v1/sandboxes", response_model=list[SandboxStatus])
|
||||
def list_sandboxes() -> list[SandboxStatus]:
|
||||
return _manager.list()
|
||||
|
||||
|
||||
@app.delete("/v1/sandboxes/{sandbox_id}", response_model=SandboxStatus)
|
||||
def destroy_sandbox(sandbox_id: str) -> SandboxStatus:
|
||||
try:
|
||||
return _manager.destroy(sandbox_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
|
@ -48,6 +48,7 @@ class SandboxManager:
|
|||
status.sandbox_id = handle["sandbox_id"]
|
||||
status.inputs["compose_file"] = handle.get("compose_file", "")
|
||||
status.inputs["ssh_user"] = handle.get("ssh_user", "")
|
||||
status.inputs["compose_cmd"] = handle.get("compose_cmd", "")
|
||||
reach = backend.wait_ready(handle)
|
||||
status.reachability = Reachability(**reach)
|
||||
status.state = SandboxState.READY
|
||||
|
|
@ -98,6 +99,7 @@ class SandboxManager:
|
|||
"compose_project": status.reachability.compose_project if status.reachability else "",
|
||||
"compose_file": status.inputs.get("compose_file", ""),
|
||||
"ssh_user": status.inputs.get("ssh_user", ""),
|
||||
"compose_cmd": status.inputs.get("compose_cmd", ""),
|
||||
}
|
||||
backend.teardown(handle)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""ext.compose-ssh — SSH + docker compose provisioning (e2e-framework lineage)."""
|
||||
"""ext.compose-ssh — SSH + compose provisioning (e2e-framework lineage)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
|
@ -20,6 +21,18 @@ class ComposeSSHExtension:
|
|||
self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer")
|
||||
self.ssh_user: str | None = cfg.get("ssh_user")
|
||||
self.compose_timeout_s: int = int(cfg.get("compose_timeout_s", 180))
|
||||
self.compose_cmd: str = cfg.get("compose_cmd", "docker compose")
|
||||
|
||||
def _compose_bin(self) -> str:
|
||||
return os.environ.get("SANDBOXER_COMPOSE_CMD", self.compose_cmd)
|
||||
|
||||
def _compose_invocation(
|
||||
self, remote_dir: str, project: str, compose_file: str, subcmd: str
|
||||
) -> str:
|
||||
return f"cd {remote_dir} && {self._compose_bin()} -p {project} -f {compose_file} {subcmd}"
|
||||
|
||||
def _is_podman_compose(self) -> bool:
|
||||
return self._compose_bin().startswith("podman-compose")
|
||||
|
||||
def provision(
|
||||
self, profile: Profile, inputs: dict[str, str], host: str
|
||||
|
|
@ -44,13 +57,10 @@ class ComposeSSHExtension:
|
|||
compose_file = self._resolve_compose_file(repo_path)
|
||||
project_name = f"sbx-{profile.id.split('.')[-1]}-{sandbox_id}"
|
||||
|
||||
up_cmd = (
|
||||
f"cd {remote_dir} && "
|
||||
f"docker compose -p {project_name} -f {compose_file} up -d"
|
||||
)
|
||||
up_cmd = self._compose_invocation(remote_dir, project_name, compose_file, "up -d")
|
||||
rc, out = ssh.run(up_cmd, timeout=self.compose_timeout_s)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"docker compose up failed: {out}")
|
||||
raise RuntimeError(f"compose up failed: {out}")
|
||||
|
||||
return {
|
||||
"sandbox_id": sandbox_id,
|
||||
|
|
@ -59,22 +69,31 @@ class ComposeSSHExtension:
|
|||
"compose_project": project_name,
|
||||
"compose_file": compose_file,
|
||||
"repo": str(repo_path),
|
||||
"ssh_user": ssh.user,
|
||||
"ssh_user": ssh.user or "",
|
||||
"compose_cmd": self._compose_bin(),
|
||||
}
|
||||
|
||||
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
"""Confirm compose services are running (no HTTP health polling)."""
|
||||
compose_cmd = handle.get("compose_cmd") or self._compose_bin()
|
||||
ssh_user = handle.get("ssh_user") or self.ssh_user or None
|
||||
ssh = SSHConfig.from_env(handle["host"], user=ssh_user)
|
||||
project = handle["compose_project"]
|
||||
remote_dir = handle["remote_dir"]
|
||||
compose_file = handle["compose_file"]
|
||||
cmd = (
|
||||
f"cd {remote_dir} && "
|
||||
f"docker compose -p {project} -f {compose_file} ps --status running -q"
|
||||
)
|
||||
rc, out = ssh.run(cmd, timeout=60)
|
||||
if rc != 0 or not out.strip():
|
||||
|
||||
if compose_cmd.startswith("podman-compose"):
|
||||
cmd = f"cd {remote_dir} && {compose_cmd} -p {project} -f {compose_file} ps"
|
||||
rc, out = ssh.run(cmd, timeout=60)
|
||||
running = rc == 0 and "Up" in out
|
||||
else:
|
||||
cmd = self._compose_invocation(
|
||||
remote_dir, project, compose_file, "ps --status running -q"
|
||||
)
|
||||
rc, out = ssh.run(cmd, timeout=60)
|
||||
running = rc == 0 and bool(out.strip())
|
||||
|
||||
if not running:
|
||||
raise RuntimeError(f"compose services not running: {out}")
|
||||
return {
|
||||
"ssh": ssh.target,
|
||||
|
|
@ -84,6 +103,7 @@ class ComposeSSHExtension:
|
|||
}
|
||||
|
||||
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
compose_cmd = handle.get("compose_cmd") or self._compose_bin()
|
||||
ssh_user = handle.get("ssh_user") or self.ssh_user or None
|
||||
ssh = SSHConfig.from_env(handle["host"], user=ssh_user)
|
||||
project = handle.get("compose_project")
|
||||
|
|
@ -92,10 +112,13 @@ class ComposeSSHExtension:
|
|||
cleaned_compose = False
|
||||
|
||||
if project and remote_dir and compose_file:
|
||||
if compose_cmd.startswith("podman-compose"):
|
||||
down_subcmd = "down -v"
|
||||
else:
|
||||
down_subcmd = "down -v --remove-orphans"
|
||||
down_cmd = (
|
||||
f"cd {remote_dir} && "
|
||||
f"docker compose -p {project} -f {compose_file} "
|
||||
f"down -v --remove-orphans 2>&1 || true"
|
||||
f"cd {remote_dir} && {compose_cmd} -p {project} -f {compose_file} "
|
||||
f"{down_subcmd} 2>&1 || true"
|
||||
)
|
||||
ssh.run(down_cmd, timeout=60)
|
||||
cleaned_compose = True
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue