The Installer
A Bun CLI that builds and inspects clusters, shipped as a single executable with every manifest compiled into it.
Commands
| Command | What it does |
|---|---|
configure |
Build or edit a configuration, interactively. The default when no command is given. |
preflight <config> |
Say whether a configuration could be installed, before installing it. Changes nothing. |
doctor <config> |
Ask a running cluster how it's doing, every node at once. Changes nothing. |
install <config> |
Build the whole cluster, every node in lock step. |
add-node <node> <config> |
Add an agent to a cluster that already exists. |
uninstall <node> <config> |
Take one node out of the cluster and K3s off it, keeping its data. |
clean <node|all> <config> |
Remove the configuration and data an uninstall kept. |
run-bundle <bundle> <node|all> <config> |
Run one bundle against one node or every node. |
run-pipeline <pipeline> <node|all> <config> |
Run several bundles in order, passing findings along. |
run-template <manifest> <config> |
Render a manifest and apply or delete it with kubectl. |
list-bundles, list-embedded |
What there is to run, and what the binary carries. |
Most commands that change things share a set of options:
| Option | Meaning |
|---|---|
--dry-run |
Work out every step and print it, changing nothing. Never opens a connection. |
--keep-going |
Carry on after a step fails instead of stopping. |
--timeout <seconds> |
How long to give a node to answer when connecting. |
--verbose |
Print each command's output as well as the report. |
Installing, adding nodes, and taking them out are covered in Cluster Operations.
Commands and bundles
A command is one step: a shell command on a node, or an HTTP call to an API. A bundle is an ordered list of them aimed at one node, sharing a context so that what one finds is available to the next. The GPU bundle detects hardware, and the labelling bundle puts the right role on the node because of it.
Every command declares two things about itself.
Where it runs. Most belong on the node the bundle is aimed at. Anything using kubectl doesn't, because only a server has a kubeconfig, so it says runOn: ControlPlane and the runner opens the connection it needs. That's what lets a bundle read the hardware on an agent and then label it — impossible from the agent itself.
What it's for, which is mostly whether it changes anything:
| Purpose | Meaning |
|---|---|
Inspect |
Reads and reports. Every answer is a valid answer. |
Require |
Reads, and demands a particular answer before work starts. |
Verify |
Reads, and demands that a change took. |
Settle |
Waits for a change to finish. Changes nothing, but can block. |
Apply |
Changes something. The default, so an unlabelled command is never run by anything that promised only to look. |
doctor and preflight are built on that: they ask a bundle for only the purposes that change nothing, so they're safe to point at a cluster in service.
Which nodes a step runs on
Two different questions look like one on a single node:
runOn |
Which machine executes the shell — the node itself, or a server with kubectl. |
scope |
How many times, and for which nodes. |
Labelling a node shows they're separate: the shell is kubectl so it has to run on a server, and it is nevertheless one command per node.
| Scope | Runs | For |
|---|---|---|
cluster |
Once | Anything addressing Kubernetes rather than a machine. The API is shared, so doing it per node would be doing it again. |
each-node |
Once per node | System configuration, hardware, storage. |
each-server |
Once per server | The K3s server install. |
each-agent |
Once per agent | The K3s agent install, which needs a server to join. |
A command that says nothing gets cluster if it runs on the control plane and each-node otherwise.
Bundles
List them, or the commands inside one, with list-bundles:
| Bundle | What it does |
|---|---|
preflight |
Whether a node could be installed on at all |
base |
The packages every node needs |
network |
Kernel networking — BBR congestion control, fair queueing |
k3s |
K3s, as a server or an agent depending on the node |
cilium |
The Gateway API resources, then Cilium as the CNI |
gateway |
A way into the cluster from outside |
lvm |
Volume groups for TopoLVM to carve volumes from |
gpu |
What video hardware a node has, and whether its tooling is installed |
nodeLabels |
Roles, labels, and what was detected |
cluster-online |
Whether the cluster is up, before joining a node to it |
node-joined |
Whether a node has joined the cluster and gone Ready |
registries |
Credentials for private registries |
helm, helm-charts |
Helm, then the configured charts in dependency order |
nebula, nebula-network |
A Defined Networking mesh |
cluster |
Records the configuration in the cluster itself |
Teardown bundles
These take a cluster apart rather than building one. Their checks assert the opposite of a working cluster — "K3s isn't running here", "this node isn't a member" — so a check-up that asks about everything leaves them out, and doctor --bundle <name> runs them when the question is meant.
| Bundle | What it does |
|---|---|
drain |
Moves the workloads off a node, respecting disruption budgets |
k3s-uninstall |
Stops K3s and removes it, keeping every directory it was using |
node-removed |
Removes a node from the cluster, and checks the cluster agrees |
clean |
Removes the directories k3s-uninstall kept |
storage-remove |
Destroys the volume groups, and everything stored in them |
Checking a cluster
preflight
Before anything is installed, preflight says what would stop an install — no server, two servers, a missing token, a node that can't be reached, a gateway with no addresses.
doctor
Once a cluster exists, doctor asks it how it's doing:
It runs on the same machinery as an install and asks every node at once. A cluster is several machines: asking them one after another makes the report a description of six different moments, and the first slow node delays every answer behind it.
Two things are relaxed, because the questions are read-only. A node that can't be reached doesn't stop the others being asked — a cluster with a machine down is exactly the case you'd run this for — and one failure doesn't stop the run, because one thing being broken is the most likely reason to want to know what else is.
doctor also compares the configuration recorded in the cluster against your file, and reports anywhere they've drifted apart.
Embedded manifests
The manifests and values files under k8s/ are compiled into the binary as a SQLite database, so a released executable can apply them on a machine that has never seen the repository. They're referred to with an embed:// URL, and the paths have no k8s/ prefix:
bun run build rebuilds src/generated/embedded.sqlite before compiling, so anything added under k8s/ is picked up automatically. To rebuild it on its own:
The database is built from scratch each time and the rows go in sorted, so the same tree always produces the same bytes. Only .yaml and .yml files are embedded.
Secrets in commands
Anything a command is given on its command line is readable by every user on that node through a process listing, and gets quoted back in error messages and logs. Credentials go over standard input instead, which only the process reading it ever sees.
A command can take a payload on stdin — a registry password, a rendered manifest — or declare secretEnv, which travels over standard input and is exported by a preamble on the far side. The K3s token, registry credentials, and Nebula enrolment codes all go that way.
{
name: "install-something",
description: "Run an installer that reads a token from its environment",
env: { INSTALL_VERSION: "1.2.3" },
// Read and exported by a preamble on the far side, so the value
// never appears in an argument list
secretEnv: (config) => ({ TOKEN: config.getToken() }),
command: "sh ./install.sh",
}
A few rules follow from a command having only one standard input:
stdinandsecretEnvcan't both be set on one commandsecretEnvvalues have to be single lines; a document belongs onstdinsecretEnvcan't be combined withsudo, which clears the environment it was given
Writing commands
A command is a string, an array of strings, or a function returning either. Nearly all of them are several lines of shell, so the array form is the usual one and the lines are joined for you:
{
name: "wait-for-service",
description: "Wait for the service to come up",
command: [
`for attempt in $(seq 60); do systemctl is-active --quiet ${SERVICE} && break; sleep 1; done`,
`systemctl is-active --quiet ${SERVICE} || { echo "${SERVICE} never came up" >&2; exit 1; }`,
],
}
The lines are joined with a newline rather than "; ", which is what shell keywords taking a body straight after them require — for x in y; do; echo $x; done is a syntax error, and an easy one to write when a join is putting the separators in.
Source layout
installer/
├── scripts/
│ ├── build.ts # Cross-compiles for eight targets
│ └── generateEmbedded.ts # Compiles k8s/ into a SQLite database
└── src/
├── cli/
│ ├── installer.ts # Entry point, commander definitions
│ ├── commands/ # What can be done to a node or cluster
│ └── configure/ # The interactive configuration builder
├── pipeline/ # Planning and running steps in lock step
├── plugins/ # Finding, approving and loading plugins
├── runners/ # What drives the commands
├── remote/ # SSH, and ~/.ssh/config resolution
├── topology/ # The fixed region list
├── util/ # Config, templating, shell, redaction
└── generated/ # The embedded manifest database
Development
Tests live in src/test, use the node:test API with node:assert, and reach nothing outside the repository — no live node, no cluster, no cc.config.json. They run under bun test, which implements node:test faithfully; Node itself can't currently run them, since the codebase uses enum and reads the embedded manifests with bun:sqlite.
Two scripts in src/test/manual talk to a live machine, so they're run by hand: