Skip to content

Sandbox templates: content-keyed records, derived-image strategies, template forks, GC - #16

Merged
czpython merged 19 commits into
mainfrom
commonzenpython/eng-877-sandbox-templates
Aug 26, 2026
Merged

Sandbox templates: content-keyed records, derived-image strategies, template forks, GC#16
czpython merged 19 commits into
mainfrom
commonzenpython/eng-877-sandbox-templates

Conversation

@czpython

@czpython czpython commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Implements the drukbox half of the sandbox-needs design (ENG-877, ENG-878, ENG-879, ENG-880 — children of ENG-527). Boot-and-bake providers (ENG-881) come in a later PR.

  • Template records keyed unique on (provider, base_image, requirements_hash); POST /templates answers 202 and callers poll to available/failed. Unique-index races resolve to the winner.
  • TemplateCapability beside HttpProxyCapability; derived-image strategy for exe, docker, and docker-sbx (exe pushes to a registry configured via EXE_TEMPLATE_REGISTRY/_USERNAME/_PASSWORD). Providers stay dumb: the handle rides host.image and create_vm is unchanged.
  • HostCreate takes template (id or requirements hash); explicit image > template > provider default. Non-available templates fail 409 with the status; drukbox never builds on lease miss. Template requests bypass the warm pool and stamp last_used_at.
  • python -m templates.janitor: abandoned builds become failed, failed builds are retained for diagnosis then reaped, unreferenced templates age out via the strategy delete.

Verification: ruff, ruff format, pyright, pytest (447) all green per commit.

czpython and others added 19 commits August 24, 2026 19:40
Templates are keyed by content — unique (provider, base_image,
requirements_hash) — with status building -> available -> failed.
POST /templates answers 202 and the caller polls; the build runs as a
background task against the TemplateCapability a provider may implement
(implementations land next). Concurrent identical creates resolve through
the unique index: the losing racer re-reads the winner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The TemplateCapability input is (base_image, setup_script) — no live host:
the strategy synthesizes FROM <base> plus the script as a bake-time root
step, builds with the docker CLI, and the resulting ref is the handle.
docker keeps the local tag, docker-sbx builds into the daemon sandboxd
reads templates from, and exe pushes to a registry exe.dev can pull
(exe.dev has no snapshot verb — the image is its snapshot; registry
credentials are EXE_TEMPLATE_REGISTRY/_USERNAME/_PASSWORD). Providers
stay dumb: the handle rides host.image and create_vm is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HostCreate accepts template — an id or a requirements hash — and the
resolution is explicit image > template handle > provider default, with
the resolved handle riding host.image into the unchanged provision path.
A hash matching several bases prefers the available ones, newest first.
Naming a template that is not available fails 409 with the status (and
last_error for failed builds); drukbox never builds on miss — the caller
owns when to build. A template request is a customization, so it never
claims a warm pool host, and each fork stamps last_used_at for GC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Templates cost money independent of hosts, so python -m templates.janitor
gets its own cron entry. Three sweeps: an unfinished build older than
TEMPLATE_BUILD_TIMEOUT_MINUTES becomes failed (the only exit for a build
whose process died); failed templates keep their diagnostics for
TEMPLATE_FAILED_RETENTION_HOURS, then go; available templates unleased
for TEMPLATE_UNUSED_TTL_DAYS age out through the strategy's delete --
which is also how a superseded hash dies, since edits mint a new one.
Deletes re-validate under the row lock so a fresh lease or status change
spares the row, hosts-janitor style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
create_template replaces materialize_template on TemplateCapability and
its three providers, and the DockerCLI attribute is docker_cli. The docs
and comments this branch added are rewritten to ASD-STE100 shape: short
sentences, active voice, one name per concept ("persistent" replaces
"durable"; "template" replaces "artifact").

exe.dev assumes a public image, and templates land in a private
registry, so create_vm now passes --registry-auth when the host image
lives on the configured template registry — and only then, so the
credentials never reach another registry (exe.dev docs: private-image).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The requirements hash is script-only, so one hash can match two base
images on the same provider — hash leasing had to guess with a
newest-available tie-break. The guess is gone: template is a UUID, the
lookup is exact, and the wire boundary validates the type. Callers do
not lose content addressing — POST /templates is idempotent by content
and always returns the current record with its ID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repost-as-lookup suggestion blurred the contract: POST /templates is the only path that builds, and naming a template on POST /hosts never creates one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reaping is one concern, and every resource type adding its own cron entry is operator sprawl. The reapers stay in their packages; the janitor package is the single cron entry that runs both. The pool maintainer stays its own command because it creates resources instead of reaping them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The registry setting is not template-specific in behavior — registry auth applies to any image on that registry — so the name drops the template. The three GC settings follow the LEASE_DEFAULT_TTL precedent: no unit suffix, value in seconds (TEMPLATE_BUILD_TIMEOUT=3600, TEMPLATE_FAILED_RETENTION=86400, TEMPLATE_UNUSED_TTL=1209600).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Settings defaults and UUID parsing are pydantic's contract, not ours. The provider-error build test fell to the crash test, which guards the same except arm plus the breadth of the catch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The column holds the provider image that lands in host.image, so it is named image — built from base_image, produces image. Handle named the mechanism. templates/deps.py held one pass-through factory; it now lives beside the router (hosts/deps.py keeps its file because it owns real app-state wiring).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ashes

The provider surface no longer borrows the service's word: TemplateCapability declares build_template_image -> image ref and delete_template_image — a provider author builds and deletes images, only the service creates template records. derive_image_tag and create_build_context replace the noun-phrase function names. setup_script_hash replaces requirements_hash: the column stores sha256(setup_script) and now says so next to its input field. UnknownTemplateError replaces TemplateReferenceError, mirroring UnknownProviderError on the same route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The module synthesizes a Dockerfile and drives the DockerCLI, so its home is the package that owns them. At the providers top level it inverted the layering: framework-level code importing one concrete provider. exe and docker-sbx already import from providers.docker for the CLI itself; the derived-image import now reads the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
We were growing a docker client — subprocess verbs, stderr string
matching, a temp-file dance to keep env values off argv. aiodocker
already is one. DockerAPI keeps the same seam and method names; the
transport is the Engine API, with daemon resolution at CLI parity
(DOCKER_HOST, the active docker context, socket auto-detection). The
client is created lazily on first use because providers are constructed
in sync code and an aiohttp session must be born on the running loop.

What the swap deletes: stderr parsing (404s map to the typed not-found
errors), docker login (push carries per-call credentials in the
X-Registry-Auth header — the global credential store is never touched),
the env-file workaround (env rides the API body over the socket, and
newline values now just work), and the docker binary in the image.

The build context is a gzipped in-memory tar; the Engine builds it with
the classic builder, which is enough for the synthesized three-line
Dockerfiles. Smoke-tested live against a real daemon: version, derived
image build, remove.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_translated hid the exception mapping behind a with-statement and a not-found flag parameter, and its broad ValueError/OSError arm converted genuine bugs into transport errors. Each method now carries its own narrow try/except in the codebase's boundary idiom; the no-daemon ValueError is translated in _client, the one place it can happen, and 404 branches exist only where not-found has a meaning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The built ref is called image everywhere the checklist reaches — Template.image, build_template_image, host.image — so the DockerAPI params, images.py locals, and derive_image_name stop saying tag. The only tag left is aiodocker's own build kwarg at the boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The abandoned-build sweep was a per-row locked loop for a pure status flip — now one guarded UPDATE ... RETURNING, the pool-claim pattern. The two delete sweeps and their sweep-spec tuple table collapse into a single reapable(settings) predicate that lives in the service: the janitor selects with it and delete(expired_only=True) re-checks the same predicate under the row lock, mirroring delete_host. The reap_status/reap_before parameter protocol is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The OpenAPI contract check gains the four /templates operations, and the black-box suite gains the full template lifecycle against the real daemon: 202 build, poll to available, fork a host whose image is the template's, delete both. The image-validation step that asserted docker --version dies with the binary it verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@czpython
czpython merged commit 250f26d into main Aug 26, 2026
6 checks passed
@czpython
czpython deleted the commonzenpython/eng-877-sandbox-templates branch August 26, 2026 07:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant