Deploying machines
Walkthroughs that deploy machines on an emulated QEMU/KVM site, install Ubuntu over virtual media, and run Talos on an agentless pool.
This page walks through the three deployments the repository proves: a GPU host commissioned and
deployed over on an emulated site, Ubuntu installed by its own installer
from a per-machine ISO, and Talos on machines that never run bcd. Each follows the same lifecycle from
bare metal; they differ in how the OS gets onto the disk and how the
control plane learns it is up.
Adopt or PXE
| Adopt (v0.1) | Enroll and deploy (v0.2) | |
|---|---|---|
| You start with | a machine running an OS you installed | a machine with a BMC, whatever is on its disks |
| You provide | a join token and the install script | the BMC address and credentials, a site agent, an image, a profile |
| Reimage | never | on every deploy |
| On the way back | no platform wipe | an attested wipe before ready |
| States | adopting, adopted | enrolling through ready, deploying, deployed |
Adoption is covered on joining nodes. The state table lets an adopted
node with a BMC be released, but metal acts only on machines it enrolled, so an adopted node has no
metal record and metal.undeploy answers NOT_FOUND for it today.
Before you start
A deployment that provisions metal needs, beyond the usual control plane:
metal({ hardwareProfiles })andprovisionModule({ modules: [bareMetal], userData }), as on bare metal. The examples' shared configuration (examples/shared/compose.ts) registers both modules but nouserDataoptions and no attestors, so add them for profiles that render user data and for agents that join over PXE.- A PKI bundle (
better-compute pki init), so JoinService can admit site agents, ramdisks and installed machines, and the gateway role for them to connect to. See PKI. - The
discoveryandprovisioned, wired as on sites. - A site with a connected site agent, a pool on it whose
policy.hardwareProfilenames your hardware profile, an image in the catalog, and a deploy profile.
Walkthrough: an emulated site
examples/emulated-site stands in for a rack: QEMU/KVM virtual machines are the servers,
sushy-emulator gives each a Redfish BMC over libvirt, and a bcd site agent on the libvirt bridge
runs proxy DHCP, iPXE and the image cache. It needs Linux with /dev/kvm, QEMU, libvirt, sushy-tools
and a bcd build, plus the iPXE builds and a ramdisk image, which the repository does not build. Nobody has run
it end to end yet. Its configuration registers one hardware profile:
export const hardwareProfiles = {
'emulated-redfish': {
description: 'QEMU/KVM machines behind sushy-tools (Redfish), PXE-booted by the site agent',
drivers: { power: 'redfish', management: 'redfish', boot: 'ipxe', deploy: 'image-stream' },
},
};Create the machines and their BMCs
virt-install --name bm-1 --memory 8192 --vcpus 4 --disk size=40 --pxe --network network=bc-site --noautoconsole
virt-install --name bm-2 --memory 8192 --vcpus 4 --disk size=40 --pxe --network network=bc-site --noautoconsole
sushy-emulator --libvirt-uri qemu:///system --port 8000Start the control plane
cd examples/emulated-site
better-compute pki init --trust-domain site.example.test
better-compute serveThe gateway role needs a serving certificate (better-compute pki issue-gateway); see
examples/postgres for the environment variables. Calls below run as BETTER_COMPUTE_TOKEN.
Create a site, its agent and a pool
export BETTER_COMPUTE_TENANT=root
better-compute sites create --kind shared --region lab-1 --key lab-1
better-compute site-tokens create --site site_… --ttl 3600 --key lab-1-agents --out ./site-token
echo '{"hardwareProfile":"emulated-redfish"}' > policy.json
better-compute pools create --site site_… --class metal-host --policy-file policy.jsonsite-tokens create --out writes the one-time token to a new 0600 file instead of printing it.
Join a site agent on the bc-site network with the site token (BCD_JOIN_TOKEN=bcjt_… bcd join --role site,
then bcd site --config /etc/bcd/config.toml). Its config.toml needs a [boot] section with proxy_dhcp, tftp
and tftp_root (the iPXE builds), server_ip and url on the bridge, and the ramdisk's kernel and initrd; see
installing bcd. It becomes the site's boot server once the SiteBoot controller
gives it the lease.
One sushy-emulator serves both VMs, so each enrollment names its own system in the BMC address:
https://192.168.122.1:8000/redfish/v1/Systems/<id>, where <id> is the libvirt domain's UUID
(virsh domuuid bm-1, or the Members of GET /redfish/v1/Systems on the emulator). bcd's Redfish client then
drives that system and its own manager for virtual media; an address without a system path would drive the first
system for both machines. See Redfish in bcd site.
Register the image and the profile
better-compute api images.register --data @image.json
better-compute profiles create ./ubuntu-gpu-host.json
better-compute profiles validate ubuntu-24.04-gpu-host --profile-version 1images register (with --ref, --source, --disk-format and --sha256) is the CLI form of the first call when
you would rather pass flags than a JSON body.
The profile is the GPU host from profiles and images: render user data, boot the ramdisk, stream the image, set boot to disk, kexec.
Enroll the machines
{
"tenantId": "root",
"siteId": "site_…",
"poolId": "pool_…",
"bmc": {
"address": "https://192.168.122.1:8000/redfish/v1/Systems/<libvirt domain uuid>",
"driver": "redfish",
"credentials": { "username": "admin", "password": "…" }
}
}better-compute api metal.enroll --data @enroll-bm-1.json
better-compute workflows list --node node_… --query "items[].profileId"The same enrollment with flags, the password read from a file (see from the command line):
better-compute metal enroll --site site_… --pool pool_… \
--bmc-address 'https://192.168.122.1:8000/redfish/v1/Systems/<libvirt domain uuid>' --bmc-driver redfish \
--bmc-username admin --bmc-password-file ./bmc-passwordThe node is enrolling, and the reconciler carries it through metal-enroll, metal-inspect and
metal-commission to ready with no further calls. The example's README also lists metal.manage,
metal.inspect and metal.provide here: you do not need them after an enrollment. They re-run
phases of a machine that is already ready.
Deploy, then release
better-compute metal deploy node_… --profile ubuntu-24.04-gpu-host --profile-version 1
better-compute metal undeploy node_… --wipe quickThe deploy ends when the installed OS has joined as the same node and the profile's acceptance
steps passed: the node is deployed with role host. The undeploy wipes and powers off, and the
node is ready again only after the wipe is attested.
What the machine sees
In the lifecycle test, enrollment to ready sends exactly these commands. BMC work goes to the site
agent, disk and diagnostics work to the machine's ramdisk:
| # | Command | Sent by |
|---|---|---|
| 1 | bmc.query | power.getPower (the BMC answers with these credentials) |
| 2 | bmc.query | inspect.oob (PXE MACs, SMBIOS UUID, IP addresses) |
| 3 | bmc.boot | one-time PXE |
| 4 | boot.prepareRamdisk | a boot entry with a MAC-bound discovery token |
| 5 | bmc.power | cycle into the ramdisk |
| 6 | inventory.refresh | in-band inventory |
| 7 | boot.cleanUp | inspection's cleanup; the ramdisk stays up |
| 8 | diag.dcgm | level 2, node-signed |
| 9 | disk.wipe | quick, node-signed |
| 10 | bmc.power | off |
| 11 | boot.cleanUp | commissioning's cleanup |
The deploy then sends deploy.image (the catalog source, digest, format and compression, plus a
reference to the rendered user data) and boot.kexec. The user data travels only as a reference, and
the deploy's ramdisk boot gets its own discovery token.
A machine whose BMC cannot report its MACs (IPMI) and that was enrolled with only its productUuid adds a step
before 4 (3d26428, 8de6e2f): metal.ensureRamdisk power-cycles it into PXE with no boot entry, a MAC first seen under
its UUID within 10 minutes becomes its candidate, and nothing more is sent until an operator confirms a MAC with
metal.confirmMac (better-compute metal confirm-mac <node> --mac …, after metal discovered --site … shows the
candidate, 002dd95; or Confirm on the console's PXE discovery page,
15c11ee), or at once on a pool with policy.trustProvisioningNetwork (whose inventory.refresh then checks
the ramdisk's UUID and NICs against the machine). See
discovering new machines.
What the ramdisk does
bcd ramdisk runs these kinds (daemon/internal/agent/ramdisk.go) under metal's names. Disks are named by WWN,
else serial, never by kernel name, since kernel names are not stable across boots.
| Kind | What bcd does |
|---|---|
disk.wipe | The listed disks, or every non-removable one. quick (the default) zeroes the first and last MiB of each disk, where partition tables (with the GPT backup), filesystem, LVM, RAID and ZFS signatures live, and reads them back. secure-erase and crypto-erase use the drive's own command: nvme format --ses=1 or --ses=2 for NVMe, the ATA security erase through hdparm for SATA (no crypto erase there), else blkdiscard --secure. The result lists each disk's method and whether it was verified; a disk that was not wiped and verified fails the whole command (wipe-unverified), so ready rests on a verified wipe. A listed disk that is missing is disk-missing |
deploy.image | Picks the root disk from the hint (WWN, serial, device, or a minimum size; otherwise the smallest non-removable disk of at least 4 GiB), streams the image from the site cache by digest when bc.cache is set, else from its source URL, through SHA-256 (of the stored bytes) and gzip or zstd straight onto the disk, then re-reads the partition table. A digest mismatch zeroes the disk's ends again (checksum-mismatch), so a substituted image never boots. qcow2 is refused (unsupported-format). User data is fetched with a SecretRequest bound to the step and written as a cloud-init NoCloud seed on the root filesystem, or as Ignition's config.ign on the partition labelled boot. It then installs the ramdisk's own join configuration into the OS (on the partition holding /etc, mode 0600): /etc/bcd/config.toml with the gateway, server name, site and the three pins, and /etc/bcd/join-encryption.jwk, so the installed bcd join needs nothing else from the user data but the token; a failure there is user-data-failed. The image is untrusted while it is written into, since it may be a tenant's and a crafted filesystem attacks whatever kernel mounts it. An ext2, ext3 or ext4 partition holding /etc (most images' root) is never mounted (1ae1789): bcd writes the NoCloud seed and its join files with e2fsprogs' debugfs, which reads and writes the filesystem in userspace, making parent directories, unlinking whatever the image has at a target path (a file, a symlink, a device node) without following it, writing each file root-owned 0600, and reading every file back to check it is a regular file with the bytes written; only plain absolute paths are accepted. Ignition's config.ign goes onto the partition labelled boot in userspace too (d9a900f): debugfs for ext (Fedora CoreOS) and mtools for FAT, each read back. Only xfs and btrfs, or a ramdisk without the tools, fall back to a nodev,nosuid mount, where every file goes through os.Root (a path that resolves outside the mounted filesystem is refused) and whatever is at a target path is replaced with a file created exclusively, never followed or written through; a directory there is refused. The ramdisk image therefore needs e2fsprogs (debugfs) and mtools |
boot.kexec | Journals running{boot_id}, loads the installed kernel and initrd from the root disk (the given paths, /boot/vmlinuz with /boot/initrd.img, or the newest vmlinuz-* with its initrd.img-, initramfs- or initrd- file). On an ext root it never mounts the image (d9a900f): debugfs lists /boot, resolves symlinks inside the image (at most 8 hops; an absolute target is the image's own path, never the ramdisk's) and copies the kernel and initrd, regular files of at most 512 MiB, into the ramdisk's tmpfs, which kexec loads; xfs and btrfs are mounted read-only, nodev,nosuid. The kernel command line is root=UUID=<root> ro plus the extra arguments, and bcd runs systemctl kexec (or kexec -e). Self-terminating: it completes when the installed OS rejoins |
diag.dcgm | dcgmi diag -r <level> -j (levels 1 to 4, default 1), for the listed GPUs or all; Pass and Skip pass, anything else fails with its warnings as the message. Attested, so the result is node-signed |
inventory.refresh, diag.nvml and logs.collect run in the ramdisk too. Under the fakehw build these act on
disk image files, so the Go tests run the same logic on sparse multi-terabyte files.
Walkthrough: Ubuntu autoinstall over virtual media
This is walkthrough 1 of PLAN.md §5.6: a third-party plugin, with no change to @better-compute/*,
installs Ubuntu with its own installer from an ISO mounted through the BMC, with a single-use
provisioned join token inside the autoinstall configuration so bcd joins afterwards.
import { createHash } from 'node:crypto';
import { definePlugin, manifest } from 'better-compute/core';
import { defineProfile, defineStep, renderTemplate } from 'better-compute/provision';
const autoinstall = `#cloud-config
autoinstall:
version: 1
identity: { hostname: {{hostname}} }
late-commands:
- curtin in-target -- sh -c "echo {{joinToken}} > /etc/bcd/join-token && chmod 600 /etc/bcd/join-token"
- curtin in-target -- systemctl enable bcd
`;
const renderAutoinstallIso = defineStep({
id: 'autoinstall.renderIso',
runsOn: 'control-plane',
timeout: '5m',
retrySafe: false,
input: manifest({}),
async run(_input, context) {
const node = context.node!;
const { token } = await context.sql.transaction((tx) =>
context.pki!.issueJoinToken(tx, { tenantId: 'root', role: 'provisioned', siteId: node.siteId, ttlSeconds: 7200, maxUses: 1 }),
);
const userData = renderTemplate(autoinstall, { hostname: node.id.replace(/_/g, '-'), joinToken: token });
const digest = createHash('sha256').update(userData).digest('hex');
await publishIso(digest, userData); // your ISO build and storage behind the site cache
return { url: `https://cache.site-a/autoinstall/${digest}.iso` }; // unguessable, per node
},
});
const ubuntuAutoinstall = defineProfile({
id: 'ubuntu-24.04-autoinstall',
version: 1,
target: 'metal',
role: 'host',
steps: [
{ use: 'autoinstall.renderIso' },
{ use: 'media.insert', on: 'site', with: { fromStep: 'autoinstall.renderIso' } },
{ use: 'management.setBootDevice', on: 'site', with: { device: 'cd' } },
{ use: 'power.setPower', on: 'site', with: { action: 'cycle' } },
{ use: 'wait.agentJoined', with: { role: 'node' } },
{ use: 'media.eject', on: 'site' },
],
});
export const acmeAutoinstall = definePlugin({
id: 'acme-autoinstall',
extensions: [renderAutoinstallIso],
profiles: [ubuntuAutoinstall],
});Pass the plugin to the workflow engine next to metal, so it resolves the plugin's step and profile:
provisionModule({ modules: [bareMetal, acmeAutoinstall] }). media.insert with fromStep reads the
url the render step returned and mounts it through the BMC's virtual media.
On a BMC without virtual media (IPMI), this profile is refused before the workflow starts, because a
fromStep ISO has no digest the site cache can verify. An ISO registered in the catalog works there (3d26428): name
it with { use: 'media.insert', with: { image: 'img_…' } }, and metal pins it at the site, boots it with iPXE
sanboot from the site cache, turns the later setBootDevice to cd into pxe, and has media.eject remove the
boot entry. A catalog ISO is the same for every machine, so a per-node join token cannot live inside it. See
sanboot instead of virtual media.
What the tests assert:
profiles.validateresolves every step of the plugin profile.- Enroll,
ready, deploy,deployedwith rolehost, undeploy,ready; the ISO is ejected and the token appears only inside the ISO, never in a command. - The v0.2 acceptance in simulation: 20 consecutive unattended deploy and release loops, a fresh
single-use token per install, and every transition into
readypreceded by exactly one attested wipe in that workflow. A release whose wipe is not attested endsfailedwithfailedFrom: 'releasing'and maintenance on.
Walkthrough: Talos on an agentless pool
Talos manages itself through its own API and never runs bcd. This is walkthrough 3 of PLAN.md §5.6:
a pool with agent: 'none', inventory from Redfish, and a third-party step that reaches the Talos
API through the site agent, because the control plane never talks to machines directly.
import { field, manifest } from 'better-compute/core';
import { httpViaSite } from 'better-compute/metal';
import { defineProfile, defineStep, stepNotReady } from 'better-compute/provision';
const talosReady = defineStep({
id: 'wait.talosReady',
runsOn: 'control-plane',
relays: ['site'],
sends: ['site.httpProxy'], // the kinds it relays: the workflow's batch scope includes them
timeout: '20m',
retrySafe: true,
input: manifest({ port: field.int({ min: 1, max: 65535 }).default(50000) }),
async run(input, context) {
const { rows } = await context.sql.query<{ addresses: string[] }>(
'SELECT addresses FROM metal_nodes WHERE node_id = $1', [context.node!.id],
);
const address = rows[0]?.addresses[0];
if (!address) throw stepNotReady(10_000);
const response = await httpViaSite(context, {
url: `https://${address}:${input.port}/machine/version`,
tlsSpkiSha256: talosCaSpki, // Talos serves its own CA: pin it
poll: true, // each call returns the newest answer and sends the next request
});
if (response.status !== 200) throw stepNotReady(5_000);
return { talos: response.json<{ version: string }>().version };
},
});
const talos = defineProfile({
id: 'talos-1.8',
version: 1,
target: 'metal',
role: 'host',
steps: [
{ use: 'media.insert', on: 'site', with: { url: 'https://cache.site-a/talos-1.8-metal-amd64.iso' } },
{ use: 'management.setBootDevice', on: 'site', with: { device: 'cd' } },
{ use: 'power.setPower', on: 'site', with: { action: 'cycle' } },
{ use: 'wait.talosReady' },
],
});The pool opts in to what an agentless machine cannot do:
await compute.api.pools.create(credential, {
tenantId: 'root',
siteId,
class: 'metal-host',
agent: 'none',
policy: { allowUnverifiedRelease: true, provisioningCidrs: ['10.30.0.0/16'] },
});- Enrollment runs
metal-enroll, then the out-of-band profiles: Redfish inspection records the machine's MACs and IP addresses, and commissioning only powers it off. No ramdisk ever boots. - Deploy mounts the Talos ISO, boots it and polls the Talos API until it answers. The node is
deployedwith rolehost, and every command went to the site agent. - Release ejects the media and powers off. It reaches
readywithout an attested wipe only because the pool setallowUnverifiedRelease, and metal publishesnode.release.unverified. Without it the release fails withUNVERIFIED_WIPE. - Rescue needs a ramdisk and fails the node with
UNSUPPORTED. - The node never connects, so it is never schedulable for
holdreservations. PLAN.md has such nodes servebinding: 'node'leases from v0.5.
What site.httpProxy allows
The site agent is a trusted proxy on the provisioning network, so httpViaSite refuses anything
beyond talking to the machine itself, with INVALID_INPUT, before a command exists:
- The host must be an IP literal among the machine's recorded addresses. Names are refused, so DNS rebinding cannot redirect it.
httpsonly, unless the address is inside one of the pool'spolicy.provisioningCidrs(IPv4).- No credentials in the URL, no
Hostor hop-by-hop headers, no line breaks in header values, and secret headers only assecret://references that the agent resolves and never logs. - Methods
GET,POST,PUTandDELETE, with a timeout from 1 to 60 seconds (30 by default).
A redirect is DRIVER_ERROR rather than followed, and so is a response over 1 MiB.
site.httpProxy is a destructive kind (0d42de8, f66e236): a request can change the machine it reaches, so with
approver keys configured it runs only in an approved batch, under the leaf node:<target node>. The step must
declare sends: ['site.httpProxy'] as above: the engine records the kinds a workflow can send when it starts, and
metal scopes the workflow's batch from them, so with approver keys set a relaying step without it sends commands the
queue refuses at enqueue: INVALID_INPUT (not in the batch's scope), or INVALID_STATE with reason batch-required
when the workflow has no batch at all. See who creates batches.
bcd site enforces its own limits on each request, whatever the command says, and fails anything else with
forbidden-target: http or https without credentials; GET, POST, PUT or DELETE; a host that resolves only
to site-internal addresses (private, CGNAT 100.64.0.0/10 or unique-local; never loopback, link-local and so metadata
services, multicast or public addresses), with the connection made to the address it checked, never a second lookup;
no redirects; a timeout of at most a minute (30 seconds by default); the pinned SPKI when given; secret headers
fetched with a SecretRequest. It truncates the response at 1 MiB and says so. It never reaches a BMC: every BMC host
it has driven with a bmc.* command is remembered with every address it resolved to (data_dir/bmc-hosts.json) and
refused, by name and by resolved address, and so is any /redfish path and any address in the site agent's own
[site] bmc_networks (local configuration the control plane cannot change; daemon install-script --bmc-networks
writes it, and better-compute doctor warns about site agents without it). Under the v1.0 quorum a request other
than a GET is a write, which runs only under a batch leaf bound to its exact payload; readiness polls such as
Talos's are GETs.
The host must also belong to the command's target_node_id (a2a853d). Metal puts the site's agentless machines
(nodes of agent: 'none' pools that are not retired) into the site agent's spec as SiteSpec.proxy_targets, each
with the addresses Redfish inspection recorded or metal.enroll's addresses gave (3f19859; the only source for an
IPMI machine, whose inspection reports none), and never its BMC host. The list is recomputed when such a machine is
enrolled with addresses, when inspection records addresses and when the machine is retired (8391d74). bcd then requires an IP literal to be one of the target node's listed
addresses, and a name to match a listed name (case-insensitive, trailing dot ignored) or to resolve only to listed
addresses; a target node the list does not name is refused too, all with forbidden-target. The check applies once
the site's spec lists any target, which it does whenever the site has an agentless machine.
From the command line
The metal commands (171d9be) are the metal methods with flags, each decided as the token's identity:
export BETTER_COMPUTE_TENANT=root
better-compute metal enroll --site site_01k5w… --pool pool_01k5w… --bmc-address 10.20.0.14 \
--bmc-driver redfish --bmc-credentials secret://root/bmc-r12-14 --macs b8:ce:f6:00:00:01
better-compute metal deploy node_01k5w… --profile ubuntu-24.04-gpu-host --profile-version 1
better-compute metal power node_01k5w… --action cycle
better-compute metal undeploy node_01k5w… --wipe secure-erase
better-compute metal abort node_01k5w… --reason 'wrong profile'
better-compute metal bulk --pool pool_01k5w… --verb retire --nodes-file retire.txt --reason 'EOL rack r12'- Credentials are a
secret://,vault://orenv://reference (--bmc-credentials), or--bmc-usernamewith--bmc-password-file. There is no password flag. See enrolling a machine. - One verb per node:
manage,inspect,provide,rescue,unrescue,recoverandretiretake only the node;deploytakes--profileand--profile-version,undeploytakes--wipe,aborttakes--reason, andpowertakes--action(on,off,graceful-off,reset,graceful-restart,cycle,nmi). - Many nodes at once:
metal bulkruns one--verb(manage,inspect,provide,undeploy,rescueorretire) on the nodes of--poollisted in--nodes(comma-separated) or--nodes-file(one per line, deduplicated) as onemetal.bulkbatch.--reasonis required: it is what the quorum's approvers see when they review the batch withbatch approve.--wipeis for--verb undeployonly. - PXE discovery:
metal discovered --site IDlists what the site's agents saw PXE-booting from unknown MACs, andmetal confirm-mac NODE --mac MACbinds the MAC of a machine enrolled without one (002dd95), which unblocks an IPMI machine waiting inmetal.ensureRamdisk. The console's PXE discovery page does the same from a browser (15c11ee). See discovering new machines.
workflows list --node, workflows get, workflows retry --from-step and commands list --node follow what a
verb started. Provision profiles have their own commands (profiles create from a JSON document, validate,
render, update --visibility, delete). See command groups.
Release and retire
metal.undeploytakeswipe:quick(the default),secure-erase,crypto-erase, ornone. Every mode butnonemust come back node-signed with every disk's read-back passed before the machine isready.metal.retireworks fromready,failed,enrolling,adoptingoradopted, so undeploy a deployed machine first. It crypto-erases, powers off and revokes the machine's identity;retiredis final.
Better Compute is created by Sean Filimon
Last updated
Provisioning workflows
The workflow engine expands a profile into persisted steps that run on the control plane, a site agent, the node or its ramdisk, and resume after any crash.
Dedicated-root metalexperimental
Lease whole machines to tenants with root, under tenant-controlled trust and node-tenant certificates, and take them back with a BMC-driven teardown.