feat(sandbox): per-(org, repo) size tiers - #5361
Open
pedrofrxncx wants to merge 2 commits into
Open
Conversation
nicacioliveira
approved these changes
Jul 29, 2026
Sandbox pods were one fixed size for every tenant: 500m/2Gi request, 2/4Gi limit. Repos that need more than 4Gi OOM, and the only lever was raising the limit for the whole fleet. The sandbox-env chart now renders one SandboxTemplate per entry in `values.tiers` (small / medium / large ship by default), each owning its own resources, emptyDir size limits, and placement — nodeSelector, tolerations, affinity, topology spread, podLabels, podAnnotations, warmPoolSize — so a tier is also where "this one runs on on-demand capacity only" or a per-tier cost label goes. Every tier is rendered from the same template body, so they can't drift in security posture, netinit, or org-fs wiring. `defaultTier` renders at the unsuffixed name, so existing claims and an existing STUDIO_SANDBOX_TEMPLATE_NAME keep resolving and the default tier's rendered pod spec is unchanged apart from a new `studio.decocms.com/tier` label (verified by diffing the render against main). Adding tiers to a running environment is a pure addition. Studio picks a tier per (org, repo) from STUDIO_SANDBOX_TIER_MAP — overrides only, keyed `<orgSlug>/<owner>/<repo>` or `<orgSlug>`, most specific first. Deliberately not per-agent metadata: that blob is writable by any org member, so a tenant could assign itself the largest tier, and the default has to stay the cheapest option for tiers to be priceable. Malformed config degrades to "no overrides" rather than throwing — this gates provisioning for every org. The tier stays opaque outside the hosted provider: it rides on `EnsureOptions.tier` in the runner-agnostic contract, resolution lives in apps/api, and only the agent-sandbox runner knows a tier maps to a SandboxTemplate name. Runners without tiering ignore it. Notes: - `app.kubernetes.io/name` is deliberately NOT tier-suffixed — the housekeeper's podSelector keys off it, so tiering it would drop the other tiers out of idle reaping. - A warm pool is rendered per tier even at size 0: claims always carry `warmpool: "default"`, so the pool must exist for the reference to resolve, and an empty one falls through to a cold render. - Tier names are validated for shape on both sides, but Studio can't enumerate the chart's tiers — an unknown name fails claim creation for that (org, repo) only, so the two sides move together. - A tier is frozen for a sandbox's lifetime (the claim handle can't encode it), so a re-assignment applies on the next cold provision. - The chart rejects `studio.decocms.com/*` keys in a tier's podLabels/podAnnotations: that prefix is the runner's additionalPodMetadata namespace, and the operator rejects claims that redefine a key the template already set.
pedrofrxncx
force-pushed
the
feat/sandbox-size-tiers
branch
from
July 29, 2026 19:49
ce7e0ec to
b91ceb7
Compare
…rash
16 SANDBOX_START tests died on "undefined is not an object (evaluating 'map[`${org}/${owner}/${name}`]')". resolveSandboxTier indexed the map unconditionally, and the tests' getSettings() stub is a minimal partial with no sandboxTierMap.
Fixed in the lookup rather than the stub, because the stub isn't the bug: this is an override-only map, so absent already means "no overrides, take the provider's default" - identical to an empty map. Tier resolution sits on the provisioning path ahead of runner.ensure, so throwing over a setting whose whole meaning is "may be unset" fails the provision outright. Widening the mock instead would leave that landmine for the next settings field.
Test asserts the absent-map case directly.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Sandbox pods are one fixed size for every tenant — 500m/2Gi request, 2/4Gi limit. A repo that needs more than 4Gi OOMs, and today the only lever is raising the limit for the whole fleet, which we pay for on every pod.
Note the OOM is on the limit, not the request. Each tier moves both:
values.yamlalready records that raising only the limit is what produced prod's node memory-pressure evictions.What
Chart — one
SandboxTemplateper entry invalues.tiers(small/medium/largeship by default), rendered from the same template body so tiers can't drift in security posture, netinit, or org-fs wiring. A tier owns resources and placement:nodeSelector,tolerations,affinity,topologySpreadConstraints,podLabels,podAnnotations,warmPoolSize, each wholesale-overriding the top-level value of the same name. That's the seam for "this tier runs on on-demand capacity only" (snippet included, commented — the labels need a NodePool that offers it) and for per-tier cost labels.defaultTierrenders at the unsuffixed name, so existing claims and an existingSTUDIO_SANDBOX_TEMPLATE_NAMEkeep resolving. Adding tiers to a running environment is a pure addition.Studio — the tier is picked per (org, repo) from
STUDIO_SANDBOX_TIER_MAP, overrides only, keyed<orgSlug>/<owner>/<repo>or<orgSlug>, most specific first:Deliberately not per-agent metadata: that blob is writable by any org member, so a tenant could assign itself the largest tier. Assignment stays operator-controlled and the default stays the cheapest option, which is what makes tiers priceable. No UI — that's the follow-up when the list outgrows an env var (
ponytail:comment names the upgrade path: a column onorganization_settingsplus an/api/_admintool).The tier stays provider-agnostic: it rides on
EnsureOptions.tierin the runner-agnostic contract, resolution lives inapps/api/src/sandbox/tier.ts, and only the agent-sandbox runner knows a tier maps to a SandboxTemplate name. Runners without tiering ignore it.Decisions worth reviewing
app.kubernetes.io/nameis NOT tier-suffixed. The housekeeper'spodSelectorand anytopologySpreadConstraintslabelSelector key off it, so tiering it would silently drop the other tiers out of idle reaping. Tier gets its ownstudio.decocms.com/tierlabel, which also joins cAdvisor pod metrics to a tier for cost attribution.size: 0. Claims always carrywarmpool: "default", so the pool must exist for the reference to resolve; an empty one falls through to a cold render — the same path a fully-claimed pool already takes in prod. The existing HPA stays scoped to the default tier.values.emptyDirSizeLimits, previously hardcoded 4Gi/1Gi/5Gi). Alargetier with a 40Gi ephemeral-storage ceiling but a 4Gi workdir cap would just fail on disk instead of memory.sandbox-proxy.tsderives the handle from the URL and has no tier context, so encoding it would 404 every proxy call. A re-assignment applies on the next cold provision; to resize now,SANDBOX_DELETE. The tier rides in the persistedensureOpts, so resurrection and autonomous recovery re-ensure at the same tier.tierskey fails claim creation for that (org, repo) only — the two sides have to move together. Called out in the chart README and both code comments.STUDIO_SANDBOX_TIER_MAPdegrades to "no overrides" instead of throwing: it gates provisioning for every org, so a typo in one env var must not take the fleet's sandboxes down. Bad individual entries are dropped with a warning naming them.studio.decocms.com/*keys in a tier's podLabels/podAnnotations. That prefix is the runner'sadditionalPodMetadatanamespace and the operator rejects claims redefining a key the template already set — every claim on that tier would fail. Banning the prefix rather than a key list keeps it in sync with the runner without duplicatingLABEL_KEYS.Testing
helm templateforstudio-sandbox-stagingagainstmain: identical apart from the chart-version label and the two newstudio.decocms.com/tier: "small"labels. Resources, volumes, sizeLimits, netinit, org-fs all byte-identical.medium/large: resources, emptyDir sizeLimits, and per-tier podLabels/podAnnotations land where expected.defaultTiernot intiers, emptytiers, non-DNS-label tier name, reservedstudio.decocms.com/*pod label and annotation.warmPool.size=5, default tier gets 5 and medium/large get an explicit 0 (useshasKey, notdefault—defaulttreats 0 as empty and would have leaked the global size).tier.test.ts(precedence, repo-key-without-org must not match, case-insensitivity),template-name.test.ts(unsuffixed default, suffixing, 8 malformed inputs fall back), plus 5 newresolve-configcases for the env-var parse.helm lint,bun run check,bun run lint(0 errors, same 16 pre-existing warnings as main),bun run fmt:check, and the sandbox-provider + settings + sandbox test files (232 pass) all clean.Rollout
Chart upgrade is additive and prod-inert, but the chart is consumed by a pinned
targetRevisionin deco-apps-cd — that needs a bump to0.10.0or nothing ships. Order: chart →targetRevision→STUDIO_SANDBOX_TIER_MAP. Verifymedium/largeend-to-end on staging before assigning a real tenant.Follow-ups
largetier's spot opt-out (infra, not this chart).Summary by cubic
Introduce per-(org, repo) sandbox size tiers to stop OOMs without raising limits for everyone. The chart renders per-tier SandboxTemplates; Studio assigns tiers via
STUDIO_SANDBOX_TIER_MAP, and untiered sandboxes keep using the existing default.New Features
SandboxTemplatepervalues.tierswith per-tier resources, placement,warmPoolSize, andemptyDirSizeLimits;defaultTierrenders unsuffixed; adds astudio.decocms.com/tierlabel on templates, warm pools, and pods; tier validations added.STUDIO_SANDBOX_TIER_MAP(lowercases keys, drops invalid entries, malformed JSON or absent map → no overrides), resolves overrides (<orgSlug>/<owner>/<repo>><orgSlug>; repo-less uses org-wide), and passesEnsureOptions.tier; the agent-sandbox runner maps tiers to<template>-<tier>and falls back on invalid input; runners without tiering ignore it.Migration
sandbox-envchart to0.10.0and update the CDtargetRevision.STUDIO_SANDBOX_TIER_MAP(JSON) inconfigMap.meshConfig; keys are case-insensitive; unlisted repos usedefaultTier.defaultTieras the cheapest option.Written for commit 8033b9f. Summary will update on new commits.