1
0
forked from wrenn/wrenn

Rename API routes /v1/sandboxes → /v1/capsules

This commit is contained in:
2026-04-12 21:51:04 +06:00
parent ea65fb584c
commit 565817273d
22 changed files with 208 additions and 208 deletions

View File

@ -68,7 +68,7 @@ envd is a **completely independent Go module**. It is never imported by the main
Startup (`cmd/control-plane/main.go`) wires: config (env vars) → pgxpool → `db.Queries` (sqlc-generated) → Connect RPC client to host agent → `api.Server`. Everything flows through constructor injection. Startup (`cmd/control-plane/main.go`) wires: config (env vars) → pgxpool → `db.Queries` (sqlc-generated) → Connect RPC client to host agent → `api.Server`. Everything flows through constructor injection.
- **API Server** (`internal/api/server.go`): chi router with middleware. Creates handler structs (`sandboxHandler`, `execHandler`, `filesHandler`, etc.) injected with `db.Queries` and the host agent Connect RPC client. Routes under `/v1/sandboxes/*`. - **API Server** (`internal/api/server.go`): chi router with middleware. Creates handler structs (`sandboxHandler`, `execHandler`, `filesHandler`, etc.) injected with `db.Queries` and the host agent Connect RPC client. Routes under `/v1/capsules/*`.
- **Reconciler** (`internal/api/reconciler.go`): background goroutine (every 30s) that compares DB records against `agent.ListSandboxes()` RPC. Marks orphaned DB entries as "stopped". - **Reconciler** (`internal/api/reconciler.go`): background goroutine (every 30s) that compares DB records against `agent.ListSandboxes()` RPC. Marks orphaned DB entries as "stopped".
- **Dashboard** (SvelteKit + Tailwind + Bits UI, statically built and embedded via `go:embed`, served as catch-all at root) - **Dashboard** (SvelteKit + Tailwind + Bits UI, statically built and embedded via `go:embed`, served as catch-all at root)
- **Database**: PostgreSQL via pgx/v5. Queries generated by sqlc from `db/queries/sandboxes.sql`. Migrations in `db/migrations/` (goose, plain SQL). - **Database**: PostgreSQL via pgx/v5. Queries generated by sqlc from `db/queries/sandboxes.sql`. Migrations in `db/migrations/` (goose, plain SQL).
@ -147,19 +147,19 @@ HIBERNATED → RUNNING (cold snapshot resume, slower)
### Key Request Flows ### Key Request Flows
**Sandbox creation** (`POST /v1/sandboxes`): **Sandbox creation** (`POST /v1/capsules`):
1. API handler generates sandbox ID, inserts into DB as "pending" 1. API handler generates sandbox ID, inserts into DB as "pending"
2. RPC `CreateSandbox` → host agent → `sandbox.Manager.Create()` 2. RPC `CreateSandbox` → host agent → `sandbox.Manager.Create()`
3. Manager: resolve base rootfs → acquire shared loop device → create dm-snapshot (sparse CoW file) → allocate network slot → `CreateNetwork()` (netns + veth + tap + NAT) → `vm.Create()` (start Firecracker with `/dev/mapper/wrenn-{id}`, configure via HTTP API, boot) → `envdclient.WaitUntilReady()` (poll /health) → store in-memory state 3. Manager: resolve base rootfs → acquire shared loop device → create dm-snapshot (sparse CoW file) → allocate network slot → `CreateNetwork()` (netns + veth + tap + NAT) → `vm.Create()` (start Firecracker with `/dev/mapper/wrenn-{id}`, configure via HTTP API, boot) → `envdclient.WaitUntilReady()` (poll /health) → store in-memory state
4. API handler updates DB to "running" with host_ip 4. API handler updates DB to "running" with host_ip
**Command execution** (`POST /v1/sandboxes/{id}/exec`): **Command execution** (`POST /v1/capsules/{id}/exec`):
1. API handler verifies sandbox is "running" in DB 1. API handler verifies sandbox is "running" in DB
2. RPC `Exec` → host agent → `sandbox.Manager.Exec()``envdclient.Exec()` 2. RPC `Exec` → host agent → `sandbox.Manager.Exec()``envdclient.Exec()`
3. envd client opens bidirectional Connect RPC stream (`process.Start`), collects stdout/stderr/exit_code 3. envd client opens bidirectional Connect RPC stream (`process.Start`), collects stdout/stderr/exit_code
4. API handler checks UTF-8 validity (base64-encodes if binary), updates last_active_at, returns result 4. API handler checks UTF-8 validity (base64-encodes if binary), updates last_active_at, returns result
**Streaming exec** (`WS /v1/sandboxes/{id}/exec/stream`): **Streaming exec** (`WS /v1/capsules/{id}/exec/stream`):
1. WebSocket upgrade, read first message for cmd/args 1. WebSocket upgrade, read first message for cmd/args
2. RPC `ExecStream` → host agent → `sandbox.Manager.ExecStream()``envdclient.ExecStream()` 2. RPC `ExecStream` → host agent → `sandbox.Manager.ExecStream()``envdclient.ExecStream()`
3. envd client returns a channel of events; host agent forwards events through the RPC stream 3. envd client returns a channel of events; host agent forwards events through the RPC stream

View File

