better/compute

Local development

Run a whole control plane on your laptop with better-compute dev, join simulated GPU machines with --sim, and run the deterministic fleet simulator.

better-compute dev runs the API and every worker role in one process on 127.0.0.1, with no setup: an embedded PostgreSQL (PGlite), a generated deployment secret and migrations on start. --sim N adds a gateway and N simulated GPU machines that join the way the real daemon does, so reservations, commands, liveness and tunnels all run end to end on your laptop. better-compute sim is the other tool: a seeded, deterministic fleet simulation that checks the fleet invariants and prints a digest you can reproduce.

Simulated machines, real control plane

The simulated machines are FakeDaemons from @better-compute/testing: the daemon side of the protocol in TypeScript. Everything above the daemon is the real code: JoinService, the gateway, the runtime, the reconcilers, capacity and Better IAM. The dev gateway authenticates sessions by a header over cleartext HTTP/2 and listens on loopback only; never expose it. The Go daemon bcd is built and tested but not released, and better-compute dev has no flag to join it; the dev stack in code can (see a real bcd).

Start a server

better-compute dev                 # PGlite in .better-compute/dev-db, kept between runs
better-compute dev --memory        # a throwaway in-memory database
better-compute dev --memory --sim 8

dev always migrates first, then serves the API under /api/compute and runs the worker roles controllers, scheduler, sinks and iam. It stops on SIGINT or SIGTERM, releasing its leases.

FlagDefaultMeaning
--host HOST127.0.0.1The address to listen on
--port PORT3000 (env PORT)The port; 0 picks a free one
--data DIR.better-compute/dev-dbWhere PGlite keeps the database, when there is no configuration
--memoryoffA throwaway in-memory database (pglite:memory)
--config PATHdiscovered (env BETTER_COMPUTE_CONFIG)The configuration file
--sim N0Join N simulated machines, 0 to 1000; needs @better-compute/testing
--sim-variant VARIANThgx-h100Hardware of the simulated machines, see below

Without a configuration (and without BETTER_COMPUTE_DATABASE_URL), dev builds its own deployment: PGlite in --data, and a generated secret in .better-compute/dev-secret (owner-only), so repeated runs keep their data. BETTER_COMPUTE_BASE_URL defaults to http://<host>:<port>.

With a configuration file, dev uses that deployment. --memory then sets BETTER_COMPUTE_DATABASE_URL to pglite:memory for the run, which the examples and the configuration template read. The host export's HTTP routes and metrics are mounted; its gateway role stays off in dev.

Either way the CLI adds retentionModule() from @better-compute/postgres unless the configuration registers a retention module itself. See retention.

IAM. A deployment without Better IAM configured answers only public methods. better-compute dev --sim N replaces the missing IAM with an embedded one; plain dev does not. See Better IAM.

PGlite

PGlite is PostgreSQL 18 compiled to WebAssembly, with btree_gist. It is one connection, so the executor serializes transactions behind a mutex. PGlite has no autovacuum, so the executor runs ANALYZE after migrate() and then every analyzeEveryMs (default 60,000) to keep query plans close to Postgres's. It is the database of dev, the tests and the simulators, not of production; see database.

Simulated machines

--sim N joins N machines to the dev server's own control plane. Nothing is mocked above the daemon:

Local keys and IAM

