better/compute

Installing bcd

The install script that puts bcd on a server, the files and hardened systemd unit it writes, joining with BCD_JOIN_TOKEN, and upgrades.

A server becomes a node in two moves: an operator generates an install script once per pool, then runs it on each machine with a single-use . The script downloads a pinned binary, checks its digest, writes a small configuration, installs a hardened systemd unit, joins the fleet and starts the daemon. The script contains no secret, so you can keep it in a provisioning repository; the token travels separately.

The pieces are not all runnable yet

The script generator (better-compute daemon install-script) is built and tested, and so is the GET {basePath}/downloads/bcd/{version}/linux-{arch} route it fetches from (see serving the binaries). bcd itself is built and tested: bcd join and bcd node work, and tests/e2e/bcd.test.ts runs them against a real gateway (see the overview). But bcd is not released: the release workflow exists, but no tag has been released, so there are no published bare binaries and digests for the script to pin, and no test yet runs the script itself on a host. You can build a binary from daemon/ and serve it yourself; otherwise better-compute dev --sim N shows nodes joining through the same protocol with the TypeScript FakeDaemon. See local development.

Serving the binaries

The api serves the daemon binaries itself when you configure downloads:

betterCompute({
  // …
  downloads: { dir: '/srv/bcd' }, // or { blob: blobStore }, or both (the blob store is tried first)
});

GET {basePath}/downloads/bcd/{version}/{os}-{arch} answers goreleaser's bare binary bcd_{version}_{os}_{arch}, read from downloads.blob under the key bcd/{version}/{file} or from {dir}/{file}. The route is public: the install script and self-update check the sha256 from the verified release manifest, not the transport. Responses are application/octet-stream with cache-control: public, max-age=31536000, immutable. The path must match a strict pattern (a semantic version with an optional leading v, linux, amd64 or arm64), so nothing else on disk can be reached; anything else, or a binary the deployment does not have, is 404. downloads needs dir or blob, or betterCompute() throws INVALID_CONFIG.

The same route serves the bcx-ipmi plugin (97b2ac2): GET {basePath}/downloads/bcx-ipmi/{version}/{os}-{arch} answers goreleaser's archive bcx-ipmi_{version}_{os}_{arch}.tar.gz, from the blob key bcx-ipmi/{version}/{file} or {dir}/{file}. The products are a fixed list, bcd and bcx-ipmi; any other name is 404. The route stays public because the archive's digest is in the same signed release manifest, and the install script checks the download against it (installing plugins from the release).

Where the binaries come from

The route only serves files you put there. Copy each release's bare binaries (from goreleaser's output) into the directory or blob store before you roll the version out.

Generate the script

daemon install-script reads the public half of the PKI bundle for the pins, so it needs no secret. Create the bundle first with better-compute pki init (see PKI).

better-compute daemon install-script \
  --pool pool_h100 --site fsn1 \
  --gateway-url https://gw.eu.example.com \
  --base-url https://compute.example.com \
  --bcd-version 0.1.0 \
  --sha256 amd64=<hex>,arm64=<hex> > install-bcd.sh
FlagRequiredEnvironmentMeaning
--pool IDyesThe pool the node joins; named in the script's header
--site IDyesThe pool's site; written to config.toml as site
--gateway-url URLyesBETTER_COMPUTE_GATEWAY_URLThe regional gateway bcd dials, https://...
--base-url URLyesBETTER_COMPUTE_BASE_URLThe control plane that serves the downloads
--base-path PATHnoThe API base path, default /api/compute
--bcd-version VERSIONyesBETTER_COMPUTE_BCD_VERSIONThe release to install, e.g. 0.1.0 (a leading v is dropped)
--sha256 ARCH=HEX,...one of twoSHA-256 of each architecture's bare binary
--release-manifest PATHone of twoA signed release manifest; its verified digests are pinned instead
--release-pins PINSwith a manifestBCD_RELEASE_PINSComma-separated sha256/... pins of the release keys; also written to config.toml as [update] release_pins, which self-update needs
--release-threshold NnoHow many release keys must have signed (1 to 16, default 1); [update] release_threshold
--release-sequence NnoThe node's release floor, [update] release_sequence: bcd never installs an older release; needs --release-pins
--websocket-url URLnoFor networks whose TLS-inspecting proxies break mTLS: a wss://.../v1/session URL, written as the [websocket] section so bcd runs its session and renewals over the WebSocket fallback (0ea4ab4). A configuration that already has a [websocket] section is left alone, and the script says so
--websocket-audience HOSTno[websocket] audience, the gateway's public host the node's proof names; needs --websocket-url
--proxy-ca-file PATHnoThe TLS-inspecting proxy's CA, read from the local file and embedded only when it holds PEM certificates and nothing else; the script writes it to /etc/bcd/websocket-proxy-ca.pem and sets [websocket] proxy_ca_file. It is trusted for the WebSocket's TLS only: the daemon still proves its identity inside the session, and the gateway's proof must chain to the pinned serving CA. Needs --websocket-url
--bmc-networks CIDRSnoA site agent's BMC networks (comma-separated), written as [site] bmc_networks: site.httpProxy never reaches into them
--pluginsnoPrepare the node for bcx driver plugins: a [plugins] section and the systemd drop-in; see the plugin drop-in
--privileged-plugins NAMESnoComma-separated plugins that run as root without the seccomp filter, such as a Kata runtime; implies --plugins
--install-plugins NAMESnoComma-separated bcx plugins to install from the release, each checked against its archive digest in the verified --release-manifest (adf2ae9). Only plugins a release ships are accepted: bcx-ipmi. Implies --plugins and needs --release-manifest; see installing plugins from the release
--unprivilegednoRun bcd node as the unprivileged bcd user beside a root bcd helper (169c459): a [helper] section, the bcd account, bcd-helper.service and a bcd.service drop-in; see running bcd node unprivileged. Refused with --plugins, --privileged-plugins or --install-plugins. Self-update works in this mode: the helper does the privileged half (2d67b25). On a libvirt-kvm host the helper defines and runs the domains, and the bcd account stays out of the libvirt group (d418820)
--attestor tpmnoJoin with the host's TPM instead of a join token (a006d9f): the script reads no token, fails early without /dev/tpmrm0, and runs bcd join --attestor tpm; see joining with the TPM. tpm is the only value; any other is INVALID_INPUT
--instance-bridges BRIDGESnoWith --unprivileged, on VPS and sandbox hosts: the bridges an instance's NIC may join, comma-separated interface names (1 to 15 of letters, digits, ., _ and -), written as [helper] instance_bridges unless the configuration already sets it (d418820). Without --unprivileged it is INVALID_INPUT
--approver-set-sha256 HEXnoWrites approver_set_sha256 (64 lower-case hex digits, the approverSetSha256 that pki trust-version prints) into config.toml, pinning the node's first approver set (9b7a199): a keyless bcd takes only that set, and runs nothing destructive until it has it; see pinning the first set. An upgrade run without the flag keeps the node's existing pin (b6d401f)
--command-root-sha256 HEX,...noWrites command_root_sha256, 1 to 16 lower-case hex SHA-256 digests of the command-signing root certificates (pki trust-version prints them as commandRootSha256), so bcd helper checks host config units against specs signed under them from its first request (59985ae; see signed specs). Without the flag the script pins the PKI bundle's command-signing root and, during a rotation, the added one, and nothing when the bundle has no command signing; an upgrade by a script without pins keeps the node's
--pki PATHnoThe PKI bundle to read the pins from
--out PATHnoWrite a new file with mode 0755 instead of printing; refuses to overwrite (FILE_EXISTS)

