Skip to content

Volume Plugin Development

CubeSandbox is gradually adopting e2b Volume compatibility to provide persistent storage across sandbox lifecycles. This guide starts from architecture and core concepts, then walks through protocol details and plugin development, so you can integrate any storage backend (object storage, NFS, distributed file systems, etc.) into CubeSandbox.

Version requirement

Volume features require Cube platform ≥ 0.6.0 (CubeMaster, CubeAPI, and Cubelet must all be upgraded), plus Python SDK cubesandbox ≥ 0.6.0 (Volume and Sandbox.create(volume_mounts=...)). Environments below these versions have no Volume API — do not call /volumes with an older SDK.

Current status (API / SDK)

CapabilityStatus
REST GET /volumes — list volumes✅ Supported (Cube ≥ 0.6.0)
REST POST /volumes — create volume✅ Supported
REST GET /volumes/{volumeID} — get volume + token✅ Supported
REST DELETE /volumes/{volumeID} — delete volume✅ Supported (409 when still mounted)
SDK Volume.create / connect / list / get_info / destroy✅ Supported (SDK ≥ 0.6.0)
SDK Sandbox.create(volume_mounts={path: volume})✅ Supported (e2b dict mapping)
One volume mounted by multiple sandboxes✅ Supported
Omit driver on create (e2b default)✅ Supported

e2b API vs SDK

CubeAPI exposes e2b-protocol-compatible /volumes REST endpoints. You can call them directly with HTTP clients.

The official e2b Python SDK cannot be used against CubeSandbox — it is hardcoded to the e2b.cloud backend. Use the cubesandbox Python SDK (Volume, Sandbox.create(volume_mounts={...})) or raw REST against your CubeAPI instance.


Quick start: use Volume with cubesandbox

Four steps from plugin to a working SDK demo. Details are in the sections linked below.

Implement and deploy the plugin

Implement Create / Destroy (Controller) and Attach / Detach (Node) per the Hook subsections under Core Concepts. Deploy the Controller side to CubeMaster nodes and the Node side to Cubelet nodes (same binary or process may serve both).

Reference: COS plugin (one-click packages the binary under CubeMaster/plugin/ and Cubelet/plugin/).

Configure CubeMaster / Cubelet and restart

Register the same driver name on both sides (volume_plugins), point binary_path / socket_path at the deployed plugin, then restart CubeMaster and Cubelet so the config is loaded. See Registration and Configuration.

Install the SDK

bash
pip install 'cubesandbox>=0.6.0'

Use cubesandbox, not the official e2b Python SDK. Set CUBE_API_URL, CUBE_TEMPLATE_ID, and (for remote I/O) CUBE_PROXY_NODE_IP. See Environment Setup.

Run the demo

python
from cubesandbox import Sandbox, Volume

vol = Volume.create("my-data")  # omit driver → first volume_plugins entry

with Sandbox.create(volume_mounts={"/workspace": vol}) as sb:
    sb.files.write("/workspace/hello.txt", "from volume")
    print(sb.files.read("/workspace/hello.txt"))

Volume.destroy(vol.volume_id)

Full lifecycle and multi-sandbox sharing: SDK Usage. COS end-to-end (deps + credentials): examples/volume/cos/README.md.


Core Concepts

Diagram legend: blue fill = Cube platform; orange fill = Volume Plugin (your implementation).

Problem Statement

Sandboxes need data that survives restarts and new instances (model weights, workspace files, etc.). The Cube platform handles API, orchestration, and forwarding; the Volume Plugin mounts the real backend (object storage, NFS, …) to a host hostPath, which Cubelet exposes to the microVM via virtiofs.

Dual-Role Model

Inspired by Kubernetes CSI, Hooks split into control plane and data plane, triggered by different processes:

RoleInvoked byHooksResponsibility
ControllerCubeMasterCreate / DestroyAllocate / delete Volume resources in the backend
NodeCubeletAttach / DetachMount / unmount on the host; produce hostPath

The same Hook protocol can be implemented as binary or rpc plugins. All four Hooks may live in one plugin or be split.

Control plane: Create / Destroy

Data plane: Attach / Detach

Whether binary or rpc, every plugin must implement the same Hook fields below. binary maps them to CLI flags / stdout JSON (snake_case, e.g. volume_id--volume-id, host_path in JSON); rpc uses the same names in volumeplugin.proto. CubeMaster / Cubelet pick the plugin by configured driver before calling a Hook — driver is not a Hook parameter.

Errors: binary uses non-zero exit and/or non-empty "error" in stdout JSON; rpc uses gRPC error status (responses have no error field).

