Skip to content

Containers: Dockerfiles as a first-class workflow (CLI, CI, prod-like runs, codeSplitter UX) #818

Closed

Description

@QuinnsCode

Makes Dockerfiles first-class in RustyBuns and codeSplitter. Today codeSplitter's three images (sandbox/, deploy-sandbox/, deploy-sandbox/ci.Dockerfile) only get built during a live deploy. There's no local workflow for them, local runs don't match Cloudflare's runtime, and the hosted containers' lack of Docker and Rust only shows up later as failed runs.

This issue holds the full spec. #814, #815, #816 and #817 were folded in here and closed.

Order

Each part builds on the one before it:

Part 2 can ship first on plain docker build if we want the safety net sooner. It switches to containers smoke once Part 1 lands.



Part 1: CLI: rustybuns containers (list, build, run, smoke) plus a Docker preflight on deploy (was #815)

Problem

Any RustyBuns app can declare { type: "container", dockerfile, className, ... } bindings. The CLI writes them into Alchemy and wrangler config (packages/cli/src/gen/alchemy.ts, gen/wrangler.ts), but it gives you no way to work with the images yourself. Trying one locally means hand-writing docker build -f ... <context> and docker run -p 8080:8080 ... and knowing what each server expects. A deploy with container bindings needs Docker running, and nothing checks that up front.

What good looks like

A rustybuns containers command family that knows the app's bindings:

$ rustybuns containers
  AGENT_SANDBOX   sandbox/Dockerfile             basic ×2   built 3m ago · 812 MB
  DEPLOY_RUNNER   deploy-sandbox/Dockerfile      basic ×1   not built
  CI_RUNNER       deploy-sandbox/ci.Dockerfile   basic ×2   stale (server.mjs changed)
  • rustybuns containers build [BINDING…] builds the bindings (all of them by default) for linux/amd64 with the right context. It shows a live progress line per image (step n/m, current layer, elapsed time) instead of raw BuildKit output, which --verbose restores. It finishes with a size table and a size delta against the last local build.

  • rustybuns containers run BINDING builds the image if it's stale, starts it on a free port, waits until it's ready (:8080 answering), then prints a ready line with the URL and the docker commands it ran (so nothing is magic). It streams logs prefixed with the binding name until Ctrl-C, then removes the container.

    • Env without secrets. It reads .env.schema for the names a binding expects and passes only values the user sets explicitly (--env NAME=... or an env file they name). It never reads .env or 1Password itself, and it lists which expected names were left empty.
    • --shell drops into sh in the same image as the user the image runs as, for poking around.
  • rustybuns containers smoke [BINDING…] runs each image headless, checks it boots and answers, prints ✅/❌ per binding, and exits non-zero on failure. The CI job in Part 2 can call this, so local and CI behave the same.

  • Docker preflight, everywhere it matters. deploy, containers * and build box (with cross crates) check the daemon first and fail with a single clear message:

    Docker isn't running. This app has 3 container bindings, and deploy builds their images locally. Start Docker Desktop and run this again.

    Today build box has a version of this (packages/cli/src/build.ts:580); share one helper so they all say the same thing.

  • Staleness tracking. A hash of each binding's context folder plus its Dockerfile, stored under .rustybuns/containers/, drives "stale" in the list and lets run skip a rebuild when nothing changed.

Docs

  • Add a "Containers" section to REFERENCE.md (binding fields, the commands, what local runs don't replicate; see Part 3).
  • Add a row in GETTING_STARTED.md's table for container bindings.

Acceptance

  • In apps/codesplitter, rustybuns containers run CI_RUNNER boots the check image and prints a working URL.
  • deploy with Docker stopped fails in under a second with the preflight message, before any Alchemy work.
  • Tests cover binding discovery, staleness hashing and the preflight message (Docker itself mocked).

Part 2: CI: build and smoke-test every container image a PR touches, with a size/boot summary (was #814)

Problem

codeSplitter ships three images: sandbox/Dockerfile, deploy-sandbox/Dockerfile and deploy-sandbox/ci.Dockerfile. They're declared as container bindings in apps/codesplitter/rustybuns.config.ts. No CI job builds them. A broken apt-get, a bad base tag or a COPY of a moved file only shows up when the owner runs the live deploy and Alchemy/wrangler builds the image, which is the worst place to find it.

What good looks like

A PR that touches an image's Dockerfile, or anything in its build context, gets a containers check that builds that image and reports back clearly:

  • Builds only the images that changed. scripts/ci-apps.ts already plans per-app jobs, so add a containers output to it. It reads the container bindings from each app's rustybuns.config.ts (the Dockerfile path and its context folder) and lists the bindings whose context the diff touched, plus every binding when packages/cli/src/gen/ changes.

  • Uses the right architecture. It builds for linux/amd64, the platform Cloudflare Containers runs, on a standard runner, so the job stays within scripts/ci-guard.ts.

  • Is fast when nothing big moved. Layers are cached with BuildKit's GHA cache (type=gha, scoped per image), so a PR that only changes server.mjs rebuilds just the last layers.

  • Smoke-tests each image after building it. The job runs the image, waits for :8080 to answer, and fails if the server never comes up or exits. The images set CMD to node server.mjs, so a missing node or a syntax error fails here.

  • Writes a job summary. It adds a table to $GITHUB_STEP_SUMMARY, one row per image:

    Image Build Size Δ vs main Boot
    AGENT_SANDBOX (sandbox/Dockerfile) ✅ 1m12s 812 MB +4 MB 0.9s

    Size and Δ show image bloat (e.g. someone adds Rust to the check image) before it costs cold-start time. Main's sizes come from the GHA cache, or from a small JSON artifact the last main run uploaded.

  • Says which step failed. On failure, the summary names the failing Dockerfile line and shows the last 30 lines of build output, so nobody has to open the raw log.

  • Gives a clear "no images touched" result. When the PR didn't touch any image, the job finishes in seconds with a single line saying so.

Out of scope

  • Pushing images anywhere. Deploys still build their own.
  • Making containers a required check. That's a ruleset change, which is the owner's call. Propose it once the job is stable.

Acceptance

  • Breaking sandbox/Dockerfile (e.g. a typo in a package name) fails the PR with the bad line in the summary.
  • Changing only deploy-sandbox/server.mjs rebuilds both deploy-sandbox images (they share the context) and not sandbox/.
  • A PR that touches no image runs nothing heavy.
  • The job has a timeout and runs on standard runners (ci-guard.ts passes).

Part 3: Containers: --prod-like local runs with the real egress gate and CA (was #816)

Problem

The codeSplitter images are built for Cloudflare's container runtime:

  • At start, the runtime provides a CA (/etc/cloudflare/certs/cloudflare-containers-ca.crt). Each image's CMD trusts it.
  • All HTTP and HTTPS goes through EgressGate (apps/codesplitter/src/egress.ts), which refuses any host not on the binding's list with a 403.

Locally neither exists. The CA file is missing (the CMDs quietly skip it), and egress is wide open. So a local run can pass while production fails: e.g. a new dependency needs a registry host the gate doesn't allow, or a tool ignores NODE_EXTRA_CA_CERTS. The egress rules are only tested as a pure function (test/egress.test.ts), never against the real images.

What good looks like

rustybuns containers run --prod-like BINDING (and smoke --prod-like) runs the image under the same conditions as production:

  • A local egress gate. A small Bun proxy, started next to the container on a private Docker network, intercepts TLS with a CA generated per session. It applies the exact same allowed(host, list) from src/egress.ts, with the host list each binding uses in production. This is a single source of truth, not a copy.
  • The CA in the same place. The generated CA is mounted at /etc/cloudflare/certs/cloudflare-containers-ca.crt, so the image's own CMD trusts it the same way it does in production.
  • No other way out. The container's network has no route except through the gate, so a tool that ignores the proxy env fails loudly instead of quietly getting through.
  • A clear egress log. Each request prints one line, so you can see what the image touched:
    egress  ✅ registry.npmjs.org      GET  /bun        200
    egress  ⛔ pypi.org                GET  /simple/…   403  not on CI_RUNNER's list
    
    It ends with a summary of refused hosts and the exact line to add to the list if the host should be allowed.
  • Tests against real images. Add an opt-in test (skipped without Docker, run by the CI containers job in Part 2) that boots ci.Dockerfile under the gate. It asserts that a clone from GitHub and a bun install succeed, and that an off-list host gets the 403 text from refused().

Notes

  • The CA and key are generated per session in a temp directory and deleted on exit. They are never committed and never reused.
  • This doesn't emulate instance types, cold starts or maxInstances. Say so in REFERENCE.md next to the local-run docs.

Acceptance

  • Running --prod-like with CI_RUNNER blocks a request to a host off its list and logs it as refused.
  • Changing the list in src/egress.ts changes local behavior with no second edit.
  • The opt-in test passes in the CI containers job.

Part 4: codeSplitter: flag checks/deploys that need Docker or Rust up front, with one-click Run on desktop (was #817)

Problem

The hosted containers can't run Docker, and the check image has no Rust (deploy-sandbox/ci.Dockerfile). How codeSplitter handles that today:

  • Deploys: a box whose native/ crates need another arch is refused, but only once the run reaches the deploy step (src/preview.ts:217), after the clone and install.
  • Checks: a check that needs Docker or cargo starts in the container and fails partway through with a raw command not found. The only guidance is the generic "Run it on the desktop app or a runner of your own" when a run is cancelled (src/ci-runner.ts:219).

The user finds out late, from a log, and has to work out where to go next.

What good looks like

Know before running. When a check or deploy is queued, a cheap capability scan works out what the run needs: native/Cargo.toml and the box's arch, docker/cargo/rustc in the check's command or in package.json scripts it calls, and a Dockerfile it builds. It compares that with what the target container image provides. The image's capability list lives next to its binding in rustybuns.config.ts, so the scan and the image can't drift apart.

Say it in the UI, early and visually (in codeSplitter's style: GitHub black-and-white with orange, icons rather than wrapping labels):

  • On the check or deploy row, before it starts, show a small icon chip (🐳 or the Rust gear) with a tooltip like "Needs Docker. Hosted checks don't have it."
  • Replace the Run button with a split button:
    • Run on desktop (default when the desktop app is connected) queues the run with runner = 'desktop', which ci-runner.ts already drains.
    • Run on my runner shows the scripts/runner.ts one-liner with a copy button, filled in for this repo.
    • Run hosted anyway stays available, dimmed, for when the scan is wrong.
  • If nothing can run it (no desktop connected, no runner registered), give a short empty state with the two ways to get one, not a failed run.

When it still fails in the container, recognize the missing-tool signature (docker: not found, cargo: not found, a refused arm64 crate build). Mark the run Needs desktop, not failed, with the same one-click handoff, so the PR's status doesn't go red for an infrastructure reason.

Out of scope

  • Docker-in-Docker or a privileged hosted runner. That's a security change for the hosted images. If wanted, file it separately.

Acceptance

  • A repo whose check runs cargo test shows the Rust chip and defaults to "Run on desktop" before anything starts.
  • An arm64 box deploy is flagged at queue time, not at the deploy step.
  • A container run that hits docker: not found ends as "Needs desktop" with the handoff, and the commit status isn't a failure.
  • Tests cover the capability scan and the missing-tool classifier.

Activity

  1. changed the title [-]Tracking: Dockerfiles as a first-class workflow (CI, CLI, prod-like runs, codeSplitter UX)[/-] [+]Containers: Dockerfiles as a first-class workflow (CLI, CI, prod-like runs, codeSplitter UX)[/+] on Oct 12, 2026
  2. added
    securityFound in the security audit (#135)
    github_actionsPull requests that update GitHub Actions code
    on Oct 12, 2026
  3. self-assigned this
    on Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestgithub_actionsPull requests that update GitHub Actions codesecurityFound in the security audit (#135)

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions