Fleet configuration
Keep sites, provider accounts, pools, quotas, offers, flavors and sandbox templates in one reviewed FleetConfig document; plan, apply, catch drift.
The shape of a platform changes rarely and matters a lot: which sites exist, which pools they hold, how much of each pool its owner keeps, which tenants each pool is offered to and with what quota, and which VM sizes are sold. Made through one-off API calls, those changes have no review, staging and production slowly diverge, and nobody notices when someone edits a pool by hand during an incident.
treats that shape as code, the way Better IAM's configuration as code treats an access model. The fleet is one document keyed by name. You export it once, keep it in version control, review changes as pull requests, plan them, and apply them in one transaction. A reconciler re-plans the applied document every 15 minutes and raises an event when the fleet from it.
Every fleet method decides compute:fleet:admin on compute-fleet/global, a scope that exists only in the root
tenant, so FleetConfig is a tool for platform operators. fleet.apply also decides every call it makes, as the
caller.
Turn it on
import { fleetModule } from 'better-compute/fleet';
import { betterCompute } from 'better-compute/server';
import { vps } from 'better-compute/vps';
export const compute = betterCompute({
// database, secret, iam, baseURL ...
modules: [vps(), fleetModule()],
});fleetModule({ driftEvery }) takes one option, how often the drift check runs (default 15m). It adds the
migrations fleet/0001_names (table fleet_names) and fleet/0002_applied (table fleet_applied), and the
fleet.drift . Flavors, provider accounts and sandbox templates are served by
the vps module; without it (or, for templates, without its v0.5 migrations), fleet.export leaves them out and
reports them in missing, and a document's flavors and templates plan as blocked.
The document
A document has version: 1 and any of five lists. References between items are names from the same document or
objects fleet already manages. Secrets are references only (secret://, vault:// or env://), never values, so
the document is safe to commit.
{
"version": 1,
"sites": [{ "name": "fra", "kind": "shared", "region": "eu-central" }],
"providerAccounts": [
{
"name": "cloud",
"provider": "hetzner-cloud",
"config": { "token": "secret://hetzner", "location": "fsn1" }
}
],
"pools": [
{
"name": "vps-fra",
"site": "fra",
"class": "vps-host",
"gpuModel": "L40S",
"policy": { "disruptionBudget": 0.1 },
"ownerQuota": { "gpu": 8 },
"offers": [{ "tenant": "acme", "quota": { "vcpu": 64, "gpu": 2 }, "priority": 5 }]
},
{ "name": "burst", "site": "fra", "class": "provider", "providerAccount": "cloud" }
],
"flavors": [{ "pool": "vps-fra", "name": "g1.small", "vcpu": 2, "memoryGiB": 4, "diskGiB": 40 }],
"sandboxTemplates": [
{
"name": "py-small",
"pool": "vps-fra",
"flavor": "g1.small",
"imageId": "ubuntu-24.04",
"env": { "PIP_INDEX_URL": "secret://root/sec_01k…" },
"network": "private",
"warm": { "min": 2, "max": 4 }
}
]
}Prop
Type
See sites.
Validation rejects unknown fields, a version other than 1, lists over 1000 items, repeated names (flavors repeat
by pool and name, offers by tenant) and malformed values, with INVALID_INPUT and the offending details.path.
Quota keys are resource names (a letter, then up to 63 letters, digits, ., _ or -) with integer maxima of 0
or more.
Names instead of ids
Sites and pools have no name of their own, so fleet keeps the names it gave them in fleet_names. Provider accounts
and sandbox templates use their name, flavors are named <pool>/<name>, offers <pool> -> <tenant>, and owner
quotas <pool>:<resource>. An exported template names a pool offered to the tenant by its id, since the pool is not
the tenant's.
- Objects fleet did not create appear in an export under their id (
pool_01...). Keep those names: the plan matches by name, so a new name plans a new object. - Created objects get deterministic ids. The idempotency key is
fleet:<tenant>:<kind>:<name>, and the id derives from it and the caller, so an apply retried after a failure converges instead of creating duplicates.
Plan
fleet.plan (a read) diffs a document against the tenant's current objects and changes nothing. It returns:
changes: one entry per item withkind,name,action(create,update,deleteorunchanged), the differingfields, andblockedornotewhen they apply;calls: the exact API callsfleet.applywould make, in order;summary: counts per action, andblockedwhen any change cannot be applied;planHash: SHA-256 over the canonical call list.
What each kind can do:
| Kind | Create | Update | Delete, only with prune | Cannot change |
|---|---|---|---|---|
| Provider account | providerAccounts.create | providerAccounts.update (the listed config fields) | providerAccounts.delete, once no remaining pool uses it | provider |
| Site | sites.create | Never | Never | kind, region, iamRegion |
| Pool | pools.create, with its owner quota | pools.update (policy, gpuModel) | pools.archive: running reservations keep their bookings | site, class, sharing, agent, providerAccount |
| Owner quota | quotas.set | quotas.set | No | |
| Offer | pools.offer | pools.offer (quota, includeDescendants, priority) | pools.unoffer | |
| Flavor | flavors.create | flavors.update (vcpu, memoryGiB, diskGiB, gpus, offerId, placement) | flavors.delete | |
| Sandbox template | sandboxTemplates.create | sandboxTemplates.update (flavor, imageId, userData, defaultTtlSeconds, maxTtlSeconds, network, env, warm, volumes) | sandboxTemplates.delete: its warm instances are deleted, running sandboxes keep their TTL | pool, runtime |
A change is blocked when it touches a field that cannot change, names an unknown site, pool or provider account,
offers a pool to its own owner, or lists flavors or sandbox templates on a deployment without them (a template then
reads this deployment does not run sandboxes (vps v0.5)). Fix the document, or create a new
object instead of changing a fixed field. Without prune, items the document leaves out are left alone, so you can
adopt FleetConfig one list at a time.
Calls run in dependency order: provider account creates and updates, site creates, pool creates, pool updates and quotas, offers, flavor creates and updates, sandbox template creates and updates, template deletes, flavor deletes, unoffers, pool archives, and provider account deletes.
Apply
fleet.apply takes the same input plus an optional planHash, and runs the plan through the owning modules' own
methods. It is a composite method (transaction: 'manual'): it decides each call with ctx.check while no
transaction is open, then runs them with ctx.invoke in one transaction. A host without composite methods answers
UNSUPPORTED; the standard server has them (see methods).
Plan and compare
Fleet plans the document. A blocked plan fails with INVALID_STATE and the blocked changes in details.blocked.
If you passed a planHash and the plan no longer has it, apply fails with CONFIG_DRIFT (409). A plan with no
calls only records the document and returns applied: 0.
Decide every call
Each call is decided for the caller, one IAM decision per call, with no transaction open. A single denial fails the
apply with ACCESS_DENIED before anything is written.
Run everything in one transaction
Inside one transaction fleet plans again. If the call list changed while IAM was deciding, nothing is applied and
the apply fails with CONFIG_DRIFT. Otherwise every call runs, created names go into fleet_names, and the
document is recorded as the tenant's applied document.
The caller therefore needs compute:fleet:admin and the permission of every call the plan makes:
| Calls | Action |
|---|---|
sites.create | compute:sites:manage |
pools.create | compute:pools:create |
pools.update, pools.archive | compute:pools:manage |
pools.offer, pools.unoffer | compute:pools:share |
quotas.set | compute:quotas:manage |
flavors.create, flavors.update, flavors.delete | compute:flavors:manage |
providerAccounts.create, providerAccounts.update, providerAccounts.delete | compute:providers:manage |
sandboxTemplates.create, sandboxTemplates.update, sandboxTemplates.delete | compute:sandboxes:manage |
Every call writes its own audit row, and the apply writes one more after it succeeds. The result is
{ plan, applied }, the number of calls that ran.
const { config } = await compute.api.fleet.export(credential, { tenantId: 'root' });
const plan = await compute.api.fleet.plan(credential, { tenantId: 'root', config, prune: true });
console.log(plan.summary, plan.changes.filter((change) => change.action !== 'unchanged'));
await compute.api.fleet.apply(credential, {
tenantId: 'root',
config,
prune: true,
planHash: plan.planHash, // refuse with CONFIG_DRIFT if the fleet moved since the review
});Drift
Every apply, including one with nothing to change, records the document it applied. The fleet.drift reconciler
runs its key all every driftEvery, which queues one tenant:<id> key per tenant with an applied document; each
tenant is then re-planned in its own transaction, so one tenant's failure never blocks another.
A non-empty call list, or a blocked plan, means someone changed the fleet outside FleetConfig:
fleet.driftedis published once per distinct drift, with the appliedplanHash, the summary,blocked, and up to 50 changes;fleet.convergedis published when the drift clears on its own;fleet.statusshows the drift as of the last check.
Both events have the subject fleet/<tenant> and reach webhooks like any other.
Applying the document again resets the drift.
{
"tenantId": "root",
"applied": {
"planHash": "3f1c...",
"appliedBy": "usr_ops",
"appliedAt": 1790251200000,
"prune": true,
"checkedAt": 1790252100000
},
"drift": {
"changes": [{ "kind": "pool", "name": "vps-fra", "action": "update", "fields": ["policy"] }],
"summary": { "create": 0, "update": 1, "delete": 0, "unchanged": 5 },
"blocked": false,
"planHash": "9ab0..."
}
}A tenant that never applied a document gets only { tenantId }. The console's Fleet drift page (platform
administrators) shows the same: who applied last, when it was checked, and the drifted changes by kind.
From the CLI
The four fleet commands act as BETTER_COMPUTE_TOKEN, through the configuration or against --url. They read
and write JSON; the server validates the document.
better-compute fleet export --tenant root --out fleet.json # refuses to overwrite a file
better-compute fleet plan fleet.json --tenant root # --prune, --calls
better-compute fleet apply fleet.json --tenant root --yes
better-compute fleet status --tenant rootfleet plan prints one line per change that does something, then the counts:
~ pool vps-fra (policy)
~ ownerQuota vps-fra:gpu (max)
+ offer vps-fra -> globex
+ flavor vps-fra/g1.large
2 to create, 2 to update, 0 to delete, 5 unchanged.fleet applyplans and prints the changes. Without--yesit stops there and prints the--plan-hashto apply that exact plan later. With--yesit applies what it just planned; with--plan-hashit applies a plan reviewed earlier, and fails withCONFIG_DRIFTif the fleet moved.- A blocked plan exits
PLAN_BLOCKEDfromplanandapply. --fail-on-driftmakesfleet planexitFLEET_DRIFTEDwhen any call would run, andfleet statusexitFLEET_DRIFTEDwhile the recorded drift is set.
These codes exit with status 1 (usage errors exit 2). When nothing would change, fleet apply without
--plan-hash does not call fleet.apply, so it does not re-record the document; pass --plan-hash to make an
unchanged document the new drift baseline. See the CLI reference.
In CI
Plan on every pull request and fail when production no longer matches the reviewed file; apply on merge:
- name: Production matches the reviewed document
run: better-compute fleet plan fleet.json --tenant root --fail-on-drift
env:
BETTER_COMPUTE_URL: https://compute.example.com
BETTER_COMPUTE_TOKEN: ${{ secrets.BETTER_COMPUTE_TOKEN }}
- name: Apply the reviewed document
if: github.ref == 'refs/heads/main'
run: better-compute fleet apply fleet.json --tenant root --yes
env:
BETTER_COMPUTE_URL: https://compute.example.com
BETTER_COMPUTE_TOKEN: ${{ secrets.BETTER_COMPUTE_TOKEN }}Give the pipeline an API key of a service account in the root tenant that holds compute:fleet:admin and the call
permissions above, and nothing else. Between runs, schedule fleet status --fail-on-drift or subscribe to
fleet.drifted to hear about hand edits the same day.
Better Compute is created by Sean Filimon
Last updated
Commands
The durable command queue from an operator's view, with commands.run, kinds and lanes, deadlines, safety flags, signing, cancellation and results.
Rolloutsexperimental
Register offline-signed bcd releases and roll a daemon version out in waves within each pool's disruption budget, with a failure gate, pause and abort.