dev creates throwaway JoinService keys and a node CA (createDevJoinKeys(), the dev equivalent of better-compute pki init), overriding any configured pki.join. Without configured IAM it starts an embedded, in-memory Better IAM (createEmbeddedTestIam() from @better-compute/iam/testing, on better-iam's SQLite adapter) with compute's plugin and resolver, and prints its root administrator's token. With configured IAM, BETTER_COMPUTE_TOKEN must hold a root administrator's token (MISSING_ENV otherwise).

A loopback gateway

A real gateway starts on cleartext HTTP/2 at 127.0.0.1 on a free port, wired to the control plane's gateway services, work queue, outbox and report handlers. Sessions authenticate by the x-bc-test-node header, and only for node ids that joined through this fleet and whose certificate is not revoked. Pokes after each commit reach the gateway in process (devFleetNotifier()).

A site, a pool and a join token

As the root administrator, dev creates a shared site in region dev-1 and a metal-host pool with diag.nvml acceptance (and an expected GPU count for the H100 and L40S variants), then mints one join token with maxUses: N.

Join, session, acceptance, adoption

Each machine seals the token to JoinService's key and binds it to its CSR, calls the Join RPC through the gateway, verifies the signed JoinResponse against the pinned keys, then opens its Session stream. The runtime runs diag.nvml, adopts the node and pushes its NodeSpec. dev reports adoption once every node has applied a NodeSpec of generation 1 or later.

--sim-variantMachinesPool
hgx-h1008x H100 SXM5 80GBGPU model H100, expects 8 GPUs
hgx-h100-gpu-missingthe same board with a GPU missingexpects 8 GPUs, so acceptance fails and they are never adopted
l40s4x L40SGPU model L40S, expects 4 GPUs
cpuno GPUsno GPU model, no GPU count check

Standard error shows the progress (from examples/dev):

better-compute listening on http://127.0.0.1:3000/api/compute
Dev gateway (h2c, header auth, loopback only): http://127.0.0.1:52011
Embedded IAM root tenant ten_…; root token (dev only): biam_ses_…
Joined 8 simulated hgx-h100 node(s) into pool_…
8 simulated node(s) adopted

The embedded IAM lives in memory for this run only, and its root token is a development credential.

Look around

In a second terminal, as the root administrator (see the CLI):

export BETTER_COMPUTE_URL=http://127.0.0.1:3000
export BETTER_COMPUTE_TOKEN=biam_ses_…   # the root token dev printed
export BETTER_COMPUTE_TENANT=ten_…       # the root tenant dev printed
better-compute nodes list
export POOL=pool_…                        # the pool dev created (pools list)
better-compute pools offer $POOL --to $BETTER_COMPUTE_TENANT --quota gpu=64
better-compute reserve --pool $POOL --gpus 4 --for 1h
source <(better-compute completion bash) # optional: tab completion for every command

The reservation binds through the node's desired state and turns active once the simulated daemon reports AllocationBound; its CDI device names are in the reservation's binding. With a configuration that mounts the events module's GET /watch (the examples do), better-compute events watch --types "node.*,reservation.*" prints the tenant's events one JSON line each (curl -N on the same route shows the raw stream); cordon a node in another terminal (better-compute nodes cordon node_… --reason demo) to see node.cordoned arrive. This is the walkthrough of examples/dev/README.md (786cc80).

Tunnels

With --sim, dev issues exec tickets (the configured tunnels.issuer, or a throwaway one) and mounts GET /api/compute/tunnels/{ticket}. The gateway verifies tickets and serves its control API on loopback, and every simulated machine answers OpenTunnel with an echo shell, so tunnels work end to end: better-compute console --node node_… opens a session on a simulated machine, where size prints your terminal size, exit ends it with status 0, false with 1, and Ctrl-] detaches (tests/cli-console.test.ts). Unless the deployment's tunnels options bring their own, it also adds a throwaway break-glass SSH CA (tunnels.ssh, for nodes.issueSshCert and better-compute ssh-cert) and a separate throwaway sandbox SSH CA (tunnels.sandboxSsh), so both can be tried against the simulated fleet. It adds these throwaway CAs only on a PGlite store: against a real database every node's spec would start trusting a CA that dies with the process, so there dev --sim uses only the CAs the deployment configures. With the embedded IAM, an open tunnel is not re-authorized every 60 seconds: the embedded test IAM has no session reviewer. tunnels.open still decides every ticket.

The dev stack in code

The same pieces are exported from @better-compute/testing for your own hosts and tests.

ExportWhat it does
createDevJoinKeys({ trustDomain? })A node CA and JoinService keys (pki, for betterCompute({ pki })), the pins a daemon checks and what the agent half needs. Default trust domain compute.dev.local.
devFleetNotifier()A Notifier for betterCompute({ notifier }) that turns gw:<id> publishes into gateway.poke(nodeIds) in process.
attachDevFleet(options)Adds a loopback h2c gateway and simulated machines to a running control plane. Demands insecureHeaderAuth: true.
createDevStack(options?)All of it on PGlite in one call: control plane, workers, gateway, tunnels, retentionModule().
DEV_ROOT_TOKENbc_dev_root: the token createDevStack()'s default fake IAM accepts as a root administrator (it allows everything).
dev-stack.test.ts
import { afterEach, expect, it } from 'vitest';
import { createDevStack, type DevStack } from 'better-compute/testing';

let dev: DevStack | undefined;
afterEach(() => dev?.close());

it('adopts three simulated machines', async () => {
  dev = await createDevStack();
  const { poolId } = await dev.createPool({ expectGpuCount: 8 });
  const nodes = await dev.join({ poolId, count: 3 }); // sealed-token Join, then a Session each
  await dev.waitFor(nodes); // adopted, NodeSpec generation 1 or later applied
  expect(dev.daemons.size).toBe(3);
});

