Documentation

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:

list-bundles
$ community-cloud list-bundles
$ community-cloud list-bundles lvm
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.

preflight
$ community-cloud preflight cc.config.json
$ community-cloud preflight cc.config.json -n phoenix # one node

doctor

Once a cluster exists, doctor asks it how it's doing:

doctor
$ community-cloud doctor cc.config.json # everything, every node
$ community-cloud doctor cc.config.json --verifications # only what asserts
$ community-cloud doctor cc.config.json -n phoenix # one node
$ community-cloud doctor cc.config.json -b lvm # one bundle
$ community-cloud doctor cc.config.json --wait # include slow settle checks

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:

run-template
$ community-cloud run-template embed://ai/vLLM/vllm-amd.yaml cc.config.json
$ community-cloud run-template ./ops/my-template.yaml cc.config.json
$ community-cloud list-embedded # everything it carries
$ community-cloud list-embedded authentik # filtered

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:

installer
$ bun run build:embedded

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:

  • stdin and secretEnv can't both be set on one command
  • secretEnv values have to be single lines; a document belongs on stdin
  • secretEnv can't be combined with sudo, 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

installer
$ bun run cc <args> # run from source without building
$ bun run typecheck # tsc --noEmit
$ bun run test # everything
$ bun run test:watch # on change

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:

manual tests
$ bun src/test/manual/cliInstall.ts cc.config.json phoenix
$ bun src/test/manual/K3sInstall.ts cc.config.json phoenix