Profiles and images
Versioned metal profiles that name steps, drivers, an image and user data; the profiles metal ships for each provision state; and the signed image catalog.
A is the recipe for one machine: which steps run, which drivers do each job, which goes on the disk, what user data it boots with and what host configuration the installed OS converges to. Profiles are versioned documents, and a version becomes immutable the first time a workflow uses it, so you can always tell exactly what was run on a machine.
Defining a profile
defineProfile(document) checks a profile and returns a deeply frozen copy. This GPU host profile
follows the one the metal test suite deploys over PXE, with host config, acceptance and cleanup
added:
import { defineProfile } from 'better-compute/provision';
export const ubuntuGpuHost = defineProfile({
id: 'ubuntu-24.04-gpu-host',
version: 1,
target: 'metal',
role: 'host',
image: { ref: 'ubuntu-24.04-server@2026.09', format: 'raw', compression: 'zstd' },
userData: { kind: 'cloud-init', template: 'bcd-host.yaml.hbs' },
hostConfig: { 'nvidia-driver': { branch: '580' }, 'fabric-manager': { when: 'nvswitch' } },
steps: [
{ use: 'userdata.render' },
{ use: 'boot.prepareRamdisk', on: 'site' },
{ use: 'management.setBootDevice', on: 'site', with: { device: 'pxe' } },
{ use: 'power.setPower', on: 'site', with: { action: 'cycle' } },
{ use: 'wait.agentJoined', with: { role: 'ramdisk' } },
{ use: 'deploy.deploy', on: 'ramdisk', timeout: '30m' },
{ use: 'management.setBootDevice', on: 'site', with: { device: 'disk' } },
{ use: 'boot.kexec', on: 'ramdisk', await: 'rejoin', timeout: '15m' }, // completes when the OS joins
],
acceptance: [{ use: 'diag.dcgm', with: { level: 1 } }],
cleanup: [{ use: 'boot.cleanUp', on: 'site' }],
});Prop
Type
defineProfile rejects unknown fields, malformed steps, ids and durations, values that are not
JSON, and fields that belong to the other target, with INVALID_CONFIG whose details.path names
the first offending field (for example steps[2].timeout). Through the API the same problems are
INVALID_INPUT. The step fields (on, with, timeout, retries, await, onFailure,
fallback, when) are described on provisioning workflows.
Storing and checking profiles
Profiles live in two places: those your code ships through provisionModule({ profiles }) or a
module's profiles (read-only, visible to every tenant, listed under the tenant builtin), and
stored ones in the profiles table, per tenant and version. A lookup prefers the tenant's own
profile, then a public stored one, then a shipped one.
| Method | Action | What it does |
|---|---|---|
profiles.create | compute:profiles:manage | stores a new version with visibility private (default) or public; an existing version is CONFLICT |
profiles.get | compute:profiles:read | one version, the latest when profileVersion is left out |
profiles.list | compute:profiles:read | the tenant's, public and shipped profiles, up to 200 a page |
profiles.validate | compute:profiles:read | { valid, issues }: every use that does not resolve and every with its step rejects |
profiles.render | compute:profiles:read | the expanded steps a workflow of this profile would run |
profiles.update | compute:profiles:manage | replaces an unused version's document, or changes visibility; a used version is INVALID_STATE |
profiles.delete | compute:profiles:manage | hides one version, or all, from new workflows; running workflows keep their steps |
Run validate before the first deploy: it reports every problem at once with its path, where a
deploy would fail on the first.
better-compute profiles create ./ubuntu-gpu-host.json --tenant root # --public for every tenant
better-compute profiles validate ubuntu-24.04-gpu-host --tenant root --profile-version 1
better-compute profiles render ubuntu-24.04-gpu-host --tenant root # the latest versionThe profiles commands (171d9be) default --profile-version to the latest version where the method allows it;
profiles update takes --file (a replacement document) or --visibility, and --if-version for the row's
version.
The profiles metal ships
Metal runs one shipped profile per workflow-driven state. Deploying is the exception: it runs the
profile named in metal.deploy.
| Profile | State | Main steps | Cleanup |
|---|---|---|---|
metal-enroll@1 | enrolling | power.getPower, inspect.oob | none |
metal-inspect@2 | inspecting | metal.ensureRamdisk, inventory.refresh | boot.cleanUp |
metal-commission@1 | commissioning | metal.ensureRamdisk, diag.dcgm level 2, disk.wipe quick, power.setPower off | boot.cleanUp |
metal-release@1 | releasing | metal.ensureRamdisk, disk.wipe with the undeploy's mode, power.setPower off | boot.cleanUp |
metal-retire@1 | retiring | metal.ensureRamdisk, disk.wipe crypto-erase, power.setPower off (each continues on failure), metal.revokeIdentity | boot.cleanUp |
metal-rescue@1 | rescue | metal.ensureRamdisk | none |
metal-unrescue@1 | rescue, started by metal.unrescue | management.setBootDevice disk (persistent), power.setPower cycle, wait.agentJoined role node (20m) | boot.cleanUp |
Pools whose machines never run bcd (agent: 'none') cannot boot a ramdisk, so they get
out-of-band variants: metal-inspect-oob@1 (inspect.oob), metal-commission-oob@1 (power off),
metal-release-oob@1 (media.eject, then power off) and metal-retire-oob@1 (power off, then
metal.revokeIdentity). Rescue on such a pool fails the node with UNSUPPORTED. The two tenant-root
teardown profiles are on dedicated-root metal, and provision
ships remediate-reboot@1 and remediate-gpu-reset@1 for health remediation.
Steps you can use
| Step | Runs | Input | Timeout |
|---|---|---|---|
power.getPower | relays to the site agent | none | 2m |
power.setPower | relays to the site agent | action: on, off, graceful-off, reset, graceful-restart, cycle, nmi | 5m |
management.setBootDevice | relays to the site agent | device: pxe, http, disk, cd; persistent | 2m |
inspect.oob | relays to the site agent | none | 5m |
media.insert | relays to the site agent | url (https), fromStep or image (a catalog iso) | 30m |
media.eject | relays to the site agent | none | 5m |
boot.prepareRamdisk, boot.cleanUp | relays to the site agent | none | 2m |
metal.ensureRamdisk | control plane | none | 20m |
inventory.refresh | relays to the ramdisk or node | none | 10m |
diag.dcgm | relays to the ramdisk or node | level 1 to 4 (default 1) | 1h |
disk.wipe | relays to the ramdisk | mode: none, quick, secure-erase, crypto-erase | 6h |
deploy.deploy | relays to the ramdisk | image (defaults to the profile's) | 1h |
boot.kexec | ramdisk, await: 'rejoin' | none | 15m |
userdata.render | control plane | none | 2m |
wait.agentJoined | control plane | role: node, ramdisk or site | 15m |
wait.generationObserved | control plane | none | 10m |
metal.ensureRamdiskdoes nothing when the machine's ramdisk is already connected. Otherwise it sets one-time PXE, sends the boot entries and power-cycles the machine, then waits for the ramdisk to join. A machine known only by its product UUID is first power-cycled into PXE with no entry, so its site agent lists it; the step then waits until the MAC PXE discovery proposes is confirmed withmetal.confirmMac, or bound at once on a pool withpolicy.trustProvisioningNetwork(3d26428, 8de6e2f).inventory.refreshchecks that the machine that booted is the one enrolled (8de6e2f): a product UUID in the ramdisk'ssystemsection must be a spelling of the enrolled one, and for a MAC PXE discovery bound it waits for the ramdisk'ssystemandnicssections and requires the NICs to include that MAC. A mismatch fails the workflow withDRIVER_ERROR(reasonmachine-identity-mismatch) before anything is written.diag.dcgmfails withDRIVER_ERRORunless the result is node-signed and every test passed.disk.wipesucceeds withattested: falsewhen the signature or a disk's read-back check fails; metal then refuses to release the node toready(UNVERIFIED_WIPE).deploy.deployresolves the image in the machine owner's tenant (its own image first, then a public one), refuses aniso(INVALID_INPUT), sendsdeploy.imagewith the source URL, digest, format, compression and a user-data reference, and fails withDRIVER_ERRORwhen the digest the ramdisk wrote differs from the catalog's.media.insertwithfromStepmounts theurlan earlier step of the same workflow returned: how a plugin hands a per-node ISO to the BMC (see deploying).media.insertwithimage(3d26428) names anisoof the catalog: virtual media mounts itshttps://source, and a BMC without virtual media boots it with iPXEsanbootfrom the site cache instead; a latermanagement.setBootDevicetocdthen setspxe, andmedia.ejectremoves the boot entry. See sanboot instead of virtual media.
Driver bindings from the hardware profile
Machines in a pool usually share their BMC and boot hardware, so driver choices live in a hardware profile rather than on each node:
metal({
hardwareProfiles: {
'gpu-redfish': { drivers: { power: 'redfish', management: 'redfish', boot: 'ipxe', deploy: 'image-stream', inspect: 'agent' } },
},
});
await compute.api.pools.create(credential, {
tenantId: 'root', siteId, class: 'metal-host', policy: { hardwareProfile: 'gpu-redfish' },
});At enrollment, resolveDriverBindings builds the node's bindings in three layers:
- The BMC given to
metal.enrollbindspower,managementandvirtual-media, with its address and credential reference as config. - The hardware profile (the node's
hardwareProfile, else the pool'spolicy.hardwareProfile) overrides per concern. - Per-node overrides win.
An unknown hardware profile is INVALID_INPUT. The bindings are stored on the node and served in its
NodeSpec. Metal's own BMC steps use the BMC driver the node was enrolled with; a profile's drivers
apply to the driver operations it invokes directly.
User data
A deployed OS has to join as the same node, so it needs a join token in its first-boot
configuration. userdata.render builds that configuration from the profile's userData:
- It mints a single-use
provisionedjoin token for the node's pool and site (valid 2 hours, ortokenTtlSeconds). - It renders the named template with the variables
nodeId,hostname(the node id with_turned into-),joinToken,controlPlaneUrl,bcdUrlandbcdSha256. - It stores the document in the secret store and returns only
{ userDataRef, kind, joinTokenId }.deploy.deploypasses the reference, and the ramdisk fetches the document with a SecretRequest.
Two templates ship: bcd-host.yaml.hbs (cloud-init) and bcd-host.ign (Ignition 3.4.0, built as
JSON so values cannot change its structure). The cloud-init one writes the token to a 0600 file and a
bcd.service unit, downloads bcd, checks its digest, joins, then starts the node agent:
#cloud-config
hostname: {{hostname}}
write_files:
- path: /etc/bcd/join-token
permissions: '0600'
content: {{joinToken}}
- path: /etc/systemd/system/bcd.service
permissions: '0644'
content: |
[Unit]
Description=better-compute node agent
After=network-online.target bcd-join.service
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/bcd node
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
runcmd:
- [sh, -c, "curl -fsSL '{{bcdUrl}}' -o /usr/local/bin/bcd && echo '{{bcdSha256}} /usr/local/bin/bcd' | sha256sum -c - && chmod 0755 /usr/local/bin/bcd"]
- [/usr/local/bin/bcd, join, --server, {{controlPlaneUrl}}, --token-file, /etc/bcd/join-token, --node-id, {{nodeId}}, --serving-ca-pin, {{servingCaPin}}, --join-signing-pin, {{joinSigningPin}}, --join-encryption-pin, {{joinEncryptionPin}}]
- [systemctl, enable, --now, bcd.service](The template is a function; the snippet shows its output with placeholders. Without pins the three --…-pin
arguments are left out.)
The Ignition one does the same with two units: bcd-join.service, a oneshot that runs only while the token file
exists, and bcd.service after it. Both run exactly bcdJoinArgv() and bcdNodeArgv() (exported from
@better-compute/provision), which bcd's own tests check against its flags:
bcd join --server URL --token-file PATH --node-id IDjoins with theprovisionedtoken and removes the file once joined. bcd refuses an identity other than the node the OS was installed for, and--serversupplies the join encryption key from the control plane's well-known document whenjoin-encryption.jwkis absent (it must match the pinned key).- The ramdisk's
deploy.imageinstalls its pinnedconfig.tomlandjoin-encryption.jwkinto the OS it wrote, sobcd nodefinds its configuration at the default path. - The three pins are the trust anchors bcd checks the well-known document against: the gateway serving CA, the key
that signs
JoinResponse, and the JoinService key the token is sealed to. They come from the host's authenticated PKI bundle (userData.pins, see below), never from that document, so a forged document (DNS takeover plus a Web PKI certificate) cannot swap them. A real bcd refuses to bootstrap without all three.
controlPlaneUrl must be an http(s) URL without spaces, quotes or shell and systemd special characters (it is a plain
argument). bcdUrl is single-quoted for sh -c, so a presigned URL's & and % are fine; it may not hold quotes,
backslashes or whitespace. bcdSha256 is 64 lower-case hex digits, and each pin is sha256/ plus the base64 SPKI
digest. provisionModule refuses anything else with INVALID_CONFIG, including a partial set of pins.
- Configure it with
provisionModule({ userData: { controlPlaneUrl, bcdUrl, bcdSha256, pins?, templates?, tokenTtlSeconds? } }), wherepinsis{ servingCaPin, joinSigningPin, joinEncryptionPin }from the host's bundle. Without the URLs and the digest, rendering fails withINVALID_CONFIG. Custom templates get the pins as the variables of the same names. - A template is a string with
{{name}}placeholders or a function of the variables. Every value must be a single line without{{, so a value can never add lines to the document; a placeholder with no value isINVALID_CONFIG. - No template ships for
kind: 'talos'; bring your own. - A profile with
userDatawhose deploy runs before anyuserdata.renderstep fails withINVALID_CONFIG. A re-run after a crash mints a new token; the unused one is single-use and expires, so it never becomes a second way in. - The installed
bcdjoins through theprovisionedattestor, which maps the token back to the running workflow's node (see sites for the wiring).
Host config
hostConfig is not a step: when the workflow succeeds, its units become the node's desired state
(the NodeSpec host section), and bcd's host converger applies them and sets drift back on its next
pass (6c2343f). The built-in units, in the order bcd applies them:
| Unit | Settings |
|---|---|
kernel-modules | load (module names), options (module: "key=value ...") |
sysctl | sysctl names with scalar values; keys that run programs as root or lower the host's defenses are INVALID_INPUT |
hugepages | pages2m, pages1g, transparent (always, madvise, never), perNuma |
cpu | governor (performance, schedutil, ondemand, powersave), isolate (a cpulist, never CPU 0; effective after a reboot) |
iommu | passthrough, default true (effective after a reboot) |
nvidia-driver | branch (three digits), open |
fabric-manager | none |
gpu-settings | powerLimitWatts (1 to 10,000), ecc (effective after a reboot) |
mig-layout | from: 'pool' takes the pool's policy.migLayout, or a layout |
gpu-sharing | mode (mps or timeslice), replicas (1 to 48) |
container-toolkit | cdi, default true |
k8s-join | server, caCertSha256, tokenRef (a secret:// reference), kubernetesVersion, nodeLabels, taints, gpu, gpuApi (device-plugin or dra) |
Any unit takes when, a label expression. The pool can set units too, and a pool's unit replaces the profile's:
policy.hugepages, policy.kernelModules, policy.cpu, policy.iommu, policy.gpu and policy.kubernetes, and
gpu-sharing for a pool shared by mps or timeslice. Host configuration describes
what each unit does on the machine, the files it writes, what it refuses and when it reports RebootRequired, and
the host config units reference lists each unit's settings field by field. Add
units with provisionModule({ configUnits }); bcd applies only the units it knows, and reports any other as a
terminal failure once the units it knows had their turn.
The image catalog
Images are built outside the control plane (mkosi or Packer, as raw.zst or qcow2) and registered
by digest. The catalog records where the artifact lives, what it is, and whether a trusted signer
vouched for it. It never stores the image itself.
import { sign } from 'node:crypto';
import { imageSigningInput } from 'better-compute/provision';
const image = {
ref: 'ubuntu-24.04-server@2026.09',
kind: 'os',
format: 'raw',
compression: 'zstd',
sha256: '<64 hex digits>',
} as const;
const signature = sign(null, imageSigningInput(image), signingKey).toString('base64'); // Ed25519
await compute.api.images.register(credential, {
tenantId: 'root',
...image,
sizeBytes: 3_221_225_472,
source: 'https://images.example.com/ubuntu-24.04-server-2026.09.raw.zst', // https:// or oci://
signerId: 'platform-2026',
signature,
});Prop
Type
- What is signed.
imageSigningInputis the UTF-8 textbc-image-v1, then the kind, ref, format, compression and lower-case digest, one per line. Sign it with Ed25519, or ECDSA P-256 over SHA-256 (IEEE P1363 or DER encoding). - Signers. Allowlists are rows of
image_signersper tenant, with SPKI PEM keys; the root tenant's signers are the platform's. Hosts add and revoke them withaddImageSignerandrevokeImageSigner; there is no API method. Images a revoked signer verified keep their flag, but no longer deploy (below). - Verification. A signature must verify against the named signer of the tenant or the platform,
else
INVALID_INPUT. A verified image hasverified: true. The default check is offline againstimage_signers; swap in Sigstore or a KMS withprovisionModule({ imageVerifier }). - Visibility. Unsigned images stay private to their tenant forever (a table
CHECKenforces it).images.promotemakes a verified image public to every tenant; on an unsigned one it isINVALID_STATE. - At deploy time. Before metal relays
deploy.image,verifyImageForUsere-verifies the image's stored signature against the signer allowlist it was verified against at registration. An unsigned image, a revoked signer or a tampered catalog row stops the deploy withINVALID_STATE(details.reasonunsignedorsignature), and the node fails out ofdeployingwithout anydeploy.imagesent. Pass the verifier you gaveprovisionModule({ imageVerifier })asmetal({ images: { verifier } })when it is not the default allowlist.images: { allowUnsigned: true }lets a tenant-private unsigned OS image through, for development only; never a ramdisk, and never a signed image whose signature fails. - Reading.
images.getandimages.list(filter bykind, up to 200 a page) return the tenant's own images and public ones. A profile'simage.refresolves to the tenant's own image first.
images.register and images.promote are decided as compute:images:publish, get and list as
compute:images:read.
From the command line (d1e83ca), images register takes the format as --disk-format (--format is the CLI's
output flag), and --signature and --signer only together:
better-compute images register --tenant root --ref ubuntu-24.04-server@2026.09 \
--source https://images.example.com/ubuntu-24.04-server-2026.09.raw.zst \
--disk-format raw --compression zstd --sha256 <64 hex digits> --size 3221225472 \
--signer platform-2026 --signature "$SIGNATURE"
better-compute images list --tenant root --kind os
better-compute images promote img_01k5w… --tenant rootSite caches
The catalog also tracks which site caches hold which images (image_locations: pulling, ready,
failed, evicting). The rows are derived from the site agents' own caches: each agent reports its cache
in the images inventory section (node_image_cache), a change queues the provision.site-cache
reconciler for the site, and syncSiteImageLocations marks a catalog image whose digest a live site agent
holds as at the site (ready if any agent has it ready, else pulling or evicting), and drops the
locations no agent holds any more unless a deploy pinned them. recordImageLocation and
touchImageLocation record reports and use (and pin an image while a deploy streams it), imagesCachedAt(siteId) lists the ready images at a site (the
image-cached placement scorer reads nodes' own node_image_cache rows instead, see
scheduler plugins), and evictionCandidates(siteId, bytes, inUse) picks the least recently used ready images,
never pinned or in-use ones. What to pre-warm is desired state today: a pool's policy.prewarmImages lists
name@version refs, and provision puts the ones that verify into its site agents' NodeSpec site.cacheImages
(see sites). bcd site pulls that list into its cache, verifies each digest before
the copy becomes visible, serves it to the site's ramdisks by digest and reports it in its images inventory
section; a ramdisk's deploy.image tries the cache first and falls back to the source (see
the boot server). It does not pull on demand: an image
no pool pre-warms streams from its source.
Better Compute is created by Sean Filimon
Last updated
BMC drivers and credentials
The driver concerns metal adds, how BMC operations reach the site agent, enrolling with sealed BMC credentials, power control and two-phase BMC password rotation.
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.