Skip to content

feat(sandbox): tenant warm pools — pods already running the dev server - #5830

Merged
pedrofrxncx merged 9 commits into
mainfrom
feat/sandbox-tenant-warm-pools
Aug 6, 2026
Merged

feat(sandbox): tenant warm pools — pods already running the dev server#5830
pedrofrxncx merged 9 commits into
mainfrom
feat/sandbox-tenant-warm-pools

Conversation

@pedrofrxncx

@pedrofrxncx pedrofrxncx commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

A member of an opted-in org opens a project and the dev server is already
up
— no clone, no install, no Vite boot. Spec (updated to match what shipped):
packages/sandbox/tenant-warm-pools-spec.md.

A pool is keyed (org, repo). Studio bootstraps its pods while they are
unbound; a claim from that org binds one instead of a generic empty warm pod.
Off everywhere by default: no pool configured → not a single code path changes.

What's in it

Provider (packages/sandbox/server/provider/agent-sandbox/)

  • tenant-pools.ts — pool config (STUDIO_SANDBOX_TENANT_POOLS, a JSON array)
    and resolution. The pool is resolved from the authenticated user's org,
    never from a request field: the operator has no notion of a tenant and binds
    whatever pool a claim names, so the server-built claim is the boundary.
  • runner.ts — the claim swaps warmpool to the pool's name; a reconciler
    (60s, no-op with no pools) clones/installs/starts each unbound pool pod and
    refreshes it every 30 min. That refresh is the clone-credential refresh,
    so no pod outlives its ~1h token. Bound pods are never touched.
  • Runs on every replica with no leader election: the daemon classifies each
    config post itself and its setup queue collapses concurrent requests — so
    duplicated effort, never duplicated clones.

Daemon (daemon-go)

  • claimed now flips only for a config carrying a user identity. Without
    this, bootstrapping a pool pod makes the housekeeper reap it 15 min later —
    warming a pool would be what kills it. Unbound pods report
    claimed:false, prewarmed:true.
  • env-change now maps to a dev-server restart. It was a silent no-op: the
    merged env only reached the dev server if some other transition happened to
    restart it.

CharttenantPools: renders a SandboxWarmPool per entry against the
shared SandboxTemplate.

Webhook (optional)POST /api/_github/webhook makes a refresh immediate
on a push. 503 without GITHUB_WEBHOOK_SECRET; nothing here needs it
pools refresh on their own schedule regardless.

Testing

