Help us shape the future of Go! Take the 2026 Go Developer Survey

croft

package module
v0.9.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: MIT Imports: 38 Imported by: 0

README

croft

A croft is a small enclosed plot of land one party works on its own. This library gives a program the same thing: an isolated, snapshot-backed workspace on containerd's overlayfs snapshotter — provisioned from an image, worked through exec and file I/O, and checkpointed into states that outlive the sandbox that captured them.

Croft is Linux-only (OverlayFS, a containerd daemon, root or user namespaces) and is deliberately not a container runtime: it is the library a host uses when it wants a real filesystem boundary per workspace, with cheap checkpoints, rollback, and forks over that boundary.

What it does

Create       resolve the image rootfs, prepare the base snapshot, start the task
Exec,
ReadFile,
WriteFile    work the workspace through the boundary
Capture      commit the active layer, pin it as a state, continue on a fresh layer
Apply        resume this (or any) sandbox from a captured state
Changes      list the differences between a state and the current workspace
Diff         render those differences as a unified text diff
ApplyDelta   merge a state's changes onto the current workspace (tree-level)
ReleaseState drop a state's pin; the daemon's GC reclaims the layer
Destroy      stop the task, remove the active layer; committed states survive

The properties, honestly:

  • Layered — every Capture commits an immutable overlayfs layer and continues on a new one; lineage is recorded in the state.
  • States outlive sandboxes — a captured state survives Destroy, crashes, and process restarts, and any equally configured sandbox can resume it.
  • Rollback — Apply rebinds a sandbox to any captured state, exactly and O(1) (a re-point, not a replay).
  • Fork — there is no fork orchestration; Capture on the parent plus Apply on the child is the whole recipe, at layer-sharing cost.
  • Serialized — lifecycle operations are exclusive, workspace operations shared: an in-flight Exec finishes before a Capture commits the layer it is writing to, by construction.
  • A state's identity is its snapshot key — sequence-derived, so two captures of identical content are distinct states. Identity, not content equality; croft has no digest, and a host that wants a record identity hashes the four-field struct itself.
  • Network-isolated by default — the container gets its own network namespace with only loopback: no egress, no ingress, DNS fails fast. Config.Network: "host" is the explicit opt-in that shares the daemon host's network instead — package installs, git remotes, and everything else the host can reach (the namespace is shared, not the resolver: /etc/resolv.conf stays the image's). Untrusted code keeps the default; trusted workloads opt in.

Diffing states

Two states can be compared without applying either. Changes returns the sorted difference between a captured state and the current workspace as a []Change (add / modify / delete, with mode and size); Diff renders that difference as a unified text diff — ---/+++ headers, @@ hunks, binary files as "Binary files … differ", mode-only changes as old mode/new mode pairs, symlinks as target lines. Both are read-only: they never stop or restart the task, never commit, never write.

state, _ := sb.Capture(ctx)
// ... work happens ...
changes, _ := sb.Changes(ctx, state)  // what differs?
text, _ := sb.Diff(ctx, state)        // unified diff, for display
files, _ := sb.ReadFilesAt(ctx, state, []string{"config.yaml"}) // pre-images

ReadFilesAt is a concrete method on ContainerdSandbox (not on the Sandbox interface): it reads files from a state's committed layer without applying it, via a throwaway reader task. A state whose layer has been released makes Changes, Diff, and ReadFilesAt return ErrStateUnavailable.

Merging states

ApplyDelta merges a state's changes into the current workspace without rebinding to it — the git-era delta's successor, with no git anywhere. It is a tree-level three-way merge: two daemon-side diffs against the state's recorded merge base (BaseKey, the layer its capturing sandbox last Applied), one from the base to the incoming state ("theirs") and one from the base to the current workspace ("ours"). Theirs-only changes apply, ours-only changes are kept, edits both sides made identically are kept once, and everything else — divergent edits to the same file, mode disagreement, modify-vs-delete, or one side deleting a subtree the other wrote into — is a conflict: ErrDeltaConflict names the offending paths and the workspace is completely untouched. The refusal is atomic; a merge that starts applying is not transactional, matching the git-era version's clean path.

