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.
- **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".
- **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).
@ -147,19 +147,19 @@ HIBERNATED → RUNNING (cold snapshot resume, slower)
### Key Request Flows
**Sandbox creation** (`POST /v1/sandboxes`):
**Sandbox creation** (`POST /v1/capsules`):
1. API handler generates sandbox ID, inserts into DB as "pending"
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
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
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
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
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

View File

@ -17,11 +17,11 @@ export type 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>> {
return apiFetch('GET', `/api/v1/sandboxes/${id}`);
return apiFetch('GET', `/api/v1/capsules/${id}`);
}
export type CreateCapsuleParams = {
@ -32,19 +32,19 @@ export type CreateCapsuleParams = {
};
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>> {
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>> {
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>> {
return apiFetch('DELETE', `/api/v1/sandboxes/${id}`);
return apiFetch('DELETE', `/api/v1/capsules/${id}`);
}
export type Snapshot = {
@ -57,8 +57,8 @@ export type Snapshot = {
platform: boolean;
};
export async function createSnapshot(sandboxId: string, name?: string): Promise<ApiResult<Snapshot>> {
return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: sandboxId, name });
export async function createSnapshot(capsuleId: string, name?: string): Promise<ApiResult<Snapshot>> {
return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: capsuleId, name });
}
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]}`;
}
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 {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
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',
headers,
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 {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
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',
headers,
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' };
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',
headers,
body: JSON.stringify({ path }),

View File

@ -15,8 +15,8 @@ export type MetricsResponse = {
points: MetricPoint[];
};
export async function fetchSandboxMetrics(id: string, range: MetricRange): Promise<ApiResult<MetricsResponse>> {
return apiFetch('GET', `/api/v1/sandboxes/${id}/metrics?range=${range}`);
export async function fetchCapsuleMetrics(id: string, range: MetricRange): Promise<ApiResult<MetricsResponse>> {
return apiFetch('GET', `/api/v1/capsules/${id}/metrics?range=${range}`);
}
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>> {
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> = {

View File

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

View File

@ -3,12 +3,12 @@
import { auth } from '$lib/auth.svelte';
type Props = {
sandboxId: string;
capsuleId: string;
isRunning: boolean;
visible?: boolean;
};
let { sandboxId, isRunning, visible = true }: Props = $props();
let { capsuleId, isRunning, visible = true }: Props = $props();
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
@ -93,7 +93,7 @@
function getWsUrl(): string {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
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) {

View File

@ -17,7 +17,7 @@ let loadingPromise: Promise<HighlighterGeneric<any, any>> | null = null;
const THEME = 'vesper';
// 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> = {
// Go
go: 'go', mod: 'go', sum: 'go',

View File

@ -77,7 +77,7 @@
// Delete confirmation
let deleteTarget = $state<Host | null>(null);
let deletePreviewSandboxes = $state<string[]>([]);
let deletePreviewCapsules = $state<string[]>([]);
let deletePreviewLoading = $state(false);
let deleting = $state(false);
let deleteError = $state<string | null>(null);
@ -124,12 +124,12 @@
async function openDeleteConfirm(host: Host) {
deleteTarget = host;
deleteError = null;
deletePreviewSandboxes = [];
deletePreviewCapsules = [];
deletePreviewLoading = true;
const preview = await getDeletePreview(host.id);
deletePreviewLoading = false;
if (preview.ok) {
deletePreviewSandboxes = preview.data.sandbox_ids;
deletePreviewCapsules = preview.data.sandbox_ids;
}
}
@ -137,7 +137,7 @@
if (!deleteTarget) return;
deleting = true;
deleteError = null;
const result = await deleteHost(deleteTarget.id, deletePreviewSandboxes.length > 0);
const result = await deleteHost(deleteTarget.id, deletePreviewCapsules.length > 0);
if (result.ok) {
allHosts = allHosts.filter((h) => h.id !== deleteTarget!.id);
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>
Checking active capsules…
</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">
<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 class="mt-0.5 text-meta text-[var(--color-amber)]/70">
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>
Deleting…
{:else}
{deletePreviewSandboxes.length > 0 ? 'Force Delete' : 'Delete'}
{deletePreviewCapsules.length > 0 ? 'Force Delete' : 'Delete'}
{/if}
</button>
</div>

View File

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

View File

@ -43,7 +43,7 @@
// Delete confirmation
let deleteTarget = $state<Host | null>(null);
let deletePreviewSandboxes = $state<string[]>([]);
let deletePreviewCapsules = $state<string[]>([]);
let deletePreviewLoading = $state(false);
let deleting = $state(false);
let deleteError = $state<string | null>(null);
@ -97,12 +97,12 @@
async function openDeleteConfirm(host: Host) {
deleteTarget = host;
deleteError = null;
deletePreviewSandboxes = [];
deletePreviewCapsules = [];
deletePreviewLoading = true;
const preview = await getDeletePreview(host.id);
deletePreviewLoading = false;
if (preview.ok) {
deletePreviewSandboxes = preview.data.sandbox_ids;
deletePreviewCapsules = preview.data.sandbox_ids;
}
}
@ -110,7 +110,7 @@
if (!deleteTarget) return;
deleting = true;
deleteError = null;
const result = await deleteHost(deleteTarget.id, deletePreviewSandboxes.length > 0);
const result = await deleteHost(deleteTarget.id, deletePreviewCapsules.length > 0);
if (result.ok) {
hosts = hosts.filter((h) => h.id !== deleteTarget!.id);
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>
Checking active capsules…
</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">
<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 class="mt-0.5 text-meta text-[var(--color-amber)]/70">
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>
Deleting…
{:else}
{deletePreviewSandboxes.length > 0 ? 'Force Delete' : 'Delete Host'}
{deletePreviewCapsules.length > 0 ? 'Force Delete' : 'Delete Host'}
{/if}
</button>
</div>

View File

@ -45,7 +45,7 @@ type execResponse struct {
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) {
sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context()

View File

@ -47,7 +47,7 @@ type wsOutMsg struct {
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) {
sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context()

View File

@ -25,7 +25,7 @@ func newFilesHandler(db *db.Queries, pool *lifecycle.HostClientPool) *filesHandl
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:
// - "path" text field: absolute destination path inside the sandbox
// - "file" file field: binary content to write
@ -105,7 +105,7 @@ type readFileRequest struct {
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.
func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) {
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}
}
// 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.
// Streams file content directly from the request body to the host agent without buffering.
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)
}
// 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.
func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id")

View File

@ -56,7 +56,7 @@ type removeRequest struct {
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) {
sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context()
@ -113,7 +113,7 @@ func (h *fsHandler) ListDir(w http.ResponseWriter, r *http.Request) {
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) {
sandboxIDStr := chi.URLParam(r, "id")
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)})
}
// 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) {
sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context()

View File

@ -38,7 +38,7 @@ type metricsResponse struct {
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) {
sandboxIDStr := chi.URLParam(r, "id")
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) {
sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context()

View File

@ -73,7 +73,7 @@ func sandboxToResponse(sb db.Sandbox) sandboxResponse {
return resp
}
// Create handles POST /v1/sandboxes.
// Create handles POST /v1/capsules.
func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createSandboxRequest
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))
}
// List handles GET /v1/sandboxes.
// List handles GET /v1/capsules.
func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
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)
}
// Get handles GET /v1/sandboxes/{id}.
// Get handles GET /v1/capsules/{id}.
func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id")
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))
}
// Pause handles POST /v1/sandboxes/{id}/pause.
// Pause handles POST /v1/capsules/{id}/pause.
func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id")
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))
}
// Resume handles POST /v1/sandboxes/{id}/resume.
// Resume handles POST /v1/capsules/{id}/resume.
func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id")
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))
}
// Ping handles POST /v1/sandboxes/{id}/ping.
// Ping handles POST /v1/capsules/{id}/ping.
func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context())
@ -205,7 +205,7 @@ func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) {
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) {
sandboxIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context())

View File

@ -43,7 +43,7 @@ type statsResponse struct {
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) {
ac := auth.MustFromContext(r.Context())

View File

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

View File

@ -116,8 +116,8 @@ func New(
// JWT-authenticated: user search (for add-member UI).
r.With(requireJWT(jwtSecret)).Get("/v1/users/search", usersH.Search)
// Sandbox lifecycle: accepts API key or JWT bearer token.
r.Route("/v1/sandboxes", func(r chi.Router) {
// Capsule lifecycle: accepts API key or JWT bearer token.
r.Route("/v1/capsules", func(r chi.Router) {
r.Use(requireAPIKeyOrJWT(queries, jwtSecret))
r.Post("/", sandbox.Create)
r.Get("/", sandbox.List)
@ -229,7 +229,7 @@ func serveDocs(w http.ResponseWriter, r *http.Request) {
<head>
<meta charset="UTF-8">
<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">
<style>
body { margin: 0; background: #fafafa; }