sandbox

package
v0.6.0 Latest Latest
Warning

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

Go to latest
Published: Aug 19, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

Documentation

Overview

Package sandbox runs untrusted workloads in an isolated environment behind a single Backend interface.

Two backends ship with it. PodmanBackend starts one container per sandbox: fast, cheap, and sharing the host kernel. QemuBackend starts one virtual machine per sandbox: slower, and isolated at the hardware boundary. Both shell out to their tool (podman, qemu-system) through an injectable runner, so the entire host-side surface — argument vectors, QMP control, vsock dialling — is testable without either tool installed.

Identity

A sandbox is identified by Spec.Name, a string the caller chooses. It becomes the container or VM name, the work volume name, a container label, and the per-sandbox state directory, so it must match [A-Za-z0-9_-]+. Beyond that, sandbox attaches no meaning to it: what a name refers to, and whether two sandboxes belong to the same anything, is the caller's model, not this package's.

Configuration

Backends take a Config. Its zero value is usable, every field has a neutral default, and nothing is read from the environment unless the caller asks for it with ConfigFromEnv. Naming — container prefix, label key, snapshot repository, firewall table — is configurable so that a product can brand its own resources without this package knowing the brand.

Data lifetime

A sandbox's work volume (its overlay disk, for QEMU) is where the caller's data lives, and exactly one method deletes it: Purge. Recreate replaces the container — new image included — against the existing volume, and is built so the volume-deleting code is not reachable from it; use it for rolling image updates. The split is deliberate after a consumer nearly lost data to the old Destroy, whose name said "infrastructure" and whose effect included "and the data" (it was renamed to Purge in v0.5.0 so migrating callers must decide which operation they meant).

The guarantee is mechanical: the volume survives Recreate. Whether the new image can read what the old image wrote is the consumer's compatibility problem — this package owns the mechanism, the consumer owns its schema and any migration between versions of it. Recreate also does no sequencing: rolling sandboxes one at a time is the caller's policy, not this package's.

What reaches the host's process list

Everything on a tool's argv is world-readable on the host for as long as the tool runs (ps, /proc/<pid>/cmdline), and callers put credentials in Spec.Env. This package therefore never places an environment VALUE on an argv: Spec.Env and ExecOpts.Env travel as name-only "--env K" flags, with the values passed through the tool's own process environment, which the kernel exposes only to the same user and root. Spec.Files content travels on stdin. Error strings keep the same discipline: a tool's stderr — which can quote fragments of its invocation — is withheld from Error and available through CommandError.Detail for whoever deliberately asks.

Portability

This package compiles on every platform Go supports, and is functionally Linux-only. Cross-platform programs can import it, reference its types, and decide at runtime whether to construct a backend; nothing fails at build or init time. Off Linux, three things do not work:

  • Egress lockdown needs the host's nft and nsenter. Since an empty Spec.NetworkPolicy means NetworkPolicyInternalOnly, the default configuration cannot create a Podman sandbox anywhere else: Create fails closed with ErrEgressUnavailable rather than running a workload with unconfirmed egress restrictions. Only NetworkPolicyNone and NetworkPolicyOpen skip that step.
  • The QEMU guest control bridge rides on AF_VSOCK, which exists only on Linux. VMs may still boot under hvf (macOS) or whpx (Windows), but Exec, WriteFile, and ReadFile return ErrQemuGuestUnavailable.
  • QEMU pidfile liveness checks degrade to "assume running".

The failures are all typed errors returned from method calls. Callers meant to run everywhere should gate construction on runtime.GOOS == "linux" and say "sandboxing requires Linux" in their own words, rather than letting one of these errors surface to a user who cannot act on it.

Index

Constants

This section is empty.

Variables

View Source
var ErrEgressUnavailable = errors.New("egress lockdown unavailable: host nftables/nsenter missing or rules failed to apply")

ErrEgressUnavailable is returned by Create when an internal-only/filtered sandbox cannot have its host-applied egress lockdown installed or verified (e.g. the host lacks nft/nsenter, or the rules did not take effect). Create fails closed in this case: it never returns a running sandbox with unconfirmed egress restrictions.

View Source
var ErrPodmanUnavailable = errors.New("podman unavailable: ensure Podman is installed and accessible in PATH")

ErrPodmanUnavailable is returned when the podman binary cannot be found or exits with a status that indicates it is not installed/configured. Callers should check errors.Is(err, ErrPodmanUnavailable) and surface a clear "install Podman" message rather than crashing.

View Source
var ErrQemuGuestUnavailable = errors.New("qemu guest bridge unreachable: a desktop image with the in-guest vsock bridge is required for exec/file ops")

ErrQemuGuestUnavailable is returned when the in-guest control bridge cannot be reached over virtio-vsock: no guest image with the bridge daemon present, the VM is not booted far enough to answer, or the host is not Linux (AF_VSOCK is a Linux socket family). The host side of the protocol is implemented here; supplying a guest image that carries the bridge is the caller's job.

View Source
var ErrQemuUnavailable = errors.New("qemu unavailable: install QEMU and set Config.BaseImage to a qcow2 base disk")

ErrQemuUnavailable is returned when QEMU cannot be used because the qemu-system binary is not installed or no base disk image is configured. Callers should check errors.Is(err, ErrQemuUnavailable) and surface a clear "install QEMU and configure a base image" message rather than crashing.

