Provision an ephemeral Talos Linux cluster on a CI runner from a
declarative config file instead of a wall of flags. Wraps talosctl cluster create,
with either the QEMU or the Docker provisioner.
- uses: home-operations/talosctl-cluster-action@v1
with:
config: test/e2e/cluster.yaml# test/e2e/cluster.yaml
apiVersion: v1alpha1
kind: TalosCluster
metadata:
name: e2e
spec:
controlplanes:
count: 1
workers:
count: 2
cpus: 2
memory: 4GiB
network:
cidr: 10.5.0.0/24
config-patches:
workers:
- machine:
sysctls:
net.ipv4.ip_forward: "1"Subsequent steps get KUBECONFIG and TALOSCONFIG in the environment, so kubectl
and talosctl just work. The cluster is destroyed in the post step.
Real per-node kernels, real talosctl upgrade, real Pod Security enforcement: things
kind cannot give you. The setup is fiddly in the same way every time (QEMU packages,
swap headroom, an Image Factory schematic, sudo, a chown afterwards, a socket path
length limit that is not obvious until it bites), so it lives here once.
spec.provider picks how the nodes run. They are not interchangeable:
qemu (default) |
docker |
|
|---|---|---|
| Nodes are | VMs with their own kernel | containers on the host kernel |
| Control planes | any number | always exactly one |
| Disks, schematics | yes | no |
talosctl upgrade |
yes | no |
| Needs | /dev/kvm, passwordless sudo |
a reachable Docker daemon |
| Boots in | minutes | seconds |
Use qemu when the test needs a real kernel, real disks, or a real upgrade; docker
when it only needs a Kubernetes API to talk to. Both need talosctl v1.14 or
newer on PATH, and the clusters run Talos 1.14+: the ephemeral profile is written
as the typed config documents that 1.14 configs use, which older nodes reject as
unknown. Repos on older Talos should pin an older release of this action rather than
upgrade.
The docker provider maps onto talosctl cluster create docker. The qemu provider is
driven through talosctl cluster create dev: upstream froze the qemu
subcommand's flag surface and points anyone needing the full set at dev, so the
action pins the behavior the qemu subcommand had — the same Image Factory boot media
(as explicit URLs), the same boot-to-maintenance-then-apply-over-the-API lifecycle,
the same schematic-pinned install image — through explicit flags, and layers the
extra capabilities only dev exposes on top over time. dev makes no
interface-stability promise between talosctl releases; this action's e2e pins the
tested version, and a talosctl release the action has not caught up with may need an
action update. Check the release notes before jumping a talosctl minor.
The action provisions the cluster and nothing else: it does not install packages or
touch the host's swap. That keeps it working on any distribution and keeps host
mutation in your workflow, where you can see it. Both providers need talosctl
v1.14 or newer on PATH.
- name: Install talosctl
env:
# renovate: datasource=github-releases depName=siderolabs/talos
TALOS_VERSION: v1.14.0-beta.1
run: |
curl -sfL "https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" -o talosctl
sudo install -m 0755 talosctl /usr/local/bin/talosctl
- name: Install QEMU
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends qemu-system-x86 qemu-utils ovmf
# Runner swap, for the runner's own kernel, not the cluster. The nodes are QEMU VMs,
# so their RAM is ordinary host memory: under an upgrade's transient spike (each node
# unpacking an installer alongside etcd and the API server) the kernel pages a QEMU
# process out to here instead of OOM-killing it, which would read as a flaky test. The
# guest never sees this swap, so it needs no Talos or kubelet config. Its own file, so
# it adds to the runner's existing swap; skip it and a 16GB runner OOMs mid-upgrade.
- name: Enable swap
run: |
sudo fallocate -l 8G /mnt/e2e-swapfile
sudo chmod 600 /mnt/e2e-swapfile
sudo mkswap /mnt/e2e-swapfile
sudo swapon /mnt/e2e-swapfiletalosctl, plus one kernel module. The daemon is already running on GitHub-hosted
runners and the runner user is already in the docker group, so no install is needed.
- name: Install talosctl
env:
# renovate: datasource=github-releases depName=siderolabs/talos
TALOS_VERSION: v1.14.0-beta.1
run: |
curl -sfL "https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" -o talosctl
sudo install -m 0755 talosctl /usr/local/bin/talosctl
# The nodes are containers on the runner's kernel, so flannel runs against host modules
# it cannot load itself. Without br_netfilter its `bridge-nf-call-iptables` probe fails,
# flannel never writes /run/flannel/subnet.env, and every pod sandbox then fails on the
# missing CNI subnet. cluster create sits waiting on CoreDNS until it times out. Runners
# do not load this module by default.
- name: Load br_netfilter
run: sudo modprobe br_netfilterThe action shells out to sudo for exactly one thing: talosctl cluster create dev.
That is not a choice. The QEMU provisioner's first preflight check is
os.Geteuid() != 0, and its own error recommends sudo -E:
error: please run as root user (CNI, qemu hvf requirement), we recommend running with
sudo -E
Root is what lets it run the CNI plugins that build the bridge and tap devices, and
write the iptables NAT rules. -E matters too: sudo resets HOME, and cluster state
lives at $HOME/.talos/clusters. If the runner is already root, the action skips
sudo entirely. The docker provider never uses sudo: its provisioner has no root
check at all, it just needs a reachable daemon.
Every qemu run starts by downloading boot media from the Image Factory. talosctl
already keeps those downloads in ~/.talos/cache, keyed by source URL, and checks
there before downloading; cache: true carries that directory across runs through
the GitHub Actions cache, so only the first run per combination touches the Factory:
- uses: home-operations/talosctl-cluster-action@v1
with:
config: test/e2e/cluster.yaml
cache: trueThe cache key covers everything that changes what talosctl downloads: Talos version, schematic, presets, factory URL, and architecture. A changed spec misses and downloads fresh; a hit cannot serve wrong bytes, because talosctl's own lookup is by full URL. Off by default because it spends the repository's cache quota, roughly one ISO per Talos version and schematic. qemu only: the docker provider pulls a container image, and no boot assets are involved.
metadata.name is the cluster name. Every field under spec maps to exactly one
flag. A field the spec omits is not passed, so it keeps talosctl's own default rather
than one this action invents and then has to track across Talos releases.
Shared by both providers (the flag column shows the qemu provider's dev dialect; docker keeps its own names for a few of them):
| Field | Flag |
|---|---|
metadata.name |
--name |
spec.provider |
selects the subcommand |
spec.profile |
(see below) |
spec.kubernetes-version |
--kubernetes-version |
spec.controlplanes.count |
--controlplanes (qemu only) |
spec.controlplanes.cpus |
--cpus (docker: --cpus-controlplanes) |
spec.controlplanes.memory |
--memory (docker: --memory-controlplanes) |
spec.workers.count |
--workers |
spec.workers.cpus |
--cpus-workers |
spec.workers.memory |
--memory-workers |
spec.network.cidr |
--cidr on qemu, --subnet on docker |
spec.network.mtu |
--mtu |
spec.config-patches.cluster |
--config-patch |
spec.config-patches.controlplanes |
--config-patch-control-plane / -controlplanes |
spec.config-patches.workers |
--config-patch-worker / -workers |
Provider-specific, and an error if it does not match spec.provider:
| Field | Becomes |
|---|---|
spec.qemu.talos-version |
--talos-version, and the version in every Factory URL |
spec.qemu.disks |
--disk + --extra-disks* (see Disks) |
spec.qemu.schematic / .schematic-id |
the schematic in the boot media and install-image URLs |
spec.qemu.image-factory.url |
the base of every Factory URL the action builds |
spec.qemu.image-factory.auth |
an authenticated pre-download into talosctl's asset cache |
spec.qemu.presets |
the boot media flag (--iso-path, --disk-image-path, …) |
spec.docker.image |
--image |
spec.docker.host-ip |
--host-ip |
spec.docker.exposed-ports |
--exposed-ports |
spec.docker.mounts |
--mount (repeated) |
spec.network.cidr is one field because both subcommands set the same underlying
option. spec.controlplanes.count is the awkward one: docker never exposes it and
always runs exactly one, so setting anything else there is rejected rather than
silently ignored.
spec is optional: a document with only metadata.name boots a qemu cluster on every
talosctl default.
The document is validated against a JSON schema before anything is provisioned, and unknown fields are an error rather than a silent no-op. Point your editor at it for completion:
# yaml-language-server: $schema=https://raw.githubusercontent.com/home-operations/talosctl-cluster-action/main/schema/talos-cluster.jsonA cluster that lives for one CI run wants a set of settings that a real cluster does
not. spec.profile defaults to ephemeral and applies them, so a spec is only what
makes your cluster different:
| Setting | Why |
|---|---|
talos.dashboard.disabled=1 |
The console dashboard redraws for nobody to watch. |
talos.auditd.disabled=1 |
Kernel audit events nothing reads. |
mitigations=off |
Side-channel mitigations cost cycles in a throwaway guest. |
init_on_alloc=0 |
Zeroing every allocation costs cycles in a throwaway guest. |
KubeletConfig GC + eviction thresholds at zero |
Stops image GC and pod eviction from reading as flakes. |
KubeletConfig serializeImagePulls: false (3 parallel pulls) |
Pulls images in parallel, the slowest part of a cold start. |
machine.time.disabled |
NTP sync gates etcd, the kubelet and trustd on every boot. |
| discovery service off | Nothing local needs the public discovery service. |
etcd unsafe-no-fsync |
Durability for I/O, on a cluster about to be deleted. |
KubeAuditPolicyConfig rules: None |
Talos logs every request to disk by default. |
mitigations=off does not cover page-table isolation, and nothing can make it. Talos
bakes pti=on into every image, and since Linux 6.17 an explicit pti=on outranks
mitigations=off, so PTI stays on even on CPUs carrying no Meltdown bug. Negating it is
not available either: Talos enforces pti=on and slab_nomerge as KSPP requirements and
a node missing either fails its systemRequirements boot phase.
Every run logs exactly what it applied; nothing here happens silently.
profile: none applies none of it. Under the docker provider the kernel args are
skipped, because they ride in an Image Factory schematic and docker boots a prebuilt
image instead; the kubelet, etcd, and audit settings still apply.
Overriding one setting does not mean giving up the rest. The profile's settings are ordinary config patches emitted before yours, and talosctl applies patches in order with a deep merge, so your value wins on the key you set and everything else stays:
spec:
config-patches:
controlplanes:
- cluster:
etcd:
extraArgs:
unsafe-no-fsync: "false" # keep the rest of the profileThe kubelet and audit-policy settings are the 1.14 typed documents
(KubeletConfig, KubeAuditPolicyConfig) rather than v1alpha1 sections, because a
1.14 config that sets the same area in both is rejected; overriding those means
patching the same document kind. Patching .machine.kubelet or
.cluster.apiServer yourself hits the same rejection. Discovery is not a patch at
all: the qemu provider turns it off at generation time, and the docker provider
deletes the generated DiscoveryServiceConfig document; wanting it back on means
adding that document via config-patches.
The install image is pinned by the action itself, not by the profile: it always
passes --install-image pointing at the Factory installer for the schematic in play
(or the empty one), because the dev subcommand's default is the generic installer,
which would silently drop every schematic extension on the first talosctl upgrade.
Because the pin needs a Talos version, and a spec need not name one, the action falls
back to the version talosctl would have chosen for itself.
Kernel args are the exception to the override rule. They live in the Image Factory schematic rather than in a config patch, so no later patch can override them. They merge by key instead, and your schematic wins:
spec:
qemu:
schematic:
customization:
extraKernelArgs:
- mitigations=auto # beats the profile's mitigations=offIf you pass a pre-registered spec.qemu.schematic-id, the action cannot fold kernel args
into an opaque id; it warns and applies the rest of the profile.
--talos-version wants a leading v and --kubernetes-version refuses one. Write
either the way it appears in the release notes; the action normalises both.
spec.qemu.disks is a cluster-wide list, not a per-role setting. The first entry goes to
every node and any after it are attached to workers only; that is talosctl's own
semantics.
spec:
qemu:
disks:
- virtio:10GiB # every node
- virtio:20GiB # workers onlyTwo constraints come from the dev subcommand's count-based disk flags, and violating
either is a validation error rather than a silent reshaping: the first disk must be
virtio (and carries no tag=/serial=), and the extra disks must all be the same
size. Extras keep per-disk drivers, tags, and serials.
Disks are always sparse: the dev subcommand defaults to preallocating them, which turns a spec's nominal sizes into real bytes and fills a CI runner's disk before the first node boots, so the action pins the qemu subcommand's old sparse behavior. And when a spec names no disks at all, talosctl's default is now a single 6GiB disk per node — the previous subcommand defaulted to 10GiB plus a 6GiB extra on workers, so a test that relied on that implicit second disk must declare it.
Both take an inline object or an "@path" string, resolved relative to the config
file so a spec can sit next to the files it references:
spec:
qemu:
schematic: "@schematic.yaml"
config-patches:
cluster: ["@patches/registry.yaml"]
controlplanes:
- cluster:
etcd:
extraArgs:
unsafe-no-fsync: "true"Patches may reference ${SCHEMATIC_ID}, ${TALOS_VERSION}, ${KUBERNETES_VERSION},
${CLUSTER_NAME}, and ${GATEWAY}. SCHEMATIC_ID is why this exists: talosctl never sets
machine.install.image, so nodes come up on the generic installer and lose the
schematic's extensions on the first upgrade. Pinning it needs the schematic id, which
is only known after this action registers the schematic:
spec:
config-patches:
cluster:
- machine:
install:
image: factory.talos.dev/installer/${SCHEMATIC_ID}:${TALOS_VERSION}GATEWAY is the host's own address on the cluster network, the first in the CIDR,
and the only address a patch can use to point nodes at something the runner serves.
The classic case is a registry pull-through cache: kubelet image pulls dominate a
cold bring-up, and a mirror on the host makes them cacheable between runs. The
workflow runs the mirror, with its storage under actions/cache:
- name: Run a registry.k8s.io pull-through cache
run: |
docker run -d -p 5001:5000 \
-e REGISTRY_PROXY_REMOTEURL=https://registry.k8s.io \
-v "$HOME/.registry-mirror:/var/lib/registry" registry:3and the spec points the nodes at it:
spec:
config-patches:
cluster:
- machine:
registries:
mirrors:
registry.k8s.io:
endpoints: ["http://${GATEWAY}:5001"]spec.qemu.image-factory points both consumers at a self-hosted or mirrored factory.
Leave it out and everything uses the official https://factory.talos.dev/.
spec:
qemu:
image-factory:
url: https://factory.internal/image-factory
auth: ${FACTORY_CREDENTIALS} # username:password
schematic:
customization:
systemExtensions:
officialExtensions:
- siderolabs/drbdThe action is the only consumer: it registers the schematic over HTTP, builds the
boot media URLs against the same base, and with auth set it downloads the media
itself — HTTP Basic in headers, never in a command line — into the URL-keyed cache
talosctl checks before downloading anything. A URL with a path is kept intact, so
/image-factory above is registered at /image-factory/schematics. The one
host-shaped exception is the install image, which is an OCI reference and therefore
uses the factory URL's host only. Keep auth in a GitHub secret rather than
committing it.
talosctl's maintenance preset ("Skip applying machine configuration and leave the
machines in maintenance mode") passes through like any other, and the action detects
it. That turns the action into a "give me unconfigured Talos nodes on this runner"
primitive for repos that bring their own config management: an e2e workflow for a
cluster template can exercise its real bootstrap flow against freshly booted nodes,
the same way a user would against bare metal.
spec:
controlplanes:
count: 1
workers:
count: 0
qemu:
presets: [iso, maintenance]No cluster forms behind the nodes, so the action skips the kubeconfig fetch and
exports neither KUBECONFIG nor TALOSCONFIG; the kubeconfig output is empty.
The hand-off is the controlplane-ips, worker-ips, and gateway outputs: the
nodes answer on the insecure maintenance API, and your tooling takes it from there.
- uses: home-operations/talosctl-cluster-action@v1
id: cluster
with:
config: test/e2e/maintenance.yaml
- run: talosctl -n "${IPS%%,*}" get links --insecure
env:
IPS: ${{ steps.cluster.outputs.controlplane-ips }}Three details worth knowing:
- The action waits for the maintenance API before returning.
cluster createitself returns as soon as the VMs launch, since there is no configuration to wait on, so the action polls until every node accepts a connection on port 50000; the firsttalosctl --insecurea later step runs does not race the boot. - Machine configs are still generated, with the profile and any
spec.config-patchesfolded in; they are written toconfig-dirbut never applied, and thetalosconfigoutput points at the matching client config. Apply them yourself or ignore them entirely. - The post-step teardown is unchanged: the nodes and their network are destroyed at the end of the job as usual.
Testing several cluster shapes is the common case: one leg per shape, each on its own
runner. Give every leg its own document and keep the bulky parts in shared files
pulled in by @path, so a leg is only the handful of lines that actually differ.
A worked set lives in examples/matrix/:
examples/matrix/
├── 1cp-0w.yaml # one leg per cluster shape
├── 1cp-1w.yaml
├── 3cp-0w.yaml
└── patches/
└── registry.yaml # shared: whatever the profile does not cover
A leg is small, because the profile covers the throwaway-cluster settings and
anything else common sits behind an @path:
apiVersion: v1alpha1
kind: TalosCluster
metadata:
name: e2e-3cp-0w
spec:
qemu:
talos-version: v1.14.0-beta.1
controlplanes:
count: 3
workers:
count: 0
config-patches:
cluster: ["@patches/registry.yaml"]@path resolves relative to the config file, so the leg documents and the files they
share sit in one directory and move together.
jobs:
e2e:
name: E2E (${{ matrix.leg }})
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
# The legs are independent clusters, so a failure in one should not cost
# you the signal from the others.
fail-fast: false
matrix:
leg: [1cp-0w, 1cp-1w, 3cp-0w]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: home-operations/talosctl-cluster-action@v1
id: cluster
with:
config: test/e2e/${{ matrix.leg }}.yaml
# KUBECONFIG and TALOSCONFIG are already exported, so nothing to wire up.
- run: kubectl wait --for=condition=Ready node --all --timeout=5m
- run: talosctl -n "$ENDPOINT" version
env:
ENDPOINT: ${{ steps.cluster.outputs.endpoint }}Three things worth knowing before you scale the matrix out:
- Give each leg a distinct name, and keep every one of them short. The name is in
the QEMU monitor socket path twice (see Notes), so
e2e-3cp-0wis fine and anything carryinggithub.run_idis not. The action fails immediately with the character budget rather than letting QEMU fail later. - One cluster per runner. What bounds a leg is VM count against runner memory, not the number of legs, so adding legs is cheap and adding nodes to a leg is not. A 16GB runner fits roughly three VMs at the default 2GiB.
- Overlap the cluster boot with your image build. Neither depends on the other,
and booting VMs is mostly waiting. GitHub's
parallel steps
do this natively: mark both
background: true, give them ids, thenwaiton both before the tests. (Maximum 10 background steps per job.)
- uses: home-operations/talosctl-cluster-action@v1
id: cluster
background: true
with:
config: test/e2e/${{ matrix.leg }}.yaml
- uses: docker/build-push-action@v7
id: image
background: true
with:
context: .
- wait: [cluster, image]
- run: ./test/run.sh| Input | Default | Description |
|---|---|---|
config |
.talos-cluster.yaml |
Path to the TalosCluster document. |
talosctl |
PATH lookup | Absolute path to talosctl. Only needed when PATH cannot reach it. |
config-dir |
$RUNNER_TEMP/<name> |
Where the kubeconfig, talosconfig, and machine configs are written. |
cleanup |
true |
Destroy the cluster in the post step. |
cache |
false |
Keep Image Factory boot assets in the GitHub Actions cache. |
| Output | Description |
|---|---|
cluster-name |
Name of the created cluster. |
provider |
Provisioner used, qemu or docker. |
kubeconfig |
Path to the kubeconfig. Also exported as $KUBECONFIG. |
talosconfig |
Path to the talosconfig. Also exported as $TALOSCONFIG. |
schematic-id |
Image Factory schematic the nodes booted from, if any. |
endpoint |
First control plane address. |
gateway |
Host end of the QEMU bridge (first address in the CIDR). |
controlplane-ips |
Comma-separated control plane addresses. |
worker-ips |
Comma-separated worker addresses. |
Under the maintenance preset kubeconfig is empty and neither
environment variable is exported; everything else is emitted as usual.
Keep metadata.name short. It appears twice in the QEMU monitor socket path
(~/.talos/clusters/<name>/<name>-controlplane-N.monitor), which is capped at 107
characters by the UNIX socket limit. A name carrying github.run_id overflows it, and
QEMU's error names the socket rather than the name that caused it. The action checks
this up front and tells you how many characters you have.
IPv6 is not exposed yet. The dev subcommand the action now drives does have
--ipv6, so this is a planned spec field rather than an upstream limitation. Until
then, for IPv6 inside the cluster, patch cluster.network.podSubnets /
serviceSubnets under spec.config-patches. Setting spec.network.ipv6 is a
validation error that says so.
One cluster per CIDR per host. The provisioner puts the bridge on the first
address of the CIDR, so two clusters sharing a CIDR share a bridge and its tap
devices: creating the second disrupts the first, and destroying either tears down the
network under both, silently and after the fact. An ephemeral runner never hits
this; a self-hosted runner or a developer machine already running a cluster hits it
immediately. The action checks the gateway address before provisioning anything and
refuses with the name of the interface already holding it, so give each concurrent
cluster its own spec.network.cidr.
Do not mix providers on one host without care. A qemu cluster creates
~/.talos/clusters as root, because that provisioner needs root; a later docker run
on the same host is not root and cannot write there. The action detects this and says
so, rather than surfacing a bare mkdir failure.
Cluster state stays at ~/.talos/clusters, not under config-dir, because the
monitor sockets live beside it and a longer path overflows the limit above.
mise run test # unit tests
mise run build # rebuild the committed bundle in dist/
mise run e2e # boot examples/ on a KVM-capable hostGitHub executes dist/, not src/, so the bundle is committed. A pre-commit hook
rebuilds it and CI fails if it is stale.