better/compute

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

compute.ts
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.

fleet.json
{
  "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 with kind, name, action (create, update, delete or unchanged), the differing fields, and blocked or note when they apply;
  • calls: the exact API calls fleet.apply would make, in order;
  • summary: counts per action, and blocked when any change cannot be applied;
  • planHash: SHA-256 over the canonical call list.

What each kind can do:

KindCreateUpdateDelete, only with pruneCannot change
Provider accountproviderAccounts.createproviderAccounts.update (the listed config fields)providerAccounts.delete, once no remaining pool uses itprovider
Sitesites.createNeverNeverkind, region, iamRegion
Poolpools.create, with its owner quotapools.update (policy, gpuModel)pools.archive: running reservations keep their bookingssite, class, sharing, agent, providerAccount
Owner quotaquotas.setquotas.setNo
Offerpools.offerpools.offer (quota, includeDescendants, priority)pools.unoffer
Flavorflavors.createflavors.update (vcpu, memoryGiB, diskGiB, gpus, offerId, placement)flavors.delete
Sandbox templatesandboxTemplates.createsandboxTemplates.update (flavor, imageId, userData, defaultTtlSeconds, maxTtlSeconds, network, env, warm, volumes)sandboxTemplates.delete: its warm instances are deleted, running sandboxes keep their TTLpool, 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:

CallsAction
sites.createcompute:sites:manage
pools.createcompute:pools:create
pools.update, pools.archivecompute:pools:manage
pools.offer, pools.unoffercompute:pools:share
quotas.setcompute:quotas:manage
flavors.create, flavors.update, flavors.deletecompute:flavors:manage
providerAccounts.create, providerAccounts.update, providerAccounts.deletecompute:providers:manage
sandboxTemplates.create, sandboxTemplates.update, sandboxTemplates.deletecompute: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.

Plan, review, apply exactly that plan
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.drifted is published once per distinct drift, with the applied planHash, the summary, blocked, and up to 50 changes;
  • fleet.converged is published when the drift clears on its own;
  • fleet.status shows 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.

fleet.status result
{
  "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 root

fleet 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 apply plans and prints the changes. Without --yes it stops there and prints the --plan-hash to apply that exact plan later. With --yes it applies what it just planned; with --plan-hash it applies a plan reviewed earlier, and fails with CONFIG_DRIFT if the fleet moved.
  • A blocked plan exits PLAN_BLOCKED from plan and apply.
  • --fail-on-drift makes fleet plan exit FLEET_DRIFTED when any call would run, and fleet status exit FLEET_DRIFTED while 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:

.github/workflows/fleet.yml (excerpt)
- 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.

Was this page helpful?

Better Compute is created by Sean Filimon

Last updated

On this page