Unit: pool parsing/resolution (including "a user of another org never resolves
this pool"), webhook HMAC. Daemon e2e (real binary, black-box):
identity-less bootstrap stays unclaimed, an identity-bearing config claims,
env-only change restarts dev. Full daemon-e2e green (209/209).

End-to-end on a local rancher-desktop cluster (real operator, real pods, real
daemon built from this branch):

  1. reconciler bootstraps the pool pod → repo cloned;
  2. it reports claimed:false, prewarmed:true (housekeeper-safe);
  3. a claim from the pool's org gets warmpool: tenant-local-hello and binds
    that pod; claiming flips claimed:true;
  4. releasing the claim destroys the used pod (never reused — it holds the
    previous user's credential, identity and working tree) and the pool refills;
    the refill self-warms;
  5. a signed push payload through the webhook handler refreshes the warm pod,
    and a push on another branch/repo matches nothing.

Notes

  • One template, not two: the claim swaps only warmpool. A per-pool template
    exists solely to carry per-pool node placement (on-demand / do-not-disrupt),
    which this drops — reinstate both together.
  • connectionId is optional; without it pods clone anonymously (public repos).
  • Every path still works at pool size 0 — the pool is an optimization, never a
    dependency.

Summary by cubic

Adds tenant warm pools so members of opted-in orgs open a project with the dev server already running. Pools are per org/repo, bootstrapped by Studio, scoped per release, and off by default.

  • New Features

    • Tenant warm pools resolved from the authenticated user's org; Studio bootstraps unbound pods (clone/install/start) and refreshes them every 30 min. Claims bind a warm pod by setting warmpool to the pool name; no pools configured → no behavior change. Added studio.sandbox.pool.pods gauge (ready/bound/pending/failed).
    • Server reads pools from STUDIO_SANDBOX_TENANT_POOLS (strict parser); when set, the sandbox provider starts eagerly at boot so pods pre-warm before the first request.
    • Optional GitHub push webhook POST /api/_github/webhook (HMAC via GITHUB_WEBHOOK_SECRET) marks pools dirty to refresh immediately; periodic refresh still runs without it.
    • Helm chart: tenantPools: renders a SandboxWarmPool per entry against the shared SandboxTemplate (chart v0.12.0). Names must end with -<envName> and size must be ≥1. Server reads pools from STUDIO_SANDBOX_TENANT_POOLS.
  • Bug Fixes

    • Runner Role grants sandboxwarmpools: get; the reconciler re-checks a pod is unbound immediately before config and clone, treating unreadable pods as bound to avoid hard resets on live working trees.
    • Failure cap now backs off 30 minutes and retries once, preventing a transient failure from permanently blackholing a slot.
    • Daemon sets claimed:true only when a user identity is present; warm pool pods report claimed:false, prewarmed:true. env-change now restarts the dev server.
    • Housekeeper sweep fixes claimed parsing across sed variants and adds tests for probe classification; added tests for tenant-pool binding, push matching, and daemon e2e (branch change keeps deps; different-repo config is refused).

Written for commit 785580f. Summary will update on new commits.

Review in cubic

Pedro França added 9 commits August 6, 2026 18:56
A member of an opted-in org opens a project and the dev server is already up:
no clone, no install, no boot. A pool is keyed (org, repo) and its pods are
bootstrapped by Studio while unbound; a claim from that org binds one.

- provider: SANDBOX_TENANT_POOLS-style config (STUDIO_SANDBOX_TENANT_POOLS),
  pool resolution from the *authenticated user's* org (never a request field),
  and a reconciler that clones/installs/starts each unbound pool pod and
  refreshes it — which is also its clone-credential refresh.
- daemon: 'claimed' now flips only for a config carrying a user identity, so
  the housekeeper's idle sweep leaves warm pods alone; env-change restarts dev.
- chart: tenantPools renders a SandboxWarmPool per entry (shared template).
- optional GitHub push webhook makes a refresh immediate; without it pools
  still refresh on their own schedule.
…und re-check

Two blockers against the spec:

- The reconciler reads each SandboxWarmPool's status.selector, but the runner
  Role never granted sandboxwarmpools. A 403 isn't swallowed (only 404 is), so
  every tick threw and no pod was ever warmed — while the pool still cost N
  pods. Grant `sandboxwarmpools: get`.
- The pool listing is a snapshot and warming walks it pod by pod over minutes.
  A claim binding a pod mid-walk got `setup/clone` — a hard reset over a live
  working tree. Re-read the pod's labels before the config post and before the
  clone step, per the spec's own mitigation. Errs closed: an unreadable pod
  counts as bound.
…ap recover, emit depth

- The chart suffixes every object with envName because releases share
  agent-sandbox-system; tenantPools wrote a raw literal, so staging and prod
  would fight over one SandboxWarmPool. Validate the -<envName> suffix rather
  than derive it — the string stays identical on both sides, and a collision
  fails at template time. Also require size >= 1.
- The 3-failure cap is per-replica in-memory, so a transient port-forward or
  mint failure permanently blackholed a slot until the pod restarted. Back off
  30 min, then try once more.
- Add studio.sandbox.pool.pods{pool,org,state} (ready/bound/pending/failed).
  Sustained ready=0 is the only signal that separates a working pool from N
  pods of pure cost; both silent failure modes produce it.
The housekeeper test found a real bug: `\(true\|false\)` is a GNU BRE
extension, so on any sed without it the `claimed` capture yields nothing —
which reads as claimed and reaps the warm pod 15 minutes after Studio warms it.
That branch had no test at all; the script now has a source-only seam and the
sweep's classification is exercised against a real HTTP daemon.

Also: the claim's warmpool field (resolved pool / default / none) and the push
matcher are extracted as pure functions and tested — the first is the single
line that binds a tenant pod and had no coverage. Daemon e2e gains the two
cases the spec lists and the first pass skipped: a same-repo branch change
after bootstrap keeps its deps, and a different-repo config is refused.

Not covered: refresh coalescing (dirtyPools is Set semantics behind a live
provider — a harness for it would test the Set, not the behaviour).
Env var is STUDIO_SANDBOX_TENANT_POOLS, not SANDBOX_TENANT_POOLS. Record the
webhook's actual route, that `size` lives chart-side and the pool name must
carry the -<envName> suffix, the one RBAC verb this needs, the failure cap's
backoff, and the depth gauge. Drops the test bullet for a sandboxTemplateRef
swap v1 doesn't do, and says plainly what isn't covered.
@pedrofrxncx
pedrofrxncx merged commit 5329b7c into main Aug 6, 2026
@pedrofrxncx
pedrofrxncx deleted the feat/sandbox-tenant-warm-pools branch August 6, 2026 23:34
decocms Bot pushed a commit that referenced this pull request Aug 6, 2026
PR: #5830 feat(sandbox): tenant warm pools — pods already running the dev server
Bump type: minor

- decocms (apps/api/package.json): 4.190.4 -> 4.191.0
- @decocms/native (apps/native/package.json): 4.190.4 -> 4.191.0
- @decocms/sandbox (packages/sandbox/package.json): 1.42.0 -> 1.43.0
- deploy/helm/sandbox-env (chart 0.12.1) (deploy/helm/sandbox-env/values.yaml deploy/helm/sandbox-env/Chart.yaml): image.tag/appVersion -> 1.43.0

Deploy-Scope: server
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