Documentation
¶
Overview ¶
Package croft implements an isolated, snapshot-backed workspace on top of containerd's overlayfs snapshotter: a small enclosed plot of filesystem a program works in, whose states can be captured, resumed, layered, and released, with commands and file I/O executed in-band inside the boundary.
A croft is provisioned from an image (Create), worked through Exec, ReadFile, and WriteFile, and checkpointed with Capture, which commits the writable overlayfs layer into an immutable state the caller can later Apply — from the same sandbox or a fresh one — or hand to another sandbox entirely, which is the fork primitive: states outlive the sandboxes that capture them.
Two states can be compared without applying either: Changes reports the sorted difference between a captured state and the current workspace as a []Change, and Diff renders that difference as a unified text diff. Both are read-only against the workspace and never stop or restart the task. A state's changes can also be merged into a diverged workspace: ApplyDelta runs a tree-level three-way merge against the merge base the state carries (BaseKey), refusing atomically on conflict — no git, no repository, no patch payload.
Design constraints ¶
Each of these contradicts a naive implementation, so read them before changing the lifecycle:
Use the snapshotter, not unix.Mount. An active snapshot cannot serve as another snapshot's parent; only a committed one can. Prepare -> Commit -> Remove is the supported primitive, and it already handles lowerdir chain depth and mount-option length limits that hand-rolled mounting would force you to reimplement.
A committed layer must stop being written to. containerd's Commit is a metadata operation (storage.CommitActive) that renames the key; the snapshot directory is keyed by an immutable id, so a running task's mount survives it. Convenient, but dangerous: the freshly committed layer immediately becomes a lowerdir of the next active layer, and OverlayFS lowerdirs must not be written. Capture therefore stops the task before committing and restarts it on the new mounts. See Capture.
Sharing a state is a rebase, not a copy. Making a committed snapshot the parent of another sandbox's active layer is O(1). Copying upper directories is wrong: deletions are whiteout character devices and directory opacity is a trusted.overlay.opaque xattr, so a naive copy resurrects deleted files.
Linux only. OverlayFS and the containerd daemon require Linux with root or user namespaces.
Containment is "contained", not "full". A container is namespaces plus a separate rootfs: strong, but a kernel exploit escapes it. Claiming ContainFull would overstate the guarantee.
The image contract ¶
The workspace's file operations shell out inside the container, so the image must provide a shell and the small coreutils croft itself uses: mkdir (Create provisions the workdir), and cat, dirname, printf, base64, and chmod (ReadFile and WriteFile). The diff machinery adds ls and readlink: reader tasks use them to read pre-images, enumerate directories, and resolve symlink targets from a captured state. The delta machinery adds rm and ln: ApplyDelta clears replaced and deleted paths and recreates symlinks.
Status ¶
The snapshot lifecycle, teardown ordering, layer pruning, file I/O, the snapshot-native diff machinery (Changes, Diff, ReadFilesAt), and the snapshot-native delta (ApplyDelta) are implemented and unit-tested against containerd's real snapshots.Snapshotter interface, using an in-memory fake that enforces the same invariants as the overlayfs snapshotter: a key cannot be prepared twice, a parent must be committed, only an active layer can be committed (exactly once — an already committed layer is rejected), a committed layer is read-only, and a layer with children cannot be removed.
The daemon adapter (client.go) compiles but has not been exercised on every supported daemon configuration; the live test suite runs it against a real containerd when one is reachable. The live suite covers the full lifecycle, the diff path end to end (Changes, Diff, ReadFilesAt against a real daemon-side diff service), and the delta path (two sandboxes merging over a shared base, conflict refusal, symlink and directory application). Treat container and OCI spec construction, the exec round-trip, and the daemon-side diff service as verified only where the live suite has run.
Lifecycle ¶
Create resolve the image rootfs, Prepare the base active snapshot, start
the task on its mounts
Capture stop the task, Commit the active snapshot, pin it as a state under
the namespace's states lease, Prepare a fresh active snapshot on
top of it, restart the task
Apply Prepare a new active snapshot from a committed key, rebind, restart
Destroy stop the task, Remove the active layer, release the sandbox lease.
Committed layers survive: they are states, and states outlive the
sandbox that captured them
ReleaseState drop a state's pin; the daemon's GC reclaims the layer once
nothing references it
Apply rebinds this sandbox's identity to the applied state's layer rather than replaying into an existing tree; callers must not assume a state can only be applied once, but must not assume two sandboxes applying the same state share identity either.
Disk comes back through ReleaseState, Destroy's active-layer removal, or namespace teardown — in that order of specificity. Destroy deliberately does not remove committed layers: every one was returned to a caller as a state, and a sibling sandbox may be running on a child of the same layer.
Diffing states ¶
Changes and Diff compare a captured state against the current workspace without applying either side. The daemon's diff service computes the change-set blob daemon-side; croft pins it under a short-lived per-call lease, parses the tar into a sorted []Change, and — for Diff — reads pre-images through a throwaway reader task and renders a unified text diff with a Myers diff engine. Both operations take the shared operation lock: they never interleave with Capture/Apply/Destroy, so they always see a quiescent workspace. A state whose layer has been released surfaces ErrStateUnavailable.
Merging states ¶
ApplyDelta merges a state's changes into the current workspace: a tree-level three-way merge of two daemon-side diffs — base→theirs (the incoming state) and base→ours (the active layer) — against the merge base the state carries (BaseKey, the layer its capturing sandbox last Applied). Theirs-only changes apply, ours-only changes are kept, identical edits are kept once; divergent edits to the same file, mode disagreement, modify-vs-delete, or a deleted subtree the other side wrote into are conflicts. The refusal is atomic — ErrDeltaConflict names the paths and the workspace is untouched; the application phase after a clean merge is not transactional, matching the git-era version it replaces. Granularity is per-file, not per-line; line-level merge is a scoped follow-up.
Index ¶
- Variables
- type BackendOption
- type Change
- type ChangeKind
- type Config
- type ContainerdSandbox
- func (s *ContainerdSandbox) Apply(ctx context.Context, ws WorkspaceState) error
- func (s *ContainerdSandbox) ApplyDelta(ctx context.Context, ws WorkspaceState) error
- func (s *ContainerdSandbox) Capture(ctx context.Context) (WorkspaceState, error)
- func (s *ContainerdSandbox) Changes(ctx context.Context, ws WorkspaceState) ([]Change, error)
- func (s *ContainerdSandbox) Create(ctx context.Context) error
- func (s *ContainerdSandbox) Destroy(ctx context.Context) error
- func (s *ContainerdSandbox) Diff(ctx context.Context, ws WorkspaceState) (string, error)
- func (s *ContainerdSandbox) Exec(ctx context.Context, req ExecRequest) (ExecResult, error)
- func (s *ContainerdSandbox) ID() string
- func (s *ContainerdSandbox) ListPinnedStates(ctx context.Context) ([]string, error)
- func (s *ContainerdSandbox) ReadFile(ctx context.Context, path string) ([]byte, error)
- func (s *ContainerdSandbox) ReadFilesAt(ctx context.Context, ws WorkspaceState, paths []string) (map[string][]byte, error)
- func (s *ContainerdSandbox) ReleaseState(ctx context.Context, ws WorkspaceState) error
- func (s *ContainerdSandbox) WriteFile(ctx context.Context, path string, data []byte, perm fs.FileMode) error
- type ExecRequest
- type ExecResult
- type Limits
- type Sandbox
- type SecretMount
- type WorkspaceState
Constants ¶
This section is empty.
Variables ¶
var ErrDeltaConflict = errors.New("croft: delta conflicts with the workspace's own changes")
ErrDeltaConflict is returned by ApplyDelta when the incoming state's changes conflict with the workspace's own changes since their common base: both sides touched the same path incompatibly (divergent edits, mode disagreement, modify vs delete, type change), or one side's rigid entry (delete, file, symlink) contradicts a change underneath it on the other. The refusal is atomic — the workspace is untouched — and the error names the conflicting paths.
var ErrNotImplemented = errors.New("containerd sandbox: not implemented")
ErrNotImplemented is returned when the containerd surface could not be connected — the operation is supported, the daemon connection is what failed, so reporting anything else would be false.
ErrStateUnavailable is returned when a WorkspaceState's snapshot layer has been released: the state's pin is gone and the daemon's garbage collector may reclaim (or already has reclaimed) the layer. Retrying with a fresh Capture is the only recovery.
Functions ¶
This section is empty.
Types ¶
type BackendOption ¶
type BackendOption func(*ContainerdSandbox)
BackendOption configures optional collaborators on a NewWithBackend sandbox.
func WithContentDigest ¶ added in v0.9.0
func WithContentDigest() BackendOption
WithContentDigest makes Capture populate WorkspaceState.ContentDigest with the daemon differ's content-addressed digest of the workspace (croft-dlb). It costs one full-tree diff against the image rootfs per capture; hosts that only need the no-change fast path can leave it off.
func WithDiffService ¶
func WithDiffService(ds diffService) BackendOption
WithDiffService supplies the daemon-side diff collaborator for hosts embedding the sandbox over an existing containerd client — pass the sandbox's own adapter or an equivalent namespace-wrapped client.DiffService(). Without it, a NewWithBackend sandbox has no change-set operations.
func WithLeases ¶
func WithLeases(mgr leases.Manager) BackendOption
WithLeases supplies the daemon's lease manager for hosts embedding the sandbox over an existing containerd client — pass client.LeasesService(). Without it, pinState and unpinState are no-ops and captured states are not durable (see NewWithBackend).
type Change ¶
type Change struct {
// Path is workspace-relative, slash-separated. Changes are sorted by Path.
Path string
// Kind classifies the change.
Kind ChangeKind
// IsDir reports whether the path is a directory.
IsDir bool
// Mode is the new mode. For ChangeDelete the permission bits stay 0,
// but the type bit (ModeSymlink) may be set from the state's layer so
// consumers can tell a deleted symlink from a deleted regular file;
// deleted directories are identified by IsDir.
Mode fs.FileMode
// Size is the new size in bytes; 0 for ChangeDelete and directories.
Size int64
}
Change describes one difference between a WorkspaceState and the current workspace.
type ChangeKind ¶
type ChangeKind int
ChangeKind classifies a workspace change.
const ( // ChangeAdd is a path that exists in the current workspace but not in // the state. ChangeAdd ChangeKind = iota // ChangeModify is a path that exists in both the state and the current // workspace but differs (content, mode, or type). ChangeModify // ChangeDelete is a path that exists in the state but not in the // current workspace. ChangeDelete )
func (ChangeKind) String ¶
func (k ChangeKind) String() string
String returns a short lowercase label for the kind, for logs and tests.
type Config ¶
type Config struct {
// Address is the containerd socket or endpoint. Empty uses containerd's
// own default resolution.
Address string
// Namespace isolates this backend's containers and snapshots from other
// containerd users on the same host.
Namespace string
// Image is the base image provisioned for the workspace. Required: a
// sandbox with no base filesystem has nothing to run.
Image string
// ContentDigest makes Capture populate WorkspaceState.ContentDigest with
// the daemon differ's content-addressed digest of the workspace
// (croft-dlb). It costs one full-tree diff against the image rootfs per
// capture; the no-change fast path — the part settlement needs — does not
// require it. NewWithBackend can enable it with WithContentDigest instead.
ContentDigest bool
// Runtime is the OCI runtime name, for example "io.containerd.runc.v2".
// Empty uses containerd's default.
Runtime string
// Snapshotter selects the containerd snapshotter plugin. Empty uses
// "overlayfs". Other snapshotters differ in commit semantics, so this is not
// assumed.
Snapshotter string
// InitArgs is the long-lived command a container runs so it stays up between
// commands; Exec provides the actual work. Empty uses
// `/bin/sh -c "sleep infinity"`, which requires a shell in the image.
InitArgs []string
// Workdir is the workspace path inside the container. Relative file paths
// and commands resolve here. Empty defaults to "/workspace".
Workdir string
// WriteChunkBytes bounds how much file content a single WriteFile exec
// carries, in raw bytes before base64 expansion. Empty uses
// defaultWriteChunkBytes. Larger values mean fewer container execs per file
// and a larger argv; each exec's payload is split into arguments under the
// kernel's per-argument ceiling regardless, so this only controls how much
// travels per exec.
WriteChunkBytes int
// MaxOutputBytes caps each of an exec's stdout and stderr in memory,
// applied per stream independently to public Exec traffic only —
// ReadFile and the diff reader tasks carry data, not diagnostics, and
// stay lossless regardless of this knob. Over the cap, the first and
// last MaxOutputBytes/2 bytes survive with a "[... N bytes truncated
// ...]" marker between them — heads carry the command's banner, tails
// the actual error. The cut is byte-based: a UTF-8 sequence or line may
// split at a boundary (output is diagnostic text). Zero or negative
// means unbounded — the behavior before this knob existed; a negative
// value is treated as unbounded rather than rejected, matching
// WriteChunkBytes' tolerance for non-positive knobs.
MaxOutputBytes int
// FIFODir is where the containerd *client* creates the FIFOs that carry an
// exec's stdio. Empty uses containerd's default (/run/containerd/fifo),
// which is root-owned — and because the client creates these itself, an
// unprivileged caller fails locally with EACCES before any RPC reaches the
// daemon. Set this to a directory the calling user can write to in order to
// drive the backend without root.
FIFODir string
// Limits bounds the sandbox's container resources (CPU, memory, process
// count). Nil means unbounded: every container this sandbox starts —
// including the reader tasks behind Changes/Diff/ReadFilesAt — runs with
// the runtime's defaults. A non-nil Limits with zero-valued fields is
// valid: unset fields pass nothing through, so partial limits compose
// with the image and runtime defaults.
//
// Limits apply at container creation (Create, Apply, Capture's restart,
// reader tasks) and are fixed at construction: New copies Config by
// value, so reassigning cfg.Limits on the caller's struct after New has
// no effect on any generation. Mutating the shared *Limits in place
// while the sandbox starts containers is a data race — decide limits
// before New, not after.
Limits *Limits
// Network selects the workspace's network posture.
//
// "" or "none" (the default): the container's own network namespace
// with only loopback — no egress, no ingress, DNS fails fast. This
// is the posture croft's containers have always had: containerd's
// default spec unshares the network namespace, and croft wires no
// CNI. It is the right default for workspaces running untrusted
// code.
//
// "host": share the daemon host's network namespace — package
// installs, git remotes, and egress work, and so does everything
// else the host can reach. An explicit opt-in; every container
// generation this sandbox starts inherits it, reader tasks
// included. What host mode shares is the namespace, not the
// resolver: /etc/resolv.conf is the image's, so hostname-based
// operations also need an image with a usable resolver config.
//
// Any other value is rejected loudly at every entry that starts a
// container generation — Create, Apply, and the ReadFilesAt reader
// path (the config-typo convention).
Network string
// Secrets are host files bind-mounted read-only into the container.
// The container reads a normal file; the content never appears in
// argv, process environment, or any exec payload. Source must be an
// absolute path on the daemon host; Destination must be an absolute
// in-container path.
Secrets []SecretMount
// StagingDir is a host directory used for argv-free writes. When set,
// WriteFile copies the payload to the staging directory on the host,
// bind-mounts that directory read-write into the container at
// /run/croft-staging, and then moves the file into place atomically
// inside the container. When unset, WriteFile uses the base64-in-argv
// path (documented exposure).
StagingDir string
}
Config configures the containerd backend.
type ContainerdSandbox ¶
type ContainerdSandbox struct {
// contains filtered or unexported fields
}
func New ¶
func New(cfg Config) *ContainerdSandbox
New returns a sandbox bound to cfg, backed by a real containerd client. The client is connected lazily on first use.
func NewWithBackend ¶
func NewWithBackend(cfg Config, snap snapshots.Snapshotter, tasks taskService, images imageService, opts ...BackendOption) *ContainerdSandbox
NewWithBackend returns a sandbox backed by the supplied implementations. Exported for tests and for embedding the sandbox in a host that already holds a containerd client.
Without WithLeases the sandbox has no lease manager, and state durability is inert: Capture still succeeds and hands out states, but nothing pins their snapshots against the daemon's GC (see pinState). A host embedding a real snapshotter should therefore pass WithLeases with the client's LeasesService(); test fakes have no GC and lose nothing by omitting it.
func (*ContainerdSandbox) Apply ¶
func (s *ContainerdSandbox) Apply(ctx context.Context, ws WorkspaceState) error
Apply resumes the workspace from a previously captured state by preparing a new active snapshot from the committed key and rebinding this sandbox.
Layers created after the applied state are no longer ancestors of this sandbox's active layer, but they survive: each was handed to a caller as a WorkspaceState, and states outlive sandboxes. Reclaim them with ReleaseState when the caller is done with them. Only the abandoned active layer is removed. Applying a different state moves the sandbox again: a state's layer becomes whatever runs next, and callers must not assume two sandboxes applying the same state share identity.
func (*ContainerdSandbox) ApplyDelta ¶
func (s *ContainerdSandbox) ApplyDelta(ctx context.Context, ws WorkspaceState) error
ApplyDelta merges ws's changes — everything since ws.BaseKey — onto the current workspace at the tree level: a three-way merge of two daemon-side diffs (base→ws, base→active) computed fully before anything is written.
Mutating on success; the workspace is untouched when it refuses. Errors: ErrDeltaConflict (conflicting paths named), ErrStateUnavailable (the base or the state's layer has been released). The merge is tree-level, not line-level: two divergent edits to the same file conflict even when they touch different lines.
A state with an empty BaseKey was captured from a sandbox that never Applied — it has no merge base and cannot be merged.
Like WriteFile, ApplyDelta takes the shared operation lock: it mutates through the running task and never stops it, so exclusive lifecycle ops (Capture/Apply/Destroy) serialize it away. Consistency under concurrent Exec equals Diff's: the merge reflects the workspace as of some moment during the call. The application phase itself is not transactional — the refusal is the atomic part — matching the git-era version's clean-merge path.
func (*ContainerdSandbox) Capture ¶
func (s *ContainerdSandbox) Capture(ctx context.Context) (WorkspaceState, error)
Capture commits the current workspace as a resumable state.
The task is stopped before the commit and restarted afterwards. That is not gratuitous: containerd's Commit renames the snapshot key in metadata while the directory keeps its immutable id, so a running task's mount survives — but the committed layer immediately becomes a lowerdir of the next active layer, and OverlayFS lowerdirs must not be written to. Leaving the task running would let it write into a layer that is now read-only by contract, corrupting the layer beneath its successor.
Stopping the task loses in-container process state. That is the price of a coherent snapshot with this API.
func (*ContainerdSandbox) Changes ¶
func (s *ContainerdSandbox) Changes(ctx context.Context, ws WorkspaceState) ([]Change, error)
Changes reports the differences between ws and the current workspace.
It takes the shared operation lock: Capture/Apply/Destroy hold the exclusive lock, so Changes always sees a quiescent workspace and its reader tasks cannot be stopped mid-exec by a lifecycle op.
func (*ContainerdSandbox) Create ¶
func (s *ContainerdSandbox) Create(ctx context.Context) error
Create provisions the base snapshot from Config.Image and starts the task. The caller sets its own deadline on the context; the whole operation — daemon connection, lease acquisition, image resolution — runs under it.
func (*ContainerdSandbox) Destroy ¶
func (s *ContainerdSandbox) Destroy(ctx context.Context) error
Destroy stops the task, removes the sandbox's active layer, and releases the sandbox lease.
Committed layers survive. Every one was returned by Capture as a WorkspaceState, and states have no lifecycle: a caller may Apply one long after this sandbox is gone, and sibling sandboxes may be running on children of the same layers right now. They stay pinned by the namespace's states lease (see pinState) and are reclaimed through ReleaseState or namespace teardown, never here.
Idempotent: it clears its own bookkeeping, so a second call is a no-op rather than a wave of not-found errors. The image rootfs snapshot is never removed — it belongs to the image.
func (*ContainerdSandbox) Diff ¶
func (s *ContainerdSandbox) Diff(ctx context.Context, ws WorkspaceState) (string, error)
Diff returns a unified text diff between ws and the current workspace.
It takes the shared operation lock: like Changes, it never interleaves with Capture/Apply/Destroy, so it always sees a quiescent workspace.
func (*ContainerdSandbox) Exec ¶
func (s *ContainerdSandbox) Exec(ctx context.Context, req ExecRequest) (ExecResult, error)
Exec runs a command inside the sandbox.
A non-empty ExecRequest.Stdin is delivered as data: staged to a temp file in the container and redirected into the process, which sees EOF when the bytes run out. The shim's stdin FIFO — which never signals EOF — is bypassed entirely; see ExecRequest.Stdin for the contract and the exposure caveat.
func (*ContainerdSandbox) ID ¶
func (s *ContainerdSandbox) ID() string
ID returns the current container identity, or "" before Create. It changes across Apply, so callers must not cache it.
func (*ContainerdSandbox) ListPinnedStates ¶
func (s *ContainerdSandbox) ListPinnedStates(ctx context.Context) ([]string, error)
ListPinnedStates returns the snapshot keys currently pinned under the namespace-scoped states lease. This is the StateLister extension the broker's GC sweeper needs for the daemon→DB reconciliation direction: pinned states absent from the registry (pre-registry captures, crashes) can be found and adopted or released.
A nil manager (tests, NewWithBackend without WithLeases) returns nil.
func (*ContainerdSandbox) ReadFile ¶
ReadFile reads a file inside the sandbox.
There is no host path to open: the workspace lives in a container snapshot, so the read runs in-container. The path is passed as a positional argument rather than interpolated into the script so it cannot alter the command.
func (*ContainerdSandbox) ReadFilesAt ¶
func (s *ContainerdSandbox) ReadFilesAt(ctx context.Context, ws WorkspaceState, paths []string) (map[string][]byte, error)
ReadFilesAt reads files from a workspace state without applying it — inspect a checkpoint's contents as they were when captured. The returned map is keyed by the paths as given.
This is a concrete method on ContainerdSandbox, not on the Sandbox interface: it is independently useful for hosts that hold a concrete sandbox, and promotion to the interface is a later decision (the StateReleaser precedent).
The reader task runs concurrently with the sandbox's main task and is torn down before returning; the main task is untouched. A missing path fails the call (the workspace raced; retryable).
func (*ContainerdSandbox) ReleaseState ¶
func (s *ContainerdSandbox) ReleaseState(ctx context.Context, ws WorkspaceState) error
ReleaseState drops the pin holding a captured state's snapshot, letting the daemon's garbage collector reclaim the layer once no other snapshot, container, or lease references it.
This is the state-discard half of the "a WorkspaceState has no lifecycle" contract: the state stays valid for exactly as long as the caller wants it, and disk comes back when the caller says so.
Idempotent: releasing an already-released state is a no-op.
The pin is addressed by the releasing backend's own namespace and snapshotter — a WorkspaceState carries no config identity — so release through a backend configured the same way as the one that captured the state. A mismatched release reports success (the target resource is simply not found) while the real pin persists.
func (*ContainerdSandbox) WriteFile ¶
func (s *ContainerdSandbox) WriteFile(ctx context.Context, path string, data []byte, perm fs.FileMode) error
WriteFile writes a file inside the sandbox, creating parent directories.
Like ReadFile this runs in-container. perm is applied as an octal mode.
The content travels as base64 in positional arguments rather than on stdin. That is forced, not preferred: the runc-v2 shim holds its own O_WRONLY handle on an exec's stdin FIFO for the process's whole lifetime (cmd/containerd-shim-runc-v2/process/exec.go, execProcess.openStdin), so a reader such as `cat` never observes EOF and a stdin-based write blocks until the context expires. Verified live against containerd 2.3.5 on 2026-10-05; see the KNOWN CONSTRAINT note on containerdTasks.Exec.
base64 preserves the injection safety the stdin version had: its alphabet carries no shell metacharacters, and every chunk is a quoted positional argument rather than text spliced into the script. It does not preserve confidentiality — argv is world-readable through /proc/<pid>/cmdline, so the content is exposed to other processes on the host for the life of the exec. Callers writing secrets should not rely on this backend.
Payloads larger than Config.WriteChunkBytes are split across several execs, and each exec's payload across several arguments under maxArgPayload. The first exec truncates, so retrying after a partial failure cannot append to stale content.
type ExecRequest ¶
type ExecRequest struct {
Command string
Args []string
Cwd string
Env map[string]string
Timeout time.Duration
// Stdin is delivered to the command as data: the bytes are staged to a
// mode-0600 temp file inside the container (through the WriteFile
// path, so arbitrary sizes chunk) and redirected into the process's
// stdin, which sees EOF when the bytes run out — the shim's stdin
// FIFO, which never signals EOF, is bypassed entirely. The staged file
// is removed on every outcome path.
//
// This is data delivery, not an interactive session: nothing can be
// written after the process starts (interactive sessions are a
// deferred scope boundary). The exposure caveat inherits from
// WriteFile — content travels base64 in exec arguments, visible in
// /proc/*/cmdline while staging runs: stdin-shaped data, never
// secrets.
Stdin []byte
// contains filtered or unexported fields
}
ExecRequest describes one command execution inside a sandbox.
type ExecResult ¶
ExecResult is the outcome of one command execution. Stdout and Stderr are the capped strings described on Config.MaxOutputBytes when that knob is set.
type Limits ¶
type Limits struct {
// (cgroup v1 cpu.shares semantics; the runtime converts it to the
// cgroup v2 cpu.weight scale). The v1 range is roughly [2, 262144]
// with a default of 1024; a v1 runtime may reject an out-of-range
// value with an opaque container-creation failure, while v2 clamps.
// Zero uses the runtime default.
CPUShares uint64
// CPUQuota caps CPU bandwidth: at most CPUQuota microseconds of CPU
// time per scheduling period (the runtime's default period, typically
// 100ms — 50000 therefore limits the container to half a CPU). Zero
// or negative leaves it unset.
CPUQuota int64
// MemoryBytes is the memory limit in bytes (cgroup v2 memory.max).
// It governs memory alone: swap is accounted separately
// (memory.swap.max) and left at the runtime default, so on a
// swap-enabled host a container can hold MemoryBytes of memory plus
// swap. Zero or negative leaves it unset.
MemoryBytes int64
// Pids caps the number of processes in the container's cgroup — the
// fork-bomb fairness bound. Zero or negative leaves it unset.
Pids int64
}
Limits bounds a sandbox's container resources, mapped onto the OCI runtime spec's Linux resources (cgroup v2 on a modern daemon): CPUShares to cpu.weight, CPUQuota to the cpu.max bandwidth cap, MemoryBytes to memory.max, Pids to pids.max. Zero-valued fields are left unset — the runtime's defaults apply — so a Limits carrying only MemoryBytes constrains memory alone.
type Sandbox ¶
type Sandbox interface {
// Create provisions the workspace: resolves the image rootfs, prepares
// the base active snapshot, and starts the task. The caller bounds the
// operation with the context.
Create(ctx context.Context) error
// Destroy releases resources this sandbox provisioned. It must never
// destroy resources it did not create.
Destroy(ctx context.Context) error
// Capture records the current workspace state: commits the active
// snapshot, pins it, and continues on a fresh layer. Non-mutating to
// the caller's workspace metadata.
//
// Contract caveat: the state's identity is its snapshot key, which is
// sequence-derived, so two captures of identical content produce
// different states — identity, not content equality. A content-derived
// state identity is a recorded follow-up.
Capture(ctx context.Context) (WorkspaceState, error)
// Apply resumes the workspace from a previously captured state by
// preparing a new active snapshot from the committed key and rebinding
// this sandbox. Layers created after the applied state survive as
// states; reclaim them with ReleaseState.
Apply(ctx context.Context, ws WorkspaceState) error
// ReleaseState drops the pin holding a captured state's snapshot. The
// daemon's garbage collector reclaims the layer once nothing references
// it; until then the state stays valid. Idempotent.
ReleaseState(ctx context.Context, ws WorkspaceState) error
// Exec runs a command in the sandbox and returns its result.
//
// A non-empty ExecRequest.Stdin is delivered to the command as data:
// staged to a temp file in the container and redirected into the
// process, which sees EOF (see ExecRequest.Stdin for the contract and
// the exposure caveat). Stdout and Stderr are capped per stream by
// Config.MaxOutputBytes when set; over-cap streams keep their head and
// tail around a truncation marker. A command that backgrounds its work
// (`nohup … &`) returns immediately and survives subsequent Execs —
// and dies at Capture, like every process.
Exec(ctx context.Context, req ExecRequest) (ExecResult, error)
// ReadFile reads a file inside the sandbox workspace.
ReadFile(ctx context.Context, path string) ([]byte, error)
// WriteFile writes a file inside the sandbox workspace.
WriteFile(ctx context.Context, path string, data []byte, perm fs.FileMode) error
// Changes reports the differences between ws and the current workspace.
// Paths are workspace-relative, slash-separated, and sorted. Returns
// ErrStateUnavailable once ws's layer has been released.
//
// Changes is read-only against the workspace: it never stops or restarts
// the task, never commits, never writes. It takes the shared operation
// lock, so it cannot interleave with Capture/Apply/Destroy — it always
// sees a quiescent workspace.
Changes(ctx context.Context, ws WorkspaceState) ([]Change, error)
// Diff returns a unified text diff between ws and the current
// workspace. Binary files render as "Binary files ... differ"; mode-only
// changes as "old mode"/"new mode" pairs; symlinks as target lines.
// Returns ErrStateUnavailable once ws's layer has been released.
//
// Like Changes, Diff is read-only and takes the shared operation lock.
Diff(ctx context.Context, ws WorkspaceState) (string, error)
// ApplyDelta merges ws's changes — everything since ws.BaseKey — onto
// the current workspace at the tree level. Mutating on success; the
// workspace is untouched when it refuses. Errors: ErrDeltaConflict
// (conflicting paths named in the error), ErrStateUnavailable (the base
// or the state's layer has been released). A state with an empty
// BaseKey was captured from a sandbox that never Applied and cannot be
// merged.
//
// The merge is tree-level, not line-level: two divergent edits to the
// same file conflict even when they touch different lines. Like
// WriteFile, ApplyDelta takes the shared operation lock: it mutates
// through the running task and never stops it.
ApplyDelta(ctx context.Context, ws WorkspaceState) error
}
Sandbox is an isolated workspace: it owns a filesystem boundary and can provision it, capture it, resume it, and execute in it.
The capture/apply pair is the state lifecycle: Capture returns a WorkspaceState that outlives the sandbox (the snapshot is pinned against the daemon's garbage collector), Apply rebinds a sandbox to a previously captured state, and any sandbox configured the same way can apply it — that is how a state crosses from one sandbox to another. ReleaseState is the only discard verb.
Every method takes a context so remote daemons honor cancellation and deadlines.
Non-mutating contract: Capture must leave the caller's workspace metadata unchanged on return.
type SecretMount ¶
type SecretMount struct {
// Source is an absolute path on the daemon host.
Source string
// Destination is the absolute in-container path.
Destination string
}
SecretMount binds a host file read-only into the container.
type WorkspaceState ¶
type WorkspaceState struct {
// SnapshotKey is the committed snapshot this state resumes from.
SnapshotKey string
// ParentKey is the snapshot this one was prepared from, recording the
// layer lineage.
ParentKey string
// BaseKey is the snapshot key of the last state the capturing sandbox
// had Applied — the layer its working tree diverged from. It is the
// merge base ApplyDelta diffs against: the state's delta spans every
// layer since BaseKey, not just the last capture. Empty means the
// sandbox never Applied anything (a fresh lineage), so the state has
// no merge base and cannot be merged; ApplyDelta rejects it with a
// clear error. BaseKey and ParentKey are equal on the first capture
// after an Apply but diverge on repeated captures — the next capture's
// parent is the previous capture, while the base stays the last applied
// state. Both are load-bearing.
BaseKey string
// Image is the image the workspace was provisioned from.
Image string
// ContentDigest is the content-addressed digest of this state's workspace
// tree, relative to the image rootfs — the daemon differ's blob digest.
// Two states with identical content (and the same image) carry the same
// ContentDigest even when their snapshot keys differ, which is what lets a
// host compare content across a moved timeline. Empty when the capture
// could not compute it (a sandbox without a diff service, or a differ
// failure); Capture still succeeds.
ContentDigest string
}
WorkspaceState is a captured workspace: the committed snapshot Capture returns and Apply consumes. It is a plain record — three keys naming real snapshotter state — so it can be stored, logged, and handed back by value. A host that wants a content identity for records can hash the struct; croft itself has none.