Plugin Types

TypeControllerNodeDescription
binaryExternal executable; each Hook forks a child process; CLI + stdout JSON
rpcLong-running gRPC plugin; connect via socket_path (Unix socket or TCP)

Config field type selects the plugin type; name (driver) must match end-to-end on CubeMaster and Cubelet. rpc proto definition: rpc Plugin Proto Definition.

Hooks

HookSideTrigger
CreateControllerPOST /volumes
DestroyControllerDELETE /volumes/:id
AttachNodeSandbox create (volumeMounts)
DetachNodeSandbox destroy or create-failure rollback

Create

DirectionFieldTypeDescription
InputvolumeIDstringStable ID (UUID or same as name)
InputnamestringDisplay name
OutputtokenstringOptional auth token returned to SDK
Outputprivate_datastringOpaque plugin state (max 1024 bytes). Persisted in t_cube_volume and forwarded to Attach on sandbox create. Not returned to API/SDK clients. May be empty.
Outputerrorstring"" on success (binary stdout JSON only)

binary example

Input (CLI):

bash
/path/to/my-plugin --op create --volume-id my-vol --name my-vol

Output (stdout JSON, exit 0):

json
{"token":"","private_data":"","error":""}

Destroy

DirectionFieldTypeDescription
InputvolumeIDstringVolume to delete in backend
Outputerrorstring"" on success (binary stdout JSON only)

Plugin must locate backend resources using only volumeID (e.g. delete prefix volumes/<volumeID>/). Destroy does not auto-Detach running sandboxes.

binary example

Input (CLI):

bash
/path/to/my-plugin --op destroy --volume-id my-vol

Output (stdout JSON, exit 0):

json
{"error":""}

Attach

DirectionFieldTypeDescription
InputsandboxIDstringSandbox being created
Inputnamespacestringcontainerd namespace
InputvolumeIDstringSame as volumeMounts[].name
InputrefCountint64Sandbox count on this node before attach; 0 = first on this node
InputvolumeBaseDirstringParent dir; hostPath must be inside it
Inputprivate_datastringSame opaque blob Create returned (from t_cube_volume); may be empty. binary: optional --private-data (omitted when empty)
OutputhostPathstringPath in Cubelet mntns for virtiofs bind
Outputmetadatamap[string]stringOpaque state; echoed back on Detach
Outputerrorstring"" on success (binary stdout JSON only)
  • refCount == 0: first attach on this node — perform backend mount.
  • refCount > 0: another sandbox on this node already references the volume — return existing hostPath (+ metadata); do not mount again.
  • hostPath: absolute path under volumeBaseDir (recommended <volumeBaseDir>/<plugin-name>-<volumeID>). Otherwise Cubelet rejects attach, rolls back, and fails sandbox create. Default volumeBaseDir: /data/cube-shared/volume.

binary example

Input (CLI):

bash
/path/to/my-plugin --op attach \
  --sandbox-id sb-001 --namespace default \
  --volume-id my-vol --ref-count 0 \
  --volume-base-dir /data/cube-shared/volume
# optional when Create returned non-empty private_data:
#   --private-data 'volumes/my-vol/'

Output (stdout JSON, exit 0):

json
{"host_path":"/data/cube-shared/volume/my-storage-my-vol","metadata":{"mount_dir":"/data/cube-shared/volume/my-storage-my-vol"},"error":""}

Detach

DirectionFieldTypeDescription
InputsandboxIDstringSame as Attach
InputnamespacestringSame as Attach
InputvolumeIDstringSame as Attach
InputrefCountint64Sandbox count on this node after detach; 0 = last on this node
Inputmetadatamap[string]stringExact map from Attach
Outputerrorstring"" on success (binary stdout JSON only)
  • refCount == 0: last sandbox on this node — tear down shared backend mount (keep persistent data).
  • refCount > 0: other sandbox(es) on this node still attached — no-op.

binary example

Input (CLI):

bash
/path/to/my-plugin --op detach \
  --sandbox-id sb-001 --namespace default \
  --volume-id my-vol --ref-count 0 \
  --metadata '{"mount_dir":"/data/cube-shared/volume/my-storage-my-vol"}'

Output (stdout JSON, exit 0):

json
{"error":""}

RefCount

One Volume may be shared by multiple sandboxes. Cubelet maintains a per-node reference count and passes refCount into Node Hooks:

WhenrefCountPlugin behavior
Before Attach0First sandbox on this node; establish backend mount
Before Attach> 0Another sandbox on this node already attached; return existing hostPath
After Detach0Last sandbox on this node; tear down shared backend mount
After Detach> 0Other sandbox(es) on this node still attached; no-op

When a node's local count flips 0→1 or 1→0, Cubelet notifies CubeMaster, which updates t_cube_volume.refcount; control-plane DELETE /volumes is rejected while that count is non-zero.

End-to-End Lifecycle

CubeAPI forwards volume_mounts for plugin volumes via the plugin-volume-mounts annotation; CubeMaster injects them into each container's VolumeMounts before calling Cubelet (see hostdir_mount.go).


SDK Usage

Examples below use Python SDK cubesandbox ≥ 0.6.0. CubeAPI exposes e2b-compatible /volumes REST endpoints; applications should prefer the SDK over raw HTTP.

e2b compatibility note

Layere2b compatible?Notes
CubeAPI /volumes REST✅ YesPOST/GET/DELETE /volumes, GET /volumes/{volumeID}
Official e2b Python SDK❌ NoHardcoded to e2b.cloud; do not use with CubeSandbox
cubesandbox Python SDK✅ YesVolume, Sandbox.create(volume_mounts={path: volume}) (e2b dict)
Omit driver on create✅ YesCubeMaster uses the first volume_plugins entry

For a full COS plugin walkthrough, see examples/volume/cos/README.md.

Environment Setup

bash
pip install 'cubesandbox>=0.6.0'

export CUBE_API_URL=http://<cubeapi-host>:3000
export CUBE_TEMPLATE_ID=<your-template-id>

# Required for remote access: data plane via CubeProxy, bypassing *.cube.app DNS
export CUBE_PROXY_NODE_IP=<cubeproxy-node-ip>

# Optional when auth is enabled
# export CUBE_API_KEY=<key>
VariableDescription
CUBE_API_URLCubeAPI control-plane address
CUBE_TEMPLATE_IDTemplate ID for sandbox creation
CUBE_PROXY_NODE_IPCubeProxy node IP; sandbox I/O after mount uses the data plane
CUBE_API_KEYOptional; maps to X-API-Key when auth is enabled

Full Lifecycle (create → mount → unmount → delete)

python
from cubesandbox import Sandbox, Volume

# ① Create Volume (control plane) → live Volume instance (e2b compatible)
# e2b compatible: omit driver — same as Volume.create("my-data")
vol = Volume.create("my-data")
# Or pick a specific plugin:
# vol = Volume.create("my-data", driver="my-storage")
print(vol.volume_id, vol.name, vol.token)  # token from plugin; may be empty

# List / get_info → VolumeInfo (plain data, not a live handle)
for v in Volume.list():
    print(v.volume_id, v.name)              # list omits token
info = Volume.get_info(vol.volume_id)       # get_info includes token

# Reconnect to an existing volume (e2b Volume.connect)
# vol = Volume.connect(vol.volume_id)

# ② Create sandbox with Volume mount (data plane: Attach)
with Sandbox.create(
    volume_mounts={"/workspace": vol},
) as sb:
    sb.files.write("/workspace/note.txt", "persisted!")
    print(sb.files.read("/workspace/note.txt"))

# ③ Exit with / sb.kill() destroys sandbox (data plane: Detach)

# ④ Delete Volume (control plane: Destroy)
Volume.destroy(vol.volume_id)  # returns True; False when already gone (idempotent)
SDK parameterDescription
Volume.create(name, driver=...)Returns a Volume instance; name optional; server generates UUID as volume_id if omitted; must match ^[a-zA-Z0-9_-]+$, max 128 chars
Volume.connect(volume_id)Returns a Volume instance (e2b compatible; wraps get_info)
Volume.list()Returns list[VolumeInfo] (no token)
Volume.get_info(volume_id)Returns VolumeInfo; includes token (empty string when the plugin returns none)
Volume.destroy(volume_id)e2b-compatible delete; True on success, False on 404 (idempotent)
Volume.delete(...)Backward-compat alias for destroy (prefer destroy)
driverOptional plugin name; e2b compatible usage omits it — SDK sends no field, CubeMaster uses the first entry in volume_plugins
volume_mountse2b dict {mount_path: Volume | volume_id | name} — key is path inside sandbox, value is a Volume instance or volume ID string

driver is stored in t_cube_volume and forwarded to Cubelet via annotations — volume_plugins[].name must match on both CubeMaster and Cubelet.

Multiple Sandboxes Sharing One Volume

python
# e2b compatible: omit driver (defaults to first plugin in CubeMaster config)
vol = Volume.create("shared-data")
# vol = Volume.create("shared-data", driver="my-storage")