@ -17,11 +17,11 @@ export type Capsule = {
export async function listCapsules(): Promise<ApiResult<Capsule[]>> { export async function listCapsules(): Promise<ApiResult<Capsule[]>> {
return apiFetch('GET', '/api/v1/sandboxes'); return apiFetch('GET', '/api/v1/capsules');
} }
export async function getCapsule(id: string): Promise<ApiResult<Capsule>> { export async function getCapsule(id: string): Promise<ApiResult<Capsule>> {
return apiFetch('GET', `/api/v1/sandboxes/${id}`); return apiFetch('GET', `/api/v1/capsules/${id}`);
} }
export type CreateCapsuleParams = { export type CreateCapsuleParams = {
@ -32,19 +32,19 @@ export type CreateCapsuleParams = {
}; };
export async function createCapsule(params: CreateCapsuleParams): Promise<ApiResult<Capsule>> { export async function createCapsule(params: CreateCapsuleParams): Promise<ApiResult<Capsule>> {
return apiFetch('POST', '/api/v1/sandboxes', params); return apiFetch('POST', '/api/v1/capsules', params);
} }
export async function pauseCapsule(id: string): Promise<ApiResult<Capsule>> { export async function pauseCapsule(id: string): Promise<ApiResult<Capsule>> {
return apiFetch('POST', `/api/v1/sandboxes/${id}/pause`); return apiFetch('POST', `/api/v1/capsules/${id}/pause`);
} }
export async function resumeCapsule(id: string): Promise<ApiResult<Capsule>> { export async function resumeCapsule(id: string): Promise<ApiResult<Capsule>> {
return apiFetch('POST', `/api/v1/sandboxes/${id}/resume`); return apiFetch('POST', `/api/v1/capsules/${id}/resume`);
} }
export async function destroyCapsule(id: string): Promise<ApiResult<void>> { export async function destroyCapsule(id: string): Promise<ApiResult<void>> {
return apiFetch('DELETE', `/api/v1/sandboxes/${id}`); return apiFetch('DELETE', `/api/v1/capsules/${id}`);
} }
export type Snapshot = { export type Snapshot = {
@ -57,8 +57,8 @@ export type Snapshot = {
platform: boolean; platform: boolean;
}; };
export async function createSnapshot(sandboxId: string, name?: string): Promise<ApiResult<Snapshot>> { export async function createSnapshot(capsuleId: string, name?: string): Promise<ApiResult<Snapshot>> {
return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: sandboxId, name }); return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: capsuleId, name });
} }
export async function listSnapshots(typeFilter?: string): Promise<ApiResult<Snapshot[]>> { export async function listSnapshots(typeFilter?: string): Promise<ApiResult<Snapshot[]>> {

View File

@ -53,12 +53,12 @@ export function formatFileSize(bytes: number): string {
return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`; return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
} }
export async function listDir(sandboxId: string, path: string, depth = 1): Promise<ApiResult<ListDirResponse>> { export async function listDir(capsuleId: string, path: string, depth = 1): Promise<ApiResult<ListDirResponse>> {
try { try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
const res = await fetch(`/api/v1/sandboxes/${sandboxId}/files/list`, { const res = await fetch(`/api/v1/capsules/${capsuleId}/files/list`, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ path, depth }), body: JSON.stringify({ path, depth }),
@ -72,12 +72,12 @@ export async function listDir(sandboxId: string, path: string, depth = 1): Promi
} }
} }
export async function readFile(sandboxId: string, path: string): Promise<ApiResult<string>> { export async function readFile(capsuleId: string, path: string): Promise<ApiResult<string>> {
try { try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
const res = await fetch(`/api/v1/sandboxes/${sandboxId}/files/read`, { const res = await fetch(`/api/v1/capsules/${capsuleId}/files/read`, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ path }), body: JSON.stringify({ path }),
@ -100,11 +100,11 @@ export async function readFile(sandboxId: string, path: string): Promise<ApiResu
} }
} }
export async function downloadFile(sandboxId: string, path: string, filename: string): Promise<void> { export async function downloadFile(capsuleId: string, path: string, filename: string): Promise<void> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
const res = await fetch(`/api/v1/sandboxes/${sandboxId}/files/read`, { const res = await fetch(`/api/v1/capsules/${capsuleId}/files/read`, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ path }), body: JSON.stringify({ path }),

View File

@ -15,8 +15,8 @@ export type MetricsResponse = {
points: MetricPoint[]; points: MetricPoint[];
}; };
export async function fetchSandboxMetrics(id: string, range: MetricRange): Promise<ApiResult<MetricsResponse>> { export async function fetchCapsuleMetrics(id: string, range: MetricRange): Promise<ApiResult<MetricsResponse>> {
return apiFetch('GET', `/api/v1/sandboxes/${id}/metrics?range=${range}`); return apiFetch('GET', `/api/v1/capsules/${id}/metrics?range=${range}`);
} }
export const METRIC_RANGES: MetricRange[] = ['5m', '10m', '1h', '6h', '24h']; export const METRIC_RANGES: MetricRange[] = ['5m', '10m', '1h', '6h', '24h'];

View File

@ -24,7 +24,7 @@ export type StatsResponse = {
}; };
export async function fetchStats(range: TimeRange): Promise<ApiResult<StatsResponse>> { export async function fetchStats(range: TimeRange): Promise<ApiResult<StatsResponse>> {
return apiFetch('GET', `/api/v1/sandboxes/stats?range=${range}`); return apiFetch('GET', `/api/v1/capsules/stats?range=${range}`);
} }
export const POLL_INTERVALS: Record<TimeRange, number> = { export const POLL_INTERVALS: Record<TimeRange, number> = {

View File

@ -11,11 +11,11 @@
import { tokenize, type ThemedToken } from '$lib/highlight'; import { tokenize, type ThemedToken } from '$lib/highlight';
type Props = { type Props = {
sandboxId: string; capsuleId: string;
isRunning: boolean; isRunning: boolean;
}; };
let { sandboxId, isRunning }: Props = $props(); let { capsuleId, isRunning }: Props = $props();
// Directory navigation state // Directory navigation state
let currentPath = $state('~'); let currentPath = $state('~');
@ -124,7 +124,7 @@
dirLoading = true; dirLoading = true;
dirError = null; dirError = null;
const gen = ++dirGeneration; const gen = ++dirGeneration;
const result = await listDir(sandboxId, currentPath); const result = await listDir(capsuleId, currentPath);
if (gen !== dirGeneration) return; // stale response if (gen !== dirGeneration) return; // stale response
if (result.ok) { if (result.ok) {
entries = result.data.entries ?? []; entries = result.data.entries ?? [];
@ -159,7 +159,7 @@
fileLoading = true; fileLoading = true;
const gen = ++fileGeneration; const gen = ++fileGeneration;
const result = await readFile(sandboxId, entry.path); const result = await readFile(capsuleId, entry.path);
if (gen !== fileGeneration) return; // stale response — user clicked another file if (gen !== fileGeneration) return; // stale response — user clicked another file
if (result.ok) { if (result.ok) {
if (looksLikeBinary(result.data)) { if (looksLikeBinary(result.data)) {
@ -197,7 +197,7 @@
if (!selectedFile || downloading) return; if (!selectedFile || downloading) return;
downloading = true; downloading = true;
try { try {
await downloadFile(sandboxId, selectedFile.path, selectedFile.name); await downloadFile(capsuleId, selectedFile.path, selectedFile.name);
} catch { } catch {
fileError = 'Download failed'; fileError = 'Download failed';
} }
@ -214,7 +214,7 @@
async function navigateOrOpenFile(path: string) { async function navigateOrOpenFile(path: string) {
// First try as directory // First try as directory
const dirResult = await listDir(sandboxId, path); const dirResult = await listDir(capsuleId, path);
if (dirResult.ok) { if (dirResult.ok) {
// Resolve actual path from entries (handles ~ expansion by envd) // Resolve actual path from entries (handles ~ expansion by envd)
const resolvedEntries = dirResult.data.entries ?? []; const resolvedEntries = dirResult.data.entries ?? [];
@ -245,7 +245,7 @@
// Navigate to parent directory // Navigate to parent directory
currentPath = parentPath; currentPath = parentPath;
pathInput = parentPath; pathInput = parentPath;
const parentResult = await listDir(sandboxId, parentPath); const parentResult = await listDir(capsuleId, parentPath);
if (parentResult.ok) { if (parentResult.ok) {
entries = parentResult.data.entries ?? []; entries = parentResult.data.entries ?? [];
// Find the file in parent listing // Find the file in parent listing

View File

@ -3,12 +3,12 @@
import { auth } from '$lib/auth.svelte'; import { auth } from '$lib/auth.svelte';
type Props = { type Props = {
sandboxId: string; capsuleId: string;
isRunning: boolean; isRunning: boolean;
visible?: boolean; visible?: boolean;
}; };
let { sandboxId, isRunning, visible = true }: Props = $props(); let { capsuleId, isRunning, visible = true }: Props = $props();
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'; type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
@ -93,7 +93,7 @@
function getWsUrl(): string { function getWsUrl(): string {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = auth.token ? `?token=${encodeURIComponent(auth.token)}` : ''; const token = auth.token ? `?token=${encodeURIComponent(auth.token)}` : '';
return `${proto}//${window.location.host}/api/v1/sandboxes/${sandboxId}/pty${token}`; return `${proto}//${window.location.host}/api/v1/capsules/${capsuleId}/pty${token}`;
} }
function wsSend(ws: WebSocket | null, data: string) { function wsSend(ws: WebSocket | null, data: string) {

View File

@ -17,7 +17,7 @@ let loadingPromise: Promise<HighlighterGeneric<any, any>> | null = null;
const THEME = 'vesper'; const THEME = 'vesper';
// Extensions → shiki language IDs. // Extensions → shiki language IDs.
// Only map what we expect users to encounter in sandboxes. // Only map what we expect users to encounter in capsules.
const EXT_TO_LANG: Record<string, string> = { const EXT_TO_LANG: Record<string, string> = {
// Go // Go
go: 'go', mod: 'go', sum: 'go', go: 'go', mod: 'go', sum: 'go',

View File

@ -77,7 +77,7 @@
// Delete confirmation // Delete confirmation
let deleteTarget = $state<Host | null>(null); let deleteTarget = $state<Host | null>(null);
let deletePreviewSandboxes = $state<string[]>([]); let deletePreviewCapsules = $state<string[]>([]);
let deletePreviewLoading = $state(false); let deletePreviewLoading = $state(false);
let deleting = $state(false); let deleting = $state(false);
let deleteError = $state<string | null>(null); let deleteError = $state<string | null>(null);
@ -124,12 +124,12 @@
async function openDeleteConfirm(host: Host) { async function openDeleteConfirm(host: Host) {
deleteTarget = host; deleteTarget = host;
deleteError = null; deleteError = null;
deletePreviewSandboxes = []; deletePreviewCapsules = [];
deletePreviewLoading = true; deletePreviewLoading = true;
const preview = await getDeletePreview(host.id); const preview = await getDeletePreview(host.id);
deletePreviewLoading = false; deletePreviewLoading = false;
if (preview.ok) { if (preview.ok) {
deletePreviewSandboxes = preview.data.sandbox_ids; deletePreviewCapsules = preview.data.sandbox_ids;
} }
} }
@ -137,7 +137,7 @@
if (!deleteTarget) return; if (!deleteTarget) return;
deleting = true; deleting = true;
deleteError = null; deleteError = null;
const result = await deleteHost(deleteTarget.id, deletePreviewSandboxes.length > 0); const result = await deleteHost(deleteTarget.id, deletePreviewCapsules.length > 0);
if (result.ok) { if (result.ok) {
allHosts = allHosts.filter((h) => h.id !== deleteTarget!.id); allHosts = allHosts.filter((h) => h.id !== deleteTarget!.id);
deleteTarget = null; deleteTarget = null;
@ -627,10 +627,10 @@
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> <svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Checking active capsules… Checking active capsules…
</div> </div>
{:else if deletePreviewSandboxes.length > 0} {:else if deletePreviewCapsules.length > 0}
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-amber)]/30 bg-[var(--color-amber)]/6 px-3 py-2.5"> <div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-amber)]/30 bg-[var(--color-amber)]/6 px-3 py-2.5">
<p class="text-meta font-semibold text-[var(--color-amber)]"> <p class="text-meta font-semibold text-[var(--color-amber)]">
{deletePreviewSandboxes.length} active capsule{deletePreviewSandboxes.length === 1 ? '' : 's'} will be destroyed. {deletePreviewCapsules.length} active capsule{deletePreviewCapsules.length === 1 ? '' : 's'} will be destroyed.
</p> </p>
<p class="mt-0.5 text-meta text-[var(--color-amber)]/70"> <p class="mt-0.5 text-meta text-[var(--color-amber)]/70">
All running workloads on this host will be terminated immediately. All running workloads on this host will be terminated immediately.
@ -661,7 +661,7 @@
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> <svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Deleting… Deleting…
{:else} {:else}
{deletePreviewSandboxes.length > 0 ? 'Force Delete' : 'Delete'} {deletePreviewCapsules.length > 0 ? 'Force Delete' : 'Delete'}
{/if} {/if}
</button> </button>
</div> </div>

View File

@ -9,14 +9,14 @@
import FilesTab from '$lib/components/FilesTab.svelte'; import FilesTab from '$lib/components/FilesTab.svelte';
import TerminalTab from '$lib/components/TerminalTab.svelte'; import TerminalTab from '$lib/components/TerminalTab.svelte';
import { import {
fetchSandboxMetrics, fetchCapsuleMetrics,
METRIC_RANGES, METRIC_RANGES,
METRIC_POLL_INTERVALS, METRIC_POLL_INTERVALS,
type MetricRange, type MetricRange,
type MetricPoint type MetricPoint
} from '$lib/api/metrics'; } from '$lib/api/metrics';
const sandboxId: string = $page.params.id ?? ''; const capsuleId: string = $page.params.id ?? '';
let capsule = $state<Capsule | null>(null); let capsule = $state<Capsule | null>(null);
let capsuleLoading = $state(true); let capsuleLoading = $state(true);
@ -96,7 +96,7 @@
); );
async function loadCapsule() { async function loadCapsule() {
const result = await getCapsule(sandboxId); const result = await getCapsule(capsuleId);
if (result.ok) { if (result.ok) {
capsule = result.data; capsule = result.data;
capsuleError = null; capsuleError = null;
@ -108,7 +108,7 @@
async function loadMetrics() { async function loadMetrics() {
if (!metricsAvailable) return; if (!metricsAvailable) return;
const result = await fetchSandboxMetrics(sandboxId, range); const result = await fetchCapsuleMetrics(capsuleId, range);
if (result.ok) { if (result.ok) {
points = result.data.points; points = result.data.points;
metricsError = null; metricsError = null;
@ -441,7 +441,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Wrenn — {sandboxId}</title> <title>Wrenn — {capsuleId}</title>
</svelte:head> </svelte:head>
<style> <style>
@ -575,11 +575,11 @@
<!-- Tab content --> <!-- Tab content -->
<!-- Terminal stays mounted so sessions survive tab switches --> <!-- Terminal stays mounted so sessions survive tab switches -->
<div class="flex flex-1 min-h-0" style:display={activeTab === 'terminal' ? 'flex' : 'none'}> <div class="flex flex-1 min-h-0" style:display={activeTab === 'terminal' ? 'flex' : 'none'}>
<TerminalTab sandboxId={sandboxId} isRunning={capsule.status === 'running'} visible={activeTab === 'terminal'} /> <TerminalTab capsuleId={capsuleId} isRunning={capsule.status === 'running'} visible={activeTab === 'terminal'} />
</div> </div>
{#if activeTab === 'files'} {#if activeTab === 'files'}
<div class="anim-in flex flex-1 min-h-0" style="animation-delay: 0.05s"> <div class="anim-in flex flex-1 min-h-0" style="animation-delay: 0.05s">
<FilesTab sandboxId={sandboxId} isRunning={capsule.status === 'running'} /> <FilesTab capsuleId={capsuleId} isRunning={capsule.status === 'running'} />
</div> </div>
{:else if activeTab === 'metrics'} {:else if activeTab === 'metrics'}
<div <div
@ -757,14 +757,14 @@
<SnapshotDialog <SnapshotDialog
open={showSnapshot} open={showSnapshot}
capsuleId={sandboxId} capsuleId={capsuleId}
onclose={() => { showSnapshot = false; }} onclose={() => { showSnapshot = false; }}
onsnapshot={() => { goto('/dashboard/capsules'); }} onsnapshot={() => { goto('/dashboard/capsules'); }}
/> />
<DestroyDialog <DestroyDialog
open={showDestroy} open={showDestroy}
capsuleId={sandboxId} capsuleId={capsuleId}
onclose={() => { showDestroy = false; }} onclose={() => { showDestroy = false; }}
ondestroyed={() => { goto('/dashboard/capsules'); }} ondestroyed={() => { goto('/dashboard/capsules'); }}
/> />

View File

@ -43,7 +43,7 @@
// Delete confirmation // Delete confirmation
let deleteTarget = $state<Host | null>(null); let deleteTarget = $state<Host | null>(null);
let deletePreviewSandboxes = $state<string[]>([]); let deletePreviewCapsules = $state<string[]>([]);
let deletePreviewLoading = $state(false); let deletePreviewLoading = $state(false);
let deleting = $state(false); let deleting = $state(false);
let deleteError = $state<string | null>(null); let deleteError = $state<string | null>(null);
@ -97,12 +97,12 @@
async function openDeleteConfirm(host: Host) { async function openDeleteConfirm(host: Host) {
deleteTarget = host; deleteTarget = host;
deleteError = null; deleteError = null;
deletePreviewSandboxes = []; deletePreviewCapsules = [];
deletePreviewLoading = true; deletePreviewLoading = true;
const preview = await getDeletePreview(host.id); const preview = await getDeletePreview(host.id);
deletePreviewLoading = false; deletePreviewLoading = false;
if (preview.ok) { if (preview.ok) {
deletePreviewSandboxes = preview.data.sandbox_ids; deletePreviewCapsules = preview.data.sandbox_ids;
} }
} }
@ -110,7 +110,7 @@
if (!deleteTarget) return; if (!deleteTarget) return;
deleting = true; deleting = true;
deleteError = null; deleteError = null;
const result = await deleteHost(deleteTarget.id, deletePreviewSandboxes.length > 0); const result = await deleteHost(deleteTarget.id, deletePreviewCapsules.length > 0);
if (result.ok) { if (result.ok) {
hosts = hosts.filter((h) => h.id !== deleteTarget!.id); hosts = hosts.filter((h) => h.id !== deleteTarget!.id);
deleteTarget = null; deleteTarget = null;
@ -583,10 +583,10 @@
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> <svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Checking active capsules… Checking active capsules…
</div> </div>
{:else if deletePreviewSandboxes.length > 0} {:else if deletePreviewCapsules.length > 0}
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-amber)]/20 bg-[var(--color-amber)]/5 px-3 py-2.5"> <div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-amber)]/20 bg-[var(--color-amber)]/5 px-3 py-2.5">
<p class="text-meta font-semibold text-[var(--color-amber)]"> <p class="text-meta font-semibold text-[var(--color-amber)]">
{deletePreviewSandboxes.length} active capsule{deletePreviewSandboxes.length === 1 ? '' : 's'} will be destroyed. {deletePreviewCapsules.length} active capsule{deletePreviewCapsules.length === 1 ? '' : 's'} will be destroyed.
</p> </p>
<p class="mt-0.5 text-meta text-[var(--color-amber)]/70"> <p class="mt-0.5 text-meta text-[var(--color-amber)]/70">
All running workloads on this host will be terminated immediately. All running workloads on this host will be terminated immediately.
@ -617,7 +617,7 @@
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> <svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Deleting… Deleting…
{:else} {:else}
{deletePreviewSandboxes.length > 0 ? 'Force Delete' : 'Delete Host'} {deletePreviewCapsules.length > 0 ? 'Force Delete' : 'Delete Host'}
{/if} {/if}
</button> </button>
</div> </div>

View File

@ -45,7 +45,7 @@ type execResponse struct {
Encoding string `json:"encoding"` Encoding string `json:"encoding"`
} }
// Exec handles POST /v1/sandboxes/{id}/exec. // Exec handles POST /v1/capsules/{id}/exec.
func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) { func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()

View File

@ -47,7 +47,7 @@ type wsOutMsg struct {
ExitCode *int32 `json:"exit_code,omitempty"` // only for "exit" ExitCode *int32 `json:"exit_code,omitempty"` // only for "exit"
} }
// ExecStream handles WS /v1/sandboxes/{id}/exec/stream. // ExecStream handles WS /v1/capsules/{id}/exec/stream.
func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) { func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()

View File

@ -25,7 +25,7 @@ func newFilesHandler(db *db.Queries, pool *lifecycle.HostClientPool) *filesHandl
return &filesHandler{db: db, pool: pool} return &filesHandler{db: db, pool: pool}
} }
// Upload handles POST /v1/sandboxes/{id}/files/write. // Upload handles POST /v1/capsules/{id}/files/write.
// Expects multipart/form-data with: // Expects multipart/form-data with:
// - "path" text field: absolute destination path inside the sandbox // - "path" text field: absolute destination path inside the sandbox
// - "file" file field: binary content to write // - "file" file field: binary content to write
@ -105,7 +105,7 @@ type readFileRequest struct {
Path string `json:"path"` Path string `json:"path"`
} }
// Download handles POST /v1/sandboxes/{id}/files/read. // Download handles POST /v1/capsules/{id}/files/read.
// Accepts JSON body with path, returns raw file content with Content-Disposition. // Accepts JSON body with path, returns raw file content with Content-Disposition.
func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) { func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")

View File

@ -26,7 +26,7 @@ func newFilesStreamHandler(db *db.Queries, pool *lifecycle.HostClientPool) *file
return &filesStreamHandler{db: db, pool: pool} return &filesStreamHandler{db: db, pool: pool}
} }
// StreamUpload handles POST /v1/sandboxes/{id}/files/stream/write. // StreamUpload handles POST /v1/capsules/{id}/files/stream/write.
// Expects multipart/form-data with "path" text field and "file" file field. // Expects multipart/form-data with "path" text field and "file" file field.
// Streams file content directly from the request body to the host agent without buffering. // Streams file content directly from the request body to the host agent without buffering.
func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request) { func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request) {
@ -150,7 +150,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// StreamDownload handles POST /v1/sandboxes/{id}/files/stream/read. // StreamDownload handles POST /v1/capsules/{id}/files/stream/read.
// Accepts JSON body with path, streams file content back without buffering. // Accepts JSON body with path, streams file content back without buffering.
func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Request) { func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")

View File

@ -56,7 +56,7 @@ type removeRequest struct {
Path string `json:"path"` Path string `json:"path"`
} }
// ListDir handles POST /v1/sandboxes/{id}/files/list. // ListDir handles POST /v1/capsules/{id}/files/list.
func (h *fsHandler) ListDir(w http.ResponseWriter, r *http.Request) { func (h *fsHandler) ListDir(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()
@ -113,7 +113,7 @@ func (h *fsHandler) ListDir(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, listDirResponse{Entries: entries}) writeJSON(w, http.StatusOK, listDirResponse{Entries: entries})
} }
// MakeDir handles POST /v1/sandboxes/{id}/files/mkdir. // MakeDir handles POST /v1/capsules/{id}/files/mkdir.
func (h *fsHandler) MakeDir(w http.ResponseWriter, r *http.Request) { func (h *fsHandler) MakeDir(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()
@ -164,7 +164,7 @@ func (h *fsHandler) MakeDir(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, makeDirResponse{Entry: fileEntryFromPB(resp.Msg.Entry)}) writeJSON(w, http.StatusOK, makeDirResponse{Entry: fileEntryFromPB(resp.Msg.Entry)})
} }
// Remove handles POST /v1/sandboxes/{id}/files/remove. // Remove handles POST /v1/capsules/{id}/files/remove.
func (h *fsHandler) Remove(w http.ResponseWriter, r *http.Request) { func (h *fsHandler) Remove(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()

View File

@ -38,7 +38,7 @@ type metricsResponse struct {
Points []metricPointResponse `json:"points"` Points []metricPointResponse `json:"points"`
} }
// GetMetrics handles GET /v1/sandboxes/{id}/metrics?range=10m|2h|24h. // GetMetrics handles GET /v1/capsules/{id}/metrics?range=10m|2h|24h.
func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Request) { func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()

View File

@ -79,7 +79,7 @@ func (w *wsWriter) writeJSON(v any) {
} }
} }
// PtySession handles WS /v1/sandboxes/{id}/pty. // PtySession handles WS /v1/capsules/{id}/pty.
func (h *ptyHandler) PtySession(w http.ResponseWriter, r *http.Request) { func (h *ptyHandler) PtySession(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()

View File

@ -73,7 +73,7 @@ func sandboxToResponse(sb db.Sandbox) sandboxResponse {
return resp return resp
} }
// Create handles POST /v1/sandboxes. // Create handles POST /v1/capsules.
func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createSandboxRequest var req createSandboxRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@ -104,7 +104,7 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, sandboxToResponse(sb)) writeJSON(w, http.StatusCreated, sandboxToResponse(sb))
} }
// List handles GET /v1/sandboxes. // List handles GET /v1/capsules.
func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
sandboxes, err := h.svc.List(r.Context(), ac.TeamID) sandboxes, err := h.svc.List(r.Context(), ac.TeamID)
@ -121,7 +121,7 @@ func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
} }
// Get handles GET /v1/sandboxes/{id}. // Get handles GET /v1/capsules/{id}.
func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
@ -141,7 +141,7 @@ func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, sandboxToResponse(sb)) writeJSON(w, http.StatusOK, sandboxToResponse(sb))
} }
// Pause handles POST /v1/sandboxes/{id}/pause. // Pause handles POST /v1/capsules/{id}/pause.
func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
@ -163,7 +163,7 @@ func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, sandboxToResponse(sb)) writeJSON(w, http.StatusOK, sandboxToResponse(sb))
} }
// Resume handles POST /v1/sandboxes/{id}/resume. // Resume handles POST /v1/capsules/{id}/resume.
func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
@ -185,7 +185,7 @@ func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, sandboxToResponse(sb)) writeJSON(w, http.StatusOK, sandboxToResponse(sb))
} }
// Ping handles POST /v1/sandboxes/{id}/ping. // Ping handles POST /v1/capsules/{id}/ping.
func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
@ -205,7 +205,7 @@ func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// Destroy handles DELETE /v1/sandboxes/{id}. // Destroy handles DELETE /v1/capsules/{id}.
func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())

View File

@ -43,7 +43,7 @@ type statsResponse struct {
Series statsSeriesResponse `json:"series"` Series statsSeriesResponse `json:"series"`
} }
// GetStats handles GET /v1/sandboxes/stats?range=5m|1h|6h|24h|30d // GetStats handles GET /v1/capsules/stats?range=5m|1h|6h|24h|30d
func (h *statsHandler) GetStats(w http.ResponseWriter, r *http.Request) { func (h *statsHandler) GetStats(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())

View File

@ -1,6 +1,6 @@
openapi: "3.1.0" openapi: "3.1.0"
info: info:
title: Wrenn Sandbox API title: Wrenn API
description: MicroVM-based code execution platform API. description: MicroVM-based code execution platform API.
version: "0.1.0" version: "0.1.0"
@ -393,7 +393,7 @@ paths:
- bearerAuth: [] - bearerAuth: []
description: | description: |
Owner only. Soft-deletes the team and destroys all running/paused/starting Owner only. Soft-deletes the team and destroys all running/paused/starting
sandboxes. All DB records are preserved. The team slug is permanently reserved. capsulees. All DB records are preserved. The team slug is permanently reserved.
responses: responses:
"204": "204":
description: Team deleted description: Team deleted
@ -570,11 +570,11 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes: /v1/capsules:
post: post:
summary: Create a sandbox summary: Create a capsule
operationId: createSandbox operationId: createCapsule
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -582,14 +582,14 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/CreateSandboxRequest" $ref: "#/components/schemas/CreateCapsuleRequest"
responses: responses:
"201": "201":
description: Sandbox created description: Capsule created
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Sandbox" $ref: "#/components/schemas/Capsule"
"502": "502":
description: Host agent error description: Host agent error
content: content:
@ -598,26 +598,26 @@ paths:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
get: get:
summary: List sandboxes for your team summary: List capsulees for your team
operationId: listSandboxes operationId: listCapsules
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
responses: responses:
"200": "200":
description: List of sandboxes description: List of capsulees
content: content:
application/json: application/json:
schema: schema:
type: array type: array
items: items:
$ref: "#/components/schemas/Sandbox" $ref: "#/components/schemas/Capsule"
/v1/sandboxes/stats: /v1/capsules/stats:
get: get:
summary: Get sandbox usage stats for your team summary: Get capsule usage stats for your team
operationId: getSandboxStats operationId: getCapsuleStats
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
parameters: parameters:
@ -631,15 +631,15 @@ paths:
description: Time window for the time-series data. description: Time window for the time-series data.
responses: responses:
"200": "200":
description: Sandbox stats for the team description: Capsule stats for the team
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/SandboxStats" $ref: "#/components/schemas/CapsuleStats"
"400": "400":
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
/v1/sandboxes/{id}: /v1/capsules/{id}:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -648,36 +648,36 @@ paths:
type: string type: string
get: get:
summary: Get sandbox details summary: Get capsule details
operationId: getSandbox operationId: getCapsule
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
responses: responses:
"200": "200":
description: Sandbox details description: Capsule details
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Sandbox" $ref: "#/components/schemas/Capsule"
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
delete: delete:
summary: Destroy a sandbox summary: Destroy a capsule
operationId: destroySandbox operationId: destroyCapsule
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
responses: responses:
"204": "204":
description: Sandbox destroyed description: Capsule destroyed
/v1/sandboxes/{id}/exec: /v1/capsules/{id}/exec:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -688,7 +688,7 @@ paths:
post: post:
summary: Execute a command summary: Execute a command
operationId: execCommand operationId: execCommand
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -705,19 +705,19 @@ paths:
schema: schema:
$ref: "#/components/schemas/ExecResponse" $ref: "#/components/schemas/ExecResponse"
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/ping: /v1/capsules/{id}/ping:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -726,32 +726,32 @@ paths:
type: string type: string
post: post:
summary: Reset sandbox inactivity timer summary: Reset capsule inactivity timer
operationId: pingSandbox operationId: pingCapsule
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | description: |
Resets the last_active_at timestamp for a running sandbox, preventing Resets the last_active_at timestamp for a running capsule, preventing
the auto-pause TTL from expiring. Use this as a keepalive for sandboxes the auto-pause TTL from expiring. Use this as a keepalive for capsulees
that are idle but should remain running. that are idle but should remain running.
responses: responses:
"204": "204":
description: Ping acknowledged, inactivity timer reset description: Ping acknowledged, inactivity timer reset
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/metrics: /v1/capsules/{id}/metrics:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -760,22 +760,22 @@ paths:
type: string type: string
get: get:
summary: Get per-sandbox resource metrics summary: Get per-capsule resource metrics
operationId: getSandboxMetrics operationId: getCapsuleMetrics
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
- bearerAuth: [] - bearerAuth: []
description: | description: |
Returns time-series CPU, memory, and disk metrics for a sandbox. Returns time-series CPU, memory, and disk metrics for a capsule.
Three tiers are available with different granularity and retention: Three tiers are available with different granularity and retention:
- `10m`: 500ms samples, last 10 minutes - `10m`: 500ms samples, last 10 minutes
- `2h`: 30-second averages, last 2 hours - `2h`: 30-second averages, last 2 hours
- `24h`: 5-minute averages, last 24 hours - `24h`: 5-minute averages, last 24 hours
For running sandboxes, data comes from the host agent's in-memory For running capsulees, data comes from the host agent's in-memory
ring buffer. For paused sandboxes, data is read from persisted ring buffer. For paused capsulees, data is read from persisted
snapshots in the database. Stopped/destroyed sandboxes return 404. snapshots in the database. Stopped/destroyed capsulees return 404.
parameters: parameters:
- name: range - name: range
in: query in: query
@ -791,7 +791,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/SandboxMetrics" $ref: "#/components/schemas/CapsuleMetrics"
"400": "400":
description: Invalid range parameter description: Invalid range parameter
content: content:
@ -799,13 +799,13 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"404": "404":
description: Sandbox not found or metrics not available description: Capsule not found or metrics not available
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/pause: /v1/capsules/{id}/pause:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -814,30 +814,30 @@ paths:
type: string type: string
post: post:
summary: Pause a running sandbox summary: Pause a running capsule
operationId: pauseSandbox operationId: pauseCapsule
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | description: |
Takes a snapshot of the sandbox (VM state + memory + rootfs), then Takes a snapshot of the capsule (VM state + memory + rootfs), then
destroys all running resources. The sandbox exists only as files on destroys all running resources. The capsule exists only as files on
disk and can be resumed later. disk and can be resumed later.
responses: responses:
"200": "200":
description: Sandbox paused (snapshot taken, resources released) description: Capsule paused (snapshot taken, resources released)
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Sandbox" $ref: "#/components/schemas/Capsule"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/resume: /v1/capsules/{id}/resume:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -846,24 +846,24 @@ paths:
type: string type: string
post: post:
summary: Resume a paused sandbox summary: Resume a paused capsule
operationId: resumeSandbox operationId: resumeCapsule
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | description: |
Restores a paused sandbox from its snapshot using UFFD for lazy Restores a paused capsule from its snapshot using UFFD for lazy
memory loading. Boots a fresh Firecracker process, sets up a new memory loading. Boots a fresh Firecracker process, sets up a new
network slot, and waits for envd to become ready. network slot, and waits for envd to become ready.
responses: responses:
"200": "200":
description: Sandbox resumed (new VM booted from snapshot) description: Capsule resumed (new VM booted from snapshot)
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Sandbox" $ref: "#/components/schemas/Capsule"
"409": "409":
description: Sandbox not paused description: Capsule not paused
content: content:
application/json: application/json:
schema: schema:
@ -877,9 +877,9 @@ paths:
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | description: |
Pauses a running sandbox, takes a full snapshot, copies the snapshot Pauses a running capsule, takes a full snapshot, copies the snapshot
files to the images directory as a reusable template, then destroys files to the images directory as a reusable template, then destroys
the sandbox. The template can be used to create new sandboxes. the capsule. The template can be used to create new capsulees.
parameters: parameters:
- name: overwrite - name: overwrite
in: query in: query
@ -902,7 +902,7 @@ paths:
schema: schema:
$ref: "#/components/schemas/Template" $ref: "#/components/schemas/Template"
"409": "409":
description: Name already exists or sandbox not running description: Name already exists or capsule not running
content: content:
application/json: application/json:
schema: schema:
@ -957,7 +957,7 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/write: /v1/capsules/{id}/files/write:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -968,7 +968,7 @@ paths:
post: post:
summary: Upload a file summary: Upload a file
operationId: uploadFile operationId: uploadFile
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -981,7 +981,7 @@ paths:
properties: properties:
path: path:
type: string type: string
description: Absolute destination path inside the sandbox description: Absolute destination path inside the capsule
file: file:
type: string type: string
format: binary format: binary
@ -990,7 +990,7 @@ paths:
"204": "204":
description: File uploaded description: File uploaded
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
@ -1002,7 +1002,7 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/read: /v1/capsules/{id}/files/read:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1013,7 +1013,7 @@ paths:
post: post:
summary: Download a file summary: Download a file
operationId: downloadFile operationId: downloadFile
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -1031,13 +1031,13 @@ paths:
type: string type: string
format: binary format: binary
"404": "404":
description: Sandbox or file not found description: Capsule or file not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/list: /v1/capsules/{id}/files/list:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1048,7 +1048,7 @@ paths:
post: post:
summary: List directory contents summary: List directory contents
operationId: listDir operationId: listDir
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -1065,19 +1065,19 @@ paths:
schema: schema:
$ref: "#/components/schemas/ListDirResponse" $ref: "#/components/schemas/ListDirResponse"
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/mkdir: /v1/capsules/{id}/files/mkdir:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1088,7 +1088,7 @@ paths:
post: post:
summary: Create a directory summary: Create a directory
operationId: makeDir operationId: makeDir
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -1105,19 +1105,19 @@ paths:
schema: schema:
$ref: "#/components/schemas/MakeDirResponse" $ref: "#/components/schemas/MakeDirResponse"
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/remove: /v1/capsules/{id}/files/remove:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1128,7 +1128,7 @@ paths:
post: post:
summary: Remove a file or directory summary: Remove a file or directory
operationId: removePath operationId: removePath
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -1141,19 +1141,19 @@ paths:
"204": "204":
description: File or directory removed description: File or directory removed
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/exec/stream: /v1/capsules/{id}/exec/stream:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1164,7 +1164,7 @@ paths:
get: get:
summary: Stream command execution via WebSocket summary: Stream command execution via WebSocket
operationId: execStream operationId: execStream
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | description: |
@ -1194,19 +1194,19 @@ paths:
"101": "101":
description: WebSocket upgrade description: WebSocket upgrade
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/pty: /v1/capsules/{id}/pty:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1217,7 +1217,7 @@ paths:
get: get:
summary: Interactive PTY session via WebSocket summary: Interactive PTY session via WebSocket
operationId: ptySession operationId: ptySession
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | description: |
@ -1266,25 +1266,25 @@ paths:
Sessions have a 120-second inactivity timeout (reset on input/resize). Sessions have a 120-second inactivity timeout (reset on input/resize).
Sessions persist across WebSocket disconnections — the process keeps Sessions persist across WebSocket disconnections — the process keeps
running in the sandbox. Use the `tag` from the "started" response to running in the capsule. Use the `tag` from the "started" response to
reconnect later. reconnect later.
responses: responses:
"101": "101":
description: WebSocket upgrade description: WebSocket upgrade
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/stream/write: /v1/capsules/{id}/files/stream/write:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1295,11 +1295,11 @@ paths:
post: post:
summary: Upload a file (streaming) summary: Upload a file (streaming)
operationId: streamUploadFile operationId: streamUploadFile
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | description: |
Streams file content to the sandbox without buffering in memory. Streams file content to the capsule without buffering in memory.
Suitable for large files. Uses the same multipart/form-data format Suitable for large files. Uses the same multipart/form-data format
as the non-streaming upload endpoint. as the non-streaming upload endpoint.
requestBody: requestBody:
@ -1312,7 +1312,7 @@ paths:
properties: properties:
path: path:
type: string type: string
description: Absolute destination path inside the sandbox description: Absolute destination path inside the capsule
file: file:
type: string type: string
format: binary format: binary
@ -1321,19 +1321,19 @@ paths:
"204": "204":
description: File uploaded description: File uploaded
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/stream/read: /v1/capsules/{id}/files/stream/read:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1344,11 +1344,11 @@ paths:
post: post:
summary: Download a file (streaming) summary: Download a file (streaming)
operationId: streamDownloadFile operationId: streamDownloadFile
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | description: |
Streams file content from the sandbox without buffering in memory. Streams file content from the capsule without buffering in memory.
Suitable for large files. Returns raw bytes with chunked transfer encoding. Suitable for large files. Returns raw bytes with chunked transfer encoding.
requestBody: requestBody:
required: true required: true
@ -1365,13 +1365,13 @@ paths:
type: string type: string
format: binary format: binary
"404": "404":
description: Sandbox or file not found description: Capsule or file not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
@ -1469,14 +1469,14 @@ paths:
description: | description: |
Admins can delete any host. Team owners and admins can delete BYOC hosts Admins can delete any host. Team owners and admins can delete BYOC hosts
belonging to their team. Without `?force=true`, returns 409 if the host belonging to their team. Without `?force=true`, returns 409 if the host
has active sandboxes. With `?force=true`, destroys all sandboxes first. has active capsulees. With `?force=true`, destroys all capsulees first.
parameters: parameters:
- name: force - name: force
in: query in: query
required: false required: false
schema: schema:
type: boolean type: boolean
description: If true, destroy all sandboxes on the host before deleting. description: If true, destroy all capsulees on the host before deleting.
responses: responses:
"204": "204":
description: Host deleted description: Host deleted
@ -1487,11 +1487,11 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Host has active sandboxes (only when force is not set) description: Host has active capsulees (only when force is not set)
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/HostHasSandboxesError" $ref: "#/components/schemas/HostHasCapsulesError"
/v1/hosts/{id}/token: /v1/hosts/{id}/token:
parameters: parameters:
@ -1644,7 +1644,7 @@ paths:
security: security:
- bearerAuth: [] - bearerAuth: []
description: | description: |
Returns the list of sandbox IDs that would be destroyed if the host Returns the list of capsule IDs that would be destroyed if the host
were deleted with `?force=true`. No state is modified. were deleted with `?force=true`. No state is modified.
responses: responses:
"200": "200":
@ -1917,7 +1917,7 @@ components:
type: apiKey type: apiKey
in: header in: header
name: X-API-Key name: X-API-Key
description: API key for sandbox lifecycle operations. Create via POST /v1/api-keys. description: API key for capsule lifecycle operations. Create via POST /v1/api-keys.
bearerAuth: bearerAuth:
type: http type: http
@ -2002,7 +2002,7 @@ components:
description: Full plaintext key. Only returned on creation, never again. description: Full plaintext key. Only returned on creation, never again.
nullable: true nullable: true
CreateSandboxRequest: CreateCapsuleRequest:
type: object type: object
properties: properties:
template: template:
@ -2018,11 +2018,11 @@ components:
type: integer type: integer
default: 0 default: 0
description: > description: >
Auto-pause TTL in seconds. The sandbox is automatically paused Auto-pause TTL in seconds. The capsule is automatically paused
after this duration of inactivity (no exec or ping). 0 means after this duration of inactivity (no exec or ping). 0 means
no auto-pause. no auto-pause.
SandboxStats: CapsuleStats:
type: object type: object
properties: properties:
range: range:
@ -2073,7 +2073,7 @@ components:
items: items:
type: integer type: integer
Sandbox: Capsule:
type: object type: object
properties: properties:
id: id:
@ -2114,7 +2114,7 @@ components:
properties: properties:
sandbox_id: sandbox_id:
type: string type: string
description: ID of the running sandbox to snapshot. description: ID of the running capsule to snapshot.
name: name:
type: string type: string
description: Name for the snapshot template. Auto-generated if omitted. description: Name for the snapshot template. Auto-generated if omitted.
@ -2180,7 +2180,7 @@ components:
properties: properties:
path: path:
type: string type: string
description: Absolute file path inside the sandbox description: Absolute file path inside the capsule
ListDirRequest: ListDirRequest:
type: object type: object
@ -2188,7 +2188,7 @@ components:
properties: properties:
path: path:
type: string type: string
description: Directory path inside the sandbox description: Directory path inside the capsule
depth: depth:
type: integer type: integer
default: 1 default: 1
@ -2238,7 +2238,7 @@ components:
properties: properties:
path: path:
type: string type: string
description: Directory path to create inside the sandbox description: Directory path to create inside the capsule
MakeDirResponse: MakeDirResponse:
type: object type: object
@ -2252,7 +2252,7 @@ components:
properties: properties:
path: path:
type: string type: string
description: Path to remove inside the sandbox description: Path to remove inside the capsule
CreateHostRequest: CreateHostRequest:
type: object type: object
@ -2390,9 +2390,9 @@ components:
type: array type: array
items: items:
type: string type: string
description: IDs of sandboxes that would be destroyed on force-delete. description: IDs of capsulees that would be destroyed on force-delete.
HostHasSandboxesError: HostHasCapsulesError:
type: object type: object
properties: properties:
error: error:
@ -2407,7 +2407,7 @@ components:
type: array type: array
items: items:
type: string type: string
description: IDs of active sandboxes blocking deletion. description: IDs of active capsulees blocking deletion.
AddTagRequest: AddTagRequest:
type: object type: object
@ -2471,7 +2471,7 @@ components:
items: items:
$ref: "#/components/schemas/TeamMember" $ref: "#/components/schemas/TeamMember"
SandboxMetrics: CapsuleMetrics:
type: object type: object
properties: properties:
sandbox_id: sandbox_id:

View File

@ -116,8 +116,8 @@ func New(
// JWT-authenticated: user search (for add-member UI). // JWT-authenticated: user search (for add-member UI).
r.With(requireJWT(jwtSecret)).Get("/v1/users/search", usersH.Search) r.With(requireJWT(jwtSecret)).Get("/v1/users/search", usersH.Search)
// Sandbox lifecycle: accepts API key or JWT bearer token. // Capsule lifecycle: accepts API key or JWT bearer token.
r.Route("/v1/sandboxes", func(r chi.Router) { r.Route("/v1/capsules", func(r chi.Router) {
r.Use(requireAPIKeyOrJWT(queries, jwtSecret)) r.Use(requireAPIKeyOrJWT(queries, jwtSecret))
r.Post("/", sandbox.Create) r.Post("/", sandbox.Create)
r.Get("/", sandbox.List) r.Get("/", sandbox.List)
@ -229,7 +229,7 @@ func serveDocs(w http.ResponseWriter, r *http.Request) {
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wrenn Sandbox API</title> <title>Wrenn API</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.18.2/swagger-ui.css" integrity="sha384-rcbEi6xgdPk0iWkAQzT2F3FeBJXdG+ydrawGlfHAFIZG7wU6aKbQaRewysYpmrlW" crossorigin="anonymous"> <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.18.2/swagger-ui.css" integrity="sha384-rcbEi6xgdPk0iWkAQzT2F3FeBJXdG+ydrawGlfHAFIZG7wU6aKbQaRewysYpmrlW" crossorigin="anonymous">
<style> <style>
body { margin: 0; background: #fafafa; } body { margin: 0; background: #fafafa; }