View Source
var ErrSandboxNotFound = errors.New("sandbox not found")

ErrSandboxNotFound is returned when an operation targets a sandbox ID that does not map to a known container.

View Source
var ErrSpecUnsupported = errors.New("spec field not supported by this backend")

ErrSpecUnsupported is returned by Create when a Spec asks for something the chosen backend cannot deliver (for example Spec.Command or Spec.Files on the QEMU backend). Failing loudly is deliberate: silently ignoring a command vector or a config file would hand the caller a sandbox that runs the wrong thing, or runs without the file it was promised.

View Source
var ErrWrongProfile = errors.New("operation not supported for this sandbox profile")

ErrWrongProfile is returned when DesktopEndpoint is called on a non-desktop sandbox or WebEndpoint on a non-web sandbox.

Functions

This section is empty.

Types

type Backend

type Backend interface {
	// Create provisions a new sandbox from Spec and starts it (or leaves it
	// stopped — up to the implementation).  Returns a Handle on success.
	// In the Podman backend, Create also calls Start; the container is
	// running when Create returns.
	Create(ctx context.Context, spec Spec) (Handle, error)

	// Start starts a previously-stopped sandbox.
	Start(ctx context.Context, id string) error

	// Stop gracefully stops a running sandbox (SIGTERM → wait).  The
	// container's filesystem is preserved for a subsequent Start or Purge.
	Stop(ctx context.Context, id string) error

	// Recreate replaces the sandbox's runtime — the container or VM process —
	// with one built from spec, while preserving the sandbox's work volume and
	// everything on it.  spec.Name selects the sandbox; the other fields,
	// including a new Image, take effect as in Create.  This is the operation
	// a rolling image update needs: no path through Recreate deletes the
	// volume, structurally, so a caller cannot lose data to it.
	//
	// The volume surviving is this package's guarantee; whether the new image
	// can read what the old one wrote is the caller's compatibility problem.
	// keel owns the mechanism (the volume is still there); the consumer owns
	// its schema and any migration between versions of it.
	//
	// Recreate does no sequencing.  Rolling one sandbox at a time, draining
	// first, or stopping on the first failure is policy, and policy belongs to
	// the caller.
	//
	// On the QEMU backend the disk image cannot change: an overlay is bound to
	// the base image it was created from, so a non-empty spec.Image is
	// rejected with ErrSpecUnsupported (a new base disk requires Purge and
	// Create, which deletes the data — deliberately not reachable from here).
	Recreate(ctx context.Context, spec Spec) (Handle, error)

	// Purge stops (if running) and removes the sandbox INCLUDING its named
	// work volume — the caller's data.  Irreversible.  It is the only method
	// in this interface that deletes the volume; every other operation,
	// Recreate above in particular, leaves it in place.
	//
	// This method was named Destroy before v0.5.0.  It was renamed because
	// deleting a consumer's data must never hide behind a routine
	// infrastructure verb: a supervisor that means "remove the old container"
	// must not be able to reach for a name that also, silently, means "and
	// the data".  If you are migrating a Destroy call, decide which you meant:
	// container replacement is Recreate, retiring the sandbox and its data is
	// Purge.
	Purge(ctx context.Context, id string) error

	// Exec runs cmd inside the sandbox and returns the collected result.
	// stdin is not supported; use ExecStream for interactive use.
	Exec(ctx context.Context, id string, cmd []string, opts ExecOpts) (ExecResult, error)

	// ExecStream runs cmd inside the sandbox and returns a ReadCloser backed
	// by the combined stdout+stderr stream.  The caller must close the reader
	// to release resources.  Useful for long-running commands (builds, tests).
	ExecStream(ctx context.Context, id string, cmd []string, opts ExecOpts) (io.ReadCloser, error)

	// WriteFile writes data to path inside the sandbox, creating parent
	// directories as needed.  Equivalent to `podman exec -i sh -c 'cat > path'`.
	//
	// The sandbox is already running when WriteFile acts, so a file the
	// workload needs at startup would arrive after the race is lost.  For
	// those, use Spec.Files at Create, which lands before the entrypoint runs.
	WriteFile(ctx context.Context, id string, path string, data []byte) error

	// ReadFile reads the content of path from inside the sandbox.
	ReadFile(ctx context.Context, id string, path string) ([]byte, error)

	// Snapshot commits the current container state to an OCI image and
	// returns a SnapshotRef.  The container continues running.
	Snapshot(ctx context.Context, id string, label string) (SnapshotRef, error)

	// RemoveSnapshot deletes a previously-created snapshot image identified by
	// ref (SnapshotRef.Ref).  It is best-effort: a missing image is not an
	// error.  Callers use it to reclaim disk when a sandbox is retired.
	RemoveSnapshot(ctx context.Context, ref string) error

	// Restore recreates the sandbox from a previously-taken snapshot.
	// The current container is stopped and removed; a new container is
	// created from the snapshot image and started.
	// The Handle.ContainerID in the returned Handle reflects the new container.
	Restore(ctx context.Context, id string, ref SnapshotRef) (Handle, error)

	// Inspect returns the live status of the sandbox, including running state
	// and current host-port mappings.
	Inspect(ctx context.Context, id string) (Status, error)

	// DesktopEndpoint returns the http://127.0.0.1:<port> URL of the noVNC
	// websocket endpoint for profile=desktop sandboxes.
	// Returns an error if the sandbox is not running or profile≠desktop.
	DesktopEndpoint(ctx context.Context, id string) (string, error)

	// WebEndpoint returns the http://127.0.0.1:<port> URL of the agent's
	// HTTP server for profile=web sandboxes.
	// Returns an error if the sandbox is not running or profile≠web.
	WebEndpoint(ctx context.Context, id string) (string, error)

	// ContainerAddr returns the "<ip>:<port>" dial address of an in-container
	// service so the host can reach ANY port a server is listening on inside the
	// sandbox (the host can reach the container bridge IP directly; egress rules
	// only restrict the container's OUTBOUND traffic, not inbound from the host).
	// Used by the on-demand port-preview reverse proxy.
	// Returns ErrSandboxNotFound if the container is gone, or an unsupported
	// error for backends that cannot expose container IPs (e.g. QEMU VMs).
	ContainerAddr(ctx context.Context, id string, port int) (string, error)
}

Backend is the runtime-agnostic interface for sandbox lifecycle, execution, file transfer, snapshotting, and endpoint discovery.

All methods accept a context; callers should pass a request-scoped context with an appropriate timeout. Methods that mutate container state (Create, Start, Stop, Recreate, Purge, Restore) should be treated as not concurrency-safe for the same sandbox ID — the caller must serialise them.

type CommandError added in v0.3.0

type CommandError struct {
	// Tool is the binary that ran, for example "podman".
	Tool string

	// Subcommand is the tool's first argument, for example "run". Empty when
	// the tool was invoked without one.
	Subcommand string

	// ExitCode is the tool's exit status.
	ExitCode int

	// Stderr is the tool's standard error, trimmed. It is retained for
	// Detail and never rendered by Error.
	Stderr string

	// Err is the underlying error, an *exec.ExitError, kept so errors.As
	// still reaches it.
	Err error
}

CommandError reports that a host tool this package shells out to (podman, qemu-system, qemu-img) ran and exited non-zero.

What Error does not say

Error renders only the tool, its subcommand, and the exit code — never the tool's stderr. An error string is the one part of a failure that reaches a log by default, and a tool's stderr is whatever the tool chose to print, which can include fragments of its own invocation. Sandbox invocations carry caller configuration, so rendering stderr by default would make every consumer responsible for scrubbing it. The text is not lost, only unlisted: read CommandError.Detail when deliberately debugging, at which point disclosing it is a decision rather than an accident. (llm.APIError withholds provider error bodies for the same reason; this is the same pattern.)

func (*CommandError) Detail added in v0.3.0

func (e *CommandError) Detail() string

Detail returns the tool's stderr. It is a method rather than part of Error so that disclosing it is a decision: treat the result as potentially sensitive diagnostics, and log it where such text is allowed to go, or not at all.

func (*CommandError) Error added in v0.3.0

func (e *CommandError) Error() string

Error implements error. It renders the tool, subcommand, and exit code — never stderr. See the type documentation for why, and CommandError.Detail for how to get the text.

func (*CommandError) Unwrap added in v0.3.0

func (e *CommandError) Unwrap() error

Unwrap returns the underlying failure so errors.Is and errors.As reach it.

type Config

type Config struct {
	// NamePrefix is prepended to Spec.Name to form container and volume names.
	// Default "sbx-", producing "sbx-<name>" and "sbx-<name>-work".
	NamePrefix string

	// LabelKey is the container label key carrying Spec.Name.
	// Default "keel.sandbox", producing "--label keel.sandbox=<name>".
	LabelKey string

	// Image is the OCI image used when Spec.Image is empty. There is no
	// built-in default image: if both are empty, Create fails rather than
	// guessing at a registry reference.
	Image string

	// SnapshotRepo is the image repository prefix for Podman snapshots.
	// Default "keel/snap", producing "keel/snap-<name>:<label>".
	SnapshotRepo string

	// EgressTable is the nftables table name used for the host-applied egress
	// lockdown. Default "keel_egress". Change it only if it collides with
	// another table on the host.
	EgressTable string

	// PodmanBinary is the podman executable. Default "podman", resolved
	// through PATH.
	PodmanBinary string

	// QemuBinary is the qemu-system executable.
	// Default "qemu-system-x86_64", resolved through PATH.
	QemuBinary string

	// QemuImgBinary is the qemu-img executable. Default "qemu-img", resolved
	// through PATH.
	QemuImgBinary string

	// BaseImage is the qcow2 disk that per-sandbox QEMU overlays are backed
	// by. It is resolved to an absolute path at Create time. Required by
	// QemuBackend: with no base image, Create returns ErrQemuUnavailable.
	BaseImage string

	// StateDir is the root directory for per-sandbox QEMU state (one
	// subdirectory per sandbox, holding the overlay, QMP socket, pidfile, and
	// CID file). Default <os.TempDir()>/keel-sandbox/qemu, which is fine for
	// throwaway VMs and wrong for anything you want to survive a reboot.
	StateDir string
}

Config holds everything a backend needs that is not per-sandbox: which binaries to run, what to call the resources it creates, and where to keep state.