// sandbox B worked on from the same base as A:
theirs, _ := sbB.Capture(ctx)      // theirs.BaseKey = the shared base
err := sbA.ApplyDelta(ctx, theirs) // merge B's work into A's diverged tree

Granularity is per-file, not per-line: two divergent edits to the same file conflict even when they touch different lines (line-level merge is a scoped follow-up). The merge needs the base layer still pinned — a released base surfaces as ErrStateUnavailable, and a state captured from a sandbox that never Applied has an empty BaseKey and cannot be merged.

Quick start

sb := croft.New(croft.Config{
    Namespace: "croft",
    Image:     "docker.io/library/golang:1.26",
})
if err := sb.Create(ctx); err != nil { ... }   // the caller's ctx bounds it
defer sb.Destroy(ctx)

res, _ := sb.Exec(ctx, croft.ExecRequest{Command: "go", Args: []string{"version"}})
state, _ := sb.Capture(ctx)   // pinned against the daemon's GC
_ = sb.Apply(ctx, state)      // resume from it — rollback, crash-resume, fork
_ = sb.ReleaseState(ctx, state) // disk comes back when the caller says so

Exec I/O

ExecRequest.Stdin is honored as data: the bytes are staged to a temp file in the container (chunked like WriteFile, mode 0600, removed on every outcome path) and redirected into the process, which sees EOF when they run out — the shim's stdin FIFO, which never signals EOF, is bypassed entirely. The staged payload inherits WriteFile's exposure: base64 in argv, visible in /proc/*/cmdline while staging runs — stdin-shaped data, never secrets. Output is bounded-but-buffered: Config.MaxOutputBytes caps stdout and stderr independently, keeping the first and last half of the budget around a [... N bytes truncated ...] marker (zero, the default, is unbounded). Two deliberate boundaries: interactive exec sessions — writing to a running process's stdin, live-tailing its output — are deferred until a consumer needs a TTY, and process-preserving Capture is permanently out of scope (CRIU-class checkpointing of arbitrary process trees is experimental and fragile).

Long-running processes

Exec waits for its command to exit — and a command that backgrounds its work returns immediately. That is the whole recipe for dev servers, test watchers, and daemons:

_, _ = sb.Exec(ctx, croft.ExecRequest{
    Command: "/bin/sh",
    Args:    []string{"-c", `nohup ./dev-server > /tmp/server.log 2>&1 &`},
})
// The exec returns at once; the child is reparented to the container's init
// and keeps running across subsequent Execs. Poll its log like any file:
log, _ := sb.ReadFile(ctx, "/tmp/server.log")

The boundary, plainly: background processes die at Capture. A coherent snapshot stops the task, commits the layer, and continues on a fresh one — files persist, processes do not. A process-preserving capture would need CRIU-class checkpointing of an arbitrary process tree and is a permanent scope boundary, not a gap awaiting a fix; restart the server after Capture if you want it running (the workspace, including its log, is intact). Interactive sessions are out of scope by decision as well: stdin is data delivered once (ExecRequest.Stdin), and output is bounded-but-buffered (Config.MaxOutputBytes), not streamed.

The image contract

The workspace's file operations shell out inside the container, so the image must provide a shell (/bin/sh — also the keep-alive command's default) 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 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). BusyBox and coreutils both carry all of them.

Scale envelope

Workspaces are source-tree-sized. ReadFile and WriteFile buffer whole files; WriteFile costs roughly one container exec per 512 KiB (configurable via WriteChunkBytes) and carries its payload base64-encoded in exec arguments, visible in /proc/*/cmdline while the exec runs — so large artifacts are out of the design scope and secrets never travel through WriteFile (or through ExecRequest.Stdin, which stages through the same path). With Config.StagingDir set, WriteFile writes host-side and moves the file into place atomically inside the container, eliminating the argv exposure entirely. Exec output is bounded-but-buffered: Config.MaxOutputBytes caps each stream in memory, keeping head and tail around a truncation marker; live streaming is a scoped follow-up.