Give exactly one of --sha256 and --release-manifest. With a manifest, the CLI verifies its signatures under the pins and threshold, checks that it is for product bcd and for --bcd-version (RELEASE_MISMATCH otherwise), and pins the one bare linux-amd64 and linux-arm64 binary it lists; archives, checksums and metadata files are skipped. See rollouts for signing releases.

Every value embedded in the script is checked against a narrow pattern before it is written, so nothing a caller passes can break out of the shell or TOML quoting:

ValueMust be
pool and site ids, privileged plugin namesletters, digits, ., _ and -, starting with a letter or digit, at most 128 characters
versiona semantic version such as 0.1.0 or v0.2.0-rc.1
pinssha256/ followed by the standard base64 of 32 bytes
digests64 lower-case hex digits, for amd64 and/or arm64 only
URLshttps:// with letters, digits and . - _ ~ / only; trailing slashes are dropped
join encryption keya P-256 public JWK (kty: EC, crv: P-256)

A bad value fails with INVALID_INPUT naming the flag.

What the script does

The output is a POSIX sh script that runs with set -eu and umask 077.

Check the host

It stops unless the host runs Linux, the caller is root, and systemctl and sha256sum exist. It maps uname -m to an architecture (x86_64 or amd64 to amd64, aarch64 or arm64 to arm64) and refuses an architecture the script carries no digest for.

Read the token

The token comes from BCD_JOIN_TOKEN, or else from the first line of standard input when standard input is not a terminal. The variable is unset right after. Anything that does not look like bcjt_<id>.<secret> is refused with exit code 2. Without a token, the script continues only when /etc/bcd/config.toml already exists; that is an upgrade (below).

Download and verify

It fetches {downloads}/bcd/{version}/linux-{arch} into a temporary directory with curl (HTTPS only, TLS 1.2 or later, three retries) or wget --https-only, and compares the file with the pinned SHA-256. A mismatch stops the script before anything is installed.

Install the files

It creates /etc/bcd and /var/lib/bcd with mode 0700 and /etc/cdi with mode 0755 (and, with --plugins, /usr/local/lib/bcd/plugins with mode 0755). Each file is written next to its target as *.new and renamed over it, so a replacement is atomic even while bcd runs. When a config.toml already exists, every section of it ([plugins], [boot], [imds], ...) is carried over below the freshly written top-level keys; with --plugins, a [plugins] section is appended unless the file already has one, and with --unprivileged a [helper] section likewise. After the unit, it installs or removes the plugin drop-in according to whether config.toml has a [plugins] section, and the helper mode (the bcd account, bcd-helper.service, the unprivileged.conf drop-in, /etc/bcd readable by the bcd group) according to whether it has a [helper] section. Then systemctl daemon-reload.

Check, join and start

It runs bcd doctor --config /etc/bcd/config.toml and prints a warning if doctor reports a problem, without stopping. Before the first join, doctor's identity check only warns that the node has not joined; a gateway it cannot reach over TCP is a failure. With a token, it pipes the token into bcd join --config /etc/bcd/config.toml with printf, a shell builtin, so the token never appears in a process listing, then runs systemctl enable --now bcd.service. Without a token, it runs systemctl restart bcd.service. In helper mode it first hands /var/lib/bcd to the bcd user (chown -R bcd:bcd, since bcd join wrote the identity as root) and enables and starts, or restarts, bcd-helper.service, so the helper is up before the node.

Download the script before running it. Piping it into sh would leave no standard input for the token.

BCD_JOIN_TOKEN=bcjt_... sudo -E sh install-bcd.sh
# or, keeping the token out of the environment:
sudo sh install-bcd.sh < token-file

Joining with the TPM

A script generated with --attestor tpm (a006d9f) joins the machine with its TPM, through the control plane's tpm attestor, and carries no token step. Its header says the attestor's policy decides where the machine joins. It differs in two places:

  • Instead of reading a token, it unsets BCD_JOIN_TOKEN and stops unless /dev/tpmrm0, the kernel's TPM resource manager, exists.
  • Instead of the token join, it runs bcd join --config /etc/bcd/config.toml --attestor tpm < /dev/null, which returns at once on a machine that already joined, so the same script installs and upgrades. Then it enables and restarts bcd-helper.service (in helper mode, after chown -R bcd:bcd /var/lib/bcd) and bcd.service. As root on a node with [helper], the join also keeps the signed join record the helper binds host units to (see signed specs).

The join makes the node key in the same TPM and sends the TPM's certification of it, which tpmAttestor requires by default (nodeKey: 'certified', cf24876). The join runs as root and opens the TPM itself, with or without [helper]; under [helper] the unprivileged bcd node then reaches that key only through bcd helper (8870b8d), and never opens /dev/tpmrm0 (below). If the TPM cannot make the key, the join stops with an error. See bcd with its TPM.

better-compute daemon install-script --pool pool_tpm --site fsn1 \
  --gateway-url https://gw.eu.example.com --base-url https://compute.example.com \
  --bcd-version 0.1.0 --sha256 amd64=<hex> --attestor tpm > install-bcd.sh
sudo sh install-bcd.sh

The control plane must run tpmAttestor with a resolve allow-list or enrollment for the machine's endorsement key, else the join is refused or waits for approval; --pool only names the pool in the header. A pool can then refuse token joins with policy.joinAttestors: ['tpm'] (see requiring an attestor).

Files on the host

bcd (0755)
config.toml (0600)
join-encryption.jwk (0644, public)
bcd.service (0644)
bcd-helper.service (0644, only on nodes with [helper])
plugins.conf (0644, only on nodes with [plugins])
unprivileged.conf (0644, only on nodes with [helper])
pins (sha256sum format) and the plugin binaries, yours to add
node.key (0600, the node key, or its TPM record)
identity.json (certificate chain, trust bundle, server time mark)
journal.db (the bbolt journal)
host-intent.json (0600, the signed spec that last set the host section)
volumes/ID.raw, ID.json (0600, persistent host volumes)
bootargs/UNIT, hugepages-1g-boot, cdi-nvidia.stamp (the host units' records)
k8s-join.json, k8s/bootstrap-token (k8s-join)
helper-intent.json (bcd helper's accepted signed spec)
libvirt/ (bcd helper's staging for domain definitions and memory saves)
bcd.sock (0600 status socket, owned by the user bcd runs as)
helper.sock (0660, root:bcd)
bcd-ALLOCATION.json (written by the devices converger)

The script writes the binary, config.toml, join-encryption.jwk, the unit and, on a plugin node, the drop-in and the (empty) plugin directory. The other paths belong to the daemon: bcd join writes the key and identity.json under data_dir, bcd node opens the journal there, serves the status socket at socket_path, and writes CDI specs for hold bindings (with env files under /run/bcd/allocations). On a host with a TPM (/dev/tpmrm0) the key is made in the TPM and node.key holds only its record, a PEM block BCD TPM NODE KEY; otherwise it is a PKCS#8 file (see the node key in the TPM). A renewal writes the next key as node.key.next and promotes it only once the new certificate is stored. A journal that does not open is moved aside as journal.db.corrupt-<unix time> and a new one starts. The token is consumed by the join and never written anywhere. With --proxy-ca-file the script also writes /etc/bcd/websocket-proxy-ca.pem. After its first self-update bcd owns <data_dir>/bin/bcd-a, bcd-b, current, previous and slots.json, upgrade.json while an update awaits its Welcome, update.json (the release floor and failed versions), and, under systemd, bcd-rollback.service and bcd.service.d/50-bcd-rollback.conf; /usr/local/bin/bcd then links to current. Under bcd helper the slots and both records are in root's [update] slots_dir instead (default /usr/local/lib/bcd), and the node downloads into <data_dir>/staging. A quorum node keeps batch-leaves.json and unbatched-destructive.json, and a site agent bmc-hosts.json, under data_dir.

config.toml

/etc/bcd/config.toml
# Written by the better-compute install script. bcd refuses unknown keys.
gateway_url = "https://gw.eu.example.com"
site = "fsn1"
serving_ca_spki_pin = "sha256/..."
join_signing_key_pin = "sha256/..."
join_encryption_key_pin = "sha256/..."

bcd reads the file with daemon/internal/config, a strict subset of TOML 1.0: bare keys, basic or literal strings, decimal integers (with _ between digits) and booleans, in the top level or a bare [section]. Arrays, inline tables, floats, dates, multi-line strings, dotted or quoted keys, unknown keys, unknown sections, duplicate keys and wrong types are all errors with a line number, so a typo fails loudly. Files over 64 KiB are refused.

KeyRequiredDefaultMeaning
gateway_urlyesAn https:// URL without credentials
siteyesThe site id, no whitespace
serving_ca_spki_pinyesPins the gateway serving CA
join_signing_key_pinyesPins the JoinService key that signs JoinResponse
join_encryption_key_pinyesPins the JoinService key the token is sealed to
data_dirno/var/lib/bcdAbsolute path for the journal and key
socket_pathno/run/bcd/bcd.sockAbsolute path of the status socket
[journal] max_bytesno67108864May lower the 64 MiB cap, never raise it
[journal] max_eventsno10000May lower the 10,000 unacked-event cap, never raise it
[log] levelnoinfodebug, info, warn or error
[log] jsonnotrueJSON lines
gateway_server_namenoThe name the gateway certificate must carry when gateway_url names an IP address (an L4 balancer, a dev stack reached from WSL)
dev_insecure_h2cnofalseAllows an http:// gateway_url (cleartext HTTP/2 to a dev gateway). Only fakehw builds accept it; the host build refuses to start
approver_set_sha256no64 lower-case hex digits: the only approver set a daemon without approver keys takes first (see pinning the first set); --approver-set-sha256 writes it
command_root_sha256noComma-separated lower-case hex SHA-256 digests of the command-signing root certificates, at most 16 (03c023d); pki trust-version prints them as commandRootSha256. bcd helper then applies a host.unit only with a spec signed under these roots, which decide alone. daemon install-script writes it by default from the PKI bundle's current command-signing root and, during a rotation, the next one; --command-root-sha256 overrides that. Without it the helper takes the roots from the SignedTrustBundle the JoinService key signed, checked against join_signing_key_pin (2d18327): it refuses a bundle older than one it already accepted (a rollback) and, once it has accepted a bundle with roots, any request without a signed spec; before that it checks nothing. A malformed value stops bcd from starting. See signed specs
[update] release_pinsnononeComma-separated sha256/... pins of the release keys. Without them bcd refuses every self-update; see self-update
[update] release_thresholdno1How many distinct pinned keys must have signed a release
[update] release_sequenceno0The release the node was installed at: no release manifest with a lower sequence is taken (0: unknown)
[update] entrypointno/usr/local/bin/bcdThe path bcd.service starts; the first update makes it a symlink to current in the slots directory. Under the helper it may not be inside data_dir ("under bcd helper, update.entrypoint may not be inside data_dir"; 9e71b1f), since root adopts the binary found there into a slot
[update] slots_dirno<data_dir>/bin; under bcd helper /usr/local/lib/bcdAn absolute path for the binary slots and, beside them, upgrade.json and update.json (without it and without the helper, the records stay in data_dir; 2d67b25). Under the helper it may neither be inside data_dir nor contain it ("under bcd helper, update.slots_dir may not be inside data_dir"; both paths cleaned first, c139b9c), since the unprivileged node owns that directory; the helper creates it
[helper] socketnoAn absolute path: bcd node then runs unprivileged and sends its privileged operations, self-update's included, to bcd helper on this unix socket (28dc2ae); --unprivileged writes /run/bcd-helper/helper.sock. The directory must be root's with no group or other write. See running bcd node unprivileged
[helper] userwith socketThe unprivileged account bcd node runs as (bcd from --unprivileged); root is refused ("helper.socket needs an absolute path and helper.user, the unprivileged account bcd node runs as")
[helper] instance_bridgesnoComma-separated bridges an instance's NIC may join (fea4d2e). bcd helper refuses to define a libvirt domain with a NIC on any other bridge, and with none set it defines no domain with a NIC at all. A name that is not an interface name stops bcd from starting ("helper.instance_bridges: … is not an interface name"). --instance-bridges writes it; see libvirt through the helper
[site] bmc_networksnoA site agent's BMC networks, comma-separated CIDRs: site.httpProxy never reaches into them, whatever the control plane asks. A site agent with it advertises the capability site.bmc-networks, and better-compute doctor warns about connected site agents without it (a019c85)
[websocket] urlnoA wss:// URL (ws:// only with dev_insecure_h2c), without credentials, of the gateway's WebSocket endpoint, .../v1/session: bcd node and bcd site then run the session and renewals over the WebSocket fallback
[websocket] proxy_ca_filenoAn absolute path to a PEM file of a TLS-inspecting proxy's CA, trusted for the TLS connection only, never for the gateway's proof
[websocket] audiencenogateway_server_name, else the URL's hostThe gateway's public host the node's proof names
[instances] balloon_floor_percentno5010 to 100: the share of its memory a ballooned guest always keeps (12e9ced); bcd also never takes a guest below its use plus 10%. Outside the range bcd does not start. See memory ballooning
[imds] listennoWhere a VPS host serves its instances' metadata service, for example 169.254.169.254:80 on the instances' bridge; empty turns it off
[host] unitsnotruefalse when something else manages the host's configuration (the NVIDIA GPU operator beside bcd as a DaemonSet): bcd applies no NodeSpec.host unit and reports the section UNSUPPORTED with reason HostManagedExternally (a193249). See host configuration
[kubernetes] allowed_clustersfor a k8s-joinComma-separated https://host[:port]#sha256:<CA pin> entries: the only clusters the k8s-join unit may join this machine to (7579184). Since 9f860ad it is required as root too: without it every join is refused, and bcd doctor fails a joined node without one. A malformed entry stops bcd node and bcd helper from starting. See k8s-join
[kubernetes] gpu_apinodevice-pluginHow a node bcd did not join itself (the cluster's DaemonSet) offers bound GPUs to the kubelet: device-plugin or dra, never both (7d24836); any other value stops bcd from starting. A node k8s-join joined takes the unit's gpuApi instead. See GPUs for pods
[boot] ...noA site agent's boot server; see site agents
[plugins] dirnoA directory of bcx driver plugins and their pins file (sha256sum format); only pinned plugins run. Empty runs none. See driver plugins in bcd
[plugins] usernoWho plugins run as when bcd is root (no supplementary groups); unset, each plugin gets its own uid from a block bcd keeps, 0x7FFE0000 to 0x7FFEFFFF. root is refused
[plugins] privilegednoPlugins (comma-separated names) that run as root without the shim or the filter, such as a Kata runtime
[plugins] sandboxnotrueRuns unprivileged plugins through bcd's __bcx-exec shim: no_new_privs and a seccomp filter. Linux only; elsewhere set false or no plugin starts
[plugins] cgroupnoAn absolute path under /sys/fs/cgroup in bcd's delegated cgroup; bcd creates it and enables the memory and pids controllers, and each plugin runs in its own child bcx-<name>. --plugins writes /sys/fs/cgroup/system.slice/bcd.service/plugins
[plugins] memory_max, [plugins] pids_maxno0 (none)The child cgroup's memory.max (bytes) and pids.max; not negative. See the plugin sandbox

A pin is sha256/ plus the standard base64 of the SHA-256 of a SubjectPublicKeyInfo, the form HPKP and curl --pinnedpubkey use. bcd doctor warns when the file is readable or writable by anyone but its owner.

join-encryption.jwk

The pins alone cannot seal a token, so the script also writes the JoinService encryption public key as a JWK. bcd join uses it only when its SPKI hash equals join_encryption_key_pin. When the file is absent and bcd join --server https://… names the control plane, bcd fetches <server>/.well-known/better-compute (system roots, a 30-second timeout, at most 64 KiB) and uses its join.encryptionJwk, again only when it matches the pin. The design's fallback for a stale file is not implemented: a mismatched file, or a missing one without --server, fails bcd join with exit code 1. See the join key and its fallback.

An OS that deploy.image installed needs neither the script nor this file: the ramdisk writes its own pinned config.toml (gateway, server name, site and the three pins) and join-encryption.jwk into the installed root, both mode 0600, and the deploy's user data runs bcd join --server … --token-file /etc/bcd/join-token --node-id … (with the three pin flags when provision is given userData.pins), then bcd node. See profiles and images.

Joining from the well-known document

A machine with no config.toml at all, such as an OS that Hetzner Robot's bcd-join.service or provision's user data set up with a token, the control plane's URL and three pins, can still join. bcd join --server https://… --serving-ca-pin … --join-signing-pin … --join-encryption-pin … reads <server>/.well-known/better-compute for what the pins cannot say (where the gateway is), checks the rest against the pins, joins, and writes its own configuration.

Field of the documentBecomes
gateway.urlgateway_url
gateway.serverNamegateway_server_name
gateway.servingCaPincompared with --serving-ca-pin, which becomes serving_ca_spki_pin
join.signingKeyPin, join.encryptionKeyPincompared with --join-signing-pin and --join-encryption-pin, which become join_signing_key_pin and join_encryption_key_pin
join.encryptionJwkthe key the token is sealed to, used only when it matches --join-encryption-pin

The pins are sha256/<base64>, as in config.toml, and a host build requires all three to bootstrap ("joining without config.toml needs --serving-ca-pin, --join-signing-pin and --join-encryption-pin from the install"). A document whose published pin differs from one given is refused ("refusing to join"). Only a fakehw development build may join with no pins at all, taking them from the document (and it also accepts an http:// server). On a machine that already has config.toml, the configuration wins, and any pin flag given must match it.

The control plane publishes the gateway section when its options set gateway: { url, serverName? }, with servingCaPin computed from pki.servingCa; the examples read BETTER_COMPUTE_GATEWAY_URL and BETTER_COMPUTE_GATEWAY_SERVER_NAME. bcd fetches the document over the control plane's own TLS (system roots, a 30-second timeout, at most 64 KiB, redirects refused), refuses a document without gateway.url ("install /etc/bcd/config.toml instead"), joins, and only then writes config.toml (0600, with the site the join answered) and join-encryption.jwk (0644) beside it. Re-running it on a node that already joined exits 0 without a token, so a join unit can run on every boot. The pins come from the sealed PKI bundle: bootstrapPins(bundle) in better-compute/pki returns all three, and provision's user data (provisionModule({ userData: { pins } })) and a Robot bootstrap (vps({ bootstrap: { pins } })) render them into the machine; see profiles and images.

Why the pins travel with the install

The well-known document is authenticated only by the control plane's TLS certificate. Taking its pins on trust would be trust on first use: whoever can serve that name with a publicly trusted certificate (a DNS hijack plus a certificate for the name) could hand the machine their own gateway and JoinService keys, open the sealed token and take the node's identity. Pins that come with the install, from the sealed bundle, are stronger, which is why a host build refuses to bootstrap without them. A fakehw build joining with no pins is trust on first use; keep that to development.

The systemd unit

By default the script installs bcd node running as root, so the unit narrows what root can do. With --unprivileged the same unit gets a drop-in that runs it as the bcd user beside a root bcd helper; see running bcd node unprivileged.

/etc/systemd/system/bcd.service
[Unit]
Description=better-compute node agent (bcd)
Documentation=https://github.com/better-compute/better-compute
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=10

[Service]
Type=simple
ExecStart=/usr/local/bin/bcd node --config /etc/bcd/config.toml
Restart=always
RestartSec=5s
UMask=0077
StateDirectory=bcd
StateDirectoryMode=0700
RuntimeDirectory=bcd
RuntimeDirectoryMode=0700
ConfigurationDirectory=bcd
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=-/etc/cdi -/var/run/cdi
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
RestrictNamespaces=yes
LockPersonality=yes
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
CapabilityBoundingSet=CAP_SYS_BOOT CAP_SYS_ADMIN CAP_SYS_RAWIO CAP_DAC_READ_SEARCH CAP_NET_ADMIN CAP_CHOWN CAP_FOWNER CAP_DAC_OVERRIDE

[Install]
WantedBy=multi-user.target
SettingEffect
ProtectSystem=strictThe whole file system is read-only for bcd, except its state, runtime and configuration directories and the ReadWritePaths
ReadWritePaths=-/etc/cdi -/var/run/cdiThe CDI spec directories the devices converger writes; the - tolerates a missing directory
StateDirectory, RuntimeDirectorysystemd creates /var/lib/bcd and /run/bcd with mode 0700
NoNewPrivileges, RestrictSUIDSGIDNo privilege gain through exec or set-id files
ProtectHome, PrivateTmpNo access to home directories; a private /tmp
ProtectKernelModules, ProtectControlGroups, ProtectClock, ProtectHostnameKernel modules, cgroups, the clock and the hostname are off limits
RestrictAddressFamiliesUnix sockets, IPv4, IPv6 and netlink (for NIC inventory) only
CapabilityBoundingSetRebooting, device and raw I/O access, network administration and file ownership; nothing else
Restart=always, StartLimitBurst=10 in 300 sRestarts after 5 s, and gives up after 10 starts in five minutes

bcd node opens no TCP listener; everything it does starts with an outbound connection to the gateway. See privilege separation for the helper mode.

The plugin drop-in

The unit above cannot run sandboxed bcx plugins: its CapabilityBoundingSet has no CAP_SETUID or CAP_SETGID, which bcd needs to start each plugin under its own uid, and ProtectControlGroups=yes makes the cgroup tree read-only. The unit stays that tight; a node that runs plugins gets a drop-in instead. The script installs /etc/systemd/system/bcd.service.d/plugins.conf (0644) exactly when /etc/bcd/config.toml has a [plugins] section, and removes it otherwise, on every run, first install and upgrade alike:

/etc/systemd/system/bcd.service.d/plugins.conf (with a privileged plugin)
[Service]
# bcx plugins ([plugins] in /etc/bcd/config.toml): their own uids, their own cgroups.
CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_KILL
Delegate=yes
DelegateSubgroup=main
ProtectControlGroups=no
# plugins.privileged in /etc/bcd/config.toml: runtime plugins that run VMs as root (Kata).
CapabilityBoundingSet=CAP_MKNOD CAP_SYS_RESOURCE CAP_IPC_LOCK CAP_SYS_NICE CAP_NET_RAW CAP_SYS_CHROOT CAP_FSETID CAP_SETPCAP CAP_SYS_PTRACE
RestrictNamespaces=no
RestrictAddressFamilies=AF_VSOCK AF_PACKET
ProtectKernelModules=no
SettingWhy
CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_KILLAdded to the unit's set: switch each plugin to its own uid and gid, and stop it. Nothing else is loosened
Delegate=yes, DelegateSubgroup=mainsystemd hands bcd its cgroup, system.slice/bcd.service, and runs bcd itself in the main child, so the plugins' cgroup can sit beside it
ProtectControlGroups=nobcd can create the plugins' cgroups and write their memory.max and pids.max

The last four lines are added only when [plugins] privileged names a plugin (3b7ae79, following d902063): a runtime plugin such as Kata runs VMs as root, so it needs close to root's capabilities (locking VM memory, device nodes, virtiofsd's ptrace), namespaces and mounts, vsock and packet sockets, and the vhost and vsock kernel modules:

SettingWhy
CapabilityBoundingSet=CAP_MKNOD CAP_SYS_RESOURCE CAP_IPC_LOCK CAP_SYS_NICE CAP_NET_RAW CAP_SYS_CHROOT CAP_FSETID CAP_SETPCAP CAP_SYS_PTRACEAdded to the bounding set for the privileged plugin, which runs as root
RestrictNamespaces=noThe plugin makes namespaces and mounts
RestrictAddressFamilies=AF_VSOCK AF_PACKETAdded to the allowed socket families: vsock to its guests, packet sockets
ProtectKernelModules=noThe vhost and vsock modules

Confined plugins are unchanged: each drops every capability when bcd switches it to its own uid, with no_new_privs and the seccomp filter. A node without privileged plugins gets none of these four lines.

--plugins makes a node a plugin node. The script then creates /usr/local/lib/bcd/plugins (root-owned, 0755, so each plugin's uid can reach its binary) and, unless the configuration already has a [plugins] section, appends one:

/etc/bcd/config.toml (with --plugins --privileged-plugins kata)
[plugins]
dir = "/usr/local/lib/bcd/plugins"
cgroup = "/sys/fs/cgroup/system.slice/bcd.service/plugins"
privileged = "kata"

The cgroup is a child of bcd's delegated cgroup. At start bcd creates it and enables the memory and pids controllers in it and in its parent; on systemd older than 254, which ignores DelegateSubgroup=main, bcd first moves itself into a main leaf, since cgroup v2 gives no controllers to the children of a cgroup that holds processes itself (d902063). If the cgroup cannot be prepared, no plugin runs and the log says why. --privileged-plugins NAMES implies --plugins and writes privileged; each name must be letters, digits, ., _ and -. Other plugins you put in the directory yourself, the binaries and their lines in its pins file (see driver plugins in bcd); the plugins a release ships the script can install (below). A section you wrote by hand works the same way: any [plugins] section gets the drop-in, and removing the section removes the drop-in on the next run.

Installing plugins from the release

--install-plugins NAMES (adf2ae9) installs bcx plugins that a bcd release ships, today only bcx-ipmi, the IPMI BMC driver a site agent hosts (see IPMI through bcx-ipmi). It implies --plugins and needs --release-manifest: the CLI takes each plugin's archive digests, bcx-ipmi_<version>_linux_{amd64,arm64}.tar.gz, from the verified, signed manifest (RELEASE_INCOMPLETE when it lists neither), and refuses any other name.

better-compute daemon install-script --pool pool_site --site fsn1 \
  --gateway-url https://gw.eu.example.com --base-url https://compute.example.com \
  --bcd-version 0.9.1 --release-manifest dist/manifest.json --release-pins sha256/... \
  --install-plugins bcx-ipmi > install-bcd.sh

For each plugin the script:

  1. downloads {base-url}{base-path}/downloads/<name>/<version>/linux-<arch> over https (the same version as bcd) and checks the archive against its pinned SHA-256;
  2. refuses an archive that names a path outside it (a leading / or a ..), that has no member named after the plugin, or whose member is not a regular file (a symlink or hard link), and extracts only that member;
  3. installs it with mode 0755 as /usr/local/lib/bcd/plugins/<name> (written as <name>.new and renamed);
  4. writes its binary's SHA-256 as its line in the directory's pins file (0644), replacing an older line for the same plugin and keeping every other line.

A rerun replaces the plugin and its pin without duplicating the line. Like --plugins, it is refused with --unprivileged. The script itself sets up a node (bcd node); a site agent that reads the same config.toml finds the plugin in its [plugins] dir (see a site agent). tests/cli-pki.test.ts runs the install function in a real sh against real archives, including a wrong digest, a symlinked member and a ../ member.

Running bcd node unprivileged

bcd helper (28dc2ae, PLAN.md §6, v0.4) lets bcd node run as an unprivileged account while a root process beside it does the privileged work: the CDI specs, allocation env files and sshd files bcd owns, the bcd_instances and bcd_taps nftables tables, GPU resets, reboots and sshd reloads. Privilege separation describes the operations and the checks. The mode is opt-in: generate the script with --unprivileged (169c459).

better-compute daemon install-script --pool pool_h100 --site fsn1 \
  --gateway-url https://gw.eu.example.com --base-url https://compute.example.com \
  --bcd-version 0.1.0 --sha256 amd64=<hex> --unprivileged > install-bcd.sh

The flag adds a [helper] section to config.toml unless the file already has one. Both processes read that file:

/etc/bcd/config.toml ([helper] section, as --unprivileged writes it)
[helper]
socket = "/run/bcd-helper/helper.sock"
user = "bcd"
KeyMeaning
[helper] socketThe absolute path of the helper's unix socket. Unset (the default), bcd node does its privileged work itself, as root
[helper] userThe account bcd node runs as, and the only caller the helper serves besides root; its primary group owns the socket. Required with socket, and never root
[helper] instance_bridgesOn a VPS or sandbox host: the bridges instance NICs may join, which the helper checks every libvirt domain and every cloud-hypervisor tap (ch.taps, 7fe74a1) against; unset, it defines no domain with a NIC and creates no tap. --instance-bridges br0,br-priv adds it

The socket is not in /run/bcd: the bcd user owns that directory, and a directory the node can write would let it redirect root's work on the socket path with a symlink. So it lives in the helper's own root-owned runtime directory. Since 2d67b25 the helper refuses to start unless the socket's directory is root's and closed to group and other writes, and it binds, chmods and chowns the socket in a private 0700 directory before renaming it into place. Since c139b9c, a helper running as root also refuses to start unless every existing directory from / down to the slots directory, the socket's directory and (since 9e71b1f) the entrypoint's directory is root's and closed to group and other writes. It checks this before it touches its update records or arms a watchdog. A host whose /usr/local/lib is group-writable (root:staff 2775 on older Debian) fails it; bcd doctor, run as root on a helper node, reports each such directory as a failed helper check.

The script decides the mode from the configuration, not the flag: it sets up helper mode exactly when config.toml has a [helper] section, so an upgrade run keeps the node's mode, and deleting the section returns the node to root on the next run (the drop-in is removed; the account and bcd-helper.service are left, and the helper cannot start without the section, so disable that unit yourself). In helper mode it:

  1. creates the bcd system account if there is none (useradd --system --no-create-home --home-dir /var/lib/bcd --shell /usr/sbin/nologin bcd, or busybox adduser -S -D -H without useradd);
  2. installs bcd-helper.service and the bcd.service drop-in unprivileged.conf, both 0644;
  3. creates /usr/local/lib/bcd, where the helper keeps the self-update slots, with mode 0755, and sets an existing one to 0755 (27c9551). The helper's UMask=0077 would otherwise leave it 0700, and bcd node, running as bcd, could not execute the slot its entrypoint links to after the first update. The helper itself creates every missing directory on the way to the slots with mode 0755 whatever its umask, and never changes the mode of one that exists (c139b9c, 9e71b1f);
  4. makes /etc/bcd (0750) and config.toml (0640) group-owned by bcd, so the node can read its configuration and nothing else of /etc/bcd becomes readable to it. Both stay root's: since 3fa9021 bcd helper refuses to start as root unless its config.toml is a regular file root owns that no one else can write, in directories only root can write, because that file ([host] units, command_root_sha256, the join pin) decides what root does for the node. Do not hand the file to bcd or make it group-writable;
  5. on a host with /etc/libvirt/qemu.conf (d418820): sets dynamic_ownership = 0 there (replacing the line, commented or not, or appending it), adds libvirt-qemu or qemu, whichever exists, to the bcd group, and restarts virtqemud.service or libvirtd.service if it runs. libvirt then chowns no path the node names, and QEMU reaches the node's disk files through the bcd group (see libvirt through the helper);
  6. keeps the bcd user away from the TPM (8870b8d, security review 3). The kernel's resource manager filters no command, so any member of the tss group could change the hierarchies' authorization, clear the TPM or trip its dictionary-attack lockout, breaking root's TPM-sealed secrets (LUKS keys) and attestation. bcd node therefore never opens /dev/tpmrm0: bcd helper derives, signs with and certifies its node keys (see node keys through the helper). Scripts from 0ba2a79 until then added bcd to tss and wrote a udev rule; an upgrade run takes bcd out of tss when it is a member (gpasswd -d bcd tss, or busybox delgroup bcd tss) and deletes /etc/udev/rules.d/60-bcd-tpm.rules. It does not change the device's current group or mode back. Until that run, a host installed between 0ba2a79 and 8870b8d keeps bcd in tss, and since e6c3f53 bcd doctor fails its tpm check there: "bcd is in the tss group: it can send the TPM any command (lock or clear its hierarchies); bcd helper holds the node keys, so remove it (gpasswd -d bcd tss) and restart bcd.service". Rerun the script, or do that by hand;
  7. after bcd join (which runs as root), gives /var/lib/bcd to bcd:bcd and starts bcd-helper.service before bcd.service; an upgrade run does the same with restarts.
/etc/systemd/system/bcd-helper.service
[Unit]
Description=better-compute node agent privileged helper (bcd helper)
Documentation=https://github.com/better-compute/better-compute
Before=bcd.service

[Service]
Type=simple
ExecStart=/usr/local/bin/bcd helper --config /etc/bcd/config.toml
Restart=always
RestartSec=2s
UMask=0077
RuntimeDirectory=bcd-helper
RuntimeDirectoryMode=0755

[Install]
WantedBy=multi-user.target
/etc/systemd/system/bcd.service.d/unprivileged.conf
[Unit]
Requires=bcd-helper.service
After=bcd-helper.service

[Service]
User=bcd
Group=bcd
SupplementaryGroups=systemd-journal adm
SettingWhy
Requires=, After=bcd-helper.serviceThe node starts after the helper and stops with it
User=bcd, Group=bcdbcd node runs unprivileged; systemd hands it the unit's state and runtime directories (/var/lib/bcd, /run/bcd)
SupplementaryGroups=systemd-journal admRead access to the system journal and logs (logs.collect runs journalctl)
RuntimeDirectory=bcd-helper, mode 0755/run/bcd-helper, root-owned, holds the socket (0660, root:bcd)

The helper serves a caller only when SO_PEERCRED shows bcd's uid and gid (or root) and /proc/<pid>/exe is an installed bcd: [update] entrypoint, bcd-a or bcd-b in [update] slots_dir, or the helper's own binary. A node run from anywhere else is refused.

Self-update works in this mode (2d67b25): the node downloads a release into <data_dir>/staging, and the helper verifies it under its own [update] pins and floor, copies it into root's [update] slots_dir (default /usr/local/lib/bcd) and swaps; see self-update through the helper. Nodes installed by a script generated with --release-pins get the same [update] section as a root node.

Plugins need root

--unprivileged together with --plugins or --privileged-plugins is a usage error ("--unprivileged cannot host bcx plugins yet: bcd node would need root to start them").

What the helper does not do runs with the bcd account's own rights: the journal and the node key file (on a host with a TPM only the key's record; the key itself is used through the helper's tpm.* operations), the status socket, the libvirt-kvm runtime's reads (virsh -r, libvirt's read-only socket) and its files under data_dir/libvirt, the cloud-hypervisor runtime's files, which it shares with each VMM's user through ACL entries, and reading /dev/kmsg. Defining and running libvirt domains goes through the helper's checked libvirt.* operations (ed3c7d2; see libvirt through the helper), and cloud-hypervisor's taps, VFIO groups and VMM units through its ch.* operations (7fe74a1; see cloud-hypervisor through the helper). Only bcd node uses the helper; bcd site ignores [helper]. tests/e2e/bcd.test.ts runs both processes by hand on a fakehw build, a GPU hold and a signed self-update included; tests/cli-pki.test.ts checks the script's section, account, units and drop-in.

Joining

The join itself is bcd join, which the script runs for you:

  1. bcd generates a P-256 key and a CSR. On a host with a TPM the key is made in the TPM (50e18f9) and node.key (0600) under data_dir keeps only its record; without one, or when the TPM cannot hold the key, node.key is a PKCS#8 file. See the node key in the TPM.
  2. It seals the token to the JoinService encryption key and calls Join on the gateway over TLS pinned to the serving CA, with its hardware claims and the attestor join-token. The gateway relays the call; it can neither read the token nor forge the answer. A deferral is retried with the same CSR; retryable errors back off.
  3. It verifies the signed JoinResponse under the pinned signing key, checks that the response is for its own CSR, that the leaf certifies its key, the SPIFFE id and the role, and stores the certificate chain and trust bundle in identity.json. A journal left by an earlier identity is moved aside.

bcd never takes the token from a command-line argument, so a token pasted in the wrong place does not reach shell history or logs. bcd join rejects positional arguments without echoing them:

$ bcd join bcjt_jt_01...
bcd join: takes no positional arguments
bcd join reads the join token from BCD_JOIN_TOKEN or stdin, never from arguments

--token-file PATH reads the token from a file instead (c29bb16). The file must be its owner's alone: mode 0600 or 0400 (a Kubernetes Secret volume with defaultMode: 0400, whose ..data symlink bcd follows), a regular file, and at most 4 KiB; anything else is refused before the token is read. Once the join succeeds, or when the machine had already joined, bcd deletes the file, since a token is single use; on a read-only mount such as a Secret volume it cannot, and logs a warning instead of failing. bcd on Kubernetes joins this way.

The node then opens its session (see sessions) and appears as adopting. Tokens, attestors, refusals and certificates are covered in joining nodes.

Joining again with --reset

bcd join on a machine whose identity loads answers already joined as <node> and exits 0, whatever state the certificate is in: expired, revoked, refused at renewal, or naming an AK this TPM no longer has. That keeps a join unit safe to run on every boot, but it cannot bring such a node back. Since a1c1a46 --reset can:

# as root: see what it would move (moves nothing, exits 2)
bcd join --config /etc/bcd/config.toml --reset
# move the identity aside and join again, here with the TPM
bcd join --config /etc/bcd/config.toml --reset --yes --attestor tpm
# or with a new join token on standard input
bcd join --config /etc/bcd/config.toml --reset --yes < join-token.txt
FlagDoes
--resetBefore the join, moves the node's identity and journal aside: identity.json, node.key, a pending node.key.next and journal.db, whichever exist in data_dir, each renamed <name>.reset-<UTC time> (for example node.key.reset-20260926T114200Z)
--yesConfirms --reset. Without it bcd prints which files it would move, that the join that follows makes a new key (a new node, unless the attestor proves this machine and reclaims it), and exits 2

What it does and does not do:

  • Root only. Outside development (fakehw) builds a non-root --reset fails with "run it as root", exit 1.
  • Nothing is deleted. The old files stay in data_dir under their new names, so an operator can still read them (a TPM key's record still names its key). Remove them yourself once the node is back.
  • Then the usual join. The move happens after the configuration loads and before the join reads any credential, so the same run needs the join's credential as any join: a token (BCD_JOIN_TOKEN, standard input or --token-file), --attestor tpm, or --node-id for an installed OS. If the join then fails, the identity stays moved aside and a later plain bcd join joins. bcd's own hints spell it as two runs, bcd join --reset --yes, then bcd join. With nothing to move, --reset does nothing and the join goes on.
  • Same node or a new one. An attestor that proves the machine (tpm with the same EK, provisioned) reclaims the node it is bound to. A token join of a machine whose trusted hardware key the old node still holds needs a token with allowReclaim (see placeholder hardware and reclaims); to join as a new node instead, cordon and delete the old one first (better-compute nodes delete <node> --yes; see removing an adopted host).
  • The journal moves too (b4fcc2e). journal.db is the old node's, and bcd node refuses a journal of another node ("the journal belongs to node <old>, not <new>"). A plain join moves an old journal aside only when the old identity still loads, which after --reset it does not, so --reset moves it with the identity and the node starts a new one.
  • Under [helper] the join runs as root and writes the new node.key and identity.json as root. When it finishes, it hands both to the [helper] user with Lchown (b4fcc2e; root only, and a name swapped for a symlink changes only the link), for a new join and for already joined alike, so bcd node can read its key. Then restart bcd-helper.service and bcd.service; re-running the install script also does the restarts.

bcd doctor points at it: the identity check fails for an identity that does not load and for an expired certificate, and both details end with "bcd join --reset --yes, then bcd join". TestJoinReset covers the dry run, the move (the journal included) and that no identity loads afterwards; TestJoinHandsTheIdentityToTheNode covers the hand-over (as root; skipped otherwise).

Site agents and the ramdisk

The install script sets up nodes only: it joins with role node and its unit runs bcd node. The other two agent modes use the same binary but are set up differently.

A site agent

A reads the same config.toml and join-encryption.jwk, joins with a site token and runs bcd site:

BCD_JOIN_TOKEN=bcjt_... bcd join --role site --config /etc/bcd/config.toml
bcd site --config /etc/bcd/config.toml

bcd site refuses a data directory whose identity has another role. Without a [boot] section it runs BMC operations, site.httpProxy and boot entries but serves no PXE machine (it logs a warning). With one, it also runs the boot server:

/etc/bcd/config.toml (site agent)
gateway_url = "https://gw.eu.example.com"
site = "site_01k5w..."
serving_ca_spki_pin = "sha256/..."
join_signing_key_pin = "sha256/..."
join_encryption_key_pin = "sha256/..."

[boot]
listen = "0.0.0.0:8080"
url = "http://10.20.0.2:8080"
kernel = "http://10.20.0.2:8080/ramdisk/vmlinuz"
initrd = "http://10.20.0.2:8080/ramdisk/initrd"
cmdline = "console=ttyS0,115200"
server_ip = "10.20.0.2"
proxy_dhcp = true
tftp = ":69"
tftp_root = "/srv/bcd/ipxe"
[boot] keyMeaning
listenThe HTTP address of the boot server; empty turns the boot server off. Without join-encryption.jwk beside the config bcd site exits 1, and without kernel and initrd the boot server stops with an error
urlHow machines reach the boot server. iPXE chains to it, and the ramdisks it boots get <url>/cache as their image cache
kernel, initrdThe URLs of the site's ramdisk kernel and initramfs, which iPXE fetches
cmdlineAppended to every ramdisk's kernel command line
server_ipThe agent's IPv4 address on the provisioning network: the proxy DHCP server id and TFTP next-server
proxy_dhcptrue answers PXE and UEFI HTTP boot clients on UDP 67 and 4011, beside the site's own DHCP server, which keeps handing out addresses
tftp, tftp_rootThe TFTP server's UDP address and the directory of iPXE builds it serves: undionly.kpxe (BIOS), ipxe.efi (x86-64 UEFI), ipxe-arm64.efi (arm64 UEFI)
cache_max_bytesThe image cache's byte budget (0, the default, is unbounded): least recently used images are evicted first, never one the site section's cache_images still names

proxy_dhcp and tftp need server_ip and url, or bcd site exits 2. The iPXE builds and the ramdisk kernel and initramfs are yours to supply: the repository builds neither. Ports 67, 69 and 4011 are privileged, and the unit the install script writes keeps bcd to a capability set without CAP_NET_BIND_SERVICE, so a site agent needs its own unit. What the boot server answers, and when, is on sites.

The ramdisk

bcd ramdisk is not installed. It runs inside the PXE ramdisk the site agent boots, and takes everything from the kernel command line the site agent rendered into the machine's iPXE script, so it has no config.toml:

FlagDefaultMeaning
--cmdline/proc/cmdlineWhere to read the bc.* boot parameters
--data-dir/run/bcdThe key, identity and journal, on tmpfs: every ramdisk boot is a new identity
Boot parameterMeaning
bc.gatewayThe gateway URL (an http:// one only in fakehw builds)
bc.server_nameOptional: the name the gateway certificate carries
bc.cacheOptional: the site image cache, tried before an image's source
bc.siteThe site id
bc.pinsThe serving CA, join signing and join encryption pins, comma-separated
bc.jwkThe JoinService encryption key's public JWK, base64url; refused unless it matches its pin
bc.tokenThe machine's single-use, MAC-bound discovery join token
bc.assertionThe site agent's SignedDiscoveryAssertion, base64url

A missing parameter exits 2. The ramdisk joins with the discovery attestor as role ramdisk (see attestation), logs JSON, then runs the agent until the machine kexecs into its installed OS or powers off. Its image needs bcd plus the tools its kinds call: nvme-cli, hdparm, util-linux (blkdiscard, blockdev, lsblk, mount), e2fsprogs' debugfs and mtools (to write into and read from a deployed image's ext and FAT filesystems without mounting them; 1ae1789, d9a900f), udevadm, kexec-tools and, for diag.dcgm, dcgmi.

Upgrades

Before v0.4: rerun the script

Run the same script, or one generated for a newer --bcd-version, without a token on a host that already has /etc/bcd/config.toml. It downloads and verifies the new binary, replaces /usr/local/bin/bcd with an atomic rename, rewrites the key, the unit and the top-level keys of the configuration, and restarts the service:

sudo sh install-bcd.sh
# bcd upgraded to 0.1.1 and restarted.

The configuration's top-level keys (gateway, site and the three pins) come from the new script, and every section of the existing file ([plugins], [boot], [imds], [journal], [log], ...) is kept as it was (4225772; earlier scripts rewrote the file whole and dropped them). Top-level keys you added by hand, such as data_dir or gateway_server_name, are not kept, so put them back after an upgrade or regenerate the script. The plugin drop-in follows the kept configuration: a node with [plugins] keeps it, even when the new script was generated without --plugins.

The node keeps its identity and journal across the restart. On reconnect its Hello carries the new daemon_version, which the gateway records in node_sessions.daemon_version.

On a node that has already self-updated, the script's mv -f replaces the entrypoint symlink with a plain file. The next self-update adopts that file into the current slot before it relinks the entrypoint (9006eee), so it never falls back to the older binary the slot held; before that fix it did, rolling the script's upgrade back.

Upgrading an unprivileged libvirt host

A VPS or sandbox host installed with --unprivileged by a script from before d418820 runs bcd node in the libvirt group, which is root-equivalent, and its libvirt still relabels files. A new bcd there sends domain operations through the helper anyway (ed3c7d2), which refuses to define a domain with a NIC until [helper] instance_bridges is set, but the group membership and the relabelling stay until you run a newer script, generated with --unprivileged (and --instance-bridges for the instances' bridges), on the host without a token. It rewrites the unprivileged.conf drop-in without SupplementaryGroups=libvirt, sets dynamic_ownership = 0, adds QEMU's user to the bcd group, restarts libvirt and then both bcd units:

better-compute daemon install-script --pool pool_vps --site fsn1 \
  --gateway-url https://gw.eu.example.com --base-url https://compute.example.com \
  --bcd-version 0.9.2 --sha256 amd64=<hex> --unprivileged --instance-bridges br0 > install-bcd.sh
sudo sh install-bcd.sh

Without [helper] instance_bridges the helper defines no domain with a NIC, so set the bridges before instances with networks start there; the script adds them only when the configuration has no instance_bridges line yet. bcd doctor's libvirt check fails until the host is set up this way.

From v0.4: rollouts and self-update

The v0.4 design moves upgrades into desired state. A rollout sets NodeSpec.daemon = { version, url, sha256, signature } on nodes wave by wave (1%, 10%, 50%, 100% by default) within each pool's disruption budget, and a node succeeds when it rejoins reporting that version. A daemon too old to speak the server's minimum protocol version gets Welcome.upgrade_required, and its session accepts only the daemon.upgrade command kind, which carries the same DaemonSection; the rollout controller sends it to such nodes.

Self-update

bcd updates itself in place (de9f380, daemon/internal/agent/update.go, daemon/internal/selfupdate), for both the daemon section and daemon.upgrade (node and site roles). Since 2d67b25 the privileged half (selfupdate.Manager: verify, swap, confirm, roll back) runs in process on a root node and inside bcd helper on an unprivileged one; see self-update through the helper. The steps are the same:

  1. Only a signed release. DaemonSection.signature is the release's signed manifest (manifest.json from better-compute release sign). At least [update] release_threshold distinct keys pinned in [update] release_pins must have signed it (purpose release-manifest), and it must be for product bcd and the section's version, have a sequence not below the release this node ran or was installed at ([update] release_sequence; no rollback), be unexpired on both the host clock and server time, and list the bare binary bcd_<version>_<os>_<arch> with the section's SHA-256. Without release pins bcd takes no binary at all: the control plane alone can never hand a node one. The URL must be https://.
  2. Two slots. The binaries live in bcd-a and bcd-b of the slots directory ([update] slots_dir: default <data_dir>/bin, or /usr/local/lib/bcd under the helper), with current and previous symlinks and slots.json. The first update adopts the binary the install script put at the entrypoint ([update] entrypoint, default /usr/local/bin/bcd) into slot a and makes the entrypoint a symlink to current; an entrypoint that is a symlink somewhere else is refused, and a plain file found there later (a rerun of the install script) is copied into the current slot first (9006eee). Running as root, adoption takes only a root-owned regular file that group and others cannot write (9e71b1f). The node downloads the release into <data_dir>/staging/bcd-<version>, at most 512 MiB, checked against the manifest's size and digest as it streams, with an idle watchdog; the swap then copies it into the other slot, checking the size and digest again as it writes. It opens the staged file beneath the staging directory's parent (os.Root), refuses a symlinked staging directory, opens with O_NOFOLLOW|O_NONBLOCK (a FIFO cannot hang it), and takes only a regular file with one link; under the helper the file must also be owned by the node's uid (c139b9c).
  3. Swap and confirm. bcd writes upgrade.json with a deadline of now plus 120 seconds (fsynced), points current at the new slot and execs the entrypoint once everything is closed. The new binary confirms itself on its first Welcome: the release floor moves up (update.json) and upgrade.json goes. Without a Welcome by the deadline it swaps back and restarts; the old binary then records the version as failed ("bcd X did not start"), reports the daemon section FAILED with reason UpdateFailed, and never tries that version again until bcd update --clear-failed. Under the helper, the node asks the helper to confirm, and the helper's own watchdog also swaps back an update still unconfirmed 30 seconds after its deadline and restarts bcd.service.
  4. Crash loops. Under systemd the first update writes bcd-rollback.service and a bcd.service drop-in (50-bcd-rollback.conf: StartLimitIntervalSec=600, StartLimitBurst=5, OnFailure=bcd-rollback.service), which restores previous, only while upgrade.json exists. fakehw builds never write them.
  5. No going back below the floor. A confirmed update is never undone (c139b9c). slots.json records each slot's version, manifest sequence and the SHA-256 of the bytes written into it. The binary already in the other slot is taken without a manifest only when its recorded sequence is known and not below the release floor, so a section that names the older version the node ran before is refused: without a manifest there is nothing to verify, and that release's manifest is below the floor (the daemon section reports FAILED). An update that never confirmed is still swapped back by the deadline, the crash-loop unit or the helper's watchdog. To undo a release that confirmed, roll forward: sign a newer release with a higher sequence and roll it out (rollouts).

daemon.upgrade is self-terminating, like node.reboot: it reports RUNNING before the swap and SUCCEEDED from the new binary once it is welcomed, or FAILED with update-failed after a swap back; a restart without a reboot is decided by upgrade.json, not the boot id. Running workloads are not touched. bcd sim and bcd ramdisk run without self-update: the daemon section stays SectionUnsupported and the kind UNSUPPORTED. bcd update shows the slots, the release floor, a pending update and failed versions from [update] slots_dir (run it as root on a helper node, whose records are root's), and bcd doctor warns without release pins, fails when the slots are in use but the entrypoint does not link to current, and warns about a pending update or a failed version (2845f41). Both find the slots and records the same way (8eca2b1): [update] slots_dir, else /usr/local/lib/bcd under the helper, else <data_dir>/bin with the records in data_dir. tests/e2e/bcd.test.ts updates a real bcd to a second signed build through daemon.upgrade, a real exec and its Welcome (fc0ca6a), and delivers a signed build to an unprivileged node through bcd helper (2d67b25); no release has been cut, so no real host has self-updated yet. See delivery.

The fleet-wide order for any upgrade is: expand migrations, then workers and api, then gateways one at a time with Drain, then daemons in waves, and contract migrations in the next release. See version skew.

Removing bcd

Neither the CLI nor bcd has an uninstall step, and PLAN.md designs none. Retire the node in the control plane first so nothing is scheduled on it (see operating nodes). Then stop and disable bcd.service and remove the files listed above.

Release artifacts

bcd is configured to build with goreleaser from daemon/ (.goreleaser.yaml) for Linux on amd64 and arm64, with cgo and zig cc, set up for a go-nvml backend that would load libnvidia-ml; the GPU code in the tree today reads nvidia-smi and needs no cgo (cb987e5). A release produces bcd_<version>_linux_{amd64,arm64}.tar.gz, each with a .sha256, and the bare binaries bcd_<version>_linux_{amd64,arm64}. The bare binary is what the install script downloads, what a signed release manifest lists and what self-update verifies. The same release builds bcx-ipmi, the site agent's IPMI BMC plugin, without cgo and ships it only as bcx-ipmi_<version>_linux_{amd64,arm64}.tar.gz (348ff56), so a release still has exactly one bare binary per platform, bcd's, which rollouts and self-update take. goreleaser also writes an SPDX SBOM per archive (syft) and a keyless cosign signature and certificate for every artifact. .github/workflows/release.yml runs it on a v* tag (or a dispatch naming one), appends everything to that tag's GitHub release, and adds SLSA level 3 provenance (bcd.intoto.jsonl) from the generic SLSA generator. No tag has been released yet; CI (.github/workflows/daemon.yml) vets, tests and runs goreleaser check, and a local snapshot build is goreleaser release --snapshot --clean --skip=sign,sbom.

Until then a binary comes from a plain go build in daemon/. The development and test builds are cross-compiled without cgo (GOOS=linux GOARCH=amd64 CGO_ENABLED=0); node scripts/daemon-test.mjs --build-bcd writes the fake hardware build to daemon/bin/bcd-linux-amd64. go-nvml is not a dependency, so a cgo build gains nothing today: the default build reads GPUs through nvidia-smi without cgo (cb987e5).

Was this page helpful?

Better Compute is created by Sean Filimon

Last updated

On this page