The zero value is usable and reads nothing from the environment — a library that consults os.Getenv behind the caller's back is a library that behaves differently in production than in the test that passed. Callers who do want environment configuration ask for it explicitly with ConfigFromEnv.

The naming fields exist so that a product can brand the containers, volumes, images, and firewall tables it creates without this package having to know the brand.

func ConfigFromEnv

func ConfigFromEnv(prefix string) Config

ConfigFromEnv reads a Config from the environment, looking up each variable under the given prefix. A prefix of "ACME" reads ACME_SANDBOX_IMAGE, ACME_QEMU_BINARY, and so on; an empty prefix reads the bare names.

The variables are:

<PREFIX>_SANDBOX_IMAGE      Config.Image
<PREFIX>_SANDBOX_NAME_PREFIX Config.NamePrefix
<PREFIX>_PODMAN_BINARY      Config.PodmanBinary
<PREFIX>_QEMU_BINARY        Config.QemuBinary
<PREFIX>_QEMU_IMG           Config.QemuImgBinary
<PREFIX>_QEMU_BASE_IMAGE    Config.BaseImage
<PREFIX>_QEMU_STATE_DIR     Config.StateDir
<PREFIX>_DATA_DIR           parent of the state dir, used only when
                            <PREFIX>_QEMU_STATE_DIR is unset

Unset variables are left empty, so the defaults in withDefaults still apply. Fields with no environment variable (LabelKey, SnapshotRepo, EgressTable) are set in code or not at all: they change the shape of resources this package creates and manages, and letting the environment move them invites a half-renamed host.

type ExecOpts

type ExecOpts struct {
	// WorkDir is the working directory inside the container.  Empty means
	// the image's default.
	WorkDir string

	// Env overrides or extends the container's environment for this exec.
	Env map[string]string

	// RunAs overrides the OS account the command runs under inside the
	// sandbox (e.g. "agent").  Empty means the image's default.  This is an
	// account in the guest, and has nothing to do with whoever asked for the
	// command to be run.
	RunAs string

	// TimeoutSec, if >0, cancels the exec after this many seconds.
	TimeoutSec int
}

ExecOpts configures optional behaviour for Exec / ExecStream.

type ExecResult

type ExecResult struct {
	// ExitCode is the process exit status.
	ExitCode int

	// Stdout is the combined standard output.
	Stdout []byte

	// Stderr is the combined standard error.
	Stderr []byte
}

ExecResult holds the collected output of a completed Exec call.

type File added in v0.3.0

type File struct {
	// Path is the absolute destination inside the sandbox, for example
	// "/etc/app/config.yaml".  It must be absolute, already clean (no "." or
	// ".." elements, no doubled or trailing slashes), and not "/" itself.
	// Parent directories that do not exist are created by the copy (0755,
	// root-owned); directories that already exist are left alone.
	Path string

	// Data is the file content.
	Data []byte

	// Mode is the file's permission bits inside the sandbox, and it is
	// required: a zero Mode fails Create rather than silently choosing one,
	// because the gap between 0600 and 0644 is the gap between a private
	// credential and a world-readable one.  Only permission bits are allowed;
	// file-type bits (fs.ModeDir and friends) are rejected.
	Mode fs.FileMode

	// UID and GID set the file's owner inside the sandbox, so a workload that
	// runs as a non-root account can be handed a file only it can read.  The
	// zero values mean uid 0 / gid 0 (root).  These are accounts in the guest,
	// not on the host, and mean nothing outside the sandbox.
	UID int
	GID int
}

File is one file provisioned into a sandbox at create time (Spec.Files).

type Handle

type Handle struct {
	// ID is the sandbox identifier: the Spec.Name it was created from, and the
	// value passed to every other Backend method.
	ID string

	// ContainerID is the OCI runtime's container identifier (podman ID or
	// full hash).  Empty for VM-level sandboxes where the concept differs.
	ContainerID string

	// Endpoints maps logical endpoint names to host URLs.
	//   "desktop" -> "http://127.0.0.1:<hostPort>" (noVNC, profile=desktop)
	//   "web"     -> "http://127.0.0.1:<hostPort>" (workload HTTP, profile=web)
	Endpoints map[string]string
}

Handle is the opaque reference returned after Create. Callers must store the ID (or ContainerID) to drive subsequent operations.

type NetworkPolicy

type NetworkPolicy string

NetworkPolicy controls the egress firewall applied to a sandbox.

const (
	// NetworkPolicyNone disables all networking interfaces in the container.
	// Use for pure compute sandboxes where the host drives everything.
	NetworkPolicyNone NetworkPolicy = "none"

	// NetworkPolicyInternalOnly is the default policy.  The sandbox can reach
	// the host (host.containers.internal / host-gateway) for services the host
	// exposes to it, DNS resolvers reachable through that gateway, and
	// loopback.  All other egress is blocked.
	NetworkPolicyInternalOnly NetworkPolicy = "internal-only"

	// NetworkPolicyFiltered extends internal-only with a host-side HTTP/HTTPS
	// forward proxy that enforces the AllowDomains list.  The container is given
	// HTTP_PROXY / HTTPS_PROXY pointing at that proxy.
	NetworkPolicyFiltered NetworkPolicy = "filtered"

	// NetworkPolicyOpen places no egress restrictions.  Flagged as risky; the
	// Spec field must be set explicitly — an empty NetworkPolicy is NOT treated
	// as open (it defaults to internal-only).
	NetworkPolicyOpen NetworkPolicy = "open"
)