A DevFleet has createPool({ gpuModel?, key?, expectGpuCount? }), join({ poolId, count?, variant?, seed? }) (1 to 10,000 machines on one token), joinSite({ siteId, count?, kinds?, seed? }) for site agents (1 to 16, through siteTokens.create), waitFor(nodeIds, { provisionState?, timeoutMs? }) (default adopted, 30 s), the running daemons, the gatewayUrl, and close(). createDevStack() takes database, iam, modules, baseURL, secret, trustDomain, worker (default pollMs 100), region, gateway and daemon options, and signedIntent: true (5071dbc), which gives the stack a fresh command-signing root (pki.commandSigning), so every command and spec is signed and joining daemons receive the roots in JoinResponse.trust.

A real bcd

Two options let a daemon outside the process, such as a real bcd, join the dev stack:

OptionWhat it adds
externalTls: { host?, port?, dnsNames?, servingCa? }A second gateway listener authenticated as in production: HTTP/2 over mTLS, a serving certificate from a dev serving CA (servingCaCertificate, what the daemon pins; names localhost and gw.dev.local by default), client certificates checked against the dev node CA. Its URL is externalGatewayUrl. Its port (a free one unless given) and serving CA are chosen before the control plane starts and published in /.well-known/better-compute as gateway (https://<host or 127.0.0.1>:<port>, serverName the first DNS name, and the CA's pin). Safe to bind beyond loopback
admitExternalJoins: trueOn the loopback h2c listener, also accepts the x-bc-test-node header of nodes that joined on their own, once their current certificate is in node_certs (polled every 250 ms, or admit(nodeId) at once). bcd reaches it only as a fakehw build with dev_insecure_h2c = true

For externalTls, write a config.toml with gateway_url set to the listener, serving_ca_spki_pin from servingCaCertificate, the join pins from pins, and gateway_server_name = "gw.dev.local" when you dial an IP address; put pins.joinEncryptionJwk next to it as join-encryption.jwk. Then pipe a join token into bcd join and start bcd node. tests/e2e/bcd.test.ts does exactly this from WSL; see testing. Since the stack publishes its gateway, a machine without any config.toml can also join with bcd join --server <the stack's base URL>, which takes the gateway from the well-known document: a host build also needs --serving-ca-pin, --join-signing-pin and --join-encryption-pin (from servingCaCertificate and pins), while a fakehw build may join with --server alone, taking the pins from the document on trust (and accepts an http:// server); see joining from the well-known document.

For many real daemons at once, a fakehw build runs bcd sim --config <that config.toml> --nodes N with one join token allowing N uses on standard input: N node agents in one process, each a distinct fakehw machine with its own identity under data_dir/sim/<i> (see bcd sim).

Two fakehw behaviours help when you test placement against a real bcd:

  • Reserved huge pages show in the inventory (cd7a311). The host units write under the fake root (<data_dir>/fakehw-root), and the fakehw inventory reads nr_hugepages back from there: the machine-wide pools, and each NUMA node's own file or an even share of the machine-wide pool (node 0 takes the remainder). A pool with policy.hugepages therefore books hugepages2Mi and hugepages1Gi on a fakehw node as it would on a real host.
  • The libvirt plan is written out (9a6aaf4). The fakehw runtime still runs every domain itself, but also writes the libvirt domain XML bcd would define on a real host to <data_dir>/fakehw-plans/<id>.xml (vcpupin, numatune, huge-page backing, the memory balloon, volumes by serial, GPU hostdevs), or <id>.error when libvirt would refuse the spec, and removes it on destroy. Its balloons live on disk (<runtime>.balloons), so they survive a restart. tests/e2e/bcd-placement.test.ts reads both (see testing).

The example

examples/dev is the whole deployment every example shares (examples/shared/compose.ts: provision and metal, vps, events, fleet, retention, and rollout and audit when their keys are present) with eight simulated machines:

cd examples/dev
better-compute dev --memory --sim 8     # the package's "dev" script
better-compute dev --sim 8              # "dev:persistent": data in .better-compute/example-db

The console locally

apps/console is the Next.js console (Better IAM and Better Compute on one origin). node server.mjs --dev (its dev script) runs Next.js plus the WebSocket upgrade for tunnels on http://localhost:3100. --dev is the only mode in which the console generates its own secrets under .console-data: the session secret, the exec ticket and SSH CA keys, and on first start the root administrator's password, printed once. Its compute store defaults to PGlite in .console-data/compute and its IAM store to a libSQL file. The console does not start simulated machines. See the console.

The fleet simulator

better-compute sim runs --nodes FakeDaemons on fake hardware against the in-memory gateway stub, entirely in simulated time. A steady workload runs for --duration (every 2 simulated seconds: three commands drawn from diag.nvml, inventory.refresh, logs.collect and node.reboot, a GPU booking and sometimes a release), with the faults of --chaos. The simulator then quiesces for --settle, flushes usage and checks the invariants. Nothing reads the wall clock, so the same seed prints the same digest.

better-compute sim
better-compute sim --seed nightly-42 --nodes 500 --duration 1h --chaos heavy --log sim.log
FlagDefaultMeaning
--seed SEEDsimEverything random derives from it
--nodes N50Simulated nodes, 1 to 5000
--duration DURATION10mSimulated time under workload (30s, 10m, 2h)
--settle DURATION16mQuiet time afterwards, so the sweeper and replays settle
--chaos LEVELlightnone, light or heavy
--log PATHnoneWrite the event log to a new file (fails if it exists)
ChaosLinks (both directions)Faults, drawn every
noneperfectnone
light5 ms latency, 20 ms jitter, 0.0001 drop (reset), 0.002 duplicate, 0.0005 reorder down15 s: crash 0.1, reboot 0.05, partition 0.1, reset 0.1, stall 0.05
heavy5 ms latency, 20 ms jitter, 0.0005 drop (reset), 0.01 duplicate, 0.002 reorder down5 s: crash 0.3, reboot 0.1, journal loss 0.05, partition 0.3, isolate 0.1, half-open 0.1, reset 0.2, stall 0.2, gateway kill 0.02

It checks I1 (no overlapping bookings or node-held devices), I2 (at most one execution per command; after a journal loss, in-flight commands that are not retry-safe fail instead of replaying), I3 (a delivered spec is reported observed within 60 s while the node stays reachable), I4 (no node marked unreachable within 20 s of a gateway hearing from it), I5 (no release caused by connectivity) and I8 (usage windows add up to booked time times atoms, with no duplicate keys). The command's own help text still lists only I1, I2, I5 and I8; the checks come from the simulator's invariants(). See testing.

The report is JSON: seed, nodes, chaos, simulatedMs, wallMs, digest (SHA-256 of the event log), commands (total, open), faults (crashes, reboots, journalLosses, gatewayKills), violations and the first 20 violations. On a violation the command prints the report and exits with SIM_INVARIANT_VIOLATED; re-run with the same --seed to reproduce it, and add --log to get the event log. sim needs @better-compute/testing.

The stub is not the server

better-compute sim exercises the daemon side and the protocol against GatewayStub, a minimal in-memory control plane. The runs against the real control plane and real gateways are createControlPlaneSim and the nightly matrix; see testing. bcd sim is unrelated despite the name: it runs real Go daemons in real time against a real gateway, with no chaos or invariant checks (see bcd sim).

Working on the repository

The repository is a pnpm workspace; docs/development.md is the full guide.

  • Node.js 22.12 or later, ESM only. CI runs Node 22 and 24 on Ubuntu and Windows.
  • pnpm 11.19.0, pinned in packageManager. When your global pnpm is another major, run the pinned copy: node node_modules/pnpm/bin/pnpm.mjs <args>.
  • No database server. PGlite backs the tests. Suites that need real Postgres skip unless BETTER_COMPUTE_POSTGRES_URL is set.
  • Go 1.26 or later only for daemon/: node scripts/daemon-test.mjs runs go test -tags nonvml,fakehw without cgo or GPUs, natively on Linux and through WSL on Windows. The bcd end-to-end suite is opt-in on Windows (BCD_E2E=1); see testing.
pnpm …What it does
checktypecheck, test, test:iam-remote, lint:repo and validate:packages: what CI runs first
testvitest run over tests/, against the packages' sources (no build needed)
test:iam-remotethe IAM-dependent suites again with IAM over HTTP (vitest.iam-remote.config.ts)
typechecktsc -b, plus the client type tests
lint:repothe dependency rules, the one-clock rule (compute_now() only), the umbrella drift check, proto-gen --check and --breaking, and the v1.0 API surface freeze (scripts/api-surface.mjs)
api:surfacerecords compatible API additions in generated/api-surface.json; see the API surface freeze
test:postgresthe Postgres-only suites (needs BETTER_COMPUTE_POSTGRES_URL)
bench:scalethe scale benchmarks in tests/bench/ (vitest.bench.config.ts)
sim:nightlythe nightly simulation matrix in tests/nightly/
buildtsc -b: every package's dist/

Tests live in the root tests/ folder as tests/<area>-<topic>.test.ts; vitest.config.ts maps every @better-compute/* specifier and better-compute/* subpath to packages/*/src. See testing.

Was this page helpful?

Better Compute is created by Sean Filimon

Last updated

On this page