sb_a = Sandbox.create(volume_mounts={"/workspace": vol})
sb_a.files.write("/workspace/shared.txt", "from A")

sb_b = Sandbox.create(volume_mounts={"/workspace": vol})
print(sb_b.files.read("/workspace/shared.txt"))  # from A

sb_a.kill()
sb_b.kill()
Volume.destroy(vol.volume_id)

One Volume may be mounted by multiple sandboxes simultaneously; data written from one sandbox is visible to others. Destroy all sandboxes using the Volume before calling Volume.destroy() (see RefCount for how the platform tracks shared usage).

Common SDK Errors

ScenarioSDK exceptionTypical cause
Volume not foundVolumeNotFoundError (404)Invalid ID for Volume.get_info / Volume.connect
Unknown driverApiError (400, CubeMaster 130400)No matching volume_plugins entry
Volume still referencedApiError (409, CubeMaster 130409)Delete while the volume is still mounted by a sandbox
Invalid volume nameValueErrorClient-side validation; name fails ^[a-zA-Z0-9_-]+$
Mount non-existent volumeApiErrorSandbox volumeMounts[].name was never created

Note: When a volume is still mounted by any sandbox, Volume.destroy() returns 409. Destroy all sandboxes using the volume first, then delete. Delete does not automatically unmount running sandboxes.


Registration and Configuration

CubeMaster (conf.yaml)

yaml
volume_plugins:
  - name: <driver>
    type: binary
    binary_path: <binary_path>

  - name: <driver>
    type: rpc
    socket_path: /run/<driver>.sock   # bare Unix path; plugin SOCKET must match

Cubelet (config.toml)

toml
[plugins."io.cubelet.internal.v1.storage"]
  volume_plugin_base_dir = "<volume_plugin_base_dir>"

[[plugins."io.cubelet.internal.v1.storage".volume_plugins]]
  name        = "<driver>"
  type        = "binary"       # binary | rpc
  binary_path = "<binary_path>"

[[plugins."io.cubelet.internal.v1.storage".volume_plugins]]
  name        = "<driver>"
  type        = "rpc"
  socket_path = "/run/<driver>.sock"   # same path as plugin SOCKET

driver name must be consistent end-to-end: Volume.create(..., driver=...) (or first list entry when omitted) → DB → annotations → Cubelet routes by the same name. CubeMaster and Cubelet volume_plugins[].name must match.

volume_plugin_base_dir: every plugin host_path must be under this directory (default /data/cube-shared/volume when unset). Cubelet passes it to plugins as volumeBaseDir (rpc) / --volume-base-dir (binary) and rejects attach if host_path is outside it.

name must be unique within each process: no two volume_plugins entries with the same name. List order sets the default plugin when API/SDK omits driver.


rpc Plugin Proto Definition

rpc plugins implement gRPC services in volumeplugin.proto. Message fields match the Hook definitions above (proto uses snake_case).

FileDescription
volumeplugin.protoProtocol source
volumeplugin.pb.goGenerated Go messages
volumeplugin_grpc.pb.goGenerated gRPC stubs
ServiceCallerRPCs
VolumeControllerServiceCubeMasterCreate, Destroy
VolumePluginServiceCubeletAttach, Detach

Regenerate after editing proto: cd Cubelet && make proto. Reference implementation: examples/volume/cos/rpc/README.md.


Plugin Development Guidelines

When implementing a custom Volume plugin, follow these platform rules:

#GuidelineDescription
1mntnsNode Hooks mount inside Cubelet mntns; binary plugins inherit via fork
2Idempotent AttachWhen refCount > 0, another sandbox on this node already attached — return existing hostPath
3hostPathMust be under volumeBaseDir (recommended <volumeBaseDir>/<driver>-<volumeID>)
4Detach scopeTear down host mount only (e.g. FUSE unmount); do not delete backend data
5CredentialsKeys, bucket, region, etc. managed by the plugin (config file, env, …); the framework does not mandate layout
6CubeMaster / Cubelet alignmentBoth must register the same driver names in volume_plugins; Controller hooks (Create/Destroy) and Node hooks (Attach/Detach) must refer to the same plugin for a given Volume

Reference Implementations

The repo ships a Tencent Cloud COS reference plugin (binary Shell + rpc Go) with end-to-end walkthrough and dependency install:

DocContent
examples/volume/cos/README.mdFull COS walkthrough (deps, config, SDK verify)
examples/volume/cos/binary/README.mdbinary plugin script details
examples/volume/cos/rpc/README.mdrpc plugin build and deploy
examples/volume/cos/verify_volume.pyPython SDK verification script

COS-specific Hook behavior, object layout, trade-offs, and troubleshooting live in those example docs — not duplicated here.


Debugging and Troubleshooting

Mount Namespace

Cubelet runs in an isolated mount namespace via unshare(CLONE_NEWNS). Implementation detail, but important for debugging:

  • Node Hook hostPath (FUSE, bind, etc.) must exist in Cubelet mntns; host root mntns /proc/mounts usually won't show them.
  • binary plugins are forked by Cubelet and inherit mntns — no nsenter needed.
  • Manual mount on the host for debugging often won't appear inside sandboxes — trigger Attach via Cubelet or enter Cubelet mntns.

Inspect mounts inside Cubelet mntns:

bash
CPID=$(pgrep -f "cubelet --config" | head -1)
nsenter -t "$CPID" -m -- mount | grep -E 'volume|fuse'

Manual Plugin Test (binary)

Replace /path/to/my-plugin and my-storage with your plugin binary and configured driver name:

bash
# Simulate Controller create
/path/to/my-plugin \
  --op create --volume-id test-vol --name test-vol

# Simulate Node attach (first mount); host_path must be under --volume-base-dir
/path/to/my-plugin \
  --op attach --sandbox-id sb-001 --namespace default \
  --volume-id test-vol --ref-count 0 \
  --volume-base-dir /data/cube-shared/volume

For COS-specific manual tests, see examples/volume/cos/README.md.

Common Issues

SymptomLikely causeCheck
no plugin registered for driverCubelet missing volume_pluginsconfig.toml, restart Cubelet
unknown driverCubeMaster not configured or name mismatchCompare name on both sides
volume not foundvolumeMounts[].name ≠ existing volume_idUse volume_id from Volume.create (or pass the Volume instance in volume_mounts)
FUSE OK but invisible in sandboxMount in host mntns, not Cubelet mntnsLet Cubelet fork the plugin
Detach leak after attachUnmount shared FUSE when ref_count > 0Follow RefCount semantics

Compatibility Notes

Volume requires both CubeMaster and Cubelet to be on a release that supports the Volume Plugin. During a rolling upgrade:

CubeMasterCubeletVolume create / deleteSandbox volumeMounts
NewNewSupportedSupported
NewOld (e.g. v0.5.x)SupportedNo-op (create must not fail; the volume is not mounted)
Old (e.g. v0.5.x)New / oldUnsupportedUnsupported; do not send volumeMounts — plain create is unaffected

Do not assume mounts succeed on old nodes; schedule onto an upgraded Cubelet when you need mount behavior.


Known Limitations and Roadmap

Platform behavior (independent of a specific plugin):

ItemDescription
Delete guardReject delete when t_cube_volume.refcount ≠ 0; no auto-detach of running sandboxes
Refcount via responsesCount updates on sandbox create/destroy responses; lost responses may cause brief drift
FUSE POSIX semanticsDepends on backend/mount implementation (hard links, atomic rename, etc.)

Source Index

ModulePathDescription
Node interfaceCubelet/plugins/volume/interface.goVolumePlugin abstraction
Node request typesCubelet/plugins/volume/context.goAttachRequest / DetachRequest
Controller interfaceCubeMaster/pkg/volume/plugin/plugin.goControllerPlugin abstraction
Node binary driverCubelet/plugins/volume/binary/driver.goHook → CLI mapping
Node rpc driverCubelet/plugins/volume/rpc/driver.goHook → gRPC client
Controller binaryCubeMaster/pkg/volume/plugin/binary/driver.goHook → CLI mapping
Controller rpcCubeMaster/pkg/volume/plugin/rpc/driver.goHook → gRPC mapping
Cross-node refcountCubeMaster/pkg/volume/refcount/refcount.goParse ext_info; update t_cube_volume.refcount
Volume DB modelCubeMaster/pkg/base/db/models/volume.goVolumeRecord (includes refcount)
Plugin volume mount injectionCubeMaster/pkg/service/sandbox/hostdir_mount.goinjectPluginVolumeMounts from plugin-volume-mounts annotation
Node mount logicCubelet/storage/pluginvolume.gobind-mount + virtiofs; node-level refcount transitions
ProtoCubelet/api/services/volumeplugin/v1/volumeplugin.protorpc protocol
Generated GoCubelet/api/services/volumeplugin/v1/volumeplugin*.pb.goMessages / gRPC stubs
COS reference (binary)examples/volume/cos/binary/cube-volume-cos.shExample binary plugin
COS reference (rpc)examples/volume/cos/rpc/cmd/cube-volume-cos-rpcExample rpc plugin