type PodmanBackend

type PodmanBackend struct {
	// contains filtered or unexported fields
}

PodmanBackend implements Backend by shelling out to the podman CLI. Container names follow the pattern "<Config.NamePrefix><Spec.Name>" and the work volume is "<Config.NamePrefix><Spec.Name>-work".

The backend persists no state of its own: the caller is responsible for keeping Handle.ContainerID and Handle.Endpoints if it needs them.

It works only on a Linux host — see the package documentation.

func NewPodmanBackend

func NewPodmanBackend(cfg Config, log *slog.Logger) *PodmanBackend

NewPodmanBackend returns a PodmanBackend configured by cfg. The zero Config is usable; see Config for the defaults it implies. Pass logger from the application; if nil, slog.Default() is used.

Construction never touches the host: nothing checks for podman, and no sandbox exists until Create is called.

func (*PodmanBackend) ContainerAddr

func (b *PodmanBackend) ContainerAddr(ctx context.Context, id string, port int) (string, error)

ContainerAddr implements Backend.ContainerAddr. It resolves the container's bridge IP via `podman inspect --format '{{.NetworkSettings.IPAddress}}'` and returns "<ip>:<port>" for the host to dial directly. A missing container is mapped to ErrSandboxNotFound; an empty IP (container stopped / no NIC) is a clear error so the proxy can surface a 502 rather than crash.

func (*PodmanBackend) Create

func (b *PodmanBackend) Create(ctx context.Context, spec Spec) (Handle, error)

Create implements Backend.Create.

The function:

  1. Creates a named volume for /work.
  2. Runs `podman run -d` with the resource limits and labels from Spec (or create → copy files in → start, when Spec.Files is non-empty).
  3. Returns a Handle with the resolved ContainerID and Endpoints.

The container is running when Create returns.

func (*PodmanBackend) DesktopEndpoint

func (b *PodmanBackend) DesktopEndpoint(ctx context.Context, id string) (string, error)

DesktopEndpoint implements Backend.DesktopEndpoint.

func (*PodmanBackend) Exec

func (b *PodmanBackend) Exec(ctx context.Context, id string, cmd []string, opts ExecOpts) (ExecResult, error)

Exec implements Backend.Exec.

func (*PodmanBackend) ExecStream

func (b *PodmanBackend) ExecStream(ctx context.Context, id string, cmd []string, opts ExecOpts) (io.ReadCloser, error)

ExecStream implements Backend.ExecStream. It starts `podman exec` and returns the stdout pipe. The caller must close the returned ReadCloser to free the process.

func (*PodmanBackend) Inspect

func (b *PodmanBackend) Inspect(ctx context.Context, id string) (Status, error)

Inspect implements Backend.Inspect. Parses `podman inspect --type container --format json <container>` to determine running state and the current host-port mappings.

`--type container` is load-bearing and every inspect on this backend carries it. Bare `podman inspect NAME` searches containers *and* images, which breaks this method twice over on a sandbox that does not exist: podman reports `no such object: "NAME"` — a phrase no classifier here matches, so the absence never becomes ErrSandboxNotFound and the caller sees an opaque exit 125 forever — and if an image happens to answer to that name it succeeds instead, returning image JSON whose State.Running is false, so a missing sandbox reads as a stopped one. With the type pinned podman says `no such container NAME`, which isNoSuchContainer does match, and nothing but a container can answer.

func (*PodmanBackend) Purge added in v0.5.0

func (b *PodmanBackend) Purge(ctx context.Context, id string) error

Purge implements Backend.Purge (named Destroy before v0.5.0). Stops the container (tolerates already-stopped), removes it, then removes the named work volume — the caller's data. This is the only method on the backend that deletes the named work volume; removeContainer reaps anonymous volumes but never that one.

func (*PodmanBackend) ReadFile

func (b *PodmanBackend) ReadFile(ctx context.Context, id string, path string) ([]byte, error)

ReadFile implements Backend.ReadFile.

func (*PodmanBackend) Recreate added in v0.5.0

func (b *PodmanBackend) Recreate(ctx context.Context, spec Spec) (Handle, error)

Recreate implements Backend.Recreate: the container is replaced from spec — a new image included — while the named work volume, and the caller's data on it, survive. The volume-deleting code (Purge) is not reachable from here: nothing on this path names a volume to delete, and every failure cleanup is removeContainer, whose only volume effect is `podman rm --volumes`, which reaps the outgoing container's anonymous volumes and leaves named ones standing.

func (*PodmanBackend) RemoveSnapshot

func (b *PodmanBackend) RemoveSnapshot(ctx context.Context, ref string) error

RemoveSnapshot implements Backend.RemoveSnapshot. It removes the snapshot image (`podman rmi <ref>`), tolerating a missing image so callers can use it best-effort when retiring a sandbox.

func (*PodmanBackend) Restore

func (b *PodmanBackend) Restore(ctx context.Context, id string, ref SnapshotRef) (Handle, error)

Restore implements Backend.Restore. Stops and removes the current container, then re-creates it from the snapshot image, preserving the existing work volume and resource settings.

NOTE: Because PodmanBackend is stateless (no stored Spec), Restore re-creates the container with minimal flags (just volume, label, and image). Call Create with the original Spec if you need the full resource limits back after a Restore.

func (*PodmanBackend) Snapshot

func (b *PodmanBackend) Snapshot(ctx context.Context, id string, label string) (SnapshotRef, error)

Snapshot implements Backend.Snapshot. Commits the running container to a new image:

podman commit <container> <SnapshotRepo>-<id>:<label>

If label is empty a short random suffix is used.

func (*PodmanBackend) Start

func (b *PodmanBackend) Start(ctx context.Context, id string) error

Start implements Backend.Start. A missing container is reported as ErrSandboxNotFound, matching Inspect and ContainerAddr: podman's message for a missing name on `start` is `Error: no container with name or ID "…" found: no such container`, which isNoSuchContainer already recognises (verified against real podman 4.9.3).

func (*PodmanBackend) Stop

func (b *PodmanBackend) Stop(ctx context.Context, id string) error

Stop implements Backend.Stop. A missing container is reported as ErrSandboxNotFound, matching Inspect and ContainerAddr (see Start). Note this is a real error, not tolerated as a no-op: unlike removeContainer/Purge, whose goal state is "gone" and so treat an absent container as success, Stop is asked to act on a specific sandbox the caller believes exists, and the caller (kenward's rollOne and shutdown, among others) branches on ErrSandboxNotFound to tell that apart from a real stop failure.

func (*PodmanBackend) WebEndpoint

func (b *PodmanBackend) WebEndpoint(ctx context.Context, id string) (string, error)

WebEndpoint implements Backend.WebEndpoint.

func (*PodmanBackend) WriteFile

func (b *PodmanBackend) WriteFile(ctx context.Context, id string, path string, data []byte) error

WriteFile implements Backend.WriteFile. Uses `podman exec -i sh -c 'cat > <path>'` with the data as stdin. Parent directories are created with mkdir -p first.

type Profile

type Profile string

Profile shapes how the sandbox image is started.

const (
	// ProfileDesktop expects the image to start a graphical stack behind noVNC
	// on port 6080; the caller typically reverse-proxies that WebSocket to a
	// browser.
	ProfileDesktop Profile = "desktop"
	// ProfileWeb does not start a desktop stack; the workload serves HTTP on
	// ServePort and the caller proxies that to a browser.
	ProfileWeb Profile = "web"
	// ProfileHeadless starts no GUI and exposes no port; pure shell/batch use.
	ProfileHeadless Profile = "headless"
)

type QemuBackend

type QemuBackend struct {
	// contains filtered or unexported fields
}

QemuBackend implements Backend for LevelIsolated by launching one qemu-system process per sandbox. It mirrors PodmanBackend's injectable-runner discipline (see podman.go) so the entire host-side surface — arg-vector construction, QMP control, and vsock dialing — is unit-testable without a real VM.

Per-sandbox state lives under <stateDir>/<id>/:

overlay.qcow2   copy-on-write overlay backed by the configured base image
qmp.sock        QMP control unix socket
qemu.pid        pidfile written by the daemonized qemu process

The backend stores no other state itself; the caller persists the Handle.

Exec, WriteFile, and ReadFile need the in-guest bridge over AF_VSOCK and so work only on a Linux host — see the package documentation.

func NewQemuBackend

func NewQemuBackend(cfg Config, logger *slog.Logger) *QemuBackend

NewQemuBackend returns a Backend that drives QEMU VMs (LevelIsolated), configured by cfg. Config.BaseImage is required for real boots; without it Create returns ErrQemuUnavailable. The zero Config is otherwise usable; see Config for the defaults it implies, and ConfigFromEnv to read one from the environment.

Pass logger from the application; if nil, slog.Default() is used.

Construction picks a hardware accelerator for the host (see selectAccel) and logs a warning when it has to fall back to software emulation. It starts no process and creates no directory.

func (*QemuBackend) ContainerAddr

func (b *QemuBackend) ContainerAddr(_ context.Context, _ string, _ int) (string, error)

ContainerAddr implements Backend.ContainerAddr. QEMU VMs have no host-routable bridge IP (the control plane is out-of-band over vsock), so on-demand port preview is unsupported for LevelIsolated sandboxes.

func (*QemuBackend) Create

func (b *QemuBackend) Create(ctx context.Context, spec Spec) (Handle, error)

Create implements Backend.Create.

It requires a configured base image (else ErrQemuUnavailable), creates a per-sandbox copy-on-write overlay, then launches a daemonized qemu-system process with a QMP control socket and a virtio-vsock device for the out-of-band control bridge.

func (*QemuBackend) DesktopEndpoint

func (b *QemuBackend) DesktopEndpoint(_ context.Context, _ string) (string, error)

DesktopEndpoint implements Backend.DesktopEndpoint. v1 VMs are headless; desktop streaming is a follow-up.

func (*QemuBackend) Exec

func (b *QemuBackend) Exec(ctx context.Context, id string, cmd []string, opts ExecOpts) (ExecResult, error)

Exec implements Backend.Exec by sending a JSON-RPC "exec" request to the in-guest bridge over virtio-vsock. When no guest bridge is reachable it returns ErrQemuGuestUnavailable (a built guest image is a follow-up).

func (*QemuBackend) ExecStream

func (b *QemuBackend) ExecStream(ctx context.Context, id string, cmd []string, opts ExecOpts) (io.ReadCloser, error)

ExecStream implements Backend.ExecStream. v1 runs the command via the non-streaming bridge round-trip and returns the collected stdout as a reader. True duplex streaming over vsock is a follow-up.

func (*QemuBackend) Inspect

func (b *QemuBackend) Inspect(ctx context.Context, id string) (Status, error)

Inspect implements Backend.Inspect. It reports Running by querying VM status over QMP; if QMP is unreachable it falls back to the pidfile presence.

func (*QemuBackend) Purge added in v0.5.0

func (b *QemuBackend) Purge(ctx context.Context, id string) error

Purge implements Backend.Purge (named Destroy before v0.5.0). Best-effort QMP quit, then removes the per-sandbox state directory — overlay, sockets, pidfile. The overlay is where a QEMU sandbox's data lives, so this is the data-deleting operation; nothing else in this backend removes it. A missing VM or directory is tolerated.

func (*QemuBackend) ReadFile

func (b *QemuBackend) ReadFile(ctx context.Context, id string, path string) ([]byte, error)

ReadFile implements Backend.ReadFile over the guest bridge.

func (*QemuBackend) Recreate added in v0.5.0

func (b *QemuBackend) Recreate(ctx context.Context, spec Spec) (Handle, error)

Recreate implements Backend.Recreate. The qemu process is replaced with one launched from spec's resource settings; the per-sandbox overlay disk — where the sandbox's data lives — is preserved, and no path through this method removes the state directory.

The disk cannot change here: an overlay is bound to the base image it was created from, so a non-empty spec.Image is rejected with ErrSpecUnsupported. Moving to a new base disk means Purge then Create, which deletes the data — a decision this method refuses to make implicitly.

func (*QemuBackend) RemoveSnapshot

func (b *QemuBackend) RemoveSnapshot(ctx context.Context, ref string) error

RemoveSnapshot implements Backend.RemoveSnapshot. It deletes the named qcow2 internal snapshot from the overlay best-effort; a missing snapshot is not an error.

func (*QemuBackend) Restore

func (b *QemuBackend) Restore(ctx context.Context, id string, ref SnapshotRef) (Handle, error)

Restore implements Backend.Restore. For a running VM it uses QMP human monitor `loadvm <tag>`. If QMP is unreachable it stops, applies the disk snapshot offline (`qemu-img snapshot -a`), and relaunches. Returns a refreshed Handle.

func (*QemuBackend) Snapshot

func (b *QemuBackend) Snapshot(ctx context.Context, id string, label string) (SnapshotRef, error)

Snapshot implements Backend.Snapshot. For a running VM it uses the QMP human monitor `savevm <tag>` (captures CPU/RAM + disk state). If the VM is not running it falls back to `qemu-img snapshot -c <tag> <overlay>` on the disk.

func (*QemuBackend) Start

func (b *QemuBackend) Start(ctx context.Context, id string) error

Start implements Backend.Start. For a stopped VM (qemu process gone), it relaunches from the existing overlay. If the QMP socket answers, the VM is already running and Start is a no-op.

func (*QemuBackend) Stop

func (b *QemuBackend) Stop(ctx context.Context, id string) error

Stop implements Backend.Stop. Best-effort-graceful: it asks the guest to power down via QMP system_powerdown (async ACPI), then polls query-status for up to stopGraceTimeout; if the VM is still running it escalates to a hard QMP quit so callers do not leave a VM running. If QMP is unreachable the VM is treated as already stopped.

func (*QemuBackend) WebEndpoint

func (b *QemuBackend) WebEndpoint(_ context.Context, _ string) (string, error)

WebEndpoint implements Backend.WebEndpoint. v1 VMs are headless; web reverse-proxying is a follow-up.

func (*QemuBackend) WriteFile

func (b *QemuBackend) WriteFile(ctx context.Context, id string, path string, data []byte) error

WriteFile implements Backend.WriteFile over the guest bridge.

type SandboxLevel

type SandboxLevel string

SandboxLevel describes the isolation level of a sandbox.

const (
	// LevelFast uses Podman containers: shared kernel, separate namespaces.
	LevelFast SandboxLevel = "fast"
	// LevelIsolated uses a QEMU VM for full kernel isolation.
	LevelIsolated SandboxLevel = "isolated"
)

type SnapshotRef

type SnapshotRef struct {
	// Ref is the backend-specific reference string.
	// Podman: "<Config.SnapshotRepo>-<id>:<label>".
	// QEMU:   "qemu:<id>:<label>".
	Ref string

	// Label is the human-readable name supplied at snapshot time.
	Label string
}

SnapshotRef is an opaque reference to a persisted sandbox snapshot. For Podman, this is an OCI image tag; for QEMU it would be a qcow2 internal snapshot name.

type Spec

type Spec struct {
	// Name identifies the sandbox.  It must match [A-Za-z0-9_-]+, because it
	// becomes the container or VM name, the work volume name, a container
	// label, and a directory name.  It is the value passed back to every other
	// Backend method as id.
	//
	// The caller chooses it and the caller alone knows what it refers to; this
	// package attaches no ownership or tenancy meaning to it.
	Name string

	// Image is the fully-qualified OCI image reference.  Empty means the
	// backend's configured default (Config.Image); if that is empty too,
	// Create fails.
	Image string

	// Level selects the isolation runtime: LevelFast for PodmanBackend,
	// LevelIsolated for QemuBackend.  Each backend implements one level and
	// ignores this field.
	Level SandboxLevel

	// Profile shapes the entrypoint behaviour inside the container.
	Profile Profile

	// CPUs is the number of vCPU cores to assign.  0 means no limit.
	CPUs float64

	// MemoryMB is the memory cap in mebibytes.  0 means no limit.
	MemoryMB int

	// DiskGB is the maximum ephemeral overlay size in gibibytes.  0 means
	// unbounded (relies on the host's free space).
	// NOTE: Podman named-volume size limits require a storage driver that
	// supports quotas; enforcement is best-effort.
	DiskGB int

	// Env is a set of extra environment variables injected into the container
	// at creation time.
	Env map[string]string

	// Command overrides the arguments the image's entrypoint receives (in OCI
	// terms it replaces the image's CMD; the ENTRYPOINT still runs).  Empty
	// means the image decides, which is the behaviour before this field
	// existed.
	//
	// Each element is delivered to the runtime as exactly one argv entry with
	// no shell anywhere on the path: spaces, quotes, equals signs and non-ASCII
	// text arrive byte-identical, and no element can split into two arguments.
	//
	// Supported by PodmanBackend.  QemuBackend rejects a non-empty Command with
	// ErrSpecUnsupported: a VM runs whatever its disk image boots.
	Command []string

	// Files are provisioned into the sandbox filesystem at create time, before
	// the workload's entrypoint starts, so the process never observes a moment
	// in which an expected file is missing (WriteFile, by contrast, acts on a
	// sandbox that is already running).  See File for path and mode rules.
	//
	// Supported by PodmanBackend.  QemuBackend rejects non-empty Files with
	// ErrSpecUnsupported: its file transport is the in-guest bridge, which only
	// exists once the VM has booted.
	Files []File

	// ServePort is the in-container TCP port that the workload's web server
	// listens on (Profile=web only).  The backend picks a random host port
	// and records it in Handle.Endpoints["web"].
	ServePort int

	// NetworkPolicy controls the egress firewall applied to this sandbox.
	// Empty string is treated as NetworkPolicyInternalOnly (default-deny
	// external egress; the host gateway and DNS through it stay reachable).
	//
	// NetworkPolicyNone         → --network none (no NIC at all)
	// NetworkPolicyInternalOnly → default; host-applied nftables drop-external
	// NetworkPolicyFiltered     → internal + host egress proxy for AllowDomains
	// NetworkPolicyOpen         → no restrictions (flagged risky, explicit only)
	NetworkPolicy NetworkPolicy

	// AllowDomains is the set of domains the container may reach through the
	// host egress proxy when NetworkPolicy == NetworkPolicyFiltered.
	// Values must be plain hostnames or subdomain-wildcards ("*.example.com").
	// Ignored for all other policies.
	AllowDomains []string

	// EgressProxyAddr is the host:port address of a running HTTP forward proxy
	// supplied by the caller.  Required when
	// NetworkPolicy == NetworkPolicyFiltered; set before calling Create.
	// Example: "127.0.0.1:7070"
	EgressProxyAddr string
}

Spec is the complete description of a sandbox to be created. All backend implementations derive their resource limits and networking configuration from this struct.

type Status

type Status struct {
	// Running is true when the container/VM process is actively running.
	Running bool

	// Endpoints mirrors Handle.Endpoints, refreshed from the live runtime.
	// The backend re-resolves host ports on every Inspect call because Podman
	// can remap them on restart.
	Endpoints map[string]string

	// CreatedAt is when the sandbox's current runtime object was made. Zero
	// when unknown.
	//
	// This is creation time, not start time, and the two diverge under this
	// package's own operations: Start (a restart of the same object) does
	// NOT change CreatedAt. Recreate DOES change it — Recreate replaces the
	// runtime with a new instance (PodmanBackend creates a new container;
	// QemuBackend rewrites its creation marker, see below), so CreatedAt
	// advances even though the work volume/overlay, and the caller's data on
	// it, survive untouched. A caller that provisions files at Create time
	// and wants to know whether an already-running sandbox predates a file it
	// now wants delivered should compare CreatedAt against that file's own
	// timestamp — not merely branch on the file's existence, which cannot
	// distinguish "provisioned before this sandbox was created" from
	// "provisioned after".
	//
	// PodmanBackend: parsed verbatim from `podman inspect`'s "Created" field
	// (RFC3339Nano with a numeric zone offset — measured against real podman
	// 4.9.3, which does not change this field across stop/start). A parse
	// failure leaves CreatedAt zero and logs a warning rather than failing
	// Inspect.
	//
	// QemuBackend: a VM has no inspectable analogue to podman's Created, so
	// the backend persists its own timestamp file at Create and Recreate.
	// A sandbox created by a keel build before this field existed has no
	// such file and reports zero.
	CreatedAt time.Time
}

Status describes the observed state of a sandbox at a point in time.

Jump to

Keyboard shortcuts

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