Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f51eb8bf35 | ||
|
|
d1e072146f | ||
|
|
4678f73e9f | ||
|
|
5f8c0115b5 | ||
|
|
e2f3d67f79 | ||
|
|
7b0b664a46 | ||
|
|
3abdeb2203 | ||
|
|
cdebaba2d3 | ||
|
|
dd311f4b01 | ||
|
|
36e683aee1 | ||
|
|
c34b423d38 | ||
|
|
5fd0691ed2 | ||
|
|
f0f0b0af9d | ||
|
|
4c6c23377d | ||
|
|
f7f04281c0 | ||
|
|
c4a6d72cd6 | ||
|
|
8c019e50d2 | ||
|
|
c9e9830092 | ||
|
|
7e825aff0f | ||
|
|
781b849adb | ||
|
|
af81045277 | ||
|
|
71d6bd3724 | ||
|
|
0809823511 | ||
|
|
5155581fba | ||
|
|
96acff3456 | ||
|
|
b5a1818896 | ||
|
|
1a94adb4c4 | ||
|
|
e9f3175b5b | ||
|
|
a775b675d4 | ||
|
|
a6dd1f4be2 | ||
|
|
5d923c84e0 | ||
|
|
c88ee0e9a0 | ||
|
|
b0a473d038 | ||
|
|
585ea36c9f | ||
|
|
296f79469e | ||
|
|
f1f2c6ac6b | ||
|
|
ebd9625926 | ||
|
|
6716f7dc50 | ||
|
|
d505503616 | ||
|
|
2ba950497d | ||
|
|
664bc72eb4 | ||
|
|
14e45a540c | ||
|
|
4965f53990 | ||
|
|
e7c70ea368 | ||
|
|
4187aa91cb | ||
|
|
c8b0d1cd4b |
@@ -51,7 +51,11 @@ kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste key
|
||||
```
|
||||
|
||||
**Manual unseal is required after every pod restart** (node drain, upgrade,
|
||||
crash): `kubectl -n openbao exec -it openbao-0 -- bao operator unseal`.
|
||||
crash), **per replica** — the server runs three raft peers (see
|
||||
`infrastructure/openbao/helmrelease.yaml`), and a sealed one is neither a
|
||||
voter nor a standby, so it stays out of the cluster until unsealed:
|
||||
`kubectl -n openbao exec -it openbao-<N> -- bao operator unseal`, same key for
|
||||
all three. Three peers mean one sealed replica is survivable; two are not.
|
||||
Accepted for now; auto-unseal needs a second trust root (transit/KMS) and is
|
||||
explicitly out of scope. Symptom of sealed: ESO stores go NotReady, synced
|
||||
Secrets keep their last value (they are real k8s Secrets — pods keep running).
|
||||
|
||||
@@ -31,8 +31,8 @@ all in state that outlived the VM.
|
||||
two hosts every PG is undersized with min_size 2, so one further OSD loss
|
||||
blocks IO on every CephFS mount cluster-wide.
|
||||
- [ ] Talos metal ISO on a USB stick. Schematic
|
||||
`a21cec8f35ae32ab5e9a452e7b13500ca8776053930b8e182b9cb23eed186b64`
|
||||
(`net.ifnames=1`, intel-ucode, thunderbolt; no qemu-guest-agent).
|
||||
`0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9`
|
||||
(`net.ifnames=1`, intel-ucode, thunderbolt, i915; no qemu-guest-agent).
|
||||
Verify by comparing partition offsets and sizes against the ISO with
|
||||
`fdisk -l` — blkid labels lie after a rewrite.
|
||||
- [ ] Physical access. Secure Boot must be turned off (see below), and that is
|
||||
@@ -383,6 +383,34 @@ raised to `high_recovery_ops` to speed the evacuation.
|
||||
The retired Crucial leaves a stale entry in `ceph device ls`. Remove it with
|
||||
`ceph config-key rm device/<devid>`; the mgr's devicehealth view may lag.
|
||||
|
||||
### Long-lived TCP sessions to devices outside the cluster die silently
|
||||
|
||||
Check this before declaring the conversion done. Anything holding a persistent
|
||||
connection to a device on another VLAN — the ESPHome integration is the case
|
||||
that bit us — keeps a half-open socket when the node underneath it moves. The
|
||||
peer drops its side; the pod's side never learns, because no RST arrives and no
|
||||
keepalive fires.
|
||||
|
||||
The symptom is that there is no symptom. Home Assistant logged nothing, retried
|
||||
nothing, and reported the devices as connected. The devices themselves showed
|
||||
"not connected". Hours went into reading logs that were empty by construction.
|
||||
|
||||
`/proc/net/tcp` in the pod settles it in one command — `01` is ESTABLISHED,
|
||||
`02` is SYN_SENT, and the port is hex:
|
||||
|
||||
```sh
|
||||
kubectl -n home-assistant exec <ha-pod> -c home-assistant -- \
|
||||
sh -c 'grep ":17A5" /proc/net/tcp' # 17A5 = 6053, the ESPHome API
|
||||
```
|
||||
|
||||
Seven `01` entries to devices insisting they were disconnected is the tell. A
|
||||
`02` alongside them is a genuinely dead device, not a stale socket — that one
|
||||
Home Assistant *was* retrying and *did* log.
|
||||
|
||||
Restarting the consuming pod clears it. Reloading just the integration from the
|
||||
UI does the same with less disruption. Neither is discoverable from logs, which
|
||||
is why it belongs here.
|
||||
|
||||
## Known-stale things that will bite
|
||||
|
||||
- `bootstrap.sh` cannot generate a config at all right now (issue #581), so a
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
# Cold-rebuild runbook — homelab cluster from scratch
|
||||
|
||||
Rebuild the entire cluster from **this repo + backups only**, assuming nothing
|
||||
in the cluster survives (all VMs gone, Ceph gone, in-cluster Gitea gone).
|
||||
in the cluster survives (all three nodes wiped, Ceph gone, in-cluster Gitea
|
||||
gone).
|
||||
|
||||
Order: **tofu → talos → flux → openbao → apps.**
|
||||
|
||||
All three nodes are bare-metal MS-01 machines. Nothing provisions them: there
|
||||
are no VMs to recreate, and OpenTofu owns only DNS, object storage and UniFi.
|
||||
Step 1 is therefore two unrelated halves — a `tofu apply` for the platform
|
||||
resources, and physically booting three machines from install media.
|
||||
|
||||
**Someone has to be at the machines to boot them from USB** — MS-01 has no IPMI
|
||||
and nothing here does PXE. Everything after that is remote.
|
||||
|
||||
A node in maintenance mode configures no VLANs and DHCPs untagged, so it comes
|
||||
up on whatever untagged network its ports carry — not on management, which is
|
||||
a tagged VLAN on the X710 trunk. It is reachable there: that is the address
|
||||
`apply-config --insecure` talks to in Step 2, and it is how all three
|
||||
bare-metal conversions were driven. Find the leases on the UDM.
|
||||
|
||||
## The circular-bootstrap problem this runbook exists to break
|
||||
|
||||
In steady state Flux's `GitRepository flux-system` pulls manifests from the
|
||||
@@ -61,12 +76,21 @@ These must exist outside the cluster or the rebuild cannot complete:
|
||||
`openbao-daily` offsite copy. In a total-loss rebuild the offsite Velero
|
||||
copy is the one that survives.
|
||||
- [ ] **Tooling:** `talosctl`, `kubectl`, `flux`, `tofu`, `bao`, plus the
|
||||
OpenTofu backend + Proxmox/UniFi credentials the `opentofu/` stack needs.
|
||||
OpenTofu backend + UniFi credentials the `opentofu/` stack needs. No
|
||||
Proxmox credentials — there is no Proxmox any more.
|
||||
- [ ] **Install media** written and to hand: the Image Factory ISO named in
|
||||
Step 1, on a USB stick, for all three machines.
|
||||
- [ ] **Someone at the machines to boot them from USB.** No IPMI, no PXE. A
|
||||
monitor is useful for reading boot errors but is not required to find the
|
||||
node afterwards — it DHCPs into management on its own (see the note at
|
||||
the top). Everything past the boot is remote.
|
||||
|
||||
## Step 1 — tofu: provision the VMs + supporting infra
|
||||
## Step 1a — tofu: DNS, object storage, UniFi
|
||||
|
||||
Recreate the Talos VMs and the platform resources OpenTofu owns (Proxmox VMs,
|
||||
UniFi DNS records incl. `k8s.renner.uno` / node A records, etc.).
|
||||
OpenTofu no longer provisions any node. The `proxmox_vm` module still exists in
|
||||
the tree but nothing instantiates it, so `tofu apply` here creates only the
|
||||
platform resources: UniFi DNS records including `k8s.renner.uno` and the node A
|
||||
records, the Hetzner object storage, and the NAT hairpin rules.
|
||||
|
||||
```sh
|
||||
cd opentofu
|
||||
@@ -74,44 +98,119 @@ tofu init -upgrade -backend-config="access_key=..." -backend-config="secret_key=
|
||||
tofu apply
|
||||
```
|
||||
|
||||
In CI this is `.woodpecker/deploy-infrastructure.yml` (manual, `TOFU_APPLY=true`),
|
||||
which also pre-seeds the Talos image onto every Proxmox node. Wait until the
|
||||
node FQDNs (`k8s-{1,2,3}.srv.renner.uno`) resolve and the VMs are up before
|
||||
Step 2 — `bootstrap.sh` polls DNS but will not create the records.
|
||||
Run this first regardless, because Step 2 needs `k8s.renner.uno` and the node
|
||||
FQDNs to resolve — the control-plane endpoint is baked into every generated
|
||||
config, and the nodes are addressed by name.
|
||||
|
||||
**k8s-1 is not a VM and OpenTofu does not create it.** jupiter runs Talos on
|
||||
bare metal, so this step only provisions k8s-2 (saturn) and k8s-3 (neptune).
|
||||
Before Step 2, boot jupiter from the bare-metal image below so it comes up in
|
||||
maintenance mode and answers on `k8s-1.srv.renner.uno` — `bootstrap.sh` treats
|
||||
all three nodes identically from there, since `apply-config --insecure` does
|
||||
not care whether the target is a VM or bare metal.
|
||||
## Step 1b — boot all three machines from install media
|
||||
|
||||
```
|
||||
https://factory.talos.dev/image/a21cec8f35ae32ab5e9a452e7b13500ca8776053930b8e182b9cb23eed186b64/v1.12.6/metal-amd64.iso
|
||||
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.13.9/metal-amd64.iso
|
||||
```
|
||||
|
||||
That schematic is deliberately not the one `.woodpecker/deploy-infrastructure.yml`
|
||||
seeds onto the Proxmox nodes. It keeps the same `net.ifnames=1` kernel argument
|
||||
— without it the predictable interface names that `talos/patches/k8s-1.yaml`
|
||||
matches its VLAN parents against do not exist — and differs in three ways:
|
||||
Write it to a USB stick and boot each MS-01 from it. Each comes up in
|
||||
maintenance mode with a DHCP address on an untagged network — not on
|
||||
management, which is tagged. Read the leases off the UDM; those addresses are
|
||||
what Step 2 applies the first config to.
|
||||
|
||||
- drops `siderolabs/qemu-guest-agent`, which has nothing to talk to on bare metal
|
||||
- adds `siderolabs/intel-ucode`, since no hypervisor is supplying microcode now
|
||||
- adds `siderolabs/thunderbolt`, unused so far and inert until
|
||||
`machine.kernel.modules` requests `thunderbolt`/`thunderbolt_net`. It is
|
||||
present only so enabling the MS-01 Thunderbolt mesh later does not require a
|
||||
new image and a reinstall. Note its udev rule auto-authorises any Thunderbolt
|
||||
device attached to the machine once those modules are loaded.
|
||||
The schematic `0f6fb8bd…` carries `net.ifnames=1` — without it the predictable
|
||||
interface names that `talos/patches/k8s-{1,2,3}.yaml` match their VLAN parents
|
||||
against do not exist, and the node ends up with no address on any interface.
|
||||
Beyond that it carries:
|
||||
|
||||
- `siderolabs/intel-ucode` — no hypervisor supplies microcode here
|
||||
- `siderolabs/i915` — the Alder Lake-P Iris Xe. Nothing requests `/dev/dri`
|
||||
today; present so wanting hardware transcoding later is a config change
|
||||
rather than another rolling reinstall
|
||||
- `siderolabs/thunderbolt` — inert until `machine.kernel.modules` requests
|
||||
`thunderbolt`/`thunderbolt_net`, present for the same reason. Note its udev
|
||||
rule auto-authorises any Thunderbolt device attached once those modules load
|
||||
- no `qemu-guest-agent`, which has nothing to talk to on bare metal
|
||||
|
||||
Verify the disks before going further. Talos enumerates NVMe in an order that
|
||||
is not stable across boots, and the per-node configs select by serial for
|
||||
exactly that reason — the failure mode is the installer taking the Ceph OSD
|
||||
drive. `talosctl -n <maintenance-ip> --insecure get disks` should show three
|
||||
NVMe devices whose serials match the `diskSelector` and `VolumeConfig` entries
|
||||
in `talos/patches/k8s-N.yaml`.
|
||||
|
||||
## Step 2 — talos: form the cluster + get kubeconfig
|
||||
|
||||
**Do not use `kubernetes/bootstrap.sh talos`.** That phase cannot generate a
|
||||
config: it passes `talos/patches/cilium-cni.yaml` whole, and that file's
|
||||
`$patch: delete` document fails against a base with no `inlineManifests`
|
||||
(#581). Its other phases — `flux`, `wait-apps` — are still used, in Steps 3, 5
|
||||
and 6; only config generation is broken.
|
||||
|
||||
The procedure below is what actually works, and is the same one
|
||||
`RUNBOOK-baremetal-conversion.md` uses.
|
||||
|
||||
### Generate the configs
|
||||
|
||||
This is a genuinely new cluster — nothing survives to copy secrets from, so
|
||||
generate them fresh. (In a *partial* rebuild, where one node still runs, pull
|
||||
secrets off it instead with `talosctl get mc v1alpha1` + `gen secrets
|
||||
--from-controlplane-config`, or the new node forms its own cluster rather than
|
||||
joining.)
|
||||
|
||||
```sh
|
||||
kubernetes/bootstrap.sh talos
|
||||
talosctl gen secrets -o secrets.yaml
|
||||
|
||||
for N in 1 2 3; do
|
||||
talosctl gen config homelab https://k8s.renner.uno:6443 \
|
||||
--with-secrets secrets.yaml --output-types controlplane \
|
||||
--config-patch @talos/patches/cluster.yaml \
|
||||
--config-patch @<cilium additive half> \
|
||||
--config-patch @talos/patches/registry-mirror.yaml \
|
||||
--config-patch @talos/patches/k8s-$N.yaml -o install-k8s-$N.yaml
|
||||
done
|
||||
```
|
||||
|
||||
Generates + applies Talos configs to `k8s-{1,2,3}`, bootstraps etcd/Kubernetes,
|
||||
writes `~/.talos/config` + `~/.kube/config`, and stages both into
|
||||
`kubernetes/access-staging/` for later phases. Ends when all 3 nodes are `Ready`.
|
||||
Two things the generator gets wrong for this repo, both of which will bite
|
||||
silently:
|
||||
|
||||
- **`cilium-cni.yaml` cannot be passed whole** — extract only its additive
|
||||
second document. See issue #581.
|
||||
- **Strip the generated `HostnameConfig`** — it collides with
|
||||
`machine.network.hostname` from the per-node patch.
|
||||
|
||||
Validate each before applying:
|
||||
|
||||
```sh
|
||||
talosctl validate --config install-k8s-$N.yaml --mode metal
|
||||
```
|
||||
|
||||
### Apply in two stages
|
||||
|
||||
Do **not** apply the full per-node config in maintenance mode. A document that
|
||||
references an interface which does not exist yet hangs the install: the node
|
||||
sits at `STAGE: Installing` indefinitely with `KUBELET n/a` and `APISERVER n/a`,
|
||||
and nothing times out. Concretely, the `ens19` VLAN document must be removed
|
||||
from the first-stage config — its parent exists, but the install stalls anyway.
|
||||
|
||||
```sh
|
||||
# Stage 1: minimal config, from the maintenance-mode address off the console
|
||||
talosctl apply-config --insecure -n <maintenance-ip> --file install-k8s-$N-stage1.yaml
|
||||
# Stage 2: once the node is up on management, apply the complete config
|
||||
talosctl -n k8s-$N.srv.renner.uno apply-config --mode=try --timeout=5m --file install-k8s-$N.yaml
|
||||
```
|
||||
|
||||
`--mode=try` rolls back on its own if the node becomes unreachable, which is the
|
||||
only safety net there is for a network change that can strand a machine with no
|
||||
IPMI. Use it for every network-affecting apply, then make it permanent once
|
||||
proven.
|
||||
|
||||
### Bootstrap etcd, then collect access
|
||||
|
||||
Bootstrap **exactly one** node:
|
||||
|
||||
```sh
|
||||
talosctl -n k8s-1.srv.renner.uno bootstrap
|
||||
talosctl -n k8s-1.srv.renner.uno kubeconfig .
|
||||
```
|
||||
|
||||
Ends when all three nodes are `Ready`. `scripts/import-cluster-access.sh` is the
|
||||
supported way to get the kubeconfig into place afterwards.
|
||||
|
||||
## Step 3 — flux: bootstrap GitOps **from the GitHub mirror** (break-glass)
|
||||
|
||||
@@ -149,8 +248,8 @@ Secrets, and every secret-consuming app stays Pending. This step is **manual**
|
||||
(the unseal key lives in `pass`, off-cluster by design).
|
||||
|
||||
```sh
|
||||
# 1. Wait for the OpenBao pod to exist (sealed, 0/1 Ready is expected).
|
||||
kubectl -n openbao get pod openbao-0
|
||||
# 1. Wait for the OpenBao pods to exist (all three sealed, 0/1 Ready is expected).
|
||||
kubectl -n openbao get pods
|
||||
|
||||
# 2. Restore the most recent raft snapshot. Source it from the snapshot PVC
|
||||
# (infrastructure/openbao/snapshot-cronjob.yaml) or, in a total-loss rebuild,
|
||||
@@ -158,8 +257,12 @@ kubectl -n openbao get pod openbao-0
|
||||
kubectl -n openbao cp <snapshot>.snap openbao-0:/tmp/restore.snap
|
||||
kubectl -n openbao exec -it openbao-0 -- bao operator raft snapshot restore /tmp/restore.snap
|
||||
|
||||
# 3. Unseal with the key from pass (threshold 1).
|
||||
# 3. Unseal with the key from pass (threshold 1) — EVERY replica, same key.
|
||||
# A sealed server is neither a raft voter nor a standby, so unsealing only
|
||||
# openbao-0 leaves the three-peer cluster one node short of quorum.
|
||||
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste homelab/openbao/unseal-key
|
||||
kubectl -n openbao exec -it openbao-1 -- bao operator unseal # same key
|
||||
kubectl -n openbao exec -it openbao-2 -- bao operator unseal # same key
|
||||
```
|
||||
|
||||
Verify the store recovers before moving on:
|
||||
@@ -170,7 +273,9 @@ kubectl -n <ns> get externalsecret # want: SecretSynced
|
||||
```
|
||||
|
||||
> **Manual unseal is required after every OpenBao pod restart**, not just
|
||||
> during rebuilds. Details + rationale: `MIGRATION-sops-openbao.md`.
|
||||
> during rebuilds, and for *each* replica that restarts — a rolling node
|
||||
> drain seals all three in turn. Details + rationale:
|
||||
> `MIGRATION-sops-openbao.md`.
|
||||
|
||||
## Step 5 — apps: let the rest reconcile
|
||||
|
||||
@@ -225,9 +330,28 @@ it: repo → **Settings → Repository → Mirror Settings** in Gitea, or
|
||||
in-cluster commits made **after** Flux/Gitea broke are not on the mirror
|
||||
(Gitea couldn't push them if Gitea itself was down). This is the intended
|
||||
recovery point, but be aware of it when reconciling drift.
|
||||
- `bootstrap.sh` has no `openbao` phase — Step 4 is manual because the unseal
|
||||
key must not live in CI. See issue #187 for the open question of whether to
|
||||
wire a semi-automated OpenBao restore.
|
||||
- **Booting is physical; the rest is not.** MS-01 has no IPMI and nothing here
|
||||
does PXE, so someone has to put a USB stick in three machines. From there it
|
||||
is remote — a maintenance-mode node DHCPs untagged and is reachable at that
|
||||
address, which is how all three bare-metal conversions were driven.
|
||||
#576 claims a freshly booted node "comes up unreachable and must be installed
|
||||
from a console". That is not what happens and has never been observed here.
|
||||
- **`bootstrap.sh talos` is dead** — it cannot generate a config (#581), and
|
||||
Step 2 no longer uses it. Its `flux` and `wait-apps` phases are still used in
|
||||
Steps 3, 5 and 6 and work; do not read this as the whole script being gone.
|
||||
It has also never had an `openbao` phase — Step 4 is manual because the
|
||||
unseal key must not live in CI; see issue #187 on whether to wire a
|
||||
semi-automated OpenBao restore.
|
||||
- **OpenBao seals on every restart and each replica must be unsealed
|
||||
individually.** Three raft replicas mean the service survives losing one, but
|
||||
a sealed server is neither a voter nor a standby — unsealing only one leaves
|
||||
the cluster short of quorum. Step 4 covers this; it is repeated here because
|
||||
it is the step most likely to be half-done under pressure.
|
||||
- Ceph's cluster network is a Multus attachment on VLAN 49 with whereabouts
|
||||
IPAM, not host networking. Nothing in the rebuild needs special handling for
|
||||
it — the nodes hold no address on that segment — but a rebuilt cluster will
|
||||
not have the whereabouts allocations from before, which is harmless since
|
||||
they are assigned fresh.
|
||||
- warn-and-continue waits in the `flux` phase can mask a genuinely broken
|
||||
Ceph/infra bring-up; always read the phase output rather than trusting the
|
||||
exit code.
|
||||
|
||||
@@ -37,6 +37,13 @@ resources:
|
||||
- esphome-service.yaml
|
||||
- esphome-gate-service.yaml
|
||||
- esphome-ingress.yaml
|
||||
# mDNS relay — carries IoT-VLAN announcements into HA's zeroconf listener as
|
||||
# unicast, which is the only direction the CNI will move them. Without it HA
|
||||
# never learns that a device's DHCP lease moved. See the header comment in
|
||||
# mdns-relay-deployment.yaml.
|
||||
- mdns-relay-network-attachment.yaml
|
||||
- mdns-relay-service.yaml
|
||||
- mdns-relay-deployment.yaml
|
||||
# Zigbee2MQTT — ZHA replacement bridge. Ships scaled to 0; the cutover
|
||||
# runbook (zigbee2mqtt-migration-runbook.md) scales it up. The MQTT
|
||||
# `zigbee2mqtt` broker user is provisioned by mosquitto-bootstrap.yaml
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
# mDNS relay: gives Home Assistant sight of the IoT VLAN's mDNS announcements
|
||||
# without putting a macvlan on the HA pod itself.
|
||||
#
|
||||
# THE PROBLEM
|
||||
#
|
||||
# HA's zeroconf listener joins both mDNS groups on eth0 — 224.0.0.251 and
|
||||
# ff02::fb — and never hears anything on either.
|
||||
# Multicast is link-local, so IoT-VLAN announcements do not reach the pod
|
||||
# CIDR; and Cilium runs in tunnel mode (VXLAN) without `enable-multicast`, so
|
||||
# it does not replicate multicast between pods either. Both halves of the path
|
||||
# are dead, which is why an avahi-style reflector — receive on one interface,
|
||||
# re-emit as multicast on the other — cannot work here: the re-emitted packets
|
||||
# would be dropped by the CNI.
|
||||
#
|
||||
# Consequence for HA: the ESPHome integration dials devices at the address
|
||||
# cached in its config entry. When a lease rotates, every attempt fails with
|
||||
# `[Errno 113] Connect call failed` and the device stays unreachable, because
|
||||
# the mDNS announcement that would carry the new address never arrives. New
|
||||
# devices are never discovered at all.
|
||||
#
|
||||
# THE MECHANISM
|
||||
#
|
||||
# This relay holds the multicast membership on the IoT VLAN through its own
|
||||
# macvlan (mdns-relay-network-attachment.yaml) and forwards each datagram it
|
||||
# receives to HA as ordinary UNICAST UDP. That is the one direction of travel
|
||||
# the CNI will carry.
|
||||
#
|
||||
# It works because python-zeroconf does not filter on how a packet arrived:
|
||||
# its datagram handler validates size, deduplicates, and parses, but performs
|
||||
# no check on destination address, source address, or source port. A unicast
|
||||
# datagram delivered to :5353 is processed exactly like a multicast one. (mDNS
|
||||
# unicast responses are part of RFC 6762 — the QU bit — so this is within the
|
||||
# protocol, not a trick.)
|
||||
#
|
||||
# WHAT THIS DOES AND DOES NOT RESTORE
|
||||
#
|
||||
# Restored: passive reception. RFC 6762 requires a responder to re-announce
|
||||
# when its address changes, so a device that moves to a new lease announces
|
||||
# the move, HA sees it, and the ESPHome integration updates the config entry.
|
||||
# That is the failure this exists to fix. New devices announcing themselves
|
||||
# are likewise seen.
|
||||
#
|
||||
# NOT restored: HA-initiated browsing. Queries HA sends leave on eth0 as
|
||||
# multicast and die there, so HA cannot actively solicit a response. Anything
|
||||
# depending on a query rather than an announcement stays broken. Making that
|
||||
# work needs a querier on the VLAN side, which this relay deliberately is not.
|
||||
#
|
||||
# EXPOSURE
|
||||
#
|
||||
# The receive sockets carry so-bindtodevice=net1, so only datagrams that
|
||||
# actually arrived on the IoT VLAN interface are delivered. Binding to net1's
|
||||
# address instead is not possible — it is DHCP-leased and unknown at exec time
|
||||
# — but binding to the device achieves the same confinement without needing to
|
||||
# know the address, and needs no capability: SO_BINDTODEVICE succeeds as an
|
||||
# unprivileged uid here.
|
||||
#
|
||||
# Without it socat would bind the wildcard, and since the namespace CNP grants
|
||||
# every pod in home-assistant unrestricted access to every other pod on every
|
||||
# port, any sibling pod could reach :5353 over the cluster network and have
|
||||
# records forwarded into HA's zeroconf cache.
|
||||
#
|
||||
# What remains, and cannot be closed here: any host on the IoT VLAN can send
|
||||
# this port a datagram. That is the same trust already extended to genuine
|
||||
# mDNS from that segment, which any device there can equally forge — the relay
|
||||
# widens who can reach HA's listener, not what can be claimed. macvlan traffic
|
||||
# bypasses Cilium policy entirely, so the namespace CNP does not constrain
|
||||
# this interface either way.
|
||||
#
|
||||
# Note that the relay is not what makes HA's zeroconf reachable from inside
|
||||
# the cluster: HA binds :5353 on the pod address (v4) and the wildcard (v6),
|
||||
# and the namespace CNP already permits any sibling pod to reach it directly.
|
||||
# Confining the relay removes a second route to that listener; it does not
|
||||
# remove the first.
|
||||
#
|
||||
# Nothing else is exposed: the containers run socat and only socat, with no
|
||||
# second listener to reach.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mdns-relay
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mdns-relay
|
||||
spec:
|
||||
# One replica. Extra replicas would deliver a duplicate of every announcement
|
||||
# — harmless, since zeroconf deduplicates, but they buy nothing: this is a
|
||||
# cache-refresh path, and a missed window is corrected by the next
|
||||
# announcement.
|
||||
replicas: 1
|
||||
# Recreate, not RollingUpdate: two relays would briefly hold two DHCP leases
|
||||
# on v30iot for one workload.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: mdns-relay
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mdns-relay
|
||||
annotations:
|
||||
k8s.v1.cni.cncf.io/networks: mdns-relay-iot
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
# One container per address family. mDNS is two protocols — 224.0.0.251
|
||||
# and ff02::fb — and a socket joined to one hears nothing of the other.
|
||||
# HA listens on both (its zeroconf holds both groups on eth0), so a
|
||||
# v4-only relay would silently drop every IPv6 announcement, and devices
|
||||
# that announce only over v6 would stay invisible exactly as before.
|
||||
#
|
||||
# Two processes rather than one: socat carries a single address pair per
|
||||
# invocation, and the families need different receive and send addresses.
|
||||
# They are independent — one crashing does not blind the other — and each
|
||||
# is restarted by the kubelet on its own.
|
||||
containers:
|
||||
# -u is load-bearing on both: unidirectional, left to right. Without it
|
||||
# socat would also carry HA's replies back out, and this must stay a
|
||||
# one-way tap.
|
||||
#
|
||||
# UDP{4,6}-RECV (not -RECVFROM) keeps reading from one socket instead of
|
||||
# forking per datagram — the IoT VLAN's mDNS chatter is continuous, and
|
||||
# a fork per packet would be a process storm.
|
||||
#
|
||||
# The group is joined on net1 specifically, so the relay subscribes on
|
||||
# the IoT VLAN rather than on the pod network.
|
||||
#
|
||||
# Both send targets are the same ClusterIP name, resolved once at start;
|
||||
# UDP4-SENDTO takes its A record and UDP6-SENDTO its AAAA. See
|
||||
# mdns-relay-service.yaml for why that indirection is required and why
|
||||
# the Service is dual-stack.
|
||||
- name: socat-ipv4
|
||||
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- -u
|
||||
- "UDP4-RECV:5353,ip-add-membership=224.0.0.251:net1,so-bindtodevice=net1,reuseaddr"
|
||||
- "UDP4-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353"
|
||||
# No probes. socat is a single process with no health surface: if it
|
||||
# exits, the container exits and the kubelet restarts it, which is the
|
||||
# entire liveness story. A probe here could only re-assert that PID 1
|
||||
# is running.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
# Joining ff02::fb needs no address configuration on net1: the group is
|
||||
# link-local and the kernel's automatic link-local address on the
|
||||
# macvlan is sufficient to receive on it. Whether v30iot hands out
|
||||
# global v6 addresses does not affect reception here.
|
||||
#
|
||||
# ipv6-v6only=1 keeps this socket to v6 only. Both containers share the
|
||||
# pod's network namespace, and a dual-stack v6 socket would overlap the
|
||||
# v4 container's port. Measured behaviour without it was no contention —
|
||||
# the v4 receiver kept taking packets while the v6 one ran — but that
|
||||
# depends on the kernel's bindv6only default rather than on anything
|
||||
# stated here, so the roles are made explicit instead of inherited.
|
||||
- name: socat-ipv6
|
||||
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- -u
|
||||
- "UDP6-RECV:5353,ipv6-join-group=[ff02::fb]:net1,so-bindtodevice=net1,ipv6-v6only=1,reuseaddr"
|
||||
- "UDP6-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
# mdns-relay's second NIC: macvlan onto v30iot, so the relay has real L2
|
||||
# presence on the IoT VLAN and can join both mDNS multicast groups
|
||||
# (224.0.0.251 and ff02::fb, port 5353). The v6 group needs no address
|
||||
# configuration of its own: it is link-local, and the kernel's automatic
|
||||
# link-local address on the macvlan is enough to receive on it, independently
|
||||
# of whether v30iot hands out global v6 addresses.
|
||||
# Multicast is link-local — it does not cross the L3
|
||||
# boundary between the cluster pod CIDR and v30iot — so that membership has to
|
||||
# be held by a pod sitting physically on that segment.
|
||||
#
|
||||
# The address is leased from the UniFi DHCP server on v30iot. Nothing
|
||||
# references it: the relay only receives on this interface and sends onward
|
||||
# over eth0. See mdns-relay-deployment.yaml for what the relay does with it.
|
||||
#
|
||||
# Multus's `master` is an interface name. On the k8s-{1,2,3} nodes the v30iot
|
||||
# NIC is `ens19` — a stable, busPath-derived predictable name (net.ifnames=1,
|
||||
# via the Image Factory schematic), identical across nodes and independent of
|
||||
# driver probe order. This is the same master shelly-manager-iot uses.
|
||||
apiVersion: k8s.cni.cncf.io/v1
|
||||
kind: NetworkAttachmentDefinition
|
||||
metadata:
|
||||
name: mdns-relay-iot
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
# Chained CNI, identical in shape to shelly-manager-iot: macvlan leases the
|
||||
# v30iot address by DHCP, then `sbr` confines net1's routes to a
|
||||
# source-based routing table. Without sbr the DHCP-supplied default gateway
|
||||
# (10.2.30.1, option 3) installs a second default route at the same metric
|
||||
# as eth0's, and cluster-internal traffic egresses the IoT VLAN and is
|
||||
# dropped. The relay's forward path to Home Assistant runs over eth0, so
|
||||
# that route has to stay intact.
|
||||
#
|
||||
# Requires the cni-dhcp-daemon DaemonSet in infrastructure/multus.
|
||||
config: |
|
||||
{
|
||||
"cniVersion": "0.3.1",
|
||||
"name": "mdns-relay-iot",
|
||||
"plugins": [
|
||||
{
|
||||
"type": "macvlan",
|
||||
"master": "ens19",
|
||||
"mode": "bridge",
|
||||
"ipam": {
|
||||
"type": "dhcp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "sbr"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
# Stable delivery target for the relay's forwarded mDNS traffic.
|
||||
#
|
||||
# The relay resolves its send target once, at process start (socat has no
|
||||
# re-resolution loop), so it cannot chase the HA pod IP across restarts. A
|
||||
# ClusterIP is the fix: the IP is stable for the lifetime of the Service, and
|
||||
# Cilium re-DNATs to whatever pod currently backs it.
|
||||
#
|
||||
# Deliberately a separate Service from `home-assistant`, which targets the
|
||||
# nginx proxy sidecar on 8124 and is the XFF trust anchor for the public UI.
|
||||
# This one bypasses the sidecar and addresses the home-assistant container's
|
||||
# own port directly — containers in a pod share a network namespace, so
|
||||
# targetPort 5353 lands on python-zeroconf's listener regardless of which
|
||||
# container opened it. Keeping the two apart means the UI path and the
|
||||
# discovery path cannot be changed by accident through one another.
|
||||
#
|
||||
# No CiliumNetworkPolicy change is needed: home-assistant-ns.yaml already
|
||||
# permits intra-namespace traffic on all ports, and the relay is in this
|
||||
# namespace.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: home-assistant-mdns
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: home-assistant
|
||||
spec:
|
||||
# Dual-stack, because mDNS is two protocols and HA listens on both: its
|
||||
# zeroconf socket holds 224.0.0.251 on eth0 and ff02::fb alongside it. The
|
||||
# relay forwards each family over its own transport rather than folding v6
|
||||
# announcements into a v4 hop, so the Service has to answer on both.
|
||||
ipFamilyPolicy: PreferDualStack
|
||||
ipFamilies:
|
||||
- IPv4
|
||||
- IPv6
|
||||
selector:
|
||||
app.kubernetes.io/name: home-assistant
|
||||
ports:
|
||||
- name: mdns
|
||||
port: 5353
|
||||
protocol: UDP
|
||||
targetPort: 5353
|
||||
@@ -12,6 +12,7 @@ resources:
|
||||
- prometheusrule-netbird-availability.yaml
|
||||
- prometheusrule-netbird-backup.yaml
|
||||
- prometheusrule-netbird-ingress.yaml
|
||||
- prometheusrule-openbao.yaml
|
||||
- prometheusrule-velero.yaml
|
||||
- prometheusrule-woodpecker-rotation.yaml
|
||||
# CNPG backup health. Seven Postgres clusters ship to Hetzner and nothing
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: openbao
|
||||
namespace: monitoring
|
||||
labels:
|
||||
# kube-prometheus-stack's Prometheus resource selects on this label;
|
||||
# without it the rule is created but never loaded into Prometheus.
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
groups:
|
||||
- name: openbao.rules
|
||||
rules:
|
||||
# WHY THIS WATCHES POD READINESS RATHER THAN A SEAL METRIC
|
||||
#
|
||||
# OpenBao is not scraped: there is no ServiceMonitor for it, and its
|
||||
# metrics endpoint needs either a token or an explicit
|
||||
# `unauthenticated_metrics_access` telemetry setting, neither of which
|
||||
# is configured. Confirmed against Prometheus — no bao_* or vault_*
|
||||
# series exist. External Secrets exports nothing usable either, so the
|
||||
# ClusterSecretStore going NotReady is invisible as a metric.
|
||||
#
|
||||
# kube-state-metrics readiness carries the signal anyway, and does so
|
||||
# for a structural reason rather than by luck: a sealed OpenBao fails
|
||||
# its readiness probe, because a sealed server can neither serve
|
||||
# requests nor act as a raft standby. Sealed is therefore always
|
||||
# not-Ready. The converse does not hold — a crash or a stuck
|
||||
# scheduling would also fire this — but both want a human too, so the
|
||||
# imprecision costs nothing.
|
||||
- alert: OpenBaoReplicaNotReady
|
||||
# A replica that is sealed never recovers on its own, so the
|
||||
# duration is only there to ignore ordinary rescheduling: a healthy
|
||||
# pod moving between nodes is Ready again in well under a minute.
|
||||
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 3
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "OpenBao has {{ $value }} of 3 replicas ready"
|
||||
description: >-
|
||||
An OpenBao replica has been unavailable for 10 minutes. The usual
|
||||
cause is a sealed replica after a node drain — every restart
|
||||
seals it, and it stays out of the raft cluster until unsealed by
|
||||
hand. The service is still up on the remaining replicas, so
|
||||
nothing is broken yet, but redundancy is reduced and a second
|
||||
drain would cost quorum. Check `kubectl -n openbao get pods` and
|
||||
`bao status` in the not-ready pod; unseal with the key from
|
||||
`pass` (homelab/openbao/unseal-key).
|
||||
|
||||
- alert: OpenBaoQuorumLost
|
||||
# Raft quorum of a three-node cluster is two. At one ready replica
|
||||
# the cluster cannot elect a leader and the secret store is down:
|
||||
# the ClusterSecretStore goes InvalidProviderConfig and no
|
||||
# ExternalSecret can refresh.
|
||||
#
|
||||
# Shorter window than the warning above because this is an outage,
|
||||
# not a degradation — but not zero, so a rolling restart that
|
||||
# briefly dips does not page.
|
||||
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 2
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "OpenBao below raft quorum — {{ $value }} of 3 replicas ready"
|
||||
description: >-
|
||||
Fewer than two OpenBao replicas are ready, so raft cannot hold
|
||||
quorum and the secret store is unavailable. ExternalSecrets stop
|
||||
refreshing; existing Secrets keep their last values, so failures
|
||||
surface only when something rotates or a new secret is needed.
|
||||
Unseal the sealed replicas (key in `pass`,
|
||||
homelab/openbao/unseal-key) — each one individually, since a
|
||||
sealed server cannot be a standby.
|
||||
|
||||
- alert: OpenBaoMetricsMissing
|
||||
# Guards the two alerts above. Both are threshold comparisons, so if
|
||||
# kube-state-metrics stops exporting this series — the StatefulSet
|
||||
# deleted, the namespace gone, kube-state-metrics itself broken —
|
||||
# they silently stop evaluating and the silence looks identical to
|
||||
# health. This makes that state say something.
|
||||
expr: absent(kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"})
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "No readiness metric for the OpenBao StatefulSet"
|
||||
description: >-
|
||||
kube-state-metrics has not reported
|
||||
kube_statefulset_status_replicas_ready for the openbao
|
||||
StatefulSet in 15 minutes. Either the StatefulSet is gone or
|
||||
kube-state-metrics is not scraping — and while this is absent,
|
||||
OpenBaoReplicaNotReady and OpenBaoQuorumLost cannot fire at all.
|
||||
@@ -40,17 +40,31 @@ spec:
|
||||
description: "A Velero backup run for {{ $labels.schedule }} completed with Failed or PartiallyFailed status in the last hour. Inspect with `kubectl -n velero get backup.velero.io -l velero.io/schedule-name={{ $labels.schedule }}` and the velero pod logs."
|
||||
|
||||
- alert: VeleroBackupStale
|
||||
# Daily schedule with no successful backup in over 30h: covers
|
||||
# the case where multiple consecutive runs failed (Garage flapping
|
||||
# over the backup window) without the per-event alerts being
|
||||
# acted on.
|
||||
# Daily schedule with no successful backup in over 60h: covers the
|
||||
# case where consecutive runs failed without the per-event alerts
|
||||
# being acted on.
|
||||
#
|
||||
# 60h, not 30h. At 30h a single missed cycle pages, and a single
|
||||
# missed cycle is the normal cost of touching the nodes: Velero moves
|
||||
# data through pods, so draining a node kills whatever upload is in
|
||||
# flight and the schedule is simply lost for that day — it is not
|
||||
# retried. Rolling node work between 03:15 and 08:45 UTC therefore
|
||||
# produced a guaranteed critical alert for every schedule it
|
||||
# overlapped, which is noise about planned work rather than a signal
|
||||
# about backups.
|
||||
#
|
||||
# 60h still catches what this is for. Two consecutive failures is no
|
||||
# longer explicable by one maintenance window, and the per-event
|
||||
# VeleroBackupFailed alert fires on the first failure regardless — so
|
||||
# nothing is hidden, only the escalation to critical is deferred
|
||||
# until a second cycle confirms it.
|
||||
expr: |
|
||||
time() - velero_backup_last_successful_timestamp{schedule!=""} > 30 * 3600
|
||||
time() - velero_backup_last_successful_timestamp{schedule!=""} > 60 * 3600
|
||||
and
|
||||
velero_backup_last_successful_timestamp{schedule!=""} > 0
|
||||
for: 10m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Velero {{ $labels.schedule }} has no successful backup in 30h+"
|
||||
description: "Last successful backup for {{ $labels.schedule }} was over 30 hours ago. With a daily schedule that means at least one full cycle missed. RPO is degrading."
|
||||
summary: "Velero {{ $labels.schedule }} has no successful backup in 60h+"
|
||||
description: "Last successful backup for {{ $labels.schedule }} was over 60 hours ago. With a daily schedule that is at least two consecutive cycles missed, which one maintenance window does not explain. RPO is degrading."
|
||||
|
||||
@@ -275,8 +275,11 @@ spec:
|
||||
- alertmanager-smtp
|
||||
# SMTP via Proton submission (smtp.protonmail.ch:587 STARTTLS).
|
||||
# The Proton SMTP token is a per-account credential generated in
|
||||
# the Proton webapp (Settings -> IMAP/SMTP) and lives in the
|
||||
# alertmanager-smtp SOPS-encrypted Secret. Outbound 587 from the
|
||||
# the Proton webapp (Settings -> IMAP/SMTP). It is stored in
|
||||
# OpenBao at apps/monitoring/alertmanager-smtp and projected into
|
||||
# the alertmanager-smtp Secret by
|
||||
# ./alertmanager-smtp-externalsecret.yaml — rotating the token
|
||||
# means updating OpenBao, not this repo. Outbound 587 from the
|
||||
# monitoring ns is not blocked by any CNP today.
|
||||
config:
|
||||
global:
|
||||
|
||||
@@ -55,7 +55,7 @@ spec:
|
||||
# stdlib only; Flux keeps the tag in lockstep via the shared image
|
||||
# policy) — no extra dependency.
|
||||
- name: reconcile-publish-ip
|
||||
image: ghcr.io/music-assistant/server:2.9.13 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.0 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
@@ -65,7 +65,7 @@ spec:
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: music-assistant
|
||||
image: ghcr.io/music-assistant/server:2.9.13 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.0 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
env:
|
||||
- name: LOG_LEVEL
|
||||
value: info
|
||||
|
||||
@@ -4,3 +4,12 @@ resources:
|
||||
- cni-plugins-installer.yaml
|
||||
- cni-dhcp-daemon.yaml
|
||||
- multus-daemonset.yaml
|
||||
# Whereabouts — cluster-coordinated IPAM, for attachments on segments with
|
||||
# no DHCP server. CRDs first: the DaemonSet's control loop reads them at
|
||||
# startup. See the header in whereabouts-daemonset.yaml for why neither
|
||||
# dhcp nor host-local covers the Ceph SAN.
|
||||
- whereabouts-crd-ippools.yaml
|
||||
- whereabouts-crd-overlappingrangeipreservations.yaml
|
||||
- whereabouts-crd-nodeslicepools.yaml
|
||||
- whereabouts-daemonset.yaml
|
||||
- whereabouts-reconciler.yaml
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.14.0
|
||||
name: ippools.whereabouts.cni.cncf.io
|
||||
spec:
|
||||
group: whereabouts.cni.cncf.io
|
||||
names:
|
||||
kind: IPPool
|
||||
listKind: IPPoolList
|
||||
plural: ippools
|
||||
singular: ippool
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: IPPool is the Schema for the ippools API
|
||||
properties:
|
||||
apiVersion:
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: IPPoolSpec defines the desired state of IPPool
|
||||
properties:
|
||||
allocations:
|
||||
additionalProperties:
|
||||
description: IPAllocation represents metadata about the pod/container
|
||||
owner of a specific IP
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
ifname:
|
||||
type: string
|
||||
podref:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- podref
|
||||
type: object
|
||||
description: |-
|
||||
Allocations is the set of allocated IPs for the given range. Its` indices are a direct mapping to the
|
||||
IP with the same index/offset for the pool's range.
|
||||
type: object
|
||||
range:
|
||||
description: Range is a RFC 4632/4291-style string that represents
|
||||
an IP address and prefix length in CIDR notation
|
||||
type: string
|
||||
required:
|
||||
- allocations
|
||||
- range
|
||||
type: object
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.14.0
|
||||
name: nodeslicepools.whereabouts.cni.cncf.io
|
||||
spec:
|
||||
group: whereabouts.cni.cncf.io
|
||||
names:
|
||||
kind: NodeSlicePool
|
||||
listKind: NodeSlicePoolList
|
||||
plural: nodeslicepools
|
||||
singular: nodeslicepool
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: NodeSlicePool is the Schema for the nodesliceippools API
|
||||
properties:
|
||||
apiVersion:
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: NodeSlicePoolSpec defines the desired state of NodeSlicePool
|
||||
properties:
|
||||
range:
|
||||
description: |-
|
||||
Range is a RFC 4632/4291-style string that represents an IP address and prefix length in CIDR notation
|
||||
this refers to the entire range where the node is allocated a subset
|
||||
type: string
|
||||
sliceSize:
|
||||
description: SliceSize is the size of subnets or slices of the range
|
||||
that each node will be assigned
|
||||
type: string
|
||||
required:
|
||||
- range
|
||||
- sliceSize
|
||||
type: object
|
||||
status:
|
||||
description: NodeSlicePoolStatus defines the desired state of NodeSlicePool
|
||||
properties:
|
||||
allocations:
|
||||
description: Allocations holds the allocations of nodes to slices
|
||||
items:
|
||||
properties:
|
||||
nodeName:
|
||||
description: NodeName is the name of the node assigned to this
|
||||
slice, empty node name is an available slice for assignment
|
||||
type: string
|
||||
sliceRange:
|
||||
description: SliceRange is the subnet of this slice
|
||||
type: string
|
||||
required:
|
||||
- nodeName
|
||||
- sliceRange
|
||||
type: object
|
||||
type: array
|
||||
required:
|
||||
- allocations
|
||||
type: object
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.14.0
|
||||
name: overlappingrangeipreservations.whereabouts.cni.cncf.io
|
||||
spec:
|
||||
group: whereabouts.cni.cncf.io
|
||||
names:
|
||||
kind: OverlappingRangeIPReservation
|
||||
listKind: OverlappingRangeIPReservationList
|
||||
plural: overlappingrangeipreservations
|
||||
singular: overlappingrangeipreservation
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: OverlappingRangeIPReservation is the Schema for the OverlappingRangeIPReservations
|
||||
API
|
||||
properties:
|
||||
apiVersion:
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: OverlappingRangeIPReservationSpec defines the desired state
|
||||
of OverlappingRangeIPReservation
|
||||
properties:
|
||||
containerid:
|
||||
type: string
|
||||
ifname:
|
||||
type: string
|
||||
podref:
|
||||
type: string
|
||||
required:
|
||||
- podref
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
# Whereabouts: cluster-wide IPAM for Multus attachments.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# The attachments in this cluster lease their addresses by DHCP
|
||||
# (cni-dhcp-daemon.yaml). That works where a DHCP server is on the wire —
|
||||
# v30iot has one. It does not work on the Ceph SAN (VLAN 49), which carries
|
||||
# Ceph-internal traffic and nothing else, by design: there is no DHCP server
|
||||
# there and there must not be one.
|
||||
#
|
||||
# `host-local`, already installed by cni-plugins-installer.yaml, cannot stand
|
||||
# in. It keeps its allocation state per node with no cluster-wide
|
||||
# coordination, so on a shared L2 segment two nodes hand out the same address.
|
||||
# Whereabouts coordinates through the Kubernetes API instead — allocations
|
||||
# live in the ippools/overlappingrangeipreservations CRDs, so nothing about
|
||||
# the address assignment touches the segment being addressed. That property is
|
||||
# what makes it usable on an isolated SAN.
|
||||
#
|
||||
# HOST ACCESS
|
||||
#
|
||||
# The DaemonSet runs with hostNetwork and privileged. That is how upstream
|
||||
# ships it, and it is what the three CNI components already here do —
|
||||
# multus-daemonset.yaml, cni-dhcp-daemon.yaml and cni-plugins-installer.yaml
|
||||
# all carry both. The reason is the same in each case: the pod writes a CNI
|
||||
# binary and its config into /opt/cni/bin and /etc/cni/net.d on the node,
|
||||
# which is node plumbing rather than a workload, and it must work before the
|
||||
# attachment it provides addresses for exists.
|
||||
#
|
||||
# The reconciler is deliberately NOT in here. It needs neither, and runs as an
|
||||
# ordinary pod-network Deployment — see whereabouts-reconciler.yaml.
|
||||
#
|
||||
# Vendored from upstream v0.9.4 (doc/crds/daemonset-install.yaml), with the
|
||||
# image tag pinned; upstream ships `:latest`. The CRDs it needs are the three
|
||||
# whereabouts-crd-*.yaml files alongside this one, vendored unmodified so an
|
||||
# upstream diff stays readable.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: whereabouts
|
||||
namespace: kube-system
|
||||
---
|
||||
kind: ClusterRoleBinding
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: whereabouts
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: whereabouts-cni
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: whereabouts
|
||||
namespace: kube-system
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: whereabouts-cni
|
||||
rules:
|
||||
- apiGroups:
|
||||
- whereabouts.cni.cncf.io
|
||||
resources:
|
||||
- ippools
|
||||
- overlappingrangeipreservations
|
||||
- nodeslicepools
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- create
|
||||
- update
|
||||
- patch
|
||||
- delete
|
||||
- apiGroups:
|
||||
- coordination.k8s.io
|
||||
resources:
|
||||
- leases
|
||||
verbs:
|
||||
- '*'
|
||||
- apiGroups: [""]
|
||||
resources:
|
||||
- pods
|
||||
verbs:
|
||||
- list
|
||||
- watch
|
||||
- get
|
||||
- apiGroups: [""]
|
||||
resources:
|
||||
- nodes
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups: ["k8s.cni.cncf.io"]
|
||||
resources:
|
||||
- network-attachment-definitions
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- ""
|
||||
- events.k8s.io
|
||||
resources:
|
||||
- events
|
||||
verbs:
|
||||
- create
|
||||
- patch
|
||||
- update
|
||||
- get
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: whereabouts-config
|
||||
namespace: kube-system
|
||||
annotations:
|
||||
kubernetes.io/description: |
|
||||
Configmap containing user customizable cronjob schedule
|
||||
data:
|
||||
# Read by the reconciler (whereabouts-reconciler.yaml), not by this
|
||||
# DaemonSet. Governs how often stale allocations are swept — the case that
|
||||
# matters here is a node lost hard enough that the CNI DEL never ran, which
|
||||
# otherwise leaves an address held by a pod that no longer exists.
|
||||
cron-expression: "30 4 * * *"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: whereabouts
|
||||
namespace: kube-system
|
||||
labels:
|
||||
tier: node
|
||||
app: whereabouts
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
name: whereabouts
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
tier: node
|
||||
app: whereabouts
|
||||
name: whereabouts
|
||||
spec:
|
||||
hostNetwork: true
|
||||
serviceAccountName: whereabouts
|
||||
# Every node here is a control plane node, so the NoSchedule toleration
|
||||
# is not optional — without it this schedules nowhere.
|
||||
tolerations:
|
||||
- operator: Exists
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: whereabouts
|
||||
command: ["/bin/sh"]
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
SLEEP=false source /install-cni.sh
|
||||
/token-watcher.sh &
|
||||
/ip-control-loop -log-level debug
|
||||
image: ghcr.io/k8snetworkplumbingwg/whereabouts:v0.9.4
|
||||
env:
|
||||
- name: NODENAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
apiVersion: v1
|
||||
fieldPath: spec.nodeName
|
||||
- name: WHEREABOUTS_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
resources:
|
||||
requests:
|
||||
cpu: "100m"
|
||||
memory: "100Mi"
|
||||
limits:
|
||||
cpu: "100m"
|
||||
memory: "200Mi"
|
||||
securityContext:
|
||||
privileged: true
|
||||
volumeMounts:
|
||||
- name: cnibin
|
||||
mountPath: /host/opt/cni/bin
|
||||
- name: cni-net-dir
|
||||
mountPath: /host/etc/cni/net.d
|
||||
volumes:
|
||||
- name: cnibin
|
||||
hostPath:
|
||||
path: /opt/cni/bin
|
||||
- name: cni-net-dir
|
||||
hostPath:
|
||||
path: /etc/cni/net.d
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
# Sweeps whereabouts allocations that no live pod holds any more.
|
||||
#
|
||||
# The normal release path is the CNI DEL when a pod goes away. It does not run
|
||||
# when a node is lost hard — power cut, kernel panic, a reboot that never
|
||||
# comes back — and the allocation then stays claimed by a pod that no longer
|
||||
# exists. On a small range that matters: the Ceph SAN is a /29, so a handful
|
||||
# of leaked addresses is the difference between an OSD getting an address and
|
||||
# not.
|
||||
#
|
||||
# Runs as an ordinary pod-network Deployment. Unlike the DaemonSet next to it,
|
||||
# this needs no host access at all — it only talks to the Kubernetes API,
|
||||
# where the allocations live.
|
||||
#
|
||||
# replicas stays at 1: there is no leader election, so a second replica would
|
||||
# reconcile the same records concurrently.
|
||||
#
|
||||
# Vendored from upstream v0.9.4 (doc/crds/reconciler-deployment.yaml) with the
|
||||
# image tag pinned; upstream ships `:latest`. The schedule comes from the
|
||||
# whereabouts-config ConfigMap in whereabouts-daemonset.yaml.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: whereabouts-reconciler
|
||||
namespace: kube-system
|
||||
labels:
|
||||
tier: node
|
||||
app: whereabouts-reconciler
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
name: whereabouts-reconciler
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
tier: node
|
||||
app: whereabouts-reconciler
|
||||
name: whereabouts-reconciler
|
||||
spec:
|
||||
serviceAccountName: whereabouts
|
||||
# Every node here is a control plane node.
|
||||
tolerations:
|
||||
- operator: Exists
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: whereabouts-reconciler
|
||||
command: ["/ip-reconciler"]
|
||||
args:
|
||||
- -log-level
|
||||
- debug
|
||||
image: ghcr.io/k8snetworkplumbingwg/whereabouts:v0.9.4
|
||||
env:
|
||||
- name: WHEREABOUTS_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
resources:
|
||||
requests:
|
||||
cpu: "100m"
|
||||
memory: "50Mi"
|
||||
limits:
|
||||
cpu: "100m"
|
||||
memory: "50Mi"
|
||||
volumeMounts:
|
||||
- name: cron-scheduler-configmap
|
||||
mountPath: /cron-schedule
|
||||
volumes:
|
||||
- name: cron-scheduler-configmap
|
||||
configMap:
|
||||
name: whereabouts-config
|
||||
defaultMode: 0744
|
||||
items:
|
||||
- key: "cron-expression"
|
||||
path: "config"
|
||||
@@ -42,12 +42,28 @@ spec:
|
||||
# the agent-injector mutating webhook is unused surface.
|
||||
enabled: false
|
||||
server:
|
||||
# Single-node raft (not standalone/file) so we get `bao operator raft
|
||||
# snapshot` as a consistent backup primitive. TLS terminates at the
|
||||
# Traefik ingress; in-cluster traffic is plain HTTP.
|
||||
# Raft (not standalone/file) for two reasons: `bao operator raft
|
||||
# snapshot` as a consistent backup primitive, and real HA across three
|
||||
# nodes. TLS terminates at the Traefik ingress; in-cluster traffic is
|
||||
# plain HTTP.
|
||||
ha:
|
||||
enabled: true
|
||||
replicas: 1
|
||||
# Three, not two. Raft needs a quorum, and the quorum of two is two —
|
||||
# a two-replica cluster tolerates no failures at all and would stop
|
||||
# the moment one pod is sealed or rescheduled, which is worse than
|
||||
# the single replica it replaced. Three tolerates one.
|
||||
#
|
||||
# What this buys, and what it does not: a node drain seals the replica
|
||||
# on it, and raft elects a new leader from the remaining standbys
|
||||
# within seconds, so the service does not go down. It does NOT remove
|
||||
# the manual unseal — a sealed server can neither serve requests nor
|
||||
# act as standby, so that replica stays out until someone unseals it.
|
||||
#
|
||||
# The consequence for rolling node maintenance is worth stating: a
|
||||
# Talos upgrade drains all three nodes in turn, sealing each replica
|
||||
# as it goes. Unseal each one as it comes back, or the third drain
|
||||
# takes the last unsealed replica with it and quorum is lost anyway.
|
||||
replicas: 3
|
||||
raft:
|
||||
enabled: true
|
||||
setNodeId: true
|
||||
@@ -65,8 +81,32 @@ spec:
|
||||
cluster_address = "[::]:8201"
|
||||
}
|
||||
|
||||
# retry_join is what makes a replica join the existing raft
|
||||
# cluster instead of coming up as an isolated, uninitialized
|
||||
# node. Without it, scaling the StatefulSet produces pods that
|
||||
# never become peers and would each need a manual
|
||||
# `bao operator raft join`.
|
||||
#
|
||||
# All three peers are listed on every node, including the node
|
||||
# itself. That is deliberate: the stanza is a list of possible
|
||||
# leaders, a node ignores its own entry, and listing all three
|
||||
# means the set is identical on every replica and stays correct
|
||||
# regardless of which pod currently leads.
|
||||
#
|
||||
# Addressed through the headless service, so each entry resolves
|
||||
# to one specific pod rather than load-balancing across them.
|
||||
storage "raft" {
|
||||
path = "/openbao/data"
|
||||
|
||||
retry_join {
|
||||
leader_api_addr = "http://openbao-0.openbao-internal:8200"
|
||||
}
|
||||
retry_join {
|
||||
leader_api_addr = "http://openbao-1.openbao-internal:8200"
|
||||
}
|
||||
retry_join {
|
||||
leader_api_addr = "http://openbao-2.openbao-internal:8200"
|
||||
}
|
||||
}
|
||||
|
||||
service_registration "kubernetes" {}
|
||||
|
||||
@@ -53,7 +53,13 @@ spec:
|
||||
command: ["/bin/sh", "-ec"]
|
||||
args:
|
||||
- |
|
||||
export BAO_ADDR=http://openbao.openbao.svc.cluster.local:8200
|
||||
# openbao-active, not openbao: with more than one replica
|
||||
# the plain service selects any pod, and a raft snapshot has
|
||||
# to be taken against the leader. Standbys forward ordinary
|
||||
# requests, but this is not one to leave to chance — the
|
||||
# active service selects the leader by label, so the
|
||||
# snapshot is always taken where it is consistent.
|
||||
export BAO_ADDR=http://openbao-active.openbao.svc.cluster.local:8200
|
||||
BAO_TOKEN=$(bao write -field=token auth/kubernetes/login \
|
||||
role=openbao-snapshot \
|
||||
jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)")
|
||||
|
||||
@@ -8,6 +8,17 @@ spec:
|
||||
image: quay.io/ceph/ceph:v19.2.6 # {"$imagepolicy": "flux-system:ceph"}
|
||||
allowUnsupported: false
|
||||
dataDirHostPath: /var/lib/rook
|
||||
# Do not start restarting OSDs while PGs are still recovering. The default
|
||||
# is false, so Rook would proceed regardless — and paired with the old
|
||||
# osdMaxUpdatesInParallel default that means it can take the next OSD down
|
||||
# while the previous one's data is still being caught up.
|
||||
#
|
||||
# With three OSDs and size 3 there is no spare copy to fall back on: the
|
||||
# gate between one OSD returning and the next going down is the only thing
|
||||
# that keeps a rolling change from becoming an outage. See
|
||||
# storage.osdMaxUpdatesInParallel below, which bounds the same operation
|
||||
# from the other side.
|
||||
upgradeOSDRequiresHealthyPGs: true
|
||||
mon:
|
||||
count: 3
|
||||
allowMultiplePerNode: false
|
||||
@@ -36,26 +47,27 @@ spec:
|
||||
# (paperless + audiobookshelf + immich + ... all clone at once).
|
||||
cephConfig:
|
||||
mon:
|
||||
# Ceph's 30% default assumes the mon has a filesystem to itself, where
|
||||
# 30% free is a meaningful reserve. Here the mons live on
|
||||
# dataDirHostPath /var/lib/rook — the node's /var, shared with ~29 GB
|
||||
# of containerd image and snapshot storage — and the mon DB itself is
|
||||
# ~106 MB. So the default measures container churn, not Ceph: k8s-2
|
||||
# trips MON_DISK_LOW whenever the image cache fills, and image GC does
|
||||
# not clear it (most of /var/lib/containerd is overlayfs snapshots held
|
||||
# by running containers, which GC cannot reclaim — see #434).
|
||||
# 20% of a 60 GB /var still leaves ~12 GB against a 106 MB database.
|
||||
# mon_data_avail_crit stays at the 5% default, and CephMonDiskLow in
|
||||
# monitoring-rules watches /var directly for the real risk, which is the
|
||||
# node filling rather than the mon growing.
|
||||
# The Ceph default, stated rather than left out. Removing the line would
|
||||
# not restore it: `ceph config` keeps a value that only disappears from
|
||||
# this file, so the old 20 would sit in the mon config database
|
||||
# indefinitely — the same trap the bluestore settings below describe,
|
||||
# and the one that crash-looped an OSD during the network provider
|
||||
# change when public_network survived being deleted here.
|
||||
#
|
||||
# BARE-METAL MIGRATION: revisit this. Give the mons their own volume and
|
||||
# the default 30% becomes meaningful again, at which point this override
|
||||
# should be dropped rather than carried over. It cannot be done on the
|
||||
# current cluster — every StorageClass is Ceph-backed, so a mon PVC
|
||||
# would be circular, and Rook only applies mon.volumeClaimTemplate to
|
||||
# newly created mons, never migrating existing ones.
|
||||
mon_data_avail_warn: "20"
|
||||
# It was 20 because the mons share the node's /var with containerd, and
|
||||
# on the VMs that was ~60 GB: 30% free measured image and snapshot churn
|
||||
# rather than anything about Ceph, so k8s-2 tripped MON_DISK_LOW whenever
|
||||
# the image cache filled and GC could not reclaim it (#434).
|
||||
#
|
||||
# Bare metal removed the premise. /var is now ~931 GB per node at 7-8%
|
||||
# used, so 30% free is a real reserve again and the ~106 MB mon database
|
||||
# is irrelevant against it. Reaching 70% would be a genuine signal about
|
||||
# the node rather than noise about the image cache.
|
||||
#
|
||||
# mon_data_avail_crit stays at the 5% default, and CephMonDiskLow in
|
||||
# monitoring-rules still watches /var directly for the real risk, which
|
||||
# is the node filling rather than the mon growing.
|
||||
mon_data_avail_warn: "30"
|
||||
# Stop compacting the whole RocksDB prefix on every paxos trim.
|
||||
#
|
||||
# Upstream default is true — "Compact a certain prefix (including paxos)
|
||||
@@ -147,19 +159,65 @@ spec:
|
||||
ssl: false
|
||||
monitoring:
|
||||
enabled: true
|
||||
# Dedicated Ceph cluster network on VLAN 49 / 10.99.0.0/29, carried by an
|
||||
# X710 SFP+ port PCI-passed-through to each Talos K8s VM (see
|
||||
# talos/patches/k8s-{1,2,3}.yaml and opentofu/vm_15{0,1,2}_k8s_*.tf).
|
||||
# `addressRanges` requires `provider: host` — Rook v1.18 explicitly notes
|
||||
# the default (CNI) provider ignores addressRanges. Public range matches
|
||||
# the existing v40srv subnet that the controlplane VMs already live on.
|
||||
# Dedicated Ceph cluster network on VLAN 49, carried by the second X710
|
||||
# SFP+ port (enp2s0f1np1) on each node, attached to the OSDs through Multus.
|
||||
#
|
||||
# WHY NOT provider: host, WHICH ALSO REACHES THAT LINK
|
||||
#
|
||||
# Because it reaches it for every daemon, not just the ones that need it.
|
||||
# The cluster network is an OSD concern: per the Ceph documentation it
|
||||
# carries OSD heartbeat, replication and recovery, and "Ceph Monitors always
|
||||
# operate on the public network". `addressRanges` is only honoured under
|
||||
# provider: host, so using it put the mons on host networking as a side
|
||||
# effect — making a mon's address the node's DHCP lease.
|
||||
#
|
||||
# That is not theoretical. When a lease moved, the mon could no longer bind
|
||||
# ("Cannot assign requested address") and crash-looped. Rook detected it and
|
||||
# started a failover, but with three mons on three nodes each holding the
|
||||
# mon host ports, the replacement could not be scheduled anywhere — the
|
||||
# canary sat Pending on "didn't have free ports". Two of three nodes hit
|
||||
# this during the Talos v1.12.11 upgrade and both needed hand intervention.
|
||||
#
|
||||
# Only `cluster` is selected here. With `public` left unset Rook puts client
|
||||
# traffic on the pod network, so mons, MDS, MGR and the CSI pods keep
|
||||
# Rook-managed identities and hold no host ports — which is what removes
|
||||
# that failure mode rather than working around it.
|
||||
#
|
||||
# ISOLATION IS PRESERVED
|
||||
#
|
||||
# Rook's Multus prerequisites about hosts routing to the Multus network
|
||||
# apply to a Multus *public* network. Selecting only `cluster` means they do
|
||||
# not apply, so VLAN 49 stays what it is: Ceph-internal traffic and nothing
|
||||
# else, no router, no other participants. After this settles, the node
|
||||
# addresses on that segment (10.99.0.1-.3) have no remaining purpose and
|
||||
# should come out of the Talos configs — at which point the attachment's
|
||||
# pool can take the whole /29.
|
||||
#
|
||||
# Reached from provider: "" rather than directly from host, which Rook's CRD
|
||||
# rejects. See the phase A commit.
|
||||
#
|
||||
# ONE-TIME STEP THAT THIS FILE CANNOT EXPRESS, recorded because it is not
|
||||
# reproducible from the manifests alone. Removing addressRanges here does
|
||||
# not clear public_network and cluster_network from Ceph's own config
|
||||
# database — the same trap the bluestore comment above describes: `ceph
|
||||
# config` retains a value that is only deleted from this file. During the
|
||||
# phase A rollout the first OSD came up on the pod network, still had
|
||||
# public_network 10.2.40.0/24 in the config database, found no interface in
|
||||
# it and crash-looped on "Failed to pick public address". Both keys were
|
||||
# removed with `ceph config rm global public_network` and the same for
|
||||
# cluster_network; Rook then wrote cluster_network afresh from the
|
||||
# attachment below. Anyone rebuilding this cluster from scratch will not hit
|
||||
# it — the config database starts empty — but anyone repeating a provider
|
||||
# change on a live cluster will.
|
||||
#
|
||||
# Bounded by upgradeOSDRequiresHealthyPGs and storage.osdMaxUpdatesInParallel
|
||||
# above: one OSD restarts at a time and only once PGs are clean again.
|
||||
#
|
||||
# The attachment itself is san-network-attachment.yaml.
|
||||
network:
|
||||
provider: host
|
||||
addressRanges:
|
||||
public:
|
||||
- "10.2.40.0/24"
|
||||
cluster:
|
||||
- "10.99.0.0/29"
|
||||
provider: multus
|
||||
selectors:
|
||||
cluster: rook-ceph/ceph-san
|
||||
# Nodes are listed explicitly rather than useAllNodes, so that a node can be
|
||||
# taken out of the storage fleet by removing it here. Under useAllNodes the
|
||||
# operator re-provisions an OSD on any node whose device still carries a
|
||||
@@ -193,6 +251,15 @@ spec:
|
||||
# 2326E6E8E67D Crucial — Talos EPHEMERAL (/var)
|
||||
# S437NF0M501176 PM983 — this OSD
|
||||
storage:
|
||||
# One OSD restarted at a time. The default is 20, which on a three-OSD
|
||||
# cluster means all of them at once — every PG inactive, every pool
|
||||
# unreachable, for as long as the restart takes. The default suits a
|
||||
# cluster large enough that 20 OSDs is a small fraction of it; here it is
|
||||
# the whole thing.
|
||||
#
|
||||
# This binds any change that rewrites the OSD deployment spec, not just
|
||||
# Ceph version upgrades — a network provider change is one such.
|
||||
osdMaxUpdatesInParallel: 1
|
||||
useAllNodes: false
|
||||
useAllDevices: false
|
||||
nodes:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
# Attachment for the VLAN 49 cluster network. Inert until cephcluster.yaml
|
||||
# names it under network.selectors.cluster — creating it changes nothing on
|
||||
# its own.
|
||||
- san-network-attachment.yaml
|
||||
- cephcluster.yaml
|
||||
- toolbox.yaml
|
||||
- storageclass.yaml
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
# The Ceph cluster network as a Multus attachment, so OSDs reach VLAN 49
|
||||
# without every Ceph daemon being put on host networking to get there.
|
||||
#
|
||||
# WHAT THIS REPLACES, AND WHY
|
||||
#
|
||||
# `addressRanges` only takes effect under `provider: host` — Rook ignores it
|
||||
# for the default CNI provider. So carrying replication over the dedicated
|
||||
# 10G link meant setting provider: host, which is not per-daemon: it puts
|
||||
# mons on host networking too. A mon's address is then the node's DHCP lease,
|
||||
# and when that lease moves the mon cannot bind and fails. Worse, three mons
|
||||
# on three nodes each hold the mon host ports, so Rook cannot schedule a
|
||||
# replacement anywhere and the failover it starts cannot finish.
|
||||
#
|
||||
# None of that has anything to do with the goal. Per the Ceph documentation
|
||||
# the cluster network carries OSD heartbeat, replication and recovery only —
|
||||
# "Ceph Monitors always operate on the public network". Attaching it here
|
||||
# gives the OSDs the link and leaves everything else on the pod network,
|
||||
# where Rook manages identity itself.
|
||||
#
|
||||
# ISOLATION
|
||||
#
|
||||
# VLAN 49 carries Ceph-internal traffic and nothing else. Two consequences
|
||||
# are deliberate:
|
||||
#
|
||||
# No gateway and no routes. There is no router on this segment and there
|
||||
# must not be one. whereabouts adds neither unless asked, so nothing here
|
||||
# can leak onto or off the SAN. This also means no `sbr` chaining is
|
||||
# needed — unlike the v30iot attachments, there is no DHCP-supplied default
|
||||
# route to confine, because there is no DHCP and no default route.
|
||||
#
|
||||
# The pool is the whole usable /29, .1 through .6. It was .4-.6 while the
|
||||
# migration ran, because the nodes still held .1-.3 for as long as Ceph
|
||||
# needed host networking to reach this link — three addresses for three
|
||||
# OSDs, no spare. The nodes have since given those up (the link is only a
|
||||
# macvlan parent now and needs no address of its own), so the reservation
|
||||
# has nothing left to avoid.
|
||||
#
|
||||
# Keeping the pool inside the /29 rather than moving it elsewhere on the
|
||||
# segment is not cosmetic. There is no router here, so an address outside
|
||||
# the /29 would be unreachable from anything still using one inside it —
|
||||
# which is exactly what would break a future rolling change, where migrated
|
||||
# and un-migrated OSDs have to keep talking to each other.
|
||||
#
|
||||
# Six addresses for three OSDs is the headroom the migration could not
|
||||
# afford: room for a leaked allocation to sit until the reconciler collects
|
||||
# it, and room to add a second OSD per node without touching this file.
|
||||
#
|
||||
# IPAM
|
||||
#
|
||||
# whereabouts, because neither alternative works on this segment: dhcp needs
|
||||
# a server that must not exist here, and host-local keeps its state per node
|
||||
# with no cluster-wide coordination, so two nodes would hand out the same
|
||||
# address on shared L2. See infrastructure/multus/whereabouts-daemonset.yaml.
|
||||
#
|
||||
# MTU 9000 matches the link. macvlan children inherit the master's MTU, but
|
||||
# it is stated so that a change to the parent does not silently halve
|
||||
# replication throughput without anything in the tree recording the
|
||||
# expectation.
|
||||
apiVersion: k8s.cni.cncf.io/v1
|
||||
kind: NetworkAttachmentDefinition
|
||||
metadata:
|
||||
name: ceph-san
|
||||
namespace: rook-ceph
|
||||
spec:
|
||||
# `master` is an interface name. enp2s0f1np1 is the second X710 SFP+ port,
|
||||
# a busPath-derived predictable name (net.ifnames=1 via the Image Factory
|
||||
# schematic) that is identical on k8s-1/2/3 and cannot shuffle across
|
||||
# reboots.
|
||||
config: |
|
||||
{
|
||||
"cniVersion": "0.3.1",
|
||||
"name": "ceph-san",
|
||||
"type": "macvlan",
|
||||
"master": "enp2s0f1np1",
|
||||
"mode": "bridge",
|
||||
"mtu": 9000,
|
||||
"ipam": {
|
||||
"type": "whereabouts",
|
||||
"range": "10.99.0.0/29",
|
||||
"range_start": "10.99.0.1",
|
||||
"range_end": "10.99.0.6"
|
||||
}
|
||||
}
|
||||
@@ -20,16 +20,39 @@ machine:
|
||||
# the MS-01 mesh later needs neither a new image
|
||||
# nor a reinstall — which is exactly the property
|
||||
# lost by installing without it.
|
||||
# siderolabs/i915 Intel GPU microcode and kernel modules for the
|
||||
# Alder Lake-P Iris Xe in the MS-01. No workload
|
||||
# requests /dev/dri today; it is here for the same
|
||||
# reason as thunderbolt, so that wanting hardware
|
||||
# transcoding later is a config change rather than
|
||||
# another rolling upgrade of every node.
|
||||
#
|
||||
# a21cec8f… is the bare-metal schematic described in RUNBOOK-cold-rebuild.md:
|
||||
# same net.ifnames=1 as the Proxmox one (the predictable interface names the
|
||||
# per-node VLAN parents match against), minus qemu-guest-agent.
|
||||
# Not included, and why, so the absences are not mistaken for oversights:
|
||||
# the X710 (i40e) and I226 (igc) NICs need no firmware extension —
|
||||
# intel-ice-firmware covers E810/ice only. mei is a prerequisite for Intel
|
||||
# Arc discrete GPUs, of which there are none. iscsi-tools, nvme-cli and
|
||||
# util-linux-tools have no consumer: storage is Ceph RBD, and device health
|
||||
# is reported by Ceph itself rather than a node-level exporter.
|
||||
#
|
||||
# 0f6fb8bd… is the bare-metal schematic: same net.ifnames=1 as the Proxmox
|
||||
# one (the predictable interface names the per-node VLAN parents match
|
||||
# against), minus qemu-guest-agent. It supersedes a21cec8f…, which was
|
||||
# identical but without i915 — see RUNBOOK-cold-rebuild.md.
|
||||
#
|
||||
# Only read at install and upgrade time. Changing it here does nothing to a
|
||||
# running node until that node is upgraded. Bump the tag with the Talos
|
||||
# version — it must match the running version, or the upgrade is a downgrade.
|
||||
#
|
||||
# Upgrades follow the documented path: migration is only tested between
|
||||
# adjacent minor releases, so go to the latest patch of each intermediate
|
||||
# minor rather than skipping. 1.12.11 -> 1.13.9 satisfies that — one minor
|
||||
# step from the last 1.12 patch, no intermediate needed.
|
||||
#
|
||||
# Kubernetes is a separate decision and is not forced by this. Talos 1.13
|
||||
# supports Kubernetes 1.36, 1.35, 1.34, 1.33, 1.32 and 1.31, so the cluster
|
||||
# stays on 1.35.2 until `talosctl upgrade-k8s` moves it deliberately.
|
||||
install:
|
||||
image: factory.talos.dev/metal-installer/a21cec8f35ae32ab5e9a452e7b13500ca8776053930b8e182b9cb23eed186b64:v1.12.6
|
||||
image: factory.talos.dev/metal-installer/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9:v1.13.9
|
||||
features:
|
||||
rbac: true
|
||||
stableHostname: true
|
||||
|
||||
@@ -20,6 +20,29 @@ machine:
|
||||
# un-translated SYN-ACK -> client kernel RST, verified live 2026-07-23).
|
||||
# This disables v6 entirely on that link, including link-local.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# Same argument, applied to the physical ports rather than a VLAN child.
|
||||
#
|
||||
# enp2s0f0np0 is nothing but the trunk parent for mgmt0 and ens19, and
|
||||
# enp87s0 is unused. Both sit on the untagged fallback network, where by
|
||||
# design nothing is supposed to run — and both were autoconfiguring
|
||||
# themselves from its router advertisements: an fd00::/64 ULA each, plus
|
||||
# an IPv6 default route each.
|
||||
#
|
||||
# The result was four IPv6 default routes per node at identical priority
|
||||
# 1024 — mgmt0, enp2s0f0np0, enp87s0 and lo — of which only mgmt0 has a
|
||||
# globally routable source address. With nothing to break the tie,
|
||||
# outbound IPv6 mostly left through an interface whose only usable source
|
||||
# was a ULA, so the cluster had no IPv6 egress at all while IPv4 worked
|
||||
# normally. It surfaced as two NetBird probes reporting the proxies down
|
||||
# for 30 hours; the proxies were fine and answered IPv6 in 13 ms from
|
||||
# outside.
|
||||
#
|
||||
# Verified on k8s-1 before rolling to the others: disabling v6 on a VLAN
|
||||
# parent leaves the VLAN child alone. mgmt0 kept its global address and
|
||||
# its fd40:: address, Ceph did not notice, and every failing probe
|
||||
# recovered on the next scrape.
|
||||
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
|
||||
net.ipv6.conf.enp87s0.disable_ipv6: "1"
|
||||
install:
|
||||
# Three NVMe drives are ordinary block devices to Talos here: the Kingston
|
||||
# OM8PGP41024Q-A0 system disk, the Samsung PM983 that Rook claims as the
|
||||
@@ -94,6 +117,30 @@ vlanID: 40
|
||||
parent: enp2s0f0np0
|
||||
up: true
|
||||
---
|
||||
# Management addressing is DHCP and the lease is NOT reserved anywhere in this
|
||||
# repo — the k8s-{1,2,3}.srv.renner.uno reservations live on the UDM out of band
|
||||
# and opentofu/unifi.tf declares no live resources. The addresses do move under
|
||||
# a running node: k8s-3 went 10.2.40.168 -> .138 mid-flight on 2026-08-26
|
||||
# without its Node object ever re-registering (issue #635).
|
||||
#
|
||||
# kube-apiserver does not follow when that happens. Talos starts it with
|
||||
# `--advertise-address=$(POD_IP)` and no `--endpoint-reconciler-type` override,
|
||||
# so the advertised address is fixed at pod start and the default lease
|
||||
# reconciler goes on renewing the masterlease under the OLD address. Nothing
|
||||
# expires it while the process lives, so `default/kubernetes` keeps handing out
|
||||
# a dead endpoint and the node's live address is never registered at all — 19h
|
||||
# of TargetDown on job="apiserver", a monitoring blind spot on one of three
|
||||
# apiservers, and it cleared only when the static pod restarted.
|
||||
#
|
||||
# So after any node re-IPs, check that
|
||||
# `kubectl get endpoints kubernetes -n default` agrees with
|
||||
# `kubectl get nodes -o wide`, and restart the offending node
|
||||
# (`talosctl -n <node> reboot`) if it does not.
|
||||
#
|
||||
# Pinning mgmt0 with a static AddressConfig alongside this document would remove
|
||||
# the trigger, but it needs an address outside the UDM's DHCP pool — which is
|
||||
# not declared in this repo — and §6 of RUNBOOK-baremetal-conversion.md applies:
|
||||
# prove it with `talosctl apply-config --mode=try` before making it permanent.
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: mgmt0
|
||||
@@ -117,13 +164,20 @@ parent: enp2s0f0np0
|
||||
up: true
|
||||
---
|
||||
# Dedicated Ceph cluster network on X710 port 1, VLAN 49 / 10.99.0.0/29,
|
||||
# untagged on the wire. Used by Rook for OSD<->OSD replication via
|
||||
# CephCluster.spec.network.addressRanges.cluster. Not routed off its own
|
||||
# segment, and deliberately has no default route.
|
||||
# untagged on the wire. Carries OSD<->OSD replication and nothing else.
|
||||
#
|
||||
# The node deliberately holds NO address here. Rook attaches this link to the
|
||||
# OSD pods through a Multus macvlan (infrastructure/rook-ceph-cluster/
|
||||
# san-network-attachment.yaml), and a macvlan parent only has to be up — it
|
||||
# does not need an address of its own for its children to work.
|
||||
#
|
||||
# It used to carry 10.99.0.1/29, because reaching this link required
|
||||
# CephCluster.spec.network.provider: host, and that put every Ceph daemon on
|
||||
# host networking, mons included. Nothing needs it now, and by the rule this
|
||||
# segment is built on — Ceph-internal traffic and nothing else — the node has
|
||||
# no business on it either.
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp2s0f1np1
|
||||
up: true
|
||||
mtu: 9000
|
||||
addresses:
|
||||
- address: 10.99.0.1/29
|
||||
|
||||
@@ -9,6 +9,15 @@ machine:
|
||||
# macvlan parent for the v30iot pod attachments; host addresses on that L2
|
||||
# make node replies bypass the UDM and break every UDM-DNATed flow.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# Same argument for the physical ports: enp2s0f0np0 is only the trunk
|
||||
# parent for mgmt0 and ens19, enp87s0 is unused, and both sit on the
|
||||
# untagged fallback network where nothing is supposed to run. Both were
|
||||
# taking an fd00::/64 and an IPv6 default route from its router
|
||||
# advertisements, leaving four equal-priority default routes per node of
|
||||
# which only mgmt0 has a globally routable source. See k8s-1.yaml for the
|
||||
# full reasoning and how it was verified.
|
||||
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
|
||||
net.ipv6.conf.enp87s0.disable_ipv6: "1"
|
||||
install:
|
||||
# Three NVMe drives are visible on bare metal. Select the installer target
|
||||
# by serial so it cannot land on the OSD drive or on EPHEMERAL, whatever
|
||||
@@ -89,10 +98,17 @@ up: true
|
||||
# Dedicated Ceph cluster network on X710 port 1, VLAN 49 / 10.99.0.0/29,
|
||||
# untagged on the wire. OSD<->OSD replication only. Not routed off its own
|
||||
# segment and deliberately has no default route.
|
||||
#
|
||||
# The node deliberately holds NO address here. Rook attaches this link to the
|
||||
# OSD pods through a Multus macvlan (infrastructure/rook-ceph-cluster/
|
||||
# san-network-attachment.yaml), and a macvlan parent only has to be up — it
|
||||
# does not need an address of its own for its children to work.
|
||||
#
|
||||
# It used to carry 10.99.0.2/29, because reaching this link required
|
||||
# CephCluster.spec.network.provider: host, which put every Ceph daemon on
|
||||
# host networking, mons included. Nothing needs it now.
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp2s0f1np1
|
||||
up: true
|
||||
mtu: 9000
|
||||
addresses:
|
||||
- address: 10.99.0.2/29
|
||||
|
||||
@@ -8,6 +8,15 @@ machine:
|
||||
sysctls:
|
||||
# v30iot NIC carries no host addressing — see k8s-1.yaml.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# Same argument for the physical ports: enp2s0f0np0 is only the trunk
|
||||
# parent for mgmt0 and ens19, enp87s0 is unused, and both sit on the
|
||||
# untagged fallback network where nothing is supposed to run. Both were
|
||||
# taking an fd00::/64 and an IPv6 default route from its router
|
||||
# advertisements, leaving four equal-priority default routes per node of
|
||||
# which only mgmt0 has a globally routable source. See k8s-1.yaml for the
|
||||
# full reasoning and how it was verified.
|
||||
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
|
||||
net.ipv6.conf.enp87s0.disable_ipv6: "1"
|
||||
install:
|
||||
# Three NVMe drives are visible on bare metal. Select the installer target
|
||||
# by serial so it cannot land on the OSD drive or on EPHEMERAL, whatever
|
||||
@@ -73,10 +82,16 @@ parent: enp2s0f0np0
|
||||
up: true
|
||||
---
|
||||
# Dedicated Ceph cluster network — see k8s-1.yaml.
|
||||
# The node deliberately holds NO address here. Rook attaches this link to the
|
||||
# OSD pods through a Multus macvlan (infrastructure/rook-ceph-cluster/
|
||||
# san-network-attachment.yaml), and a macvlan parent only has to be up — it
|
||||
# does not need an address of its own for its children to work.
|
||||
#
|
||||
# It used to carry 10.99.0.3/29, because reaching this link required
|
||||
# CephCluster.spec.network.provider: host, which put every Ceph daemon on
|
||||
# host networking, mons included. Nothing needs it now.
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp2s0f1np1
|
||||
up: true
|
||||
mtu: 9000
|
||||
addresses:
|
||||
- address: 10.99.0.3/29
|
||||
|
||||
Reference in New Issue
Block a user