sandbox

package
v1.801.490 Latest Latest
Warning

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

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

Documentation

Overview

runtime.go — the Kubernetes half: what a sandbox IS in the cluster, and the one channel into it.

A sandbox is a Pod, and its isolation boundary is the RUNTIME that pod names — one field, `SANDBOX_RUNTIME_CLASS`, holding `gvisor` or `kata-fc` or `kata-clh` or nothing. It is the ONLY boundary claimed here: no uid juggling, no process groups, no daemon inside the pod deciding what it is allowed to run. Everything the predecessor built to approximate that boundary in Go is deleted rather than kept "for defence in depth", because a second half-boundary is a second thing to keep true.

Empty is honest, not a hole: it means the node's default runtime, which is the containment a normal pod gets. It ships that way because runsc has to be installed on the nodes first and that restarts containerd under 204 running pods — maintenance, not a release step. The field flips afterwards with no rebuild, and that is the whole reason the runtime is a string.

There is no os/exec in this file and there must never be. It creates Kubernetes objects and streams bytes to the apiserver.

Package sandbox is the ONE compute primitive: a sandbox is a gVisor pod that runs somebody else's code, and every lifetime is the same object.

a function invoke      = a sandbox with a seconds-long lease
a code-exec call       = a sandbox with a session lease
an agentic coding run  = a sandbox with a project volume and a long lease

Not three subsystems, not three schedulers. One record, one pod spec, one way in. What differs between them is `ttlSec` and whether a volume is attached.

POST   /v1/sandboxes             {kind:"sandbox", class, project?, ttlSec?} -> Sandbox
GET    /v1/sandboxes             ?kind=&project=&status=
GET    /v1/sandboxes/:id
DELETE /v1/sandboxes/:id         ?purge=1 drops the volume too
POST   /v1/sandboxes/:id/exec    {argv|command, stdin?, timeoutSec?} -> {exitCode,stdout,stderr}
GET    /v1/sandboxes/:id/fs      ?path=  read a file, or list a directory
POST   /v1/sandboxes/:id/fs      ?path=  write a file

THERE IS EXACTLY ONE WAY INTO A SANDBOX, and it is the Kubernetes exec subresource. fs read/list/write are not a second channel — they are `cat`, `ls` and `tee` over that one channel, which is also how `kubectl cp` has always worked. The predecessor shipped an in-pod HTTP daemon with eleven endpoints, a shared pool-wide API key and a pod-IP address book; all three are gone, because Kubernetes already had the channel and we were re-implementing it badly.

A SANDBOX IS ADDRESSED BY POD NAME THROUGH THE APISERVER, NEVER BY IP. That is not a style preference, it is the fix for a real cross-tenant read: a pod that dies on its own — evicted, OOM-killed, drained — never runs a release path, so a row keeps a Host the CNI has since handed to another tenant's replacement pod, and a call to that address is served by a stranger with every credential checking out. A pod NAME is minted per sandbox and never reused, so the same mistake cannot be spelled. The header-stamping protocol that was invented to defend the IP scheme is deleted along with the scheme.

NOTHING RUNS IN THIS PACKAGE. There is no os/exec here and there must never be. It creates Kubernetes objects and streams bytes to the apiserver; the work happens inside the pod, under runsc, on the far side of a runtime boundary.

THERE IS NO POOL. A sandbox is created for a lease and deleted at its end. Claiming from a warm pool is what forced the recycle, the pod-IP address book and the label-patch race arbitration; it also grows, inevitably, into a scheduler beside Kubernetes' own. If a warm pool is ever wanted, it is a Deployment an operator sizes — not a manager in this process.

AUTH is the ordinary one: IAM terminates identity at the edge and this package reads principal.Org. It never mints a credential, and no caller credential reaches a sandbox — the pod carries no service-account token at all.

Index

Constants

View Source
const IDPrefix = "m_"

IDPrefix is how a sandbox of ours is told apart from one the compute control plane owns, on a resource both answer for. It is a prefix on the id rather than a lookup, so dispatch costs nothing and cannot go stale.

View Source
const KindSandbox = "sandbox"

KindSandbox is the sandbox this package provisions: a gVisor pod in our own cluster. It is a VALUE on the shared /v1/sandboxes resource, beside the kinds the compute control plane provisions (droplets, GPUs, bot sandbox), because "a sandbox the org has" is one noun and splitting it by who provisions it would publish two.

Variables

This section is empty.

Functions

func End

func End(s *Service, ctx context.Context, org, id string, purge bool) error

End ends the lease: the pod goes, the volume stays unless purge.

purge drops the VOLUME as well, and it is opt-in because the volume holds the only copy of the checkout and the caches. Ending a lease is cheap and reversible; deleting someone's uncommitted work is neither.

func Mount

func Mount(app cloud.Router, deps cloud.Deps) error

Mount registers the sandbox-sandbox half of /v1/sandboxes.

It is composed INTO the app that already owns the /v1/sandboxes prefix rather than claiming a manifest row of its own: zip refuses two owners for one prefix, the compute surface has held that prefix in production for months, and a second `sandbox` noun is exactly the duplication this package exists to remove. See apps/visor's mount, which is the only caller.

func Ours

func Ours(id string) bool

Ours reports whether an id names a sandbox this package provisions.

func Routes

func Routes(app cloud.Router, s *cloud.Service[state])

Routes registers everything this package serves.

The collection and member routes used to be left out, on the reasoning that they were "shared with the compute surface" — true while this served /v1/machines, which visor owns and where a second registration of one resource is a conflict. It is /v1/sandboxes now, owned outright, and leaving them out meant Create, List, Get and Delete existed as exported functions that no request could ever reach: a caller could exec in a sandbox it had no way to create. The handlers were there, the routes were not, and nothing said so — the same shape as the policy that selected no pod and the installer that installed nothing.

func Write

func Write(s *Service, ctx context.Context, org, id, path string, data []byte) (string, int, error)

Write writes data to one file, creating parent directories. It answers the RESOLVED path, because the caller's path is relative far more often than not and echoing back what it asked for would tell it nothing it did not already know.

Types

type Bound

type Bound struct {
	Namespace string
	Selector  string
}

Bound is the ONE selector anything sweeping sandboxes may use, and the check that it is safe, in one value that cannot be had without the other.

It is a type and not a pair of strings so that a caller cannot end up holding half of it. `hanzo.ai/sandbox` with no namespace is the cluster-wide selector that caused the incident; there is no way to spell that here, because the only constructor refuses a system namespace and the only field that names a label is written by this file.

type Cmd

type Cmd struct {
	Argv       []string
	Command    string
	Stdin      string
	Dir        string
	TimeoutSec int
}

Cmd is one command to run inside a sandbox. Argv is the honest form; Command is the convenience for a caller holding a shell line. Exactly one is required.

type Entry

type Entry struct {
	Path    string   `json:"path"`
	Dir     bool     `json:"dir,omitempty"`
	Data    []byte   `json:"data,omitempty"`
	Entries []string `json:"entries,omitempty"`
}

Entry is what a path IS: a file's bytes, or a directory's entries. One read answers both because one shell command answers both, and a caller that had to stat first would pay two round trips through the apiserver to learn something the same command already knew.

func Read

func Read(s *Service, ctx context.Context, org, id, path string) (Entry, error)

Read reads one file, or lists a directory when the path names one. Both go through the same exec channel — there is no second protocol and no daemon in the pod to speak one.

type ExecResult

type ExecResult struct {
	ExitCode int    `json:"exitCode"`
	Stdout   string `json:"stdout"`
	Stderr   string `json:"stderr"`
}

ExecResult is what running a command produced. A non-zero ExitCode is data, not an error: the call succeeded and the program failed.

func Run

func Run(s *Service, ctx context.Context, org, id string, cmd Cmd) (ExecResult, error)

Run runs a command inside the sandbox and answers its exit code and output.

A non-zero exit is a SUCCESSFUL call carrying a failed program: no error is returned, because "the tests failed" and "the sandbox is broken" are different facts and a caller has to be able to tell them apart.

type Sandbox

type Sandbox struct {
	ID         string `json:"id"`
	Org        string `json:"org"`
	Kind       string `json:"kind"`
	Class      string `json:"class"`
	Project    string `json:"project,omitempty"`
	Status     string `json:"status"` // pending | running | error
	Image      string `json:"image"`
	Pod        string `json:"-"`
	Volume     string `json:"volume,omitempty"`
	Error      string `json:"error,omitempty"`
	CreatedAt  int64  `json:"createdAt"`
	LastUsedAt int64  `json:"lastUsedAt"`
	ExpiresAt  int64  `json:"expiresAt,omitempty"`
}

Sandbox is one leased execution sandbox. It is the SAME record whether the lease is seconds long (a function invoke) or a week (a suspended coding session) — status and expiresAt carry the difference, not a second table.

Pod is the sandbox's ADDRESS, and it is `json:"-"` on purpose. It is a pod name in a namespace a caller has no business knowing, and handing it out both maps the cluster for whatever runs inside a sandbox and invites a client to try reaching the sandbox directly, which would mean terminating auth somewhere other than the IAM edge. The predecessor returned a pod IP in every create, get and list response.

func Get

func Get(s *Service, ctx context.Context, org, id string) (Sandbox, error)

Get answers one sandbox THIS org owns. An id belonging to another org is 404 and not 403, because a 403 would confirm the id exists and whether a given sandbox exists is itself a cross-tenant fact.

func Lease

func Lease(s *Service, ctx context.Context, org string, spec Spec) (Sandbox, error)

Lease returns the org's sandbox named by spec.ID, or mints one.

Resuming is checked against the STORE and against the row's status, so a session whose pod the reaper already ended comes back as a fresh sandbox rather than as a 502 from the first command sent into a pod that is gone.

func List

func List(s *Service, ctx context.Context, org, project, status string) ([]Sandbox, error)

List answers the org's sandboxes, newest first.

type Service

type Service = cloud.Service[state]

Service is the mounted subsystem, handed back to the app that owns the /v1/sandboxes prefix so its collection handlers can dispatch into this one.

func New

func New(deps cloud.Deps) (*Service, error)

New builds the subsystem without registering anything, for a host that wants to dispatch the shared collection routes itself.

type Spec

type Spec struct {
	ID      string
	Class   string
	Project string
	Image   string
	TTLSec  int
}

Spec is what a lease asks for.

ID is the RESUME: name a sandbox you already hold and Lease returns it instead of minting one. That is what makes "a session" expressible without a session table — the caller's session id IS the sandbox id, and the only store either needs is the one this package already keeps.

type Store

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

Store is one org's sandbox registry — ONE SQLite file per org at {DataDir}/orgs/{orgSlug}/sandbox.db (cloud.OrgDB, HIP-0302). Org isolation is PHYSICAL (a different file), with the org column kept as defence in depth so a query that somehow reached the wrong file still returns nothing.

func (*Store) Close

func (s *Store) Close() error

func (*Store) Delete

func (s *Store) Delete(ctx context.Context, org, id string) error

func (*Store) Expired

func (s *Store) Expired(ctx context.Context, org string, now int64) ([]Sandbox, error)

Expired is what a reaper reads: every sandbox whose lease has run out.

func (*Store) Get

func (s *Store) Get(ctx context.Context, org, id string) (Sandbox, error)

Get is org-scoped in the WHERE clause, always. The file is already per-org; this is the second lock on the same door.

func (*Store) IDs

func (s *Store) IDs(ctx context.Context, org string) (map[string]bool, error)

IDs is every sandbox this org still claims, with NO LIMIT — which is the whole reason it is not just List.

The orphan sweep subtracts this set from the pods in the cluster, so a truncated answer here does not mean "fewer rows": it means every sandbox past the cutoff is reported as unclaimed and its pod is deleted while its owner is working in it. List's `LIMIT 200` is right for a page a human reads and catastrophic for a set a sweep differences against, so the sweep gets its own query rather than a bigger limit somebody has to keep ahead of the fleet.

func (*Store) List

func (s *Store) List(ctx context.Context, org, project, status string) ([]Sandbox, error)

func (*Store) Live

func (s *Store) Live(ctx context.Context, org, project string) (Sandbox, error)

Live is the single-attach check: the sandbox, if any, currently holding this project's volume.

func (*Store) LiveOfClass

func (s *Store) LiveOfClass(ctx context.Context, org, class string) (int, error)

Live counts the org's sandboxes of one class that are still holding a pod.

A COUNT and not a len(List): List is LIMIT 200, so a cap read through it would stop counting at 200 and stop refusing at exactly the point refusing matters.

func (*Store) Put

func (s *Store) Put(ctx context.Context, m Sandbox) error

Jump to

Keyboard shortcuts

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