Secrets delivery

Secrets are files, not a vault. Croft delivers credential material the caller already controls into the workspace boundary without transiting any leaking surface — it does not encrypt, rotate, issue, or audit credentials.

Secret mounts (Config.Secrets): 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.

sb := croft.New(croft.Config{
    Image: "docker.io/library/golang:1.26",
    Secrets: []croft.SecretMount{{
        Source:      "/run/keys/git-token",
        Destination: "/run/secrets/git-token",
    }},
})

The source path must exist on the daemon host (the same coupling FIFODir already has); a remote client stages its secrets through whatever shared path it already has. A bind mount preserves the host file's uid/gid/mode — protect the source with 0600.

Argv-free writes (Config.StagingDir): when set, WriteFile writes the payload to a staging directory on the host, bind-mounts it read-write at /run/croft-staging, and moves the file into place atomically inside the container. The content appears in no argv at any point. When unset, the base64-in-argv path is unchanged (documented exposure).

The exposure inventory, plainly:

Surface Exposure After this plan
WriteFile base64 in argv → /proc/*/cmdline closed when StagingDir is set
ExecRequest.Stdin same argv staging path closed for writes; stdin content itself still stages via argv — documented
ExecRequest.Env /proc/*/environ not addressed — env is not a secrets channel
Exec args argv by definition not addressed — a caller passing a secret as an argument is a caller bug
Secret delivery previously: none solved: bind-mounted files

Configuration

croft.Config carries the containerd address, namespace, image, runtime, snapshotter, init command, in-container workdir, write chunking, the client-side FIFO directory (set it to a user-writable path to drive containerd without root), resource limits (Config.Limits: CPU shares/quota, memory, and pids caps applied to every container generation the sandbox starts; nil means unbounded), the network posture (Config.Network: the default keeps the container's own loopback-only namespace; "host" opts into the daemon host's network), secret mounts (Config.Secrets: host files bind-mounted read-only into the container), and the staging directory (Config.StagingDir: when set, WriteFile writes host-side and moves files into place atomically, closing the argv exposure). See Config in sandbox.go for the full contract of each field.

State format

WorkspaceState is a plain four-field record — SnapshotKey, ParentKey, BaseKey (the last applied layer the state's changes are relative to: the delta's merge base), Image — so it can be stored, logged, and handed back by value. Daemon-visible identifiers: snapshot keys use the croft/ prefix; leases and containers are labeled croft.lease and croft.sandbox; the states lease is croft-states.

Testing

go test ./...          # unit suite; no daemon required

# live suite, against a real containerd (root is required for the socket):
sudo env CROFT_CONTAINERD_ADDR=/run/containerd/containerd.sock \
  go test -count=1 -v -run TestLive_ ./...

Croft's direct dependencies beyond the Go standard library are github.com/containerd/containerd/v2 (the daemon client), github.com/containerd/errdefs (canonical daemon error kinds), github.com/sergi/go-diff (the Myers diff engine), and the github.com/opencontainers/* spec packages (go-digest, image-spec, runtime-spec). What the live suite verifies — and how to run it against a real daemon — is in docs/verification.md; how the pieces fit together is in docs/overview.md.

License

MIT — see LICENSE.

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

Constants

This section is empty.

Variables

View Source
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.

View Source
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.

View Source
var ErrStateUnavailable = errors.New("croft: state is no longer available")

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

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

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

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

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

func (s *ContainerdSandbox) ReadFile(ctx context.Context, path string) ([]byte, error)

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

type ExecResult struct {
	ExitCode int
	Stdout   string
	Stderr   string
	Duration time.Duration
}

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 {
	// CPUShares is the relative CPU weight against other containers
	// (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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL