diff --git a/crates/aenv/src/client/sandboxes.rs b/crates/aenv/src/client/sandboxes.rs index 062ed4228..c85b596c7 100644 --- a/crates/aenv/src/client/sandboxes.rs +++ b/crates/aenv/src/client/sandboxes.rs @@ -5,6 +5,23 @@ use serde_json::json; use std::collections::HashMap; use std::time::Duration; +#[derive(Debug, Serialize)] +pub struct SandboxVolumeMount { + pub name: String, + pub path: String, +} + +fn volume_mounts_request( + mounts: Option>, +) -> Option> { + mounts.map(|mounts| { + mounts + .into_iter() + .map(|(path, name)| SandboxVolumeMount { name, path }) + .collect() + }) +} + #[derive(Debug, Serialize)] pub struct NewSandbox<'a> { #[serde(rename = "templateID")] @@ -13,7 +30,7 @@ pub struct NewSandbox<'a> { pub timeout: Option, pub secure: bool, #[serde(skip_serializing_if = "Option::is_none", rename = "volumeMounts")] - pub volume_mounts: Option>, + pub volume_mounts: Option>, } #[derive(Debug, Serialize)] @@ -29,7 +46,7 @@ pub struct NewColdSandbox<'a> { pub disk_size_mb: Option, pub secure: bool, #[serde(skip_serializing_if = "Option::is_none", rename = "volumeMounts")] - pub volume_mounts: Option>, + pub volume_mounts: Option>, } #[derive(Deserialize)] @@ -86,7 +103,7 @@ impl Client { template_id, timeout, secure: true, - volume_mounts, + volume_mounts: volume_mounts_request(volume_mounts), }; let resp = handle_status(self.post("/sandboxes").send_json(&body))?; let sandbox: Sandbox = resp.into_json()?; @@ -110,7 +127,7 @@ impl Client { memory_mb, disk_size_mb, secure: true, - volume_mounts, + volume_mounts: volume_mounts_request(volume_mounts), }; let resp = handle_status(self.post("/sandboxes-cold").send_json(&body))?; let sandbox: Sandbox = resp.into_json()?; diff --git a/docs/src/concepts/volumes.md b/docs/src/concepts/volumes.md index 7d99533ce..8dd5be9c9 100644 --- a/docs/src/concepts/volumes.md +++ b/docs/src/concepts/volumes.md @@ -206,9 +206,12 @@ curl -fsS -X POST "$AENV_URL/sandboxes" \ -H "Content-Type: application/json" \ -d '{ "templateID": "ubuntu", - "volumeMounts": { - "/workspace/data": "job-42-data" - } + "volumeMounts": [ + { + "name": "job-42-data", + "path": "/workspace/data" + } + ] }' ``` diff --git a/docs/src/integration/e2b.md b/docs/src/integration/e2b.md index 8650d8595..656e17da9 100644 --- a/docs/src/integration/e2b.md +++ b/docs/src/integration/e2b.md @@ -64,6 +64,38 @@ await sandbox.kill(); Replace `` with a template that exists in your local template store. Use `e2b template list` or `GET /v2/templates` to see available templates. +### Volume mounts + +The TypeScript SDK can create a volume and pass it directly when creating a +sandbox: + +```typescript +import { Sandbox, Volume } from "e2b"; + +const volume = await Volume.create("workspace-volume", { + apiKey: process.env.E2B_API_KEY, +}); +const sandbox = await Sandbox.create("", { + apiKey: process.env.E2B_API_KEY, + volumeMounts: { + "/workspace": volume, + }, +}); +``` + +For the Python SDK, create the volume with `aenv volume create` or +`POST /volumes`, then pass its name when creating a sandbox: + +```python +sandbox = Sandbox.create( + "", + volume_mounts={"/workspace": "workspace-volume"}, +) +``` + +AgentENV supports the TypeScript SDK's volume create, list, and delete operations, and accessing the mounted filesystem through the sandbox. +The E2B SDK's direct volume content API is not supported. + ### Python SDK #### Setup diff --git a/scripts/tests/e2e/e2b_ts_sdk_compat.ts b/scripts/tests/e2e/e2b_ts_sdk_compat.ts index 7912e2ccb..1c83c1a31 100644 --- a/scripts/tests/e2e/e2b_ts_sdk_compat.ts +++ b/scripts/tests/e2e/e2b_ts_sdk_compat.ts @@ -2,7 +2,13 @@ /** * E2B TypeScript SDK compatibility checks for AgentENV. */ -import { Sandbox, Template, type BuildInfo } from "e2b"; +import { + Sandbox, + Template, + Volume, + type BuildInfo, + type ConnectionOpts, +} from "e2b"; function log(message: string): void { console.log(`[e2b-ts-sdk] ${message}`); @@ -32,7 +38,21 @@ async function retry( } } } - throw new Error(`${description} failed after ${attempts} attempts: ${lastError}`); + throw new Error( + `${description} failed after ${attempts} attempts: ${lastError}`, + ); +} + +async function deleteManagedVolume( + volumeID: string, + opts: ConnectionOpts, +): Promise { + return retry( + () => Volume.destroy(volumeID, opts), + "volume cleanup", + 60, + 1000, + ); } async function main(): Promise { @@ -40,12 +60,17 @@ async function main(): Promise { process.env.E2B_COMPAT_TEMPLATE_NAME ?? `e2b-ts-sdk-${Date.now()}`; const publicTemplate = (process.env.AENV_TEMPLATE_ID ?? "").trim(); const derivedTemplateName = `${templateName}-from-template`; - const baseImage = process.env.E2B_COMPAT_USER_IMAGE ?? "ghcr.io/linuxserver/baseimage-ubuntu:noble"; + const baseImage = + process.env.E2B_COMPAT_USER_IMAGE ?? + "ghcr.io/linuxserver/baseimage-ubuntu:noble"; const workdir = `/tmp/${templateName}`; const derivedWorkdir = `/tmp/${derivedTemplateName}`; const buildMarker = `sdk-build-marker-${Date.now()}`; const derivedMarker = `sdk-from-template-marker-${Date.now()}`; const startupMarker = `sdk-startup-marker-${Date.now()}`; + const volumeMarker = `sdk-volume-marker-${Date.now()}`; + const volumeName = `e2b-ts-volume-${Date.now()}`; + const volumeMountPath = "/mnt/e2b-volume"; const apiUrl = process.env.E2B_API_URL; const sandboxUrl = process.env.E2B_SANDBOX_URL; const apiKey = process.env.E2B_API_KEY; @@ -58,6 +83,8 @@ async function main(): Promise { let derivedBuildInfo: BuildInfo | null = null; let sandbox: Sandbox | null = null; let derivedSandbox: Sandbox | null = null; + let volumeSandbox: Sandbox | null = null; + let volume: Volume | null = null; try { log(`building template ${templateName} from ${baseImage}`); @@ -83,12 +110,25 @@ async function main(): Promise { ...connOpts, }); - check(!!buildInfo.templateId, "template build returned an empty templateId"); + check( + !!buildInfo.templateId, + "template build returned an empty templateId", + ); check(!!buildInfo.buildId, "template build returned an empty buildId"); - check(buildInfo.name === templateName, "template build returned the wrong name"); - log(`template ready: templateId=${buildInfo.templateId} buildId=${buildInfo.buildId}`); + check( + buildInfo.name === templateName, + "template build returned the wrong name", + ); + log( + `template ready: templateId=${buildInfo.templateId} buildId=${buildInfo.buildId}`, + ); + + volume = await Volume.create(volumeName, connOpts); + check(!!volume.volumeId, "Volume.create returned an empty volumeId"); + check(volume.name === volumeName, "Volume.create returned the wrong name"); + log(`volume ready: volumeId=${volume.volumeId} name=${volume.name}`); - log("creating sandbox from SDK-built template"); + log("creating sandbox with a volume through the SDK"); sandbox = await Sandbox.create(templateName, { metadata: { suite: "e2b-ts-sdk", @@ -96,16 +136,33 @@ async function main(): Promise { }, timeoutMs: 90_000, secure: true, + volumeMounts: { [volumeMountPath]: volume }, ...connOpts, }); check(!!sandbox.sandboxId, "sandbox create returned an empty sandboxId"); log(`sandbox created: ${sandbox.sandboxId}`); - const listed = await Sandbox.list({ ...connOpts, limit: 20, requestTimeoutMs: 60_000}).nextItems(); + const listed = await Sandbox.list({ + ...connOpts, + limit: 20, + requestTimeoutMs: 60_000, + }).nextItems(); const listedIds = new Set(listed.map((item) => item.sandboxId)); - check(listedIds.has(sandbox.sandboxId), "Sandbox.list did not include created sandbox"); + check( + listedIds.has(sandbox.sandboxId), + "Sandbox.list did not include created sandbox", + ); + const listedSandbox = listed.find( + (item) => item.sandboxId === sandbox!.sandboxId, + ); + check( + listedSandbox?.volumeMounts?.some( + (mount) => mount.path === volumeMountPath, + ) ?? false, + "Sandbox.list did not include the SDK-created volume mount", + ); log("sandbox list includes SDK-created sandbox"); - + const result = await retry( () => sandbox!.commands.run( @@ -118,13 +175,19 @@ async function main(): Promise { { cwd: workdir, timeoutMs: 30_000, - } - ), + }, + ), "command execution", ); check(result.exitCode === 0, `command exited with ${result.exitCode}`); - check(result.stdout.includes(`marker=${buildMarker}`), "build marker file did not match"); - check(result.stdout.includes(`workdir=${workdir}`), "WORKDIR build step was not preserved"); + check( + result.stdout.includes(`marker=${buildMarker}`), + "build marker file did not match", + ); + check( + result.stdout.includes(`workdir=${workdir}`), + "WORKDIR build step was not preserved", + ); check( result.stdout.includes(`startup=${startupMarker}`), "startup ready marker file did not match", @@ -133,7 +196,24 @@ async function main(): Promise { result.stdout.includes(`agentenv-startup-${startupMarker}`), "startCmd process was not preserved in the template snapshot", ); - log("command execution returned expected build artifacts and startup state"); + log( + "command execution returned expected build artifacts and startup state", + ); + + const volumeWrite = await retry( + () => + sandbox!.commands.run( + `printf '%s' '${volumeMarker}' > ${volumeMountPath}/sdk-marker.txt && ` + + `cat ${volumeMountPath}/sdk-marker.txt`, + { timeoutMs: 30_000 }, + ), + "volume write through mounted sandbox", + ); + check( + volumeWrite.stdout === volumeMarker, + "mounted volume write did not round trip", + ); + log("SDK-created sandbox wrote to the mounted volume"); if ((process.env.E2B_COMPAT_TEST_PAUSE ?? "1") !== "0") { log("pausing and reconnecting sandbox through SDK lifecycle APIs"); @@ -146,7 +226,10 @@ async function main(): Promise { () => sandbox!.commands.run("printf resumed", { timeoutMs: 30_000 }), "command execution after reconnect", ); - check(resumed.stdout === "resumed", "sandbox did not run commands after reconnect"); + check( + resumed.stdout === "resumed", + "sandbox did not run commands after reconnect", + ); log("sandbox reconnect succeeded"); } @@ -154,8 +237,45 @@ async function main(): Promise { sandbox = null; log("sandbox killed"); + log("creating a second sandbox with the same volume through the SDK"); + volumeSandbox = await retry( + () => + Sandbox.create(templateName, { + metadata: { suite: "e2b-ts-sdk", volume: volume!.name }, + timeoutMs: 90_000, + secure: true, + volumeMounts: { [volumeMountPath]: volume! }, + ...connOpts, + }), + "volume remount after sandbox deletion", + 60, + 1000, + ); + const persistedVolume = await retry( + () => + volumeSandbox!.commands.run(`cat ${volumeMountPath}/sdk-marker.txt`, { + timeoutMs: 30_000, + }), + "volume read after remount", + ); + check( + persistedVolume.stdout === volumeMarker, + "volume contents did not survive remount", + ); + await volumeSandbox.kill(); + volumeSandbox = null; + log("volume contents survived sandbox deletion and SDK remount"); + check( + await deleteManagedVolume(volume.volumeId, connOpts), + "Volume.destroy did not delete the volume", + ); + volume = null; + log("volume deleted"); + if (publicTemplate) { - log(`building template ${derivedTemplateName} from template ${publicTemplate}`); + log( + `building template ${derivedTemplateName} from template ${publicTemplate}`, + ); const derivedTemplate = Template() .fromTemplate(publicTemplate) .runCmd(`mkdir -p ${derivedWorkdir}`) @@ -164,15 +284,25 @@ async function main(): Promise { .runCmd(`printf '%s' "$AENV_E2B_SDK_FROM_TEMPLATE_MARKER" > marker.txt`) .runCmd("pwd > workdir.txt"); - derivedBuildInfo = await Template.build(derivedTemplate, derivedTemplateName, { - cpuCount: 1, - memoryMB: 128, - skipCache: true, - ...connOpts, - }); + derivedBuildInfo = await Template.build( + derivedTemplate, + derivedTemplateName, + { + cpuCount: 1, + memoryMB: 128, + skipCache: true, + ...connOpts, + }, + ); - check(!!derivedBuildInfo.templateId, "from_template build returned an empty templateId"); - check(!!derivedBuildInfo.buildId, "from_template build returned an empty buildId"); + check( + !!derivedBuildInfo.templateId, + "from_template build returned an empty templateId", + ); + check( + !!derivedBuildInfo.buildId, + "from_template build returned an empty buildId", + ); check( derivedBuildInfo.name === derivedTemplateName, "from_template build returned the wrong name", @@ -193,7 +323,10 @@ async function main(): Promise { secure: true, ...connOpts, }); - check(!!derivedSandbox.sandboxId, "from_template sandbox create returned an empty sandboxId"); + check( + !!derivedSandbox.sandboxId, + "from_template sandbox create returned an empty sandboxId", + ); log(`from_template sandbox created: ${derivedSandbox.sandboxId}`); const derivedResult = await retry( @@ -222,7 +355,9 @@ async function main(): Promise { derivedSandbox = null; log("from_template sandbox killed"); } else { - log("AENV_TEMPLATE_ID is not set; skipping from_template compatibility check"); + log( + "AENV_TEMPLATE_ID is not set; skipping from_template compatibility check", + ); } } finally { if (derivedSandbox !== null) { @@ -241,6 +376,22 @@ async function main(): Promise { } } + if (volumeSandbox !== null) { + try { + await volumeSandbox.kill(); + } catch (error) { + log(`cleanup volume sandbox kill failed: ${error}`); + } + } + + if (volume !== null) { + try { + await deleteManagedVolume(volume.volumeId, connOpts); + } catch (error) { + log(`cleanup volume delete failed: ${error}`); + } + } + if (derivedBuildInfo !== null) { try { await Sandbox.deleteSnapshot(derivedBuildInfo.templateId, connOpts); diff --git a/scripts/tests/e2e/suites/09_e2b_compat.sh b/scripts/tests/e2e/suites/09_e2b_compat.sh index 611b09ad6..e1bbd9b29 100755 --- a/scripts/tests/e2e/suites/09_e2b_compat.sh +++ b/scripts/tests/e2e/suites/09_e2b_compat.sh @@ -159,13 +159,13 @@ if [[ -f "$ts_sdk_script" ]] && command -v npm >/dev/null 2>&1; then log "Running: e2b TypeScript SDK compatibility (${ts_sdk_script})" if command -v timeout >/dev/null 2>&1; then if sdk_output=$(timeout "${sdk_timeout}" "$tsx_bin" "$ts_sdk_script" 2>&1); then - _pass "e2b TypeScript SDK template build, startCmd/readyCmd, sandbox lifecycle, and commands" + _pass "e2b TypeScript SDK template build, volume mount persistence, sandbox lifecycle, and commands" else log "e2b TypeScript SDK output: ${sdk_output:0:1200}" _fail "e2b TypeScript SDK compatibility" "exit 0" "non-zero" fi elif sdk_output=$("$tsx_bin" "$ts_sdk_script" 2>&1); then - _pass "e2b TypeScript SDK template build, startCmd/readyCmd, sandbox lifecycle, and commands" + _pass "e2b TypeScript SDK template build, volume mount persistence, sandbox lifecycle, and commands" else log "e2b TypeScript SDK output: ${sdk_output:0:1200}" _fail "e2b TypeScript SDK compatibility" "exit 0" "non-zero" diff --git a/scripts/tests/e2e/suites/15_volume.sh b/scripts/tests/e2e/suites/15_volume.sh index 4b78b2ada..836f61d76 100755 --- a/scripts/tests/e2e/suites/15_volume.sh +++ b/scripts/tests/e2e/suites/15_volume.sh @@ -98,7 +98,7 @@ cold_payload=$(jq -nc \ image: $image, timeout: 300, autoPause: false, - volumeMounts: {($mount_path): $volume_id} + volumeMounts: [{name: $volume_id, path: $mount_path}] }') api_post "/sandboxes-cold" "${cold_payload}" assert_status "${HTTP_STATUS}" "201" "create cold-image sandbox with a volume" @@ -139,7 +139,8 @@ track_sandbox "${restored_sandbox_id}" api_get "/sandboxes/${restored_sandbox_id}" assert_status "${HTTP_STATUS}" "200" "get restored cold-image sandbox" restored_volume_id="$(echo "${HTTP_BODY}" | jq -r \ - --arg path "${VOLUME_MOUNT_PATH}" '.volumeMounts[$path] // empty')" + --arg path "${VOLUME_MOUNT_PATH}" \ + '[.volumeMounts[]? | select(.path == $path) | .name][0] // empty')" assert_not_empty "${restored_volume_id}" "automatically restored volume ID is present" assert_not_eq "${restored_volume_id}" "${source_volume_id}" \ "volume snapshot restore creates an independent volume" diff --git a/scripts/tests/e2e/suites/16_volume_randomized.sh b/scripts/tests/e2e/suites/16_volume_randomized.sh index 6f90cdbf3..3fec90020 100644 --- a/scripts/tests/e2e/suites/16_volume_randomized.sh +++ b/scripts/tests/e2e/suites/16_volume_randomized.sh @@ -16,7 +16,7 @@ if ! e2e_mode_is compose; then fi raw_random_seed="${AENV_VOLUME_RANDOM_SEED:-21106}" -raw_random_steps="${AENV_VOLUME_RANDOM_STEPS:-100}" +raw_random_steps="${AENV_VOLUME_RANDOM_STEPS:-50}" readonly VOLUME_SIZE_MB=16 readonly VOLUME_MOUNT_PATH="/volume" @@ -169,7 +169,7 @@ start_volume_sandbox() { --arg suite "${run_name}" \ --arg step "${CURRENT_STEP}" \ --arg mount_path "${VOLUME_MOUNT_PATH}" \ - '{autoPause: false, metadata: {suite: $suite, step: $step}, volumeMounts: {($mount_path): $volume_id}}') + '{autoPause: false, metadata: {suite: $suite, step: $step}, volumeMounts: [{name: $volume_id, path: $mount_path}]}') LAST_SANDBOX_ID=$(create_sandbox "${AENV_TEMPLATE_ID}" 300 "${mount_payload}") _sync_http assert_status "${HTTP_STATUS}" "201" "${CURRENT_STEP}: create volume sandbox through gateway" @@ -293,7 +293,8 @@ fork_volume_cycle() { assert_status "${HTTP_STATUS}" "200" \ "${CURRENT_STEP}: fork child is immediately routable through gateway" local child_volume_id - child_volume_id="$(echo "${HTTP_BODY}" | jq -r --arg path "${VOLUME_MOUNT_PATH}" '.volumeMounts[$path] // empty')" + child_volume_id="$(echo "${HTTP_BODY}" | jq -r --arg path "${VOLUME_MOUNT_PATH}" \ + '[.volumeMounts[]? | select(.path == $path) | .name][0] // empty')" assert_not_empty "${child_volume_id}" "${CURRENT_STEP}: fork child volume ID is present" register_volume "${child_volume_id}" "exclusive" "${VOLUME_CONTENT[${source_volume_id}]}" log "seed=${VOLUME_RANDOM_SEED} step=${CURRENT_STEP} fork-volume=${child_volume_id} source=${source_volume_id}" diff --git a/services/gateway/internal/cluster_list.go b/services/gateway/internal/cluster_list.go index e86ea0a9e..c02e2188c 100644 --- a/services/gateway/internal/cluster_list.go +++ b/services/gateway/internal/cluster_list.go @@ -21,18 +21,37 @@ import ( const maxCursorSandboxID = "ffffffff-ffff-ffff-ffff-ffffffffffff" type listedSandbox struct { - TemplateID string `json:"templateID"` - Alias *string `json:"alias,omitempty"` - SandboxID string `json:"sandboxID"` - ClientID string `json:"clientID"` - StartedAt time.Time `json:"startedAt"` - EndAt time.Time `json:"endAt"` - CPUCount uint32 `json:"cpuCount"` - MemoryMB uint32 `json:"memoryMB"` - DiskSizeMB uint32 `json:"diskSizeMB"` - Metadata map[string]string `json:"metadata,omitempty"` - State string `json:"state"` - EnvdVersion string `json:"envdVersion"` + payload json.RawMessage + sandboxID string + startedAt time.Time + state string +} + +type listedSandboxIndex struct { + SandboxID string `json:"sandboxID"` + StartedAt time.Time `json:"startedAt"` + State string `json:"state"` +} + +func (s *listedSandbox) UnmarshalJSON(data []byte) error { + var index listedSandboxIndex + if err := json.Unmarshal(data, &index); err != nil { + return err + } + + // Keep the node response opaque so additive API fields survive aggregation. + s.payload = append(s.payload[:0], data...) + s.sandboxID = index.SandboxID + s.startedAt = index.StartedAt + s.state = index.State + return nil +} + +func (s listedSandbox) MarshalJSON() ([]byte, error) { + if len(s.payload) == 0 { + return nil, errors.New("listed sandbox payload is empty") + } + return s.payload, nil } type clusterListResult struct { @@ -263,7 +282,7 @@ func clusterListIncludesRunning(r *http.Request) bool { func runningSandboxCount(items []listedSandbox) int { count := 0 for _, item := range items { - if item.State == "running" { + if item.state == "running" { count++ } } @@ -272,16 +291,16 @@ func runningSandboxCount(items []listedSandbox) int { func sortListedSandboxes(items []listedSandbox, descending bool) { sort.Slice(items, func(i, j int) bool { - if items[i].StartedAt.Equal(items[j].StartedAt) { + if items[i].startedAt.Equal(items[j].startedAt) { if descending { - return items[i].SandboxID < items[j].SandboxID + return items[i].sandboxID < items[j].sandboxID } - return items[i].SandboxID > items[j].SandboxID + return items[i].sandboxID > items[j].sandboxID } if descending { - return items[i].StartedAt.After(items[j].StartedAt) + return items[i].startedAt.After(items[j].startedAt) } - return items[i].StartedAt.Before(items[j].StartedAt) + return items[i].startedAt.Before(items[j].startedAt) }) } @@ -295,10 +314,10 @@ func dedupListedSandboxes(items []listedSandbox) []listedSandbox { seen := make(map[string]struct{}, len(items)) deduped := make([]listedSandbox, 0, len(items)) for _, item := range items { - if _, ok := seen[item.SandboxID]; ok { + if _, ok := seen[item.sandboxID]; ok { continue } - seen[item.SandboxID] = struct{}{} + seen[item.sandboxID] = struct{}{} deduped = append(deduped, item) } return deduped @@ -312,13 +331,13 @@ func paginateListedSandboxes(items []listedSandbox, nextToken string, limit *int page := make([]listedSandbox, 0, len(items)) for _, item := range items { - pastCursor := item.StartedAt.Before(cursorTime) - pastID := item.SandboxID > cursorID + pastCursor := item.startedAt.Before(cursorTime) + pastID := item.sandboxID > cursorID if !descending { - pastCursor = item.StartedAt.After(cursorTime) - pastID = item.SandboxID < cursorID + pastCursor = item.startedAt.After(cursorTime) + pastID = item.sandboxID < cursorID } - if pastCursor || (item.StartedAt.Equal(cursorTime) && pastID) { + if pastCursor || (item.startedAt.Equal(cursorTime) && pastID) { page = append(page, item) } } @@ -379,7 +398,7 @@ func nextClusterListToken(items []listedSandbox, limit *int, descending bool) st return "" } last := items[len(items)-1] - raw := fmt.Sprintf("%s__%s", last.StartedAt.UTC().Format(time.RFC3339Nano), last.SandboxID) + raw := fmt.Sprintf("%s__%s", last.startedAt.UTC().Format(time.RFC3339Nano), last.sandboxID) if !descending { raw += "__asc" } diff --git a/services/gateway/internal/cluster_list_test.go b/services/gateway/internal/cluster_list_test.go index 1506f2167..2b4c9a6c8 100644 --- a/services/gateway/internal/cluster_list_test.go +++ b/services/gateway/internal/cluster_list_test.go @@ -44,8 +44,8 @@ func TestClusterListIncludesRunning(t *testing.T) { func TestParseClusterListNextTokenRejectsOrderMismatch(t *testing.T) { items := []listedSandbox{{ - SandboxID: "00000000-0000-0000-0000-000000000001", - StartedAt: time.Unix(1, 0).UTC(), + sandboxID: "00000000-0000-0000-0000-000000000001", + startedAt: time.Unix(1, 0).UTC(), }} limit := 1 diff --git a/services/gateway/internal/server_test.go b/services/gateway/internal/server_test.go index 1f8a0e4d9..6bb36ca92 100644 --- a/services/gateway/internal/server_test.go +++ b/services/gateway/internal/server_test.go @@ -12,6 +12,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "reflect" "strings" "testing" "time" @@ -927,19 +928,21 @@ func mustListedSandbox(id string, startedAt string, state string, envdVersion st if err != nil { panic(err) } - return listedSandbox{ - TemplateID: "template", - SandboxID: id, - ClientID: "client", - StartedAt: parsed.UTC(), - EndAt: parsed.UTC().Add(time.Hour), - CPUCount: 1, - MemoryMB: 128, - DiskSizeMB: 0, - Metadata: map[string]string{"team": "alpha"}, - State: state, - EnvdVersion: envdVersion, + payload, err := json.Marshal(map[string]any{ + "sandboxID": id, + "startedAt": parsed.UTC(), + "state": state, + "envdVersion": envdVersion, + }) + if err != nil { + panic(err) + } + + var item listedSandbox + if err := json.Unmarshal(payload, &item); err != nil { + panic(err) } + return item } func decodeListedSandboxResponse(t *testing.T, body io.Reader) []listedSandbox { @@ -954,7 +957,7 @@ func decodeListedSandboxResponse(t *testing.T, body io.Reader) []listedSandbox { func sandboxIDs(items []listedSandbox) []string { ids := make([]string, 0, len(items)) for _, item := range items { - ids = append(ids, item.SandboxID) + ids = append(ids, item.sandboxID) } return ids } @@ -1047,6 +1050,54 @@ func TestHandleProxyAggregatesSandboxListAcrossNodes(t *testing.T) { } } +func TestHandleProxyClusterListPreservesNodeFields(t *testing.T) { + const upstreamBody = `[{"templateID":"template","sandboxID":"00000000-0000-0000-0000-000000000001","startedAt":"2026-01-01T00:00:01Z","state":"running","volumeMounts":[{"name":"workspace","path":"/mnt/data"}],"futureField":{"nested":[1,true,"value"]}}]` + + node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(upstreamBody)) + })) + defer node.Close() + + server := newTestServer(t, stubSchedulerClient{ + listNodesFunc: func(_ context.Context, _ *schedulerv1.ListNodesRequest, _ ...grpc.CallOption) (*schedulerv1.ListNodesResponse, error) { + return &schedulerv1.ListNodesResponse{ + Nodes: []*schedulerv1.Node{{NodeId: "node-a", Endpoint: node.URL}}, + }, nil + }, + }, time.Second, 1024) + + gatewayServer := httptest.NewServer(authenticatedTestHandler(server)) + defer gatewayServer.Close() + + var want any + if err := json.Unmarshal([]byte(upstreamBody), &want); err != nil { + t.Fatalf("decode expected response failed: %v", err) + } + + for _, path := range []string{"/sandboxes", "/v2/sandboxes?limit=10"} { + t.Run(path, func(t *testing.T) { + resp, err := http.Get(gatewayServer.URL + path) + if err != nil { + t.Fatalf("cluster list request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + var got any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode cluster list response failed: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("cluster list response = %#v, want %#v", got, want) + } + }) + } +} + func TestHandleProxyAggregatesV2SandboxesWithGlobalPagination(t *testing.T) { requests := make(chan url.Values, 4) newNode := func(items []listedSandbox) *httptest.Server { @@ -1302,8 +1353,14 @@ func TestHandleProxyAggregatesSandboxListDedupsDuplicateSandboxIDs(t *testing.T) if len(items) != 1 { t.Fatalf("expected 1 sandbox after dedupe, got %d", len(items)) } - if items[0].EnvdVersion != "envd-new" { - t.Fatalf("deduped sandbox envdVersion = %q, want %q", items[0].EnvdVersion, "envd-new") + var payload struct { + EnvdVersion string `json:"envdVersion"` + } + if err := json.Unmarshal(items[0].payload, &payload); err != nil { + t.Fatalf("decode deduped sandbox failed: %v", err) + } + if payload.EnvdVersion != "envd-new" { + t.Fatalf("deduped sandbox envdVersion = %q, want %q", payload.EnvdVersion, "envd-new") } } diff --git a/src/api/generated/src/apis/volumes.rs b/src/api/generated/src/apis/volumes.rs index b1f79c0cb..2ff7b59b0 100644 --- a/src/api/generated/src/apis/volumes.rs +++ b/src/api/generated/src/apis/volumes.rs @@ -12,15 +12,15 @@ use crate::{models, types::*}; #[must_use] #[allow(clippy::large_enum_variant)] pub enum VolumesGetResponse { - /// Volumes returned successfully - Status200_VolumesReturnedSuccessfully { + /// Successfully listed team volumes + Status200_SuccessfullyListedTeamVolumes { body: Vec, x_next_token: Option, }, - /// Authentication error - Status401_AuthenticationError(models::Error), /// Bad request Status400_BadRequest(models::Error), + /// Authentication error + Status401_AuthenticationError(models::Error), /// Server error Status500_ServerError(models::Error), } @@ -29,10 +29,12 @@ pub enum VolumesGetResponse { #[must_use] #[allow(clippy::large_enum_variant)] pub enum VolumesPostResponse { - /// Volume created successfully - Status201_VolumeCreatedSuccessfully(models::Volume), + /// Successfully created a new team volume + Status201_SuccessfullyCreatedANewTeamVolume(models::Volume), /// Bad request Status400_BadRequest(models::Error), + /// Authentication error + Status401_AuthenticationError(models::Error), /// Conflict Status409_Conflict(models::Error), /// Server error @@ -43,8 +45,10 @@ pub enum VolumesPostResponse { #[must_use] #[allow(clippy::large_enum_variant)] pub enum VolumesVolumeIdDeleteResponse { - /// Volume deleted successfully - Status204_VolumeDeletedSuccessfully, + /// Successfully deleted a team volume + Status204_SuccessfullyDeletedATeamVolume, + /// Authentication error + Status401_AuthenticationError(models::Error), /// Not found Status404_NotFound(models::Error), /// Conflict @@ -57,8 +61,10 @@ pub enum VolumesVolumeIdDeleteResponse { #[must_use] #[allow(clippy::large_enum_variant)] pub enum VolumesVolumeIdGetResponse { - /// Volume returned successfully - Status200_VolumeReturnedSuccessfully(models::Volume), + /// Successfully retrieved a team volume + Status200_SuccessfullyRetrievedATeamVolume(models::Volume), + /// Authentication error + Status401_AuthenticationError(models::Error), /// Not found Status404_NotFound(models::Error), /// Server error @@ -71,7 +77,7 @@ pub enum VolumesVolumeIdGetResponse { pub trait Volumes: super::ErrorHandler { type Claims; - /// List volumes. + /// List team volumes. /// /// VolumesGet - GET /volumes async fn volumes_get( @@ -84,7 +90,7 @@ pub trait Volumes: super::Error query_params: &models::VolumesGetQueryParams, ) -> Result; - /// Create a volume. + /// Create team volume. /// /// VolumesPost - POST /volumes async fn volumes_post( @@ -97,7 +103,7 @@ pub trait Volumes: super::Error body: &models::NewVolume, ) -> Result; - /// Delete a volume. + /// Delete team volume. /// /// VolumesVolumeIdDelete - DELETE /volumes/{volumeID} async fn volumes_volume_id_delete( @@ -110,7 +116,7 @@ pub trait Volumes: super::Error path_params: &models::VolumesVolumeIdDeletePathParams, ) -> Result; - /// Get a volume. + /// Team volume. /// /// VolumesVolumeIdGet - GET /volumes/{volumeID} async fn volumes_volume_id_get( diff --git a/src/api/generated/src/models.rs b/src/api/generated/src/models.rs index 5b9c79780..c7f29035c 100644 --- a/src/api/generated/src/models.rs +++ b/src/api/generated/src/models.rs @@ -1887,11 +1887,10 @@ pub struct ListedSandbox { #[validate(custom(function = "check_xss_string"))] pub envd_version: String, - /// Map of absolute guest mount paths to volume IDs or names. #[serde(rename = "volumeMounts")] - #[validate(custom(function = "check_xss_map_string"))] + #[validate(nested)] #[serde(skip_serializing_if = "Option::is_none")] - pub volume_mounts: Option>, + pub volume_mounts: Option>, } impl ListedSandbox { @@ -1989,7 +1988,7 @@ impl std::str::FromStr for ListedSandbox { pub metadata: Vec>, pub state: Vec, pub envd_version: Vec, - pub volume_mounts: Vec>, + pub volume_mounts: Vec>, } let mut intermediate_rep = IntermediateRep::default(); @@ -2604,11 +2603,10 @@ pub struct NewColdSandbox { #[serde(skip_serializing_if = "Option::is_none")] pub custom_extension_params: Option>, - /// Map of absolute guest mount paths to volume IDs or names. #[serde(rename = "volumeMounts")] - #[validate(custom(function = "check_xss_map_string"))] + #[validate(nested)] #[serde(skip_serializing_if = "Option::is_none")] - pub volume_mounts: Option>, + pub volume_mounts: Option>, /// CPU cores for the cold-start sandbox. #[serde(rename = "cpuCount")] @@ -2747,7 +2745,7 @@ impl std::str::FromStr for NewColdSandbox { pub env_vars: Vec>, pub custom_extension_params: Vec>, - pub volume_mounts: Vec>, + pub volume_mounts: Vec>, pub cpu_count: Vec, pub memory_mb: Vec, pub disk_size_mb: Vec, @@ -2992,11 +2990,10 @@ pub struct NewSandbox { #[serde(skip_serializing_if = "Option::is_none")] pub mcp: Option>>, - /// Map of absolute guest mount paths to volume IDs or names. #[serde(rename = "volumeMounts")] - #[validate(custom(function = "check_xss_map_string"))] + #[validate(nested)] #[serde(skip_serializing_if = "Option::is_none")] - pub volume_mounts: Option>, + pub volume_mounts: Option>, } impl NewSandbox { @@ -3092,7 +3089,7 @@ impl std::str::FromStr for NewSandbox { pub custom_extension_params: Vec>, pub mcp: Vec>, - pub volume_mounts: Vec>, + pub volume_mounts: Vec>, } let mut intermediate_rep = IntermediateRep::default(); @@ -3255,7 +3252,7 @@ impl std::convert::TryFrom for header::IntoHeaderValue #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, validator::Validate)] #[cfg_attr(feature = "conversion", derive(frunk::LabelledGeneric))] pub struct NewVolume { - /// Unique volume name. + /// Name of the volume #[serde(rename = "name")] #[validate( regex(path = *RE_NEWVOLUME_NAME), @@ -5227,11 +5224,10 @@ pub struct SandboxDetail { #[serde(skip_serializing_if = "Option::is_none")] pub lifecycle: Option, - /// Map of absolute guest mount paths to volume IDs or names. #[serde(rename = "volumeMounts")] - #[validate(custom(function = "check_xss_map_string"))] + #[validate(nested)] #[serde(skip_serializing_if = "Option::is_none")] - pub volume_mounts: Option>, + pub volume_mounts: Option>, } impl SandboxDetail { @@ -5367,7 +5363,7 @@ impl std::str::FromStr for SandboxDetail { pub state: Vec, pub network: Vec, pub lifecycle: Vec, - pub volume_mounts: Vec>, + pub volume_mounts: Vec>, } let mut intermediate_rep = IntermediateRep::default(); @@ -6909,6 +6905,159 @@ impl std::convert::TryFrom for header::IntoHeaderValue SandboxVolumeMount { + SandboxVolumeMount { name, path } + } +} + +/// Converts the SandboxVolumeMount value to the Query Parameters representation (style=form, explode=false) +/// specified in https://swagger.io/docs/specification/serialization/ +/// Should be implemented in a serde serializer +impl std::fmt::Display for SandboxVolumeMount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let params: Vec> = vec![ + Some("name".to_string()), + Some(self.name.to_string()), + Some("path".to_string()), + Some(self.path.to_string()), + ]; + + write!( + f, + "{}", + params.into_iter().flatten().collect::>().join(",") + ) + } +} + +/// Converts Query Parameters representation (style=form, explode=false) to a SandboxVolumeMount value +/// as specified in https://swagger.io/docs/specification/serialization/ +/// Should be implemented in a serde deserializer +impl std::str::FromStr for SandboxVolumeMount { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + /// An intermediate representation of the struct to use for parsing. + #[derive(Default)] + #[allow(dead_code)] + struct IntermediateRep { + pub name: Vec, + pub path: Vec, + } + + let mut intermediate_rep = IntermediateRep::default(); + + // Parse into intermediate representation + let mut string_iter = s.split(','); + let mut key_result = string_iter.next(); + + while key_result.is_some() { + let val = match string_iter.next() { + Some(x) => x, + None => { + return std::result::Result::Err( + "Missing value while parsing SandboxVolumeMount".to_string(), + ); + } + }; + + if let Some(key) = key_result { + #[allow(clippy::match_single_binding)] + match key { + #[allow(clippy::redundant_clone)] + "name" => intermediate_rep.name.push( + ::from_str(val).map_err(|x| x.to_string())?, + ), + #[allow(clippy::redundant_clone)] + "path" => intermediate_rep.path.push( + ::from_str(val).map_err(|x| x.to_string())?, + ), + _ => { + return std::result::Result::Err( + "Unexpected key while parsing SandboxVolumeMount".to_string(), + ); + } + } + } + + // Get the next key + key_result = string_iter.next(); + } + + // Use the intermediate representation to return the struct + std::result::Result::Ok(SandboxVolumeMount { + name: intermediate_rep + .name + .into_iter() + .next() + .ok_or_else(|| "name missing in SandboxVolumeMount".to_string())?, + path: intermediate_rep + .path + .into_iter() + .next() + .ok_or_else(|| "path missing in SandboxVolumeMount".to_string())?, + }) + } +} + +// Methods for converting between header::IntoHeaderValue and HeaderValue + +#[cfg(feature = "server")] +impl std::convert::TryFrom> for HeaderValue { + type Error = String; + + fn try_from( + hdr_value: header::IntoHeaderValue, + ) -> std::result::Result { + let hdr_value = hdr_value.to_string(); + match HeaderValue::from_str(&hdr_value) { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(e) => std::result::Result::Err(format!( + r#"Invalid header value for SandboxVolumeMount - value: {hdr_value} is invalid {e}"# + )), + } + } +} + +#[cfg(feature = "server")] +impl std::convert::TryFrom for header::IntoHeaderValue { + type Error = String; + + fn try_from(hdr_value: HeaderValue) -> std::result::Result { + match hdr_value.to_str() { + std::result::Result::Ok(value) => { + match ::from_str(value) { + std::result::Result::Ok(value) => { + std::result::Result::Ok(header::IntoHeaderValue(value)) + } + std::result::Result::Err(err) => std::result::Result::Err(format!( + r#"Unable to convert header value '{value}' into SandboxVolumeMount - {err}"# + )), + } + } + std::result::Result::Err(e) => std::result::Result::Err(format!( + r#"Unable to convert header: {hdr_value:?} to string: {e}"# + )), + } + } +} + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, validator::Validate)] #[cfg_attr(feature = "conversion", derive(frunk::LabelledGeneric))] pub struct SnapshotInfo { @@ -9350,24 +9499,20 @@ impl std::convert::TryFrom for header::IntoHeaderValue, + pub mode: String, /// Effective volume size in MiB. #[serde(rename = "sizeMB")] @@ -9380,17 +9525,19 @@ pub struct Volume { pub status: String, } -lazy_static::lazy_static! { - static ref RE_VOLUME_NAME: regex::Regex = regex::Regex::new("^[a-zA-Z0-9_-]+$").unwrap(); -} - impl Volume { #[allow(clippy::new_without_default, clippy::too_many_arguments)] - pub fn new(volume_id: String, name: String, size_mb: u64, status: String) -> Volume { + pub fn new( + volume_id: String, + name: String, + mode: String, + size_mb: u64, + status: String, + ) -> Volume { Volume { volume_id, name, - mode: None, + mode, size_mb, status, } @@ -9407,9 +9554,8 @@ impl std::fmt::Display for Volume { Some(self.volume_id.to_string()), Some("name".to_string()), Some(self.name.to_string()), - self.mode - .as_ref() - .map(|mode| ["mode".to_string(), mode.to_string()].join(",")), + Some("mode".to_string()), + Some(self.mode.to_string()), Some("sizeMB".to_string()), Some(self.size_mb.to_string()), Some("status".to_string()), @@ -9505,7 +9651,11 @@ impl std::str::FromStr for Volume { .into_iter() .next() .ok_or_else(|| "name missing in Volume".to_string())?, - mode: intermediate_rep.mode.into_iter().next(), + mode: intermediate_rep + .mode + .into_iter() + .next() + .ok_or_else(|| "mode missing in Volume".to_string())?, size_mb: intermediate_rep .size_mb .into_iter() diff --git a/src/api/generated/src/server/mod.rs b/src/api/generated/src/server/mod.rs index e6097718b..71318f929 100644 --- a/src/api/generated/src/server/mod.rs +++ b/src/api/generated/src/server/mod.rs @@ -5034,7 +5034,7 @@ where let resp = match result { Ok(rsp) => match rsp { - apis::volumes::VolumesGetResponse::Status200_VolumesReturnedSuccessfully { + apis::volumes::VolumesGetResponse::Status200_SuccessfullyListedTeamVolumes { body, x_next_token, } => { @@ -5071,8 +5071,8 @@ where .unwrap()?; response.body(Body::from(body_content)) } - apis::volumes::VolumesGetResponse::Status401_AuthenticationError(body) => { - let mut response = response.status(401); + apis::volumes::VolumesGetResponse::Status400_BadRequest(body) => { + let mut response = response.status(400); { let mut response_headers = response.headers_mut().unwrap(); response_headers @@ -5089,8 +5089,8 @@ where .unwrap()?; response.body(Body::from(body_content)) } - apis::volumes::VolumesGetResponse::Status400_BadRequest(body) => { - let mut response = response.status(400); + apis::volumes::VolumesGetResponse::Status401_AuthenticationError(body) => { + let mut response = response.status(401); { let mut response_headers = response.headers_mut().unwrap(); response_headers @@ -5212,7 +5212,9 @@ where let resp = match result { Ok(rsp) => match rsp { - apis::volumes::VolumesPostResponse::Status201_VolumeCreatedSuccessfully(body) => { + apis::volumes::VolumesPostResponse::Status201_SuccessfullyCreatedANewTeamVolume( + body, + ) => { let mut response = response.status(201); { let mut response_headers = response.headers_mut().unwrap(); @@ -5248,6 +5250,24 @@ where .unwrap()?; response.body(Body::from(body_content)) } + apis::volumes::VolumesPostResponse::Status401_AuthenticationError(body) => { + let mut response = response.status(401); + { + let mut response_headers = response.headers_mut().unwrap(); + response_headers + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + } + + let body_content = tokio::task::spawn_blocking(move || { + serde_json::to_vec(&body).map_err(|e| { + error!(error = ?e); + StatusCode::INTERNAL_SERVER_ERROR + }) + }) + .await + .unwrap()?; + response.body(Body::from(body_content)) + } apis::volumes::VolumesPostResponse::Status409_Conflict(body) => { let mut response = response.status(409); { @@ -5363,75 +5383,91 @@ where let mut response = Response::builder(); let resp = match result { - Ok(rsp) => match rsp { - apis::volumes::VolumesVolumeIdDeleteResponse::Status204_VolumeDeletedSuccessfully => { - let mut response = response.status(204); - response.body(Body::empty()) - } - apis::volumes::VolumesVolumeIdDeleteResponse::Status404_NotFound(body) => { - let mut response = response.status(404); - { - let mut response_headers = response.headers_mut().unwrap(); - response_headers - .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - } + Ok(rsp) => match rsp { + apis::volumes::VolumesVolumeIdDeleteResponse::Status204_SuccessfullyDeletedATeamVolume + => { + let mut response = response.status(204); + response.body(Body::empty()) + }, + apis::volumes::VolumesVolumeIdDeleteResponse::Status401_AuthenticationError + (body) + => { + let mut response = response.status(401); + { + let mut response_headers = response.headers_mut().unwrap(); + response_headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/json")); + } - let body_content = tokio::task::spawn_blocking(move || { - serde_json::to_vec(&body).map_err(|e| { - error!(error = ?e); - StatusCode::INTERNAL_SERVER_ERROR - }) - }) - .await - .unwrap()?; - response.body(Body::from(body_content)) - } - apis::volumes::VolumesVolumeIdDeleteResponse::Status409_Conflict(body) => { - let mut response = response.status(409); - { - let mut response_headers = response.headers_mut().unwrap(); - response_headers - .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - } + let body_content = tokio::task::spawn_blocking(move || + serde_json::to_vec(&body).map_err(|e| { + error!(error = ?e); + StatusCode::INTERNAL_SERVER_ERROR + })).await.unwrap()?; + response.body(Body::from(body_content)) + }, + apis::volumes::VolumesVolumeIdDeleteResponse::Status404_NotFound + (body) + => { + let mut response = response.status(404); + { + let mut response_headers = response.headers_mut().unwrap(); + response_headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/json")); + } - let body_content = tokio::task::spawn_blocking(move || { - serde_json::to_vec(&body).map_err(|e| { - error!(error = ?e); - StatusCode::INTERNAL_SERVER_ERROR - }) - }) - .await - .unwrap()?; - response.body(Body::from(body_content)) - } - apis::volumes::VolumesVolumeIdDeleteResponse::Status500_ServerError(body) => { - let mut response = response.status(500); - { - let mut response_headers = response.headers_mut().unwrap(); - response_headers - .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - } + let body_content = tokio::task::spawn_blocking(move || + serde_json::to_vec(&body).map_err(|e| { + error!(error = ?e); + StatusCode::INTERNAL_SERVER_ERROR + })).await.unwrap()?; + response.body(Body::from(body_content)) + }, + apis::volumes::VolumesVolumeIdDeleteResponse::Status409_Conflict + (body) + => { + let mut response = response.status(409); + { + let mut response_headers = response.headers_mut().unwrap(); + response_headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/json")); + } - let body_content = tokio::task::spawn_blocking(move || { - serde_json::to_vec(&body).map_err(|e| { - error!(error = ?e); - StatusCode::INTERNAL_SERVER_ERROR - }) - }) - .await - .unwrap()?; - response.body(Body::from(body_content)) - } - }, - Err(why) => { - // Application code returned an error. This should not happen, as the implementation should - // return a valid response. - return api_impl - .as_ref() - .handle_error(&method, &host, &cookies, why) - .await; - } - }; + let body_content = tokio::task::spawn_blocking(move || + serde_json::to_vec(&body).map_err(|e| { + error!(error = ?e); + StatusCode::INTERNAL_SERVER_ERROR + })).await.unwrap()?; + response.body(Body::from(body_content)) + }, + apis::volumes::VolumesVolumeIdDeleteResponse::Status500_ServerError + (body) + => { + let mut response = response.status(500); + { + let mut response_headers = response.headers_mut().unwrap(); + response_headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/json")); + } + + let body_content = tokio::task::spawn_blocking(move || + serde_json::to_vec(&body).map_err(|e| { + error!(error = ?e); + StatusCode::INTERNAL_SERVER_ERROR + })).await.unwrap()?; + response.body(Body::from(body_content)) + }, + }, + Err(why) => { + // Application code returned an error. This should not happen, as the implementation should + // return a valid response. + return api_impl.as_ref().handle_error(&method, &host, &cookies, why).await; + }, + }; resp.map_err(|e| { error!(error = ?e); @@ -5501,73 +5537,86 @@ where let mut response = Response::builder(); let resp = match result { - Ok(rsp) => match rsp { - apis::volumes::VolumesVolumeIdGetResponse::Status200_VolumeReturnedSuccessfully( - body, - ) => { - let mut response = response.status(200); - { - let mut response_headers = response.headers_mut().unwrap(); - response_headers - .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - } + Ok(rsp) => match rsp { + apis::volumes::VolumesVolumeIdGetResponse::Status200_SuccessfullyRetrievedATeamVolume + (body) + => { + let mut response = response.status(200); + { + let mut response_headers = response.headers_mut().unwrap(); + response_headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/json")); + } - let body_content = tokio::task::spawn_blocking(move || { - serde_json::to_vec(&body).map_err(|e| { - error!(error = ?e); - StatusCode::INTERNAL_SERVER_ERROR - }) - }) - .await - .unwrap()?; - response.body(Body::from(body_content)) - } - apis::volumes::VolumesVolumeIdGetResponse::Status404_NotFound(body) => { - let mut response = response.status(404); - { - let mut response_headers = response.headers_mut().unwrap(); - response_headers - .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - } + let body_content = tokio::task::spawn_blocking(move || + serde_json::to_vec(&body).map_err(|e| { + error!(error = ?e); + StatusCode::INTERNAL_SERVER_ERROR + })).await.unwrap()?; + response.body(Body::from(body_content)) + }, + apis::volumes::VolumesVolumeIdGetResponse::Status401_AuthenticationError + (body) + => { + let mut response = response.status(401); + { + let mut response_headers = response.headers_mut().unwrap(); + response_headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/json")); + } - let body_content = tokio::task::spawn_blocking(move || { - serde_json::to_vec(&body).map_err(|e| { - error!(error = ?e); - StatusCode::INTERNAL_SERVER_ERROR - }) - }) - .await - .unwrap()?; - response.body(Body::from(body_content)) - } - apis::volumes::VolumesVolumeIdGetResponse::Status500_ServerError(body) => { - let mut response = response.status(500); - { - let mut response_headers = response.headers_mut().unwrap(); - response_headers - .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - } + let body_content = tokio::task::spawn_blocking(move || + serde_json::to_vec(&body).map_err(|e| { + error!(error = ?e); + StatusCode::INTERNAL_SERVER_ERROR + })).await.unwrap()?; + response.body(Body::from(body_content)) + }, + apis::volumes::VolumesVolumeIdGetResponse::Status404_NotFound + (body) + => { + let mut response = response.status(404); + { + let mut response_headers = response.headers_mut().unwrap(); + response_headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/json")); + } - let body_content = tokio::task::spawn_blocking(move || { - serde_json::to_vec(&body).map_err(|e| { - error!(error = ?e); - StatusCode::INTERNAL_SERVER_ERROR - }) - }) - .await - .unwrap()?; - response.body(Body::from(body_content)) - } - }, - Err(why) => { - // Application code returned an error. This should not happen, as the implementation should - // return a valid response. - return api_impl - .as_ref() - .handle_error(&method, &host, &cookies, why) - .await; - } - }; + let body_content = tokio::task::spawn_blocking(move || + serde_json::to_vec(&body).map_err(|e| { + error!(error = ?e); + StatusCode::INTERNAL_SERVER_ERROR + })).await.unwrap()?; + response.body(Body::from(body_content)) + }, + apis::volumes::VolumesVolumeIdGetResponse::Status500_ServerError + (body) + => { + let mut response = response.status(500); + { + let mut response_headers = response.headers_mut().unwrap(); + response_headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/json")); + } + + let body_content = tokio::task::spawn_blocking(move || + serde_json::to_vec(&body).map_err(|e| { + error!(error = ?e); + StatusCode::INTERNAL_SERVER_ERROR + })).await.unwrap()?; + response.body(Body::from(body_content)) + }, + }, + Err(why) => { + // Application code returned an error. This should not happen, as the implementation should + // return a valid response. + return api_impl.as_ref().handle_error(&method, &host, &cookies, why).await; + }, + }; resp.map_err(|e| { error!(error = ?e); diff --git a/src/api/impls/sandbox.rs b/src/api/impls/sandbox.rs index 18264b725..65402406b 100644 --- a/src/api/impls/sandbox.rs +++ b/src/api/impls/sandbox.rs @@ -102,8 +102,33 @@ fn end_at(expires_at: Option) -> chrono::DateTime { )) } -fn volume_mounts_model(mounts: &HashMap) -> Option> { - (!mounts.is_empty()).then(|| mounts.clone()) +fn volume_mounts_model(mounts: &HashMap) -> Vec { + mounts + .iter() + .map(|(path, name)| models::SandboxVolumeMount::new(name.clone(), path.clone())) + .collect() +} + +fn volume_mounts_from_model( + mounts: Option<&[models::SandboxVolumeMount]>, +) -> Result>, models::Error> { + let Some(mounts) = mounts.filter(|mounts| !mounts.is_empty()) else { + return Ok(None); + }; + + let mut result = HashMap::with_capacity(mounts.len()); + for mount in mounts { + if result + .insert(mount.path.clone(), mount.name.clone()) + .is_some() + { + return Err(ApiImpl::error( + 400, + format!("duplicate volume mount path: {}", mount.path), + )); + } + } + Ok(Some(result)) } #[derive(Default)] @@ -313,7 +338,6 @@ impl From for models::SandboxOnTimeout { impl From for models::ListedSandbox { fn from(m: SandboxMetadata) -> Self { - let volume_mounts = volume_mounts_model(&m.volume_mounts); Self { template_id: m.snapshot_id, alias: m.snapshot_alias, @@ -327,7 +351,7 @@ impl From for models::ListedSandbox { metadata: m.user_metadata, state: m.state.into(), envd_version: m.runtime_versions.envd_version.clone(), - volume_mounts, + volume_mounts: Some(volume_mounts_model(&m.volume_mounts)), } } } @@ -391,7 +415,6 @@ fn allow_internet_access_from_base_policy(policy: BaseSandboxNetworkPolicy) -> N impl From for models::SandboxDetail { fn from(m: SandboxMetadata) -> Self { - let volume_mounts = volume_mounts_model(&m.volume_mounts); let network = (!m.network_policy.allow_public_traffic || m.network_policy.has_explicit_egress_rules()) .then(|| models::SandboxNetworkConfig::from(&m.network_policy)); @@ -420,7 +443,7 @@ impl From for models::SandboxDetail { auto_resume: m.auto_resume, on_timeout: m.timeout_action.into(), }), - volume_mounts, + volume_mounts: Some(volume_mounts_model(&m.volume_mounts)), } } } @@ -718,12 +741,19 @@ impl Sandboxes<()> for ApiImpl { )); } + let requested_volume_mounts = match volume_mounts_from_model(body.volume_mounts.as_deref()) + { + Ok(mounts) => mounts, + Err(error) => { + return Ok(SandboxesColdPostResponse::Status400_BadRequest(error)); + } + }; let PreparedVolumeMounts { owner: pending_volume_owner, drives: volume_drives, mounts: volume_mounts, volume_ids: reserved_volume_ids, - } = match prepare_volume_mounts(self, body.volume_mounts.as_ref()).await { + } = match prepare_volume_mounts(self, requested_volume_mounts.as_ref()).await { Ok(prepared) => prepared, Err(error) if error.code >= 500 => { return Ok(SandboxesColdPostResponse::Status500_ServerError(error)); @@ -922,8 +952,16 @@ impl Sandboxes<()> for ApiImpl { let extra_drives_in_snapshot = body.volume_mounts.is_none() && !snapshot.committed().volume_snapshots.is_empty(); - let (requested_volume_mounts, restored_volume_ids) = if body.volume_mounts.is_some() { - (body.volume_mounts.clone(), Vec::new()) + let (requested_volume_mounts, restored_volume_ids) = if let Some(mounts) = + body.volume_mounts.as_deref() + { + let mounts = match volume_mounts_from_model(Some(mounts)) { + Ok(mounts) => mounts, + Err(error) => { + return Ok(SandboxesPostResponse::Status400_BadRequest(error)); + } + }; + (mounts, Vec::new()) } else { match restore_snapshot_volume_mounts(self, &snapshot).await { Ok((mounts, volume_ids)) => ((!mounts.is_empty()).then_some(mounts), volume_ids), @@ -1878,6 +1916,32 @@ impl Sandboxes<()> for ApiImpl { mod tests { use super::*; + #[test] + fn volume_mounts_from_model_maps_names_by_path() { + let mounts = vec![ + models::SandboxVolumeMount::new("cache".to_string(), "/cache".to_string()), + models::SandboxVolumeMount::new("vol_123".to_string(), "/data".to_string()), + ]; + + let result = volume_mounts_from_model(Some(&mounts)).unwrap().unwrap(); + + assert_eq!(result.get("/cache").map(String::as_str), Some("cache")); + assert_eq!(result.get("/data").map(String::as_str), Some("vol_123")); + } + + #[test] + fn volume_mounts_from_model_rejects_duplicate_paths() { + let mounts = vec![ + models::SandboxVolumeMount::new("first".to_string(), "/data".to_string()), + models::SandboxVolumeMount::new("second".to_string(), "/data".to_string()), + ]; + + let error = volume_mounts_from_model(Some(&mounts)).unwrap_err(); + + assert_eq!(error.code, 400); + assert!(error.message.contains("duplicate volume mount path")); + } + #[test] fn parse_metadata_filter_with_none_returns_none() { assert_eq!(parse_metadata_filter(&None), None); diff --git a/src/api/impls/volumes.rs b/src/api/impls/volumes.rs index 6977aa841..b52f23f13 100644 --- a/src/api/impls/volumes.rs +++ b/src/api/impls/volumes.rs @@ -122,18 +122,26 @@ async fn resolve_volume_mounts_inner( Ok((drives, normalized_mounts)) } -fn to_model(record: VolumeRecord) -> models::Volume { - let status = match record.status { - VolumeStatus::Ready => "ready", - VolumeStatus::Uploading => "uploading", - VolumeStatus::Failed => "failed", - }; - let mut model = models::Volume::new(record.id, record.name, record.size_mb, status.to_owned()); - model.mode = Some(match record.mode { - VolumeMode::ReadOnly => "ro".to_owned(), - VolumeMode::Exclusive => "exclusive".to_owned(), - }); - model +impl From for models::Volume { + fn from(record: VolumeRecord) -> Self { + let status = match record.status { + VolumeStatus::Ready => "ready", + VolumeStatus::Uploading => "uploading", + VolumeStatus::Failed => "failed", + } + .to_string(); + let mode = match record.mode { + VolumeMode::ReadOnly => "ro".to_owned(), + VolumeMode::Exclusive => "exclusive".to_owned(), + }; + models::Volume { + volume_id: record.id, + name: record.name, + mode, + size_mb: record.size_mb, + status, + } + } } pub(super) fn error_response(error: VolumeError) -> (i32, models::Error) { @@ -180,10 +188,12 @@ impl Volumes<()> for ApiImpl { ) .await { - Ok(page) => Ok(VolumesGetResponse::Status200_VolumesReturnedSuccessfully { - body: page.records.into_iter().map(to_model).collect(), - x_next_token: page.next_token, - }), + Ok(page) => Ok( + VolumesGetResponse::Status200_SuccessfullyListedTeamVolumes { + body: page.records.into_iter().map(Into::into).collect(), + x_next_token: page.next_token, + }, + ), Err(error @ (VolumeError::InvalidNextToken | VolumeError::InvalidPageLimit)) => Ok( VolumesGetResponse::Status400_BadRequest(error_response(error).1), ), @@ -243,9 +253,9 @@ impl Volumes<()> for ApiImpl { ) .await { - Ok(record) => Ok(VolumesPostResponse::Status201_VolumeCreatedSuccessfully( - to_model(record), - )), + Ok(record) => { + Ok(VolumesPostResponse::Status201_SuccessfullyCreatedANewTeamVolume(record.into())) + } Err(error) => { let (code, error) = error_response(error); match code { @@ -266,7 +276,7 @@ impl Volumes<()> for ApiImpl { path_params: &models::VolumesVolumeIdDeletePathParams, ) -> Result { match self.volume_manager.delete(&path_params.volume_id).await { - Ok(()) => Ok(VolumesVolumeIdDeleteResponse::Status204_VolumeDeletedSuccessfully), + Ok(()) => Ok(VolumesVolumeIdDeleteResponse::Status204_SuccessfullyDeletedATeamVolume), Err(error) => { let (code, error) = error_response(error); match code { @@ -288,7 +298,9 @@ impl Volumes<()> for ApiImpl { ) -> Result { match self.volume_manager.get(&path_params.volume_id).await { Ok(record) => Ok( - VolumesVolumeIdGetResponse::Status200_VolumeReturnedSuccessfully(to_model(record)), + VolumesVolumeIdGetResponse::Status200_SuccessfullyRetrievedATeamVolume( + record.into(), + ), ), Err(error) => { let (code, error) = error_response(error); diff --git a/src/api/openapi.yml b/src/api/openapi.yml index 6b7351153..d3867ff78 100644 --- a/src/api/openapi.yml +++ b/src/api/openapi.yml @@ -105,6 +105,12 @@ components: required: false schema: type: string + volumeID: + name: volumeID + in: path + required: true + schema: + type: string headers: XNextToken: @@ -245,68 +251,6 @@ components: type: string description: Environment variables for the sandbox - Volume: - type: object - required: - - volumeID - - name - - sizeMB - - status - properties: - volumeID: - type: string - description: Stable identifier of the volume. - name: - type: string - pattern: "^[a-zA-Z0-9_-]+$" - description: Unique human-readable volume name. - mode: - type: string - description: Access mode (`ro` or `exclusive`). - sizeMB: - type: integer - format: int64 - minimum: 1 - description: Effective volume size in MiB. - status: - type: string - description: Availability state (`ready`, `uploading`, or `failed`). - - NewVolume: - type: object - required: - - name - properties: - name: - type: string - pattern: "^[a-zA-Z0-9_-]+$" - description: Unique volume name. - sizeMB: - type: integer - format: int64 - minimum: 1 - default: 65536 - description: Volume size in MiB. Defaults to 65536 MiB (64 GiB). - mode: - type: string - description: Access mode (`ro` or `exclusive`). - fromVolume: - type: string - description: >- - Existing volume ID or name to use as a COW source. An exclusive - source must not be mounted by a sandbox; sandbox fork snapshots - and publishes its owned volumes internally before creating child - volumes. - image: - type: string - description: OCI or OverlayBD image reference for the initial content. - - VolumeMounts: - type: object - description: Map of absolute guest mount paths to volume IDs or names. - additionalProperties: - type: string - AttachedDriveSource: type: object description: > @@ -450,6 +394,20 @@ components: description: Whether the sandbox can auto-resume. onTimeout: $ref: "#/components/schemas/SandboxOnTimeout" + + SandboxVolumeMount: + type: object + properties: + name: + type: string + description: Name of the volume + path: + type: string + description: Path of the volume + required: + - name + - path + Sandbox: required: - templateID @@ -546,7 +504,9 @@ components: lifecycle: $ref: "#/components/schemas/SandboxLifecycle" volumeMounts: - $ref: "#/components/schemas/VolumeMounts" + type: array + items: + $ref: "#/components/schemas/SandboxVolumeMount" ListedSandbox: required: @@ -595,7 +555,9 @@ components: envdVersion: $ref: "#/components/schemas/EnvdVersion" volumeMounts: - $ref: "#/components/schemas/VolumeMounts" + type: array + items: + $ref: "#/components/schemas/SandboxVolumeMount" NewSandbox: required: @@ -638,7 +600,9 @@ components: mcp: $ref: "#/components/schemas/Mcp" volumeMounts: - $ref: "#/components/schemas/VolumeMounts" + type: array + items: + $ref: "#/components/schemas/SandboxVolumeMount" ResumedSandbox: properties: @@ -688,7 +652,9 @@ components: customExtensionParams: $ref: "#/components/schemas/CustomExtensionParams" volumeMounts: - $ref: "#/components/schemas/VolumeMounts" + type: array + items: + $ref: "#/components/schemas/SandboxVolumeMount" cpuCount: allOf: - $ref: "#/components/schemas/CPUCount" @@ -1358,6 +1324,62 @@ components: type: string description: Error + Volume: + type: object + properties: + volumeID: + type: string + description: ID of the volume + name: + type: string + description: Name of the volume + mode: + type: string + description: Access mode (`ro` or `exclusive`). + sizeMB: + type: integer + format: int64 + minimum: 1 + description: Effective volume size in MiB. + status: + type: string + description: Availability state (`ready`, `uploading`, or `failed`). + required: + - volumeID + - name + - mode + - sizeMB + - status + + NewVolume: + type: object + properties: + name: + type: string + description: Name of the volume + pattern: "^[a-zA-Z0-9_-]+$" + sizeMB: + type: integer + format: int64 + minimum: 1 + default: 65536 + description: Volume size in MiB. Defaults to 65536 MiB (64 GiB). + mode: + type: string + description: Access mode (`ro` or `exclusive`). + fromVolume: + type: string + description: >- + Existing volume ID or name to use as a COW source. An exclusive + source must not be mounted by a sandbox; sandbox fork snapshots + and publishes its owned volumes internally before creating child + volumes. + image: + type: string + description: OCI or OverlayBD image reference for the initial content. + required: + - name + tags: - name: admin - name: templates @@ -1376,122 +1398,6 @@ paths: "401": $ref: "#/components/responses/401" - /volumes: - get: - summary: List volumes - description: List independently managed persistent volumes. - tags: [volumes] - security: - - ApiKeyAuth: [] - - AuthProviderBearerAuth: [] - AuthProviderTeamAuth: [] - - AdminApiKeyAuth: [] - AdminTeamAuth: [] - parameters: - - $ref: "#/components/parameters/paginationNextToken" - - $ref: "#/components/parameters/paginationLimit" - responses: - "200": - description: Volumes returned successfully - headers: - X-Next-Token: - $ref: "#/components/headers/XNextToken" - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Volume" - "401": - $ref: "#/components/responses/401" - "400": - $ref: "#/components/responses/400" - "500": - $ref: "#/components/responses/500" - post: - summary: Create a volume - description: Create an empty, image-backed, or copy-on-write volume. - tags: [volumes] - security: - - ApiKeyAuth: [] - - AuthProviderBearerAuth: [] - AuthProviderTeamAuth: [] - - AdminApiKeyAuth: [] - AdminTeamAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/NewVolume" - responses: - "201": - description: Volume created successfully - content: - application/json: - schema: - $ref: "#/components/schemas/Volume" - "400": - $ref: "#/components/responses/400" - "409": - $ref: "#/components/responses/409" - "500": - $ref: "#/components/responses/500" - - /volumes/{volumeID}: - get: - summary: Get a volume - description: Get a volume by ID or unique name. - tags: [volumes] - security: - - ApiKeyAuth: [] - - AuthProviderBearerAuth: [] - AuthProviderTeamAuth: [] - - AdminApiKeyAuth: [] - AdminTeamAuth: [] - parameters: - - name: volumeID - in: path - required: true - schema: - type: string - responses: - "200": - description: Volume returned successfully - content: - application/json: - schema: - $ref: "#/components/schemas/Volume" - "404": - $ref: "#/components/responses/404" - "500": - $ref: "#/components/responses/500" - delete: - summary: Delete a volume - description: Delete a volume catalog entry when it is not reserved. - tags: [volumes] - security: - - ApiKeyAuth: [] - - AuthProviderBearerAuth: [] - AuthProviderTeamAuth: [] - - AdminApiKeyAuth: [] - AdminTeamAuth: [] - parameters: - - name: volumeID - in: path - required: true - schema: - type: string - responses: - "204": - description: Volume deleted successfully - "404": - $ref: "#/components/responses/404" - "409": - $ref: "#/components/responses/409" - "500": - $ref: "#/components/responses/500" - /sandboxes: get: summary: List running sandboxes @@ -2467,3 +2373,119 @@ paths: $ref: "#/components/responses/404" "500": $ref: "#/components/responses/500" + + /volumes: + get: + summary: List team volumes + description: List team volumes + tags: [volumes] + security: + - ApiKeyAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + - AdminApiKeyAuth: [] + AdminTeamAuth: [] + parameters: + - $ref: "#/components/parameters/paginationNextToken" + - $ref: "#/components/parameters/paginationLimit" + responses: + "200": + description: Successfully listed team volumes + headers: + X-Next-Token: + $ref: "#/components/headers/XNextToken" + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Volume" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + post: + summary: Create team volume + description: Create a new team volume + tags: [volumes] + security: + - ApiKeyAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + - AdminApiKeyAuth: [] + AdminTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewVolume" + responses: + "201": + description: Successfully created a new team volume + content: + application/json: + schema: + $ref: "#/components/schemas/Volume" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "409": + $ref: "#/components/responses/409" + "500": + $ref: "#/components/responses/500" + + /volumes/{volumeID}: + get: + summary: Team volume + description: Get team volume info + tags: [volumes] + security: + - ApiKeyAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + - AdminApiKeyAuth: [] + AdminTeamAuth: [] + parameters: + - $ref: "#/components/parameters/volumeID" + responses: + "200": + description: Successfully retrieved a team volume + content: + application/json: + schema: + $ref: "#/components/schemas/Volume" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + + delete: + summary: Delete team volume + description: Delete a team volume + tags: [volumes] + security: + - ApiKeyAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + - AdminApiKeyAuth: [] + AdminTeamAuth: [] + parameters: + - $ref: "#/components/parameters/volumeID" + responses: + "204": + description: Successfully deleted a team volume + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "409": + $ref: "#/components/responses/409" + "500": + $ref: "#/components/responses/500" diff --git a/src/api/proxy.rs b/src/api/proxy.rs index 182337c29..c6e69d47e 100644 --- a/src/api/proxy.rs +++ b/src/api/proxy.rs @@ -1948,22 +1948,15 @@ mod tests { } #[test] - fn e2b_volume_mounts_use_a_path_to_volume_map() { + fn volume_mounts_use_name_and_path_entries() { let request: agentenv_http_server::models::NewSandbox = serde_json::from_value(json!({ - "templateID": "template", - "volumeMounts": {"/mnt/data": "vol_123"} - })) - .expect("E2B volumeMounts map should deserialize"); - assert_eq!( - request.volume_mounts.unwrap().get("/mnt/data"), - Some(&"vol_123".to_string()) - ); - - serde_json::from_value::(json!({ "templateID": "template", "volumeMounts": [{"name": "vol_123", "path": "/mnt/data"}] })) - .expect_err("array form is not the issue 211 API"); + .expect("volumeMounts array should deserialize"); + let mount = &request.volume_mounts.unwrap()[0]; + assert_eq!(mount.name, "vol_123"); + assert_eq!(mount.path, "/mnt/data"); } #[tokio::test] diff --git a/src/volume.rs b/src/volume.rs index fcea220a8..8bc6c155f 100644 --- a/src/volume.rs +++ b/src/volume.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use thiserror::Error; use tokio::process::Command; use tokio::sync::RwLock; -use tracing::warn; +use tracing::{debug, info, warn}; use uuid::Uuid; use crate::snapshot::repository::{RepositoryError, SnapshotRepository}; @@ -267,11 +267,21 @@ impl VolumeManager { return Ok(record); } let destination = self.data_dir(&record.id).join("image.json"); + debug!( + volume_id = %record.id, + layer_count = record.backing_layers.len(), + "materializing volume backing" + ); let path = self .repository .materialize_volume_backing(&record.id, &record.backing_layers, &destination) .await .map_err(repository_error)?; + debug!( + volume_id = %record.id, + path = %path.display(), + "volume backing materialized" + ); record.backing_image_config = Some(path); self.cache_record(record.clone()).await; Ok(record) @@ -324,6 +334,12 @@ impl VolumeManager { } let id = format!("vol_{}", Uuid::now_v7().simple()); + debug!( + volume_id = %id, + mode = ?mode, + size_mb, + "creating volume" + ); let mut backing_layers = Vec::new(); let backing_image_config = if let Some((reference, source_owner)) = from_volume { let mut parent = match self.get(&reference).await { @@ -400,6 +416,12 @@ impl VolumeManager { } return Err(error); } + info!( + volume_id = %record.id, + mode = ?record.mode, + size_mb = record.size_mb, + "volume created" + ); Ok(record) } @@ -411,6 +433,7 @@ impl VolumeManager { if let Some(owner) = record.read_only_mounts.first() { return Err(VolumeError::Reserved(owner.clone())); } + debug!(volume_id = %record.id, "deleting volume"); self.repository .delete_volume(&record.id) .await @@ -427,6 +450,7 @@ impl VolumeManager { ); } } + info!(volume_id = %record.id, "volume deleted"); Ok(()) } @@ -484,11 +508,23 @@ impl VolumeManager { read_only_mounts: Vec::new(), deleting: false, }; + debug!( + volume_id = %record.id, + mode = ?record.mode, + size_mb = record.size_mb, + "creating volume from snapshot" + ); self.repository .create_volume(record.clone()) .await .map_err(repository_error)?; self.cache_record(record.clone()).await; + info!( + volume_id = %record.id, + mode = ?record.mode, + size_mb = record.size_mb, + "volume created from snapshot" + ); Ok(record) } @@ -528,6 +564,7 @@ impl VolumeManager { if !record.read_only_mounts.iter().any(|entry| entry == owner) { record.read_only_mounts.push(owner.to_owned()); } + debug!(volume_id = %record.id, "read-only volume reserved"); self.cache_record(record).await; return Ok(()); } @@ -540,6 +577,7 @@ impl VolumeManager { return Err(VolumeError::Reserved(existing)); } record.reserved_by_sandbox_id = Some(owner.to_owned()); + debug!(volume_id = %record.id, "exclusive volume reserved"); self.cache_record(record).await; Ok(()) } @@ -558,6 +596,15 @@ impl VolumeManager { if let Some(record) = self.records.write().await.get_mut(volume_id) { record.replace_owner(owner, new_owner); } + match new_owner { + Some(new_owner) => debug!( + volume_id = %volume_id, + previous_owner = %owner, + new_owner = %new_owner, + "volume reservation owner replaced" + ), + None => debug!(volume_id = %volume_id, %owner, "volume reservation released"), + } } Ok(()) } @@ -592,14 +639,26 @@ impl VolumeManager { let path = self.data_dir(&record.id).join("image.json"); if !path.exists() { record.status = VolumeStatus::Failed; - let _ = self.persist_catalog(&record).await; + if let Err(error) = self.persist_catalog(&record).await { + warn!( + volume_id = %record.id, + %error, + "failed to persist volume failure status" + ); + } self.cache_record(record.clone()).await; + warn!( + volume_id = %record.id, + path = %path.display(), + "reserved volume backing is missing" + ); return Err(VolumeError::Storage(format!( "local backing for reserved volume '{}' is missing", record.id ))); } record.backing_image_config = Some(path); + debug!(volume_id = %record.id, %owner, "volume backing recovered"); self.cache_record(record).await; } } @@ -628,30 +687,56 @@ impl VolumeManager { // upper layer so other nodes cannot mount stale content. record.status = VolumeStatus::Uploading; if let Err(error) = self.persist_catalog(&record).await { - record.status = VolumeStatus::Failed; - let _ = self.persist_catalog(&record).await; - self.cache_record(record).await; + self.mark_publication_failed(record, "mark_uploading", &error) + .await; return Err(error); } self.cache_record(record.clone()).await; + debug!(volume_id = %record.id, "publishing volume backing"); if let Err(error) = self.publish_backing(&mut record).await { - record.status = VolumeStatus::Failed; - let _ = self.persist_catalog(&record).await; - self.cache_record(record).await; + self.mark_publication_failed(record, "publish_backing", &error) + .await; return Err(error); } record.status = VolumeStatus::Ready; if let Err(error) = self.persist_catalog(&record).await { - record.status = VolumeStatus::Failed; - let _ = self.persist_catalog(&record).await; - self.cache_record(record).await; + self.mark_publication_failed(record, "mark_ready", &error) + .await; return Err(error); } + info!( + volume_id = %record.id, + layer_count = record.backing_layers.len(), + "volume backing published" + ); self.cache_record(record).await; } Ok(()) } + async fn mark_publication_failed( + &self, + mut record: VolumeRecord, + stage: &'static str, + error: &VolumeError, + ) { + record.status = VolumeStatus::Failed; + if let Err(status_error) = self.persist_catalog(&record).await { + warn!( + volume_id = %record.id, + error = %status_error, + "failed to persist volume failure status" + ); + } + warn!( + volume_id = %record.id, + stage, + %error, + "volume backing publication failed" + ); + self.cache_record(record).await; + } + async fn cache_record(&self, record: VolumeRecord) { self.records.write().await.insert(record.id.clone(), record); }