Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5 3302e2c5cd fix(bird): pin the base image by digest and self-test the binary
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Every other Alpine image under ci/images/ pins alpine:3.21 by index digest, and alpine-jq states the reason in the file: the resolved version drifts between restarts of an otherwise pinned image. The first revision used a bare alpine:3.22 tag. 3.22 is still right — bird2 first appears in 3.22/community — so the tag stays and the index digest is added.

heating-can runs --help checks before publishing so a wrong path fails the build rather than the first pod; bird documents --version, so it gets the same guarantee.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:38:46 +02:00
claude-botandClaude Opus 5 3792485c2f feat(bird): image for the pod-network BGP mesh (1/3) (#576)
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Pod east-west follows the peer's Kubernetes InternalIP on mgmt0, and Cilium offers no way to point it elsewhere — established by test in #838, and directRoutingDevice (#837) does not do it. Policy routing does: a rule on the pod source prefixes into a table whose routes carry pod0 next-hops, proven on k8s-1 by counting 4647 packets onto pod0.

Those routes have to be generated, not written down: PodCIDRs are Kubernetes-assigned and do not survive a rebuild in place. BGP between the nodes over pod0 generates them, with the session source as the next-hop.

BIRD rather than FRR. FRR's docs describe 'set table' only as setting a BGP table identifier, never a kernel table; binding bgpd to an address implies --no_kernel and would suppress the route installation the design exists for; and a gatewayless IPv6 device route has no next-hop for its route-maps to match, so the own-CIDR filter could not be uniform. BIRD documents 'kernel table', 'strict bind' without touching the kernel protocol, and an 'ifname' filter attribute that discriminates the local route from the learned ones in both families.

No maintained standalone BIRD image exists: pierky/bird self-declares as lab-only and amd64-only, calico/bird is a Calico-internal artifact expecting confd, slipmesh/bird is five weeks old, srl-labs and ibhde are lab-scoped or on BIRD 1.6. A custom controller would have needed an image of its own anyway, so the build is common to both routes.

Image only. The DaemonSet is 2/3 and the routing rules 3/3, so the table fills before anything reads it.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:29:17 +02:00
k.renner 1a1e095f76 Merge pull request 'feat(talos): route pod egress over ingress0 (#576)' (#842) from feat/pod-egress-vlan47 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#842
2026-09-16 05:40:40 +00:00
claude-botandClaude Opus 5 11df090913 feat(talos): route pod egress over ingress0 (#576)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The default route cannot simply move to ingress0: the node IP, the API VIP and apid answer on mgmt0, so a reply leaving over ingress0 would carry an mgmt0 source onto VLAN 47. Policy routing splits the two instead — pod source prefixes look up table 47, everything the node sends itself keeps main and mgmt0.

Table 47 holds only the two default routes, so destination rules at 990/991 send the cluster and local networks back to main first; without them a pod's packet to a service matches the source rule, finds only the default, and leaves for the UDM. That was measured on k8s-1, where in-cluster DNS stopped resolving until the rules were added.

The v6 gateway is the UDM's configured ULA on the segment, the address BGP already peers with, rather than the RA-learned link-local. accept_ra_defrtr stays 0 on ingress0 so the RA default does not land in main.

Verified on k8s-1 with --mode=try: a pod egressed as 2003:d6:973f:5605:5a47:caff:fe74:7027, the ingress0 GUA from the VLAN 47 prefix, while in-cluster DNS and the Talos and Kubernetes APIs kept working.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 07:35:14 +02:00
security-bot cfb1406201 chore(image-automation): bump ghcr.io/immich-app/immich-server:v3.2.2
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-15 21:06:23 +00:00
security-bot 4190c634ec chore(image-automation): bump ghcr.io/immich-app/immich-machine-learning:v3.2.2
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-15 20:21:56 +00:00
security-bot 717c5a5f8a chore(image-automation): bump ghcr.io/immich-app/immich-machine-learning:v3.2.1
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-14 19:09:30 +00:00
security-bot 4fd3c21308 chore(image-automation): bump ghcr.io/immich-app/immich-server:v3.2.1
Updates managed by image-automation-controller.
2026-09-14 19:04:00 +00:00
security-bot 5e79157e5e chore(image-automation): bump rhasspy/wyoming-whisper:3.8.1
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-14 15:51:03 +00:00
security-bot 4c36913fee chore(image-automation): bump rhasspy/wyoming-whisper:3.8.0
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-11 21:50:19 +00:00
security-bot 605690f9ff chore(image-automation): bump ghcr.io/music-assistant/server:2.10.3
Updates managed by image-automation-controller.
2026-09-11 21:50:01 +00:00
security-bot fe6fa9ec4c chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.9.2
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-11 20:50:44 +00:00
security-bot c954043f90 chore(image-automation): bump gotenberg/gotenberg:8.37.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-11 15:50:15 +00:00
security-bot 31be9a115a chore(image-automation): bump ghcr.io/immich-app/immich-server:v3.2.0
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-10 16:53:33 +00:00
security-bot deda43ab85 chore(image-automation): bump ghcr.io/immich-app/immich-machine-learning:v3.2.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-10 16:18:57 +00:00
security-bot 19a464b2bf chore(image-automation): bump ghcr.io/usetrmnl/terminus:0.73.0
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-09 19:49:44 +00:00
security-bot b29174dff2 chore(image-automation): bump woodpeckerci/woodpecker-server:v3.18.1
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-09 00:49:19 +00:00
security-bot 451e5002e9 chore(image-automation): bump woodpeckerci/woodpecker-agent:v3.18.1
Updates managed by image-automation-controller.
2026-09-09 00:49:17 +00:00
k.renner d443f435aa Merge pull request 'feat(cilium): route pod east-west over pod0 (#576)' (#837) from feat/cilium-native-pod0 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#837
2026-09-08 14:44:36 +00:00
claude-botandClaude Fable 5.1 63a3aebe84 feat(cilium): route pod east-west over pod0 (#576)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Pod-to-pod traffic currently rides the management VLAN. In tunnel mode
Cilium encapsulates to the peer's Kubernetes InternalIP, so east-west
follows VLAN 40 wherever it goes -- which blocks moving management onto
the 1G NIC without dragging pod traffic with it.

The node IP stays on VLAN 40. Instead this uses the documented device
selection: native routing hands forwarding to the kernel, and
autoDirectNodeRoutes installs the remote PodCIDR routes, which is
permitted because all three nodes share pod0's L2 segment.

  "By default, if a single device was detected or specified via devices
   then Cilium will use that device for direct routing. Otherwise,
   Cilium will use a device with Kubernetes InternalIP or ExternalIP
   set."

directRoutingDevice is therefore set explicitly: mgmt0, ingress0 and
pod0 all carry addresses, so the fallback would pick mgmt0.

Verified beforehand that pod0 is a working path between nodes -- from a
pod on k8s-2, k8s-1's kubelet answers 401 on both 10.2.48.11:10250 and
[fd02:48::11]:10250, so the L2 and both address families work.

ipv4/ipv6NativeRoutingCIDR are already set and become mandatory in this
mode; 10.244.0.0/16 and fd10:244::/48 sit inside them, so pod-to-pod
stays unmasqueraded as before.

Not rolling: dropping encapsulation is a cluster-wide datapath change
and a node that has switched cannot exchange pod traffic with one that
has not. Needs a scheduled window, not a background reconcile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:37:59 +02:00
k.renner ee2d5d1d25 Merge pull request 'feat(cilium): peer BGP over the ingress VLAN (#576)' (#836) from feat/bgp-peer-ingress into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#836
2026-09-08 13:22:05 +00:00
claude-botandClaude Fable 5.1 60b4286910 feat(cilium): peer BGP over the ingress VLAN (#576)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Phase B step 2 of the traffic-class split. The eBGP session source
address is what the UDM installs as the next-hop for every PodCIDR and
LoadBalancer prefix the cluster advertises. Peering over the management
LAN therefore pinned all world->pod return traffic to the 1G mgmt link,
which is the thing #576 exists to fix. Moving the peers onto VLAN 47
puts that traffic on the 10G ingress link instead.

No localAddress or sourceInterface override is needed: the kernel
already picks the ingress0 address for both families.

  ip -6 route get fd02:47::1
  fd02:47::1 dev ingress0 proto kernel src fd02:47::5a47:caff:fe74:7027

Both peer addresses were verified reachable from the cluster before
this change -- 10.2.47.1 and fd02:47::1 each answer HTTP with a 301,
and the UDM's neighbour entry on ingress0 is REACHABLE.

The UDM already accepts sessions from this segment: PR #835 added the
10.2.47.0/24 and fd02:47::/64 listen ranges and is uploaded. Its VLAN 40
ranges stay in place for now and get dropped in step 3, once every
session is confirmed established on 47.

gracefulRestart (120s) covers the peer replacement, so the UDM holds the
learned routes across the flap rather than withdrawing the LB VIPs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:20:13 +02:00
k.renner cc52181352 Merge pull request 'feat(bgp): let the UDM accept sessions from the ingress VLAN too' (#835) from feat/bgp-listen-ingress into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#835
2026-09-08 13:00:53 +00:00
claude-bot 56d55b6228 feat(bgp): let the UDM accept sessions from the ingress VLAN too
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
First step of moving the world-to-pod class onto ingress0 (#576). This
changes nothing about where the cluster peers from — it only widens
what the UDM will accept.

The order matters. Cilium's sessions are outbound to the UDM, and the
UDM accepts them through `bgp listen range`. Moving the cluster to
ingress0 before the UDM listens on 10.2.47.0/24 would drop every
session, and with them every advertised LoadBalancer VIP, so external
ingress would go down until the UDM caught up. Listing both segments
first removes that window entirely: a listen range only accepts a
session, it never opens one, so the 47 ranges sit idle until the
cluster actually moves.

The prefix-lists and route-maps are untouched. They filter which
prefixes the UDM accepts — pod CIDRs and the LB pool — and the cluster
advertises the same prefixes either way. Only the next hop changes.

Uploaded by hand at UniFi UI -> Settings -> Routing -> BGP, as before.
Verify with `vtysh -c "show ip bgp summary"`: the six existing sessions
on 10.2.40.x and fd40:: must still be established afterwards, and no
new ones appear until the cluster side moves.

The 40 ranges come out in a later change, once every session is
established on 47.

Refs #576
2026-09-08 14:57:49 +02:00
k.renner 6416f2f609 Merge pull request 'fix(talos): address ingress0 statically, leave mgmt0 the only DHCP client' (#833) from feat/ingress-static-addressing into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#833
2026-09-08 12:19:59 +00:00
claude-bot 80c2def518 fix(talos): stop leasing ingress0, leave mgmt0 the only DHCP client
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Adding a second DHCP client per node in #831 broke node DNS. Both
clients announce the same hostname, so the UDM registered each node
against VLAN 47 instead of VLAN 40: `k8s-1.srv.renner.uno` and
`k8s-3.srv.renner.uno` stopped resolving, and the reverse for
10.2.47.11 came back as bare `k8s-1`. The lease's resolver also
displaced the management one, leaving 10.2.47.1 ahead of 10.2.40.1 in
the node's DNS servers.

That matters beyond `talosctl -n <fqdn>`: bootstrap.sh resolves node
FQDNs in the talos phase, so a rebuild depends on those records.

The UDM cannot register a network's clients selectively, so the client
goes rather than the record. DHCP is now off on VLAN 47 entirely and
ingress0 declares .11/.12/.13 by node ordinal, matching pod0's
numbering. Its default route is declared with the same losing metric
2048 the lease supplied, so nothing about routing changes: mgmt0 keeps
both default routes until the flip.

IPv4 only. The problem was DHCPv4 announcing a hostname; IPv6 never had
one. The prefix is announced correctly, so ingress0 keeps taking its
IPv6 address by SLAAC exactly as mgmt0 takes its fd40:: address —
derived from the MAC, stable across reboots, nothing to allocate.

accept_ra therefore stays at its default of 2. An IPv6 default router
is a link-local address that can only be learned from the RA, and with
net.ipv6.conf.all.forwarding=1 — which Cilium sets — Linux ignores RAs
below 2 entirely. Only accept_ra_defrtr is set to 0, because mgmt0 owns
the default until the flip; dropping that one line is what later hands
the IPv6 default to ingress0.

pod0 keeps accept_ra 0: it is a closed segment with no router on it, so
there is genuinely nothing to learn.

No reboot needed. Address, route and sysctl changes apply live, and the
whole change is provable per node with `--mode=try`, which reverts
itself.

Refs #576
2026-09-08 14:13:57 +02:00
k.renner 0ca1753418 Merge pull request 'feat(talos): role-named links, and the pod and ingress VLANs' (#831) from feat/k8s-net-split-phase-a into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#831
2026-09-08 10:27:56 +00:00
claude-botandClaude Fable 5.1 84b415a0e0 feat(talos): role-named links, and the pod and ingress VLANs
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
First pass of the traffic-class split (#576). Purely additive: nothing
moves onto the new segments yet.

Links by role, not by PCI topology. A udev rule per node matches the
burned-in MAC and renames at boot, the way ceph-san already did:
enp2s0f0np0 -> uplink, enp87s0 -> mgmt. Shared manifests now name roles
and only each node's own patch knows the hardware. The two comments
elsewhere that named the old links go with them.

Two new VLANs on the uplink, both created on the UDM already:

- pod0, VLAN 48. Node-to-node only; the overlay rides it once the
  kubelet node IP moves. Statically addressed .11/.12/.13 out of
  10.2.48.0/24 and fd02:48::/64, no gateway, no DHCP, accept_ra 0. A
  lease would make the cluster's data path depend on the UDM being up in
  order to form, on a segment whose traffic never reaches the UDM.
- ingress0, VLAN 47. World-to-pod: BGP session, LoadBalancer next hop,
  masquerade source. Publicly routed, so DHCP — but at routeMetric 2048
  and with accept_ra_defrtr 0, because DHCPv4Config can only
  deprioritise a default route, not suppress one, and two defaults at
  1024 tie.

mgmt0 keeps the node IP and both default routes. A mistrunked VLAN 47 or
48 therefore cannot strand a node, and the flip is a later change that
needs no reboot and is provable with `--mode=try`.

Applying needs a rolling reboot: a udev rule cannot rename a link on a
running node (Resource busy). Drain first, one node at a time, and wait
for CephCluster HEALTH_OK rather than node Ready between them.

Refs #576

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 11:43:33 +02:00
k.renner c0fa69321b Merge pull request 'chore(ci): run pipelines on ci-k8s:2026-09-08' (#829) from chore/ci-k8s-2026-09-08 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#829
2026-09-08 07:42:15 +00:00
claude-botandClaude Fable 5.1 f774ca4bca chore(ci): run pipelines on ci-k8s:2026-09-08
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The tag build-ci-images published from #827 (pipeline 8573): talosctl
v1.13.9, kubectl v1.36.3, flux 2.9.5. Six references moved from
2026-08-10.

Refs #426

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 09:35:49 +02:00
k.renner 30362e23d7 Merge pull request 'feat(flux): upgrade components to v2.9.5' (#828) from feat/flux-2.9.5 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#828
2026-09-08 07:33:27 +00:00
k.renner 7efa556397 Merge pull request 'chore(ci): bump CI tool pins to match the deployed cluster' (#827) from feat/ci-tool-pins into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#827
2026-09-08 07:33:08 +00:00
k.renner 4f422eb7de Merge pull request 'feat(bootstrap): OpenBao restore and bring-up under the static seal' (#826) from feat/openbao-seal-key-bootstrap into master
Reviewed-on: k.renner/homelab#826
2026-09-08 07:32:33 +00:00
claude-botandClaude Fable 5.1 6bc23cd306 feat(flux): upgrade components to v2.9.5
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Regenerated with `flux install --export
--components-extra=image-reflector-controller,image-automation-controller`
from the v2.9.5 CLI; the same component set as before.

Controllers: source v1.8.3 -> v1.9.5, kustomize v1.8.4 -> v1.9.5, helm
v1.5.4 -> v1.6.4, notification v1.8.4 -> v1.9.4, image-reflector v1.1.1
-> v1.2.5, image-automation v1.1.2 -> v1.2.5. CRDs updated, the
allow-webhooks NetworkPolicy now names port 9292.

Prerequisites from the 2.9 release notes, verified before this commit:
the removed image v1beta2 and notification v1beta2 API versions are
neither used in the repo (image.* all v1, Alert/Provider v1beta3) nor
stored in the cluster's CRDs (storedVersions all current), so no
`flux migrate` step is needed. helm-controller 1.6 changes the default
post-render strategy; no HelmRelease here uses postRenderers.
`flux check --pre` passes on Kubernetes 1.36.3 and the manifests pass a
server-side dry run against the live cluster.

Refs #426

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 09:01:22 +02:00
claude-botandClaude Fable 5.1 6b0617b4a7 chore(ci): bump CI tool pins to match the deployed cluster
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
From the update audit (#426): the tools CI drives the cluster with
trailed what the cluster runs.

ci/images/k8s/Containerfile
- talosctl v1.12.6 -> v1.13.9, the Talos version on the nodes. An older
  talosctl can misjudge or reject config generated for a newer machine.
- kubectl v1.36.2 -> v1.36.3, the cluster's Kubernetes version.
- flux CLI 2.9.2 -> 2.9.5, the current 2.9.x. Checksums verified against
  the upstream release assets (talos sha256sum.txt, flux checksums.txt)
  and against a local download.

.woodpecker/ci-manifests-test.yml
- woodpecker-cli v3.17.0 -> v3.18.0, the version the server and agent
  already run; the lint step was one minor behind its own server.

Merging triggers build-ci-images (path ci/images/**), which publishes
ci-k8s:<date>. The six pipelines pinned to ci-k8s:2026-08-10 move to
that tag in a follow-up once it exists.

Refs #426

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 08:58:35 +02:00
claude-botandClaude Fable 5.1 193bcb362e fix(openbao): QuorumLost runbook text must not point at the recovery key
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The description told on-call to try the recovery key as a last resort,
while RUNBOOK Step 4 in the same change (and upstream: recovery keys are
"purely an authorization mechanism") says it cannot unseal an
auto-sealed server. Say what actually helps: fix the Secret, restart
the pod. Also drop a duplicate root-token computation in
restore_openbao.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 08:35:01 +02:00
claude-botandClaude Fable 5.1 35bad376b8 feat(bootstrap): OpenBao restore and bring-up under the static seal
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Follow-up to #825 (static-seal auto-unseal). The rebuild path and the
operator docs still assumed shamir with a manual unseal per replica.

bootstrap.sh
- Required secrets: `seal-key` (pass show homelab/openbao/seal-key)
  replaces `unseal-key`. The snapshots are encrypted under the seal key,
  so it is the DR-critical secret now; the former unseal key is the
  recovery key and is not needed for a restore (upstream: recovery keys
  "are purely an authorization mechanism" and cannot unseal a server
  whose auto-seal is broken).
- flux phase creates the `openbao-seal-key` Secret (namespace + Secret,
  before `flux bootstrap`) from the collected key. --from-literal via a
  substitution, because `pass show` appends a newline and OpenBao
  rejects a key that is not exactly 32 bytes of base64 ("unknown
  encoding for AES-256 key" — verified). An existing Secret with the
  same value is left alone; a different value is refused, since the
  data is written under whichever key OpenBao started with.
- restore_openbao: `init -recovery-shares=1 -recovery-threshold=1`,
  root token from the output, wait for the self-unseal (init returns
  with the node sealed; the server's own retry loop unseals it within
  ~5 s), then `snapshot restore` WITHOUT -force: OpenBao verifies the
  snapshot was written under this server's seal key and refuses a
  mismatch by name, which is the one guard against restoring under a
  wrong Secret. -force skipped that check and would leave a server that
  can never unseal. The throwaway `unseal` step and its argv/tty
  discussion are gone — under an auto-seal there is no unseal key.
- unseal_openbao -> bring_up_openbao: waits for spec.replicas pods to
  report sealed=false, walking the OrderedReady chain as before, and on
  timeout prints each still-sealed pod's `bao status` instead of a bare
  "timed out".

Rehearsed locally with two OpenBao servers sharing a static key (file://
form, bao 2.5.5): init on an auto-seal returns an empty unseal_keys_b64,
one recovery key and a root token and the server is unsealed within
seconds; a restore of A's snapshot into fresh B (both with and without
force) re-seals and reloads the root key via the auto seal ~5 s later
("done reloading root key using auto seal"), after which B serves A's
data with A's root token. Restarting B under a different key leaves it
sealed with "failed to decrypt keys from storage: message
authentication failed".

Docs and alerts: RUNBOOK-cold-rebuild Step 4 and MIGRATION-sops-openbao
describe the auto-seal flow and the recovery-key semantics; the
OpenBao alert descriptions and the snapshot CronJob comment no longer
tell the reader to unseal by hand. vmtest-job comment updated.

Not covered: a full VMTEST_FULL=1 rehearsal through the flux phase. The
wiki records none ever reaching past the Flux bootstrap checkpoint, so
that gap predates this change.

Refs #805

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 08:23:08 +02:00
k.renner 6b7736ea58 Merge pull request 'fix(alerts): page the moment OpenBao is below quorum' (#824) from fix/openbao-alerts-immediate into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#824
2026-09-08 06:02:32 +00:00
k.renner 7ff20e5b68 Merge pull request 'feat(openbao): auto-unseal with a static seal' (#825) from feat/openbao-static-seal into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#825
2026-09-08 05:54:54 +00:00
claude-botandClaude Fable 5.1 296accd1cb feat(openbao): auto-unseal with a static seal
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Every restart sealed the replica until a human unsealed it. On
2026-09-07 a rolling talosctl upgrade rebooted all three nodes; the PDB
refused the second eviction while the first replica was sealed, Talos
waited out its 5-minute drain timeout and rebooted anyway, and raft
quorum was gone for 24 minutes — every ExternalSecret and the
Kustomizations behind them with it. A power loss would do the same
with no timeout to blame.

OpenBao 2.6.1 supports a static seal: a 32-byte key the server uses to
unseal itself. The key is generated into pass (homelab/openbao/seal-key)
and created as the openbao-seal-key Secret before this release reconciles;
the chart hands it to the server as BAO_SEAL_KEY from that Secret.

This overturns the principle in bootstrap.sh that the key protecting
OpenBao must not live in the cluster OpenBao secures. Decided by the
owner on 2026-09-08: the alternatives were a transit seal from a second
OpenBao on edge-1, which raft traffic would wear out, or a workstation
unseal script that may not be at hand when needed. The exposure is
bounded by what ESO already does — every synced secret is plaintext in
etcd (encrypted at rest) — and grows only by what lives solely in
OpenBao.

Migration is a live, per-replica procedure with the shamir key becoming
the recovery key; the StatefulSet is OnDelete, so merging this changes
nothing until each pod is restarted in turn. The bootstrap and rebuild
paths (bootstrap.sh, vmtest) follow in a separate change once the
migration is verified.

Refs #805

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 07:52:27 +02:00
claude-botandClaude Fable 5.1 2665e5ebf3 fix(alerts): page the moment OpenBao is below quorum
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
OpenBaoQuorumLost waited for: 5m. Below quorum the secret store is
down — the ClusterSecretStore goes InvalidProviderConfig and every
ExternalSecret and the Kustomizations behind them stall — and a sealed
replica never recovers on its own, so the window only delayed the page.
It fires on the first evaluation now.

OpenBaoUnavailable, the external probe, drops from 5m to 1m: two
consecutive failed probes, enough to skip a single lost probe through
Traefik, not enough to sit on a sealed store.

Both StatefulSet expressions are wrapped in max by (namespace,
statefulset). kube-state-metrics stamps instance/pod on the series, so
a rescheduled exporter starts a new series and Prometheus resets the
for: timer with it — on 2026-09-07 that made OpenBaoReplicaNotReady fire
14 minutes late. Alertmanager routes match alertname/source only, so the
dropped labels change nothing downstream.

Refs #805

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 07:40:12 +02:00
k.renner 0fe512746b Merge pull request 'feat(monitoring): Intel iGPU dashboard' (#823) from feat/intel-gpu-dashboard into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#823
2026-09-08 05:24:35 +00:00
claude-botandClaude Fable 5.1 4e2303f455 feat(monitoring): Intel iGPU dashboard
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Grafana dashboard for the gpumon_* series from intel-gpu-exporter:
current Render/3D busy, GPU power and GT frequency per node as stat
tiles, then one time series per engine (Render/3D, Video, VideoEnhance,
Blitter) with node as the series, GT frequency, GPU and package power,
and memory-controller read/write bandwidth.

Node colours are fixed per node across every panel — blue, orange, aqua
for k8s-1/2/3 — rather than assigned by series order, so a node keeps
its colour whatever the $node filter shows. The trio validates for
colour-vision deficiency and normal vision on both Grafana themes
(worst all-pairs ΔE 9.4 / 20.9). Reads and writes share one axis and
differ by dash, not hue. One axis per panel throughout.

gpumon_clients_count is deliberately absent: per-client attribution
needs hostPID, which the exporter does not have, so it would read 0
forever and imply an idle GPU.

Refs #752

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 07:22:46 +02:00
k.renner 483093a104 Merge pull request 'feat(monitoring): export Intel iGPU utilisation, frequency and power' (#822) from feat/intel-gpu-exporter into master
Reviewed-on: k.renner/homelab#822
2026-09-08 05:18:43 +00:00
claude-botandClaude Fable 5.1 8d348e7f88 fix(monitoring): let intel_gpu_top pick the GPU instead of hard-coding card0
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The -device flag named /dev/dri/card0. That is the right node today on
every host, but nothing guarantees the number: a second GPU or a
different probe order and the exporter would silently watch the wrong
device or none. The plugin's monitoring resource injects every GPU on
the node and intel_gpu_top selects it on its own — verified with a probe
pod on k8s-2, which produced the same gpumon_* series without the flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 07:15:04 +02:00
claude-botandClaude Fable 5.1 6f9ff9d624 feat(monitoring): export Intel iGPU utilisation, frequency and power
A DaemonSet running clambin/intel-gpu-exporter (intel_gpu_top behind a
Prometheus endpoint) with a ServiceMonitor, giving gpumon_engine_usage,
gpumon_frequency, gpumon_power, gpumon_imc_* and gpumon_clients_count
per node.

Privilege model, validated with a one-shot pod before this commit:
/dev/dri comes from the device plugin's gpu.intel.com/monitoring
resource (every GPU on the node, no hostPath); the i915 PMU that
intel_gpu_top reads is a system-wide event source and the nodes run
perf_event_paranoid=3, so CAP_PERFMON is the one capability added.
No privileged, no hostPID, no hostNetwork, read-only root filesystem.
Root rather than a fixed uid because the capability has to be effective
without file capabilities on the binary.

End-to-end check on k8s-2 during a 40 s chat completion on llama-server:
Render/3D busy 98 %, actual frequency 1449 MHz, GPU power 18.8 W, while
the pod's CPU stayed near 100m — the "is it really on the GPU" question
this exists to answer.

XPU Manager was ruled out (Arc Pro / data-centre parts only); the
archived onedr0p exporter needs privileged + hostPID.

Fixes #752

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 07:14:24 +02:00
k.renner 7bf65783be Merge pull request 'feat(intel-gpu-plugin): advertise the monitoring resource' (#820) from feat/intel-gpu-monitoring-resource into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#820
2026-09-08 04:54:15 +00:00
k.renner dd9fb01359 Merge pull request 'fix(reloader): restart audiobookshelf and immich-server on ConfigMap change' (#819) from fix/reloader-configmap-annotations into master
Reviewed-on: k.renner/homelab#819
2026-09-08 04:52:32 +00:00
claude-botandClaude Fable 5.1 da8a725b32 feat(intel-gpu-plugin): advertise the monitoring resource
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Enables -enable-monitoring on the device plugin. With 0.36.0 that
advertises gpu.intel.com/monitoring, a single resource per node that
hands a pod every GPU rather than one of the shared slots; it is what a
metrics exporter requests to see the whole iGPU, and it is the only way
to get /dev/dri into a pod without a hostPath.

Nothing consumes it yet. The nodes currently advertise gpu.intel.com/i915
only — the issue assumed the chart default had this on, but the
DaemonSet here is hand-written and did not.

Refs #752

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 06:51:43 +02:00
claude-botandClaude Fable 5.1 6008d59c9f fix(reloader): restart audiobookshelf and immich-server on ConfigMap change
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Both consume their ConfigMap with envFrom, which Kubernetes does not
watch: a changed value applies in git and in the cluster while the
running container keeps the environment it started with. Paperless hit
exactly this in #739 and got the annotation in #741; these two were the
remaining envFrom consumers without one.

ftp-scanner, the third Deployment named in the issue, needs nothing: its
ConfigMap comes from a configMapGenerator that deliberately keeps the
content-hash suffix, so any change renames the ConfigMap, mutates the
pod template and rolls the pod on its own.

Reloader already watches all namespaces (kubernetes/infrastructure/reloader).

Fixes #742

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 06:46:12 +02:00
k.renner 5aa6c0609d Merge pull request 'fix(netbird-proxy-services): pin the digest the master build produced' (#818) from netbird-proxy-protocol-digest into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#818
2026-09-07 11:52:17 +00:00
claude-botandClaude Fable 5.1 0ebe113abb fix(netbird-proxy-services): pin the digest the master build produced
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#817 pinned the digest from the branch build (pipeline 8452). Merging
#816 rebuilt the same date tag on master (pipeline 8457), Gitea kept
only the new manifest, and the pod has been in ImagePullBackOff since:
"failed to resolve reference ...@sha256:0d266b02...". With the Recreate
strategy no controller is running, so the services are unchanged.

The kaniko step of the master build pushed sha256:caffa308...; that is
the digest to pin. Comment states the rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 13:47:22 +02:00
k.renner 1723f73b5d Merge pull request 'feat(netbird-proxy-services): roll the PROXY-protocol build (2/2)' (#817) from netbird-proxy-protocol-deploy into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#817
2026-09-07 11:34:52 +00:00
k.renner 8d8df3b635 Merge pull request 'feat(netbird): carry the WAN client address through the passthrough edge (1/2)' (#816) from netbird-proxy-protocol into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#816
2026-09-07 11:34:43 +00:00
claude-botandClaude Fable 5.1 29c91769a7 feat(netbird-proxy-services): roll the PROXY-protocol build (2/2)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Pins the controller image by digest. The registry tag is the build
date and pipeline 8452 rebuilt today's tag in place, so the tag alone
cannot carry a new build to the cluster; the digest can.

Merge only after the Traefik change from 1/2 has reconciled: from this
build on every service target sends a PROXY protocol v2 header, and
Traefik drops such connections unless websecure trusts the pod CIDRs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 13:32:06 +02:00
43 changed files with 1509 additions and 2008 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ when:
steps:
- name: fetch-alerts
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
+26
View File
@@ -376,3 +376,29 @@ steps:
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:$TAG \
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:latest
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
# artifact like heating-can; same registry so the audit tracks it.
- name: build-bird
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/bird
depends_on: []
when:
- event: deployment
- event: push
path: ["ci/images/bird/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/bird:$TAG \
--destination gitea.renner.uno/gitea_admin/bird:latest
+3 -3
View File
@@ -60,7 +60,7 @@ steps:
# run that never touched them. Tracked in #461.
- name: kustomize-build
depends_on: [] # see note above `steps:`
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
commands:
- |
set -eu
@@ -158,7 +158,7 @@ steps:
# whenever it trails the deployed server. If you bump the server, bump here.
- name: woodpecker-lint
depends_on: [] # see note above `steps:`
image: woodpeckerci/woodpecker-cli:v3.17.0-alpine
image: woodpeckerci/woodpecker-cli:v3.18.0-alpine
commands:
- |
set -eu
@@ -246,7 +246,7 @@ steps:
# this step fails permanently on its own documentation.
- name: no-digest-pins
depends_on: [] # see note above `steps:`
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
commands:
- |
set -eu
+1 -1
View File
@@ -4,7 +4,7 @@ when:
steps:
- name: e2e
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
+1 -1
View File
@@ -31,7 +31,7 @@ steps:
- sh scripts/ci/test-model-selection.sh
- name: e2e-test
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
+1 -1
View File
@@ -49,7 +49,7 @@ steps:
# (the issue-pick logic needs jq, which only the ci-claude image has);
# minting a 1h read-only token per run is cheap. Degrades to
# snapshot-only if the ci-diagnostics ServiceAccount isn't reconciled.
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
+16
View File
@@ -0,0 +1,16 @@
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
# bird: BGP daemon for the pod-network mesh over pod0. Distro package, no build
# step — the image is the binary plus its runtime.
#
# 3.22 rather than the 3.21 the other images here pin: bird2 first appears in
# 3.22/community.
#
# Config is read from /etc/bird/bird.conf, not the package default
# /etc/bird.conf, so the mount cannot shadow what the package ships.
RUN apk add --no-cache bird2
# Fails the build rather than the first pod if the binary or its path is wrong.
RUN /usr/sbin/bird --version
ENTRYPOINT ["/usr/sbin/bird", "-f", "-c", "/etc/bird/bird.conf"]
+5 -5
View File
@@ -1,9 +1,9 @@
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
ARG TALOS_VERSION=v1.12.6
ARG TALOS_VERSION=v1.13.9
ARG VELERO_VERSION=v1.16.0
ARG KUBECTL_VERSION=v1.36.2
ARG FLUX_VERSION=2.9.2
ARG KUBECTL_VERSION=v1.36.3
ARG FLUX_VERSION=2.9.5
RUN apk add --no-cache \
aws-cli \
@@ -18,7 +18,7 @@ RUN apk add --no-cache \
RUN curl -sfL -o /usr/local/bin/talosctl \
"https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" \
&& echo "4c41a3b10b075292e64b182283d558e9d8e935f43239de1ed4e4b14359593efb /usr/local/bin/talosctl" | sha256sum -c - \
&& echo "7e1d4b7d5846964bdcf63a794e3c8161bb6ef2983d5ace58ea5322f3bf32a27e /usr/local/bin/talosctl" | sha256sum -c - \
&& chmod +x /usr/local/bin/talosctl
RUN curl -sfL -o /usr/local/bin/kubectl \
@@ -31,7 +31,7 @@ RUN curl -sfL -o /usr/local/bin/kubectl \
RUN curl -sfL -o /tmp/flux.tar.gz \
"https://github.com/fluxcd/flux2/releases/download/v${FLUX_VERSION}/flux_${FLUX_VERSION}_linux_amd64.tar.gz" \
&& echo "4092b367fd060097976fb7261601f79420ecf7266328417dfd8dfee27de0e6a3 /tmp/flux.tar.gz" | sha256sum -c - \
&& echo "b853df82adfd7736f580692f9f734473d571606307139f8fd20c2a80dd1ff473 /tmp/flux.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/flux.tar.gz -C /usr/local/bin flux \
&& chmod +x /usr/local/bin/flux \
&& rm -f /tmp/flux.tar.gz
+22 -16
View File
@@ -50,15 +50,18 @@ pass insert homelab/openbao/root-token
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste key
```
**Manual unseal is required after every pod restart** (node drain, upgrade,
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).
**Unseal is automatic since 2026-09-08** (static seal, PR #825, issue #805):
the seal key lives in the `openbao-seal-key` Secret (created by `bootstrap.sh`
from `pass show homelab/openbao/seal-key`) and every restarted replica unseals
itself. The shamir key above became the **recovery key** in the migration; it
is kept in `pass` under its old name, `homelab/openbao/unseal-key`. The server
runs three raft peers (see `infrastructure/openbao/helmrelease.yaml`); a sealed
one is neither a voter nor a standby, so a replica that stays sealed after a
restart — wrong or missing seal-key Secret — costs redundancy, two cost
quorum. The trade-off taken with the static seal: the key that unlocks the
store sits in the same etcd as the Secrets ESO already syncs there in
plaintext. Symptom of sealed: ESO stores go NotReady, synced Secrets keep
their last value (they are real k8s Secrets — pods keep running).
## Phase 2 — configure + ClusterSecretStore (follow-up PR)
@@ -234,16 +237,19 @@ one full reconcile + one OpenBao pod restart/unseal cycle:
- **Backups:** ✅ nightly raft-snapshot CronJob (03:00, keeps 7 on a
dedicated PVC; see infrastructure/openbao/snapshot-cronjob.yaml) + velero
`openbao-daily` Schedule (06:15) ships that PVC offsite, 30 d retention.
A raft snapshot restore needs the matching unseal key — which lives in
A raft snapshot restore needs the matching seal key — which lives in
`pass`, outside the cluster (GPG key backed up externally 2026-07-14).
A failed snapshot Job is the sealed-after-restart alarm. Still owed:
one tested restore before phase 4.
- **Cluster rebuild order:** Flux brings up openbao (sealed, empty) → restore
raft snapshot → unseal (key from `pass`) → ClusterSecretStore Ready → ESO
materializes Secrets → apps start. Until unseal, secret-consuming apps
simply stay Pending on missing Secrets — same as any missing dependency.
- **Break-glass:** `pass` holds unseal key + root token (GPG, offline-capable).
With those two, everything is recoverable from a raft snapshot alone.
- **Cluster rebuild order:** seal-key Secret from `pass` → Flux brings up
openbao (empty) → init → restore raft snapshot → replicas unseal themselves
→ ClusterSecretStore Ready → ESO materializes Secrets → apps start
(`bootstrap.sh flux` does all of it; RUNBOOK-cold-rebuild.md Step 4 by
hand). Until then, secret-consuming apps simply stay Pending on missing
Secrets — same as any missing dependency.
- **Break-glass:** `pass` holds seal key, recovery key + root token (GPG,
offline-capable). With those, everything is recoverable from a raft
snapshot alone.
- **Ansible Vault password:** `ansible/vault-password.sh` reads
`ANSIBLE_VAULT_PASSWORD` from the environment with no offline backup as of
2026-07-14. It gates decryption of several Ansible-Vault files (PBS
+2 -2
View File
@@ -432,8 +432,8 @@ is why it belongs here.
among its devices but not `ens19`, because `ens19` is deliberately
address-less. Cilium therefore owns `mgmt0` and sees VLAN 40 frames already
untagged by the 8021q layer, while VLAN 30 frames only ever meet the filter
on the shared parent `enp2s0f0np0`, still tagged. Any future address-less
VLAN carrying pod traffic will need the same treatment.
on the shared parent `uplink`, still tagged. Any future address-less VLAN
carrying pod traffic will need the same treatment.
The symptom does not look like a VLAN problem. A tc-layer drop reaches an
AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports `no buffer space
+60 -34
View File
@@ -67,10 +67,12 @@ These must exist outside the cluster or the rebuild cannot complete:
`flux` phase creates the `sops-age` Secret, and any not-yet-migrated sops
files decrypt with it. (Post-OpenBao-phase-4 this may be retired — see
`MIGRATION-sops-openbao.md`.)
- [ ] **OpenBao unseal key + root token** — in the GPG `pass` store
(`homelab/openbao/unseal-key`, `homelab/openbao/root-token`), and the GPG
private key backed up off-machine. Without the unseal key, no OpenBao
snapshot can be restored and no secret-consuming app can start.
- [ ] **OpenBao seal key, recovery key + root token** — in the GPG `pass`
store (`homelab/openbao/seal-key`, `homelab/openbao/unseal-key` — the
former shamir key, now the recovery key — and `homelab/openbao/root-token`),
and the GPG private key backed up off-machine. The seal key is the one
the snapshots are encrypted under: without it, no OpenBao snapshot can be
restored and no secret-consuming app can start.
- [ ] **OpenBao raft snapshot** reachable — nightly PVC snapshots
(`infrastructure/openbao/snapshot-cronjob.yaml`) plus the Velero
`openbao-daily` offsite copy. In a total-loss rebuild the offsite Velero
@@ -294,42 +296,65 @@ output** — a green step over a still-forming Ceph is a known trap.
> Steady-state (non-rebuild) bootstrap is just `kubernetes/bootstrap.sh flux`
> with `GITEA_TOKEN` set; `FLUX_SOURCE` defaults to `gitea`.
## Step 4 — openbao: restore the snapshot + unseal
## Step 4 — openbao: restore the snapshot
After Step 3, OpenBao comes up **sealed and empty**. Until it is restored and
unsealed, the `ClusterSecretStore` stays NotReady, ESO cannot materialize
Secrets, and every secret-consuming app stays Pending. This step is **manual**
(the unseal key lives in `pass`, off-cluster by design).
`bootstrap.sh flux` does this step itself (`restore_openbao`): it creates the
`openbao-seal-key` Secret from `pass` before Flux brings OpenBao up, initialises
the empty server, restores the newest raft snapshot straight out of the Velero
kopia repository, and waits until every replica has unsealed itself from the
static seal. What follows is the same procedure by hand, for when the script
is not an option.
OpenBao unseals itself from the static seal (`openbao-seal-key` Secret, key
`key`, the 32-byte base64 key from `pass show homelab/openbao/seal-key`). The
snapshots are encrypted under that key, so it must be the same key the backed
up cluster used — a different one restores nothing. Until the snapshot is
restored, the `ClusterSecretStore` stays NotReady, ESO cannot materialize
Secrets, and every secret-consuming app stays Pending.
```sh
# 1. Wait for the OpenBao pods to exist (all three sealed, 0/1 Ready is expected).
kubectl -n openbao get pods
# 1. The seal key, before the pods start (a pod without it never starts).
kubectl create namespace openbao --dry-run=client -o yaml | kubectl apply -f -
kubectl -n openbao create secret generic openbao-seal-key \
--from-literal=key="$(pass show homelab/openbao/seal-key)"
# 2. Restore the most recent raft snapshot. Source it from the snapshot PVC
# 2. Wait for openbao-0 (uninitialised: 0/1 Ready is expected), then
# initialise it. With an auto-seal, init hands out recovery keys and the
# server unseals itself. These keys are throwaway: the restore replaces them.
kubectl -n openbao exec -it openbao-0 -- bao operator init \
-recovery-shares=1 -recovery-threshold=1
# 3. Restore the most recent raft snapshot. Source it from the snapshot PVC
# (infrastructure/openbao/snapshot-cronjob.yaml) or, in a total-loss rebuild,
# from the Velero `openbao-daily` offsite copy. Copy it into the pod, then:
# from the Velero `openbao-daily` offsite copy. Root token from the init
# output. No -force: OpenBao checks the snapshot was written under this
# server's seal key and refuses otherwise ("different autoseal key") — that
# refusal is the signal the Secret is wrong. -force would skip the check and
# leave a server that can never unseal.
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
kubectl -n openbao exec -it openbao-0 -- \
env BAO_TOKEN=<init root token> bao operator raft snapshot restore /tmp/restore.snap
# 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
# 4. The server seals against the snapshot's keyring and unseals itself from
# the static seal; openbao-1 and openbao-2 follow (OrderedReady). Nothing
# to type. Afterwards the tokens and recovery key from pass apply again.
kubectl -n openbao get pods -w
```
Verify the store recovers before moving on:
```sh
kubectl get clustersecretstore openbao # want: Valid / Ready
kubectl -n <ns> get externalsecret # want: SecretSynced
kubectl -n openbao exec openbao-0 -- bao status # want: Seal Type static, Sealed false
kubectl get clustersecretstore openbao # want: Valid / Ready
kubectl -n <ns> get externalsecret # want: SecretSynced
```
> **Manual unseal is required after every OpenBao pod restart**, not just
> during rebuilds, and for *each* replica that restarts — a rolling node
> drain seals all three in turn. Details + rationale:
> `MIGRATION-sops-openbao.md`.
> A replica that stays **sealed** after a restart could not use the static
> seal — the Secret is missing or does not match the key the data was
> written under. Fix the Secret and restart the pod; `bao operator unseal`
> with the recovery key (`pass show homelab/openbao/unseal-key`) does not
> unseal an auto-sealed server and is only for recovery-mode operations.
> Details + rationale: `MIGRATION-sops-openbao.md`.
## Step 5 — apps: let the rest reconcile
@@ -393,14 +418,15 @@ it: repo → **Settings → Repository → Mirror Settings** in Gitea, or
- **`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.
Its `flux` phase carries the OpenBao restore (Step 4); the seal key and the
other secrets are read from `pass` or streamed in at run time and never live
in CI.
- **OpenBao unseals itself, but only under the seal key the snapshot was
written with.** Three raft replicas mean the service survives losing one; a
sealed server is neither a voter nor a standby. A pod that stays sealed after
a restart has the wrong `openbao-seal-key` Secret — 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
@@ -21,6 +21,9 @@ metadata:
namespace: audiobookshelf
labels:
app.kubernetes.io/name: audiobookshelf
annotations:
# envFrom does not restart the pod when the ConfigMap changes; Reloader does.
configmap.reloader.stakater.com/reload: audiobookshelf-config
spec:
replicas: 1
strategy:
@@ -95,7 +95,7 @@ spec:
memory: 32Mi
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.9.1 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.9.2 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
+1 -1
View File
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: immich-machine-learning
image: ghcr.io/immich-app/immich-machine-learning:v3.1.0 # {"$imagepolicy": "flux-system:immich-machine-learning"}
image: ghcr.io/immich-app/immich-machine-learning:v3.2.2 # {"$imagepolicy": "flux-system:immich-machine-learning"}
ports:
- name: http
containerPort: 3003
+4 -1
View File
@@ -35,6 +35,9 @@ metadata:
namespace: immich
labels:
app.kubernetes.io/name: immich-server
annotations:
# envFrom does not restart the pod when the ConfigMap changes; Reloader does.
configmap.reloader.stakater.com/reload: immich-config
spec:
# Scaled to 0 alongside immich-machine-learning during single-node
# maintenance; see the note there. The CNPG Postgres cluster stays running,
@@ -84,7 +87,7 @@ spec:
mountPath: /ca
containers:
- name: immich-server
image: ghcr.io/immich-app/immich-server:v3.1.0 # {"$imagepolicy": "flux-system:immich-server"}
image: ghcr.io/immich-app/immich-server:v3.2.2 # {"$imagepolicy": "flux-system:immich-server"}
ports:
- name: http
containerPort: 3001
@@ -0,0 +1,20 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: intel-gpu-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: intel-gpu-exporter
release: kube-prometheus-stack
spec:
selector:
matchLabels:
app.kubernetes.io/name: intel-gpu-exporter
endpoints:
- port: http-metrics
path: /metrics
interval: 30s
relabelings:
# The series would otherwise carry only the exporter pod's IP.
- sourceLabels: [__meta_kubernetes_pod_node_name]
targetLabel: node
@@ -39,4 +39,5 @@ resources:
- immich-servicemonitor.yaml
- loki-servicemonitor.yaml
- smartctl-exporter-servicemonitor.yaml
- intel-gpu-exporter-servicemonitor.yaml
- probes-blackbox.yaml
@@ -34,7 +34,9 @@ spec:
# query parameter, and an exact match would break silently if the
# instance label ever encodes it differently.
expr: probe_success{job="blackbox-http", instance=~"https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
for: 5m
# Two consecutive failed probes: enough to skip a single lost probe
# through Traefik, not enough to sit on a sealed store.
for: 1m
labels:
severity: critical
annotations:
@@ -28,24 +28,28 @@ spec:
# 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
# A replica unseals itself from the static seal, so a restart is
# Ready again in well under a minute; the duration only ignores that
# ordinary rescheduling. A replica still sealed after it never
# recovers on its own: the seal failed (openbao-seal-key Secret
# missing or wrong).
# max by: kube-state-metrics stamps instance/pod on the series, so a
# rescheduled exporter starts a new series and resets the timer.
expr: max by (namespace, statefulset) (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).
An OpenBao replica has been unavailable for 10 minutes. A restart
unseals itself from the static seal, so this is not the ordinary
drain case. The service is still up on the remaining replicas,
so nothing is broken yet, but redundancy is reduced and a second
failure would cost quorum. Check `kubectl -n openbao get pods`,
the pod log and `bao status` in the not-ready pod. If it is
sealed, the static seal failed: verify the openbao-seal-key
Secret matches `pass show homelab/openbao/seal-key`.
- alert: OpenBaoQuorumLost
# Raft quorum of a three-node cluster is two. At one ready replica
@@ -53,11 +57,10 @@ spec:
# 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
# No window: below quorum the secret store is down and every
# ExternalSecret stalls with it, so waiting only delays the page.
expr: max by (namespace, statefulset) (kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"}) < 2
for: 0m
labels:
severity: critical
annotations:
@@ -67,9 +70,11 @@ spec:
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.
Replicas unseal themselves from the static seal; one that stays
sealed could not. Check its log and the openbao-seal-key Secret
against `pass show homelab/openbao/seal-key`, fix the Secret and
restart the pod. The recovery key cannot unseal an auto-sealed
server. Procedure: kubernetes/RUNBOOK-cold-rebuild.md, Step 4.
- alert: OpenBaoMetricsMissing
# Guards the two alerts above. Both are threshold comparisons, so if
@@ -0,0 +1,368 @@
{
"title": "Intel iGPU",
"uid": "intel-gpu",
"tags": ["gpu", "intel", "i915"],
"schemaVersion": 39,
"editable": true,
"timezone": "",
"refresh": "1m",
"time": { "from": "now-6h", "to": "now" },
"templating": {
"list": [
{
"name": "node",
"label": "Node",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": {
"query": "label_values(gpumon_power, node)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"definition": "label_values(gpumon_power, node)",
"includeAll": true,
"multi": true,
"refresh": 2,
"sort": 1,
"current": { "selected": true, "text": ["All"], "value": ["$__all"] }
}
]
},
"panels": [
{
"type": "stat",
"title": "Render/3D busy now",
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"decimals": 0,
"min": 0,
"max": 100,
"color": { "mode": "fixed", "fixedColor": "text" }
},
"overrides": []
},
"options": {
"colorMode": "none",
"graphMode": "none",
"justifyMode": "center",
"orientation": "horizontal",
"textMode": "value_and_name",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"targets": [
{
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"Render/3D\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "stat",
"title": "GPU power now",
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "watt",
"decimals": 1,
"color": { "mode": "fixed", "fixedColor": "text" }
},
"overrides": []
},
"options": {
"colorMode": "none",
"graphMode": "none",
"justifyMode": "center",
"orientation": "horizontal",
"textMode": "value_and_name",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"targets": [
{
"expr": "gpumon_power{type=\"gpu\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "stat",
"title": "GT frequency now",
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "suffix: MHz",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "text" }
},
"overrides": []
},
"options": {
"colorMode": "none",
"graphMode": "none",
"justifyMode": "center",
"orientation": "horizontal",
"textMode": "value_and_name",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"targets": [
{
"expr": "gpumon_frequency{type=\"actual\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "Render/3D busy",
"description": "Compute and 3D. llama.cpp's Vulkan backend runs here; 0 % during an inference means the model is on the CPU.",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"Render/3D\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "Video busy",
"description": "Fixed-function decode/encode (QuickSync).",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"Video\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "VideoEnhance busy",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"VideoEnhance\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "Blitter busy",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"Blitter\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "GT frequency (actual)",
"description": "0 while the GT sits in RC6.",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "suffix: MHz",
"min": 0,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_frequency{type=\"actual\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "GPU power",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "watt",
"min": 0,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_power{type=\"gpu\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "Package power",
"description": "Whole SoC (CPU + GPU + uncore), from the same PMU.",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 28 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "watt",
"min": 0,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_power{type=\"pkg\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "Memory controller bandwidth",
"description": "Reads solid, writes dashed. The iGPU shares DDR5 with the CPU, so this is the whole package's traffic.",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 28 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "MiBs",
"min": 0,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] },
{ "matcher": { "id": "byRegexp", "options": "/write/" }, "properties": [{ "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [10, 10] } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_imc_read{node=~\"$node\"}",
"legendFormat": "{{node}} read",
"refId": "A"
},
{
"expr": "gpumon_imc_write{node=~\"$node\"}",
"legendFormat": "{{node}} write",
"refId": "B"
}
]
}
]
}
@@ -77,6 +77,9 @@ configMapGenerator:
- name: dashboard-infrastructure-overview
files:
- infrastructure-overview.json
- name: dashboard-intel-gpu
files:
- intel-gpu.json
- name: dashboard-netbird-overview
files:
- netbird-overview.json
@@ -0,0 +1,81 @@
# Intel iGPU utilisation, frequency and power for every node (#752).
#
# Not privileged: /dev/dri comes from the device plugin's monitoring resource
# (every GPU on the node, no hostPath), and the i915 PMU that intel_gpu_top
# reads needs CAP_PERFMON — the one capability granted. No hostPID, no
# hostNetwork.
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: intel-gpu-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: intel-gpu-exporter
spec:
selector:
matchLabels:
app.kubernetes.io/name: intel-gpu-exporter
template:
metadata:
labels:
app.kubernetes.io/name: intel-gpu-exporter
spec:
# Every node is a control plane; without this the exporter covers nothing.
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: intel-gpu-exporter
image: ghcr.io/clambin/intel-gpu-exporter:0.7.4@sha256:d78761fef018834c759db154fe9dcf3dd9db5974703fa50ca21f6abc35221be1
args:
# No -device: intel_gpu_top picks the only GPU the plugin injects.
- -interval=5s
ports:
- name: http-metrics
containerPort: 9100
protocol: TCP
securityContext:
# Root so the PERFMON capability is effective without file
# capabilities on the binary.
runAsUser: 0
runAsNonRoot: false
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["PERFMON"]
resources:
requests:
cpu: 10m
memory: 32Mi
gpu.intel.com/monitoring: 1
limits:
memory: 128Mi
gpu.intel.com/monitoring: 1
livenessProbe:
httpGet:
path: /metrics
port: http-metrics
initialDelaySeconds: 15
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: intel-gpu-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: intel-gpu-exporter
spec:
clusterIP: None
selector:
app.kubernetes.io/name: intel-gpu-exporter
ports:
- name: http-metrics
port: 9100
targetPort: http-metrics
protocol: TCP
@@ -16,6 +16,7 @@ resources:
- prometheus-gate-ingress.yaml
- ci-diagnostics-rbac.yaml
- smartctl-exporter.yaml
- intel-gpu-exporter.yaml
- dashboards/
# prometheusrule-garage.yaml lives in ./monitoring-rules/ and is applied by a
# separate Flux Kustomization that dependsOn `monitoring` — otherwise the
@@ -28,7 +28,7 @@ spec:
# net1 instead. master: ens19 resolves on every k8s-{1,2,3}/edge
# worker, though it is no longer the same kind of device everywhere:
# on the bare-metal control-plane nodes it is a VLAN 30 sub-interface
# of enp2s0f0np0 rather than a NIC, which is why the datapath needs
# of uplink rather than a NIC, which is why the datapath needs
# bpf.vlanBypass to carry it (issue #592).
k8s.v1.cni.cncf.io/networks: music-assistant-iot
spec:
@@ -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.10.2 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.3 # {"$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.10.2 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.3 # {"$imagepolicy": "flux-system:music-assistant"}
env:
- name: LOG_LEVEL
value: info
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
type: RuntimeDefault
containers:
- name: gotenberg
image: gotenberg/gotenberg:8.36.0 # {"$imagepolicy": "flux-system:gotenberg"}
image: gotenberg/gotenberg:8.37.0 # {"$imagepolicy": "flux-system:gotenberg"}
args:
- "gotenberg"
- "--chromium-disable-javascript=true"
+2 -2
View File
@@ -134,7 +134,7 @@ spec:
# is a device-bricking class of risk. Bumps are a reviewed, manual
# act — see kubernetes/infrastructure/image-automation/README.md.
- name: terminus
image: ghcr.io/usetrmnl/terminus:0.72.0 # {"$imagepolicy": "flux-system:terminus"}
image: ghcr.io/usetrmnl/terminus:0.73.0 # {"$imagepolicy": "flux-system:terminus"}
ports:
- name: http
containerPort: 2345
@@ -197,7 +197,7 @@ spec:
capabilities:
drop: ["ALL"]
- name: sidekiq
image: ghcr.io/usetrmnl/terminus:0.72.0 # {"$imagepolicy": "flux-system:terminus"}
image: ghcr.io/usetrmnl/terminus:0.73.0 # {"$imagepolicy": "flux-system:terminus"}
command: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"]
envFrom:
- configMapRef:
+1 -1
View File
@@ -18,7 +18,7 @@ spec:
spec:
containers:
- name: whisper
image: rhasspy/wyoming-whisper:3.7.0 # {"$imagepolicy": "flux-system:wyoming-whisper"}
image: rhasspy/wyoming-whisper:3.8.1 # {"$imagepolicy": "flux-system:wyoming-whisper"}
args:
- --model=base-int8
- --language=de
+1 -1
View File
@@ -34,7 +34,7 @@ spec:
# Image automation, range 3.x (see image-automation/woodpecker.yaml).
# MUST stay version-matched with woodpecker-server (gRPC parity) — both
# track the same range/filter so they bump together on each release.
image: woodpeckerci/woodpecker-agent:v3.18.0 # {"$imagepolicy": "flux-system:woodpecker-agent"}
image: woodpeckerci/woodpecker-agent:v3.18.1 # {"$imagepolicy": "flux-system:woodpecker-agent"}
env:
- name: WOODPECKER_SERVER
value: woodpecker.woodpecker.svc.cluster.local:9000 # in-cluster gRPC, replaces LXC :9000
+1 -1
View File
@@ -40,7 +40,7 @@ spec:
# minor + patch auto-update within 3.x; only the 4.0 major is held for
# a reviewed bump. Safe now the DB is postgres (the old sqlite-surprise
# -bump hazard is gone). Stays in lockstep with the agent.
image: woodpeckerci/woodpecker-server:v3.18.0 # {"$imagepolicy": "flux-system:woodpecker-server"}
image: woodpeckerci/woodpecker-server:v3.18.1 # {"$imagepolicy": "flux-system:woodpecker-server"}
env:
- name: WOODPECKER_HOST
value: https://ci.renner.uno
+120 -101
View File
@@ -179,16 +179,18 @@ phase_runs() {
OPENBAO_NS="openbao"
# Where the secrets come from. On a workstation, the operator's pass store. In
# the in-cluster Job there is no pass and no GPG key — and there should not be,
# because the key that protects OpenBao has no business living in the cluster
# OpenBao secures. Set OPENBAO_SECRETS_DIR and the run reads them from files
# instead, which the operator streams in when the run asks for them:
# the in-cluster Job there is no pass and no GPG key — and there should not be:
# the pass store opens everything, and it stays on the operator's machine. Set
# OPENBAO_SECRETS_DIR and the run reads them from files instead, which the
# operator streams in when the run asks for them:
#
# pass show homelab/openbao/unseal-key \
# | kubectl -n vmtest exec -i <pod> -- sh -c 'cat > /secrets/unseal-key'
# pass show homelab/openbao/seal-key \
# | kubectl -n vmtest exec -i <pod> -- sh -c 'cat > /secrets/seal-key'
#
# They exist in the pod's filesystem for the length of the run and are never
# written to a Secret, so they do not land in etcd or in any backup of it.
# written to a Secret in this cluster, so they do not land in its etcd or in
# any backup of it. The seal key alone becomes a Secret in the cluster being
# built, which is what lets that OpenBao unseal itself.
OPENBAO_SECRETS_DIR="${OPENBAO_SECRETS_DIR:-}"
# Every secret the run needs, named once, in one place. The list is here and
@@ -243,7 +245,9 @@ OPENBAO_REQUIRED_SECRETS=(
"git-token|token for flux bootstrap, for whichever forge FLUX_SOURCE names|git_token_for_source"
"s3-access|Hetzner restore access key|pass show homelab/hetztner/k8s-restore-access"
"s3-secret|Hetzner restore secret key|pass show homelab/hetztner/k8s-restore-secret"
"unseal-key|OpenBao unseal key|pass show homelab/openbao/unseal-key"
# The static seal key. The snapshots are encrypted under it, so it has to
# be the one the backed-up cluster ran with — a fresh key restores nothing.
"seal-key|OpenBao seal key|pass show homelab/openbao/seal-key"
# Read by the node registry auth (see registry_auth_patch): the images an app
# needs while the cluster is still being built are mirrored to a private
# ghcr.io namespace, and a node has to be able to pull them before anything
@@ -277,7 +281,7 @@ OPENBAO_REQUIRED_SECRETS=(
# That phase used to need nothing but the machines, and `bootstrap.sh talos` is
# documented as the part that can run unattended. Node registry auth changed
# that: the machine config now carries a pull credential, so the phase needs
# exactly one secret. Demanding all six there would drag in the OpenBao unseal
# exactly one secret. Demanding all six there would drag in the OpenBao seal
# key, which deliberately never reaches CI — and demanding none, as before,
# left the phase to discover the missing file halfway through `talosctl gen`.
require_secrets() {
@@ -408,7 +412,7 @@ read_secret() {
# the kernel link name enp2s0f1np1, and that name cannot be manufactured here
# without assuming a Hetzner private network passes VLAN tags. See
# talos/patches/site-hcloud.yaml. Nor Flux and everything after it: that phase
# needs the OpenBao unseal key, which deliberately never reaches CI, and this
# needs the OpenBao seal key, which deliberately never reaches CI, and this
# test is meant to run unattended.
# Talos version and Image Factory schematic, read out of the installer image in
# cluster.yaml. Both throwaway-cluster phases need them and neither may state
@@ -1121,7 +1125,7 @@ EOF
# nothing has demanded the secrets yet — the full require_secrets is gated
# on the flux phase. Ask for the one this needs, and only that one: pulling
# in the whole list would make an unattended talos run depend on the OpenBao
# unseal key, which never reaches CI.
# seal key, which never reaches CI.
require_secrets ghcr-pull
REGISTRY_PATCH=$(registry_auth_patch)
[[ -s "$REGISTRY_PATCH" ]] \
@@ -1406,9 +1410,11 @@ fi
# waits on infrastructure-crds. A rebuild could not reach a reconciled state
# without someone at a keyboard.
#
# The unseal key stays where it was — the operator's `pass` store — and is read
# The seal key stays where it was — the operator's `pass` store — and is read
# here at run time. It is never written to the repo and never reaches CI, which
# is the reason this was left manual in the first place.
# is the reason this was left manual in the first place. It reaches the cluster
# as the openbao-seal-key Secret, created by the flux phase before Flux starts
# OpenBao, and from then on every replica unseals itself.
#
# Everything below is idempotent: it is safe to re-run against an OpenBao that
# is already unsealed and configured, which is what makes it usable as part of
@@ -1498,61 +1504,63 @@ restore_openbao() {
echo " extracted $(stat -c%s "$work/restore.snap") bytes"
# A snapshot can only be loaded into a running OpenBao, so the fresh one is
# initialised and unsealed with a throwaway key first. That key is used for
# the next few seconds and then stops existing: restoring replaces the
# keyring with the snapshot's, after which the real key from pass applies.
# -force is required for exactly that reason — without it OpenBao refuses a
# snapshot whose keys do not match the cluster's own.
echo -n " initialising the empty server with a throwaway key... "
local init_json throwaway
init_json=$(bao_exec "$pod" bao operator init -key-shares=1 -key-threshold=1 -format=json 2>&1) \
# initialised first. Under the static seal, init hands out a recovery key
# rather than an unseal key and the server unseals itself; the only thing
# used from its output is the root token, for the next few seconds, and
# then it stops existing: restoring replaces the keyring with the
# snapshot's, after which the root token and recovery key from pass apply.
#
# No -force. Without it OpenBao checks that the snapshot was written under
# the seal key this server runs with, and refuses a mismatch by name
# ("possibly the snapshot is using a different autoseal key"). That check
# is the one guard against restoring under the wrong seal-key Secret;
# -force would skip it and leave a server that can never unseal.
#
# The init output is not echoed on a parse failure: it carries the root
# token and the recovery key of a server that is about to be overwritten,
# but a log line is a log line.
echo -n " initialising the empty server (throwaway recovery key)... "
local init_json root
init_json=$(bao_exec "$pod" bao operator init -recovery-shares=1 -recovery-threshold=1 -format=json 2>&1) \
|| { echo; echo "$init_json" >&2; fail "could not initialise OpenBao"; }
throwaway=$(jq -r '.unseal_keys_b64[0]' <<<"$init_json")
[[ -n "$throwaway" && "$throwaway" != "null" ]] || fail "could not read the throwaway unseal key from init output"
root=$(jq -r '.root_token // empty' <<<"$init_json")
[[ -n "$root" ]] || fail "init output carried no root token"
ok
echo -n " unsealing with it... "
# The key goes as an argument, which OpenBao's own error message asks for:
#
# you are executing outside of a terminal (tty) ... If this is not an
# option, the unseal key can be provided as the first argument
#
# Two earlier shapes failed. `unseal -` is Vault syntax; OpenBao takes
# argv[1] literally, so the dash was sent as the key. Omitting the argument
# makes it prompt, and the prompt needs a real tty — a pipe gives "file
# descriptor 0 is not a terminal". There is no stdin path at all.
#
# This does put the key in the pod's process list for the length of the
# call, which the code here previously went out of its way to avoid. It is
# accepted rather than worked around: reading that list requires exec into
# the very pod that holds the key in memory, and the alternative — a
# port-forward plus curl against /v1/sys/unseal — puts a background process
# in the most critical path of a recovery. The openbao image has no curl,
# wget or nc, so it would have to run from here.
bao_exec "$pod" bao operator unseal "$throwaway" >/dev/null \
|| fail "could not unseal with the throwaway key"
ok
# Sealed after init means the static seal is not working — the Secret is
# missing or malformed — and the restore below cannot run against it.
echo -n " waiting for it to unseal itself"
for i in $(seq 1 30); do
if bao_exec "$pod" bao status -format=json 2>"$OPENBAO_SECRETS_DIR/.baoerr" \
| jq -e '.sealed == false' >/dev/null; then
echo; break
fi
printf '.'
sleep 2
if [[ $i -eq 30 ]]; then
echo
cat "$OPENBAO_SECRETS_DIR/.baoerr" >&2
fail "$pod did not unseal itself after init — check the openbao-seal-key Secret and the pod log"
fi
done
echo -n " loading the snapshot... "
kubectl -n "$OPENBAO_NS" cp "$work/restore.snap" "$pod:/tmp/restore.snap" >/dev/null \
|| fail "could not copy the snapshot into $pod"
local root
root=$(jq -r '.root_token' <<<"$init_json")
local out
out=$(bao_exec "$pod" env BAO_TOKEN="$root" bao operator raft snapshot restore -force /tmp/restore.snap 2>&1) \
out=$(bao_exec "$pod" env BAO_TOKEN="$root" bao operator raft snapshot restore /tmp/restore.snap 2>&1) \
|| { echo; echo "$out" >&2; fail "snapshot restore rejected"; }
ok
rm -rf "$work"
# The server is now sealed again, against the keyring that came with the
# snapshot. unseal_openbao carries on from here with the real key.
echo " restored — the server now expects the original unseal key"
# The server seals itself against the keyring that came with the snapshot
# and unseals again from the static seal — the same key the snapshot was
# written under. bring_up_openbao waits for that on every replica.
echo " restored — the server re-seals and unseals itself from the seal key"
}
unseal_openbao() {
step 0 "Unsealing OpenBao..."
[[ -n "$OPENBAO_SECRETS_DIR" ]] || command -v pass >/dev/null \
|| fail "pass not found — the unseal key lives in the operator's pass store, or set OPENBAO_SECRETS_DIR and stream it in"
bring_up_openbao() {
step 0 "Bringing OpenBao up..."
command -v jq >/dev/null || fail "jq not found (needed to read OpenBao's seal status)"
# OpenBao is deployed by the infrastructure Kustomization. What this step
@@ -1658,40 +1666,28 @@ unseal_openbao() {
restore_openbao "${pods[0]}"
fi
local key
key=$(read_secret unseal-key) \
|| fail "could not read the unseal key"
# Unseal every replica the StatefulSet wants, not the ones that happen to
# exist right now. On a cold start only openbao-0 exists when this runs, and
# unsealing just that one deadlocks the StatefulSet: podManagementPolicy is
# OrderedReady, a sealed pod never passes its readiness probe, so openbao-1
# is created only after openbao-0 is unsealed and openbao-2 is never created
# at all. The cluster comes up at 1 of 3.
# Wait for every replica the StatefulSet wants, not the ones that happen to
# exist right now. On a cold start only openbao-0 exists when this runs:
# podManagementPolicy is OrderedReady, so openbao-1 is created once
# openbao-0 passes its readiness probe and openbao-2 after that. Each
# unseals itself from the static seal; nothing here types a key.
#
# That alone would be survivable. What makes it fatal is that the openbao
# Service sets publishNotReadyAddresses — the chart does this so replicas can
# reach each other to join raft before any of them is unsealed — so the
# sealed openbao-1 is a live backend on the service IP. Clients are balanced
# onto a node that cannot answer, and the failure surfaces nowhere near here:
# external-secrets' ClusterSecretStore flaps between "store validated" and
# `dial tcp <svc>:8200: connect: no route to host`, every ExternalSecret in
# the cluster reports "could not get secret data from provider", and
# infrastructure-crds — which waits on those — never goes Ready, taking all
# twenty app Kustomizations with it.
#
# So this converges instead of iterating once: re-list the pods each round,
# unseal whatever is sealed, and finish when spec.replicas of them are
# unsealed. A pod that is still starting cannot be unsealed yet and is not an
# error — it is retried next round, which is exactly how the OrderedReady
# chain is meant to be walked.
# Waiting for the whole chain rather than the first pod matters because the
# openbao Service sets publishNotReadyAddresses — the chart does this so
# replicas can reach each other to join raft before any of them is Ready —
# so a replica that is still starting is a live backend on the service IP.
# Clients balanced onto it get `dial tcp <svc>:8200: connect: no route to
# host`, and the failure surfaces nowhere near here: external-secrets'
# ClusterSecretStore flaps, every ExternalSecret reports "could not get
# secret data from provider", and infrastructure-crds — which waits on those
# — never goes Ready, taking all twenty app Kustomizations with it.
local want
want=$(kubectl -n "$OPENBAO_NS" get statefulset openbao -o jsonpath='{.spec.replicas}') \
|| fail "could not read the openbao StatefulSet's replica count"
[[ "$want" =~ ^[0-9]+$ ]] || fail "openbao StatefulSet reported a non-numeric replica count: '$want'"
echo " unsealing $want replica(s)"
echo " waiting for $want replica(s) to unseal themselves"
local unsealed=0 round pod err
local unsealed=0 round pod
local -A seen=()
for round in $(seq 1 180); do
mapfile -t pods < <(kubectl -n "$OPENBAO_NS" get pods -l app.kubernetes.io/name=openbao \
@@ -1700,29 +1696,21 @@ unseal_openbao() {
for pod in "${pods[@]}"; do
if bao_exec "$pod" bao status -format=json 2>"$OPENBAO_SECRETS_DIR/.baoerr" \
| jq -e '.sealed == false' >/dev/null; then
[[ -n "${seen[$pod]:-}" ]] || { echo " $pod: already unsealed"; seen[$pod]=1; }
[[ -n "${seen[$pod]:-}" ]] || { echo -e " $pod: ${GREEN}unsealed${NC}"; seen[$pod]=1; }
unsealed=$(( unsealed + 1 ))
continue
fi
# Argument, not stdin — see the restore path above for why: OpenBao
# has no stdin form, and its prompt requires a tty a Job does not
# have. The key is exposed in the pod's process list for the length
# of the call.
if err=$(bao_exec "$pod" bao operator unseal "$key" 2>&1); then
echo -e " $pod: ${GREEN}unsealed${NC}"
seen[$pod]=1
unsealed=$(( unsealed + 1 ))
else
# Not fatal on its own: a container still in Init or still
# pulling answers this way, and the next round finds it ready.
# Only running out of rounds is a failure.
echo " $pod: not yet (${err##*$'\n'})"
fi
done
(( unsealed >= want )) && break
sleep 10
if [[ $round -eq 180 ]]; then
fail "only $unsealed of $want OpenBao replicas came unsealed after 30 minutes"
# A replica that is running and still sealed did not get a working
# seal. Say which, with its own answer, rather than "timed out".
for pod in "${pods[@]}"; do
[[ -n "${seen[$pod]:-}" ]] && continue
echo " $pod:" >&2
bao_exec "$pod" bao status 2>&1 | sed 's/^/ /' >&2 || true
done
fail "only $unsealed of $want OpenBao replicas unsealed after 30 minutes — a sealed one means the static seal failed (openbao-seal-key Secret, pod log)"
fi
done
ok
@@ -1747,7 +1735,7 @@ if phase_runs flux; then
command -v flux >/dev/null || fail "flux not found"
# In the in-cluster Job neither the SOPS key nor the Gitea token is present,
# and neither should be baked in. Both are streamed into the secrets
# directory when the run asks for them, the same way the unseal key is.
# directory when the run asks for them, the same way the seal key is.
# Unconditional: the same collection step on a laptop and in the Job, so the
# recovery path is the one that gets exercised.
require_secrets
@@ -1770,6 +1758,37 @@ if phase_runs flux; then
--dry-run=client -o yaml | kubectl apply -f -
ok
# The static seal key, before Flux brings OpenBao up: a pod whose env
# references a Secret that does not exist is never started, and OpenBao
# reads the key at start. Left alone when the cluster already holds the
# same value. A DIFFERENT value is refused rather than applied: the data
# is encrypted under whichever key OpenBao started with, and swapping the
# Secret would leave every replica sealed for good at its next restart.
# That is a migration, not a bootstrap step.
#
# --from-literal, not --from-file: `pass show` ends its output with a
# newline, the substitution strips it, and OpenBao rejects a key that is
# not exactly 32 bytes of base64. The key sits in this process's argument
# list for the length of the call, as it did in the pod's before.
echo -n "Creating OpenBao seal key secret... "
seal_key=$(read_secret seal-key) || fail "could not read the seal key"
kubectl create namespace "$OPENBAO_NS" --dry-run=client -o yaml | kubectl apply -f -
if existing=$(kubectl -n "$OPENBAO_NS" get secret openbao-seal-key \
-o go-template='{{index .data "key" | base64decode}}' 2>"$OPENBAO_SECRETS_DIR/.err"); then
[[ "$existing" == "$seal_key" ]] \
|| fail "openbao-seal-key in $OPENBAO_NS holds a different key than pass — not replacing it (OpenBao's data is written under the key it started with)"
echo "already present"
elif grep -q NotFound "$OPENBAO_SECRETS_DIR/.err"; then
kubectl -n "$OPENBAO_NS" create secret generic openbao-seal-key \
--from-literal=key="$seal_key" >/dev/null \
|| fail "could not create the openbao-seal-key Secret"
ok
else
echo; cat "$OPENBAO_SECRETS_DIR/.err" >&2
fail "could not read the openbao-seal-key Secret"
fi
unset seal_key existing
if [[ "$FLUX_SOURCE" == "github" ]]; then
step 0 "Bootstrapping FluxCD from the GitHub break-glass mirror..."
echo -e "${YELLOW}BREAK-GLASS: Flux source is github.com/${GITHUB_OWNER}/${GITHUB_REPO}, NOT in-cluster Gitea.${NC}"
@@ -1820,7 +1839,7 @@ if phase_runs flux; then
--token-auth --timeout 15m 2>&1
fi
unseal_openbao
bring_up_openbao
step 0 "Waiting for infrastructure..."
for i in $(seq 1 60); do
File diff suppressed because it is too large Load Diff
@@ -180,3 +180,17 @@ spec:
# the supernets here cover the LAN as well, which is the point.
ipv4NativeRoutingCIDR: 10.0.0.0/8
ipv6NativeRoutingCIDR: fd00::/8
# Pod east-west leaves the node on pod0 (VLAN 48) rather than the
# management VLAN. Native routing hands forwarding to the kernel;
# autoDirectNodeRoutes installs the remote PodCIDR routes, which is
# allowed because all three nodes share pod0's L2 segment.
#
# directRoutingDevice is explicit on purpose: with several addressed
# devices Cilium falls back to the one carrying the Kubernetes InternalIP,
# which is mgmt0.
#
# Encapsulation is gone, so the datapath MTU goes 1450 -> 1500.
routingMode: native
autoDirectNodeRoutes: true
nodePort:
directRoutingDevice: pod0
@@ -72,6 +72,9 @@ spec:
# they contend for it. Sized for an inference server plus room for
# a transcoder and a benchmark pod alongside it.
- -shared-dev-num=4
# Advertises gpu.intel.com/monitoring: one resource that grants a
# pod every GPU on the node instead of a slot. The exporter needs it.
- -enable-monitoring
securityContext:
seLinuxOptions:
type: "container_device_plugin_t"
@@ -76,7 +76,11 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/netbird-proxy-services:2026-09-07
# Pinned by digest: the date tag is rebuilt on every push of the
# image directory (branch and master alike) and the registry keeps
# only the newest manifest per tag, so the digest must come from
# the MASTER build of the code change.
image: gitea.renner.uno/gitea_admin/netbird-proxy-services:2026-09-07@sha256:caffa308d802c6fcc6aea919ac94657c08b993651baa6b8d18de395bcc40523e
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -25,10 +25,10 @@ spec:
name: openbao
namespace: openbao
install:
# A fresh OpenBao pod is 0/1 Ready until `bao operator init` + unseal
# (readiness probe reports sealed). helm --wait would time out and
# remediation would uninstall the release, so don't wait here; the
# init/unseal procedure is manual (see kubernetes/MIGRATION-sops-openbao.md).
# A fresh OpenBao pod is 0/1 Ready until `bao operator init` (readiness
# reports sealed). helm --wait would time out and remediation would
# uninstall the release, so don't wait here; init is manual (see
# kubernetes/MIGRATION-sops-openbao.md). Unseal is automatic (static seal).
disableWait: true
remediation:
retries: 3
@@ -42,6 +42,11 @@ spec:
# the agent-injector mutating webhook is unused surface.
enabled: false
server:
# The static seal key, from the openbao-seal-key Secret (bootstrap.sh).
extraSecretEnvironmentVars:
- envName: BAO_SEAL_KEY
secretName: openbao-seal-key
secretKey: key
# 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
@@ -53,16 +58,8 @@ spec:
# 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.
# A restarted replica unseals itself from the static seal and rejoins
# raft, so a rolling reboot only ever costs one replica at a time.
replicas: 3
raft:
enabled: true
@@ -111,6 +108,14 @@ spec:
service_registration "kubernetes" {}
# Auto-unseal. The key is a 32-byte AES key in the openbao-seal-key
# Secret, created from pass at bootstrap before this release. The
# former shamir key is the recovery key (#805).
seal "static" {
current_key_id = "1"
current_key = "env://BAO_SEAL_KEY"
}
audit "file" "stdout" {
description = "audit trail to pod stdout -> Loki"
options {
@@ -11,8 +11,8 @@
# secret is stored anywhere; each run logs in fresh.
#
# A SEALED OpenBao fails the login step and the Job goes Failed — that is
# deliberate signal, not noise: it means unseal was forgotten after a pod
# restart and backups have stopped.
# deliberate signal, not noise: it means the static seal could not unseal a
# restarted pod and backups have stopped.
#
# Retention: last 7 snapshots on the PVC; velero adds 30 d offsite.
apiVersion: batch/v1
@@ -25,13 +25,13 @@ spec:
stop: "fd99::ff"
---
# Cilium BGPv2 control-plane config peering each node with the UDM over the
# management LAN — never over v30iot, which is the untrusted IoT segment.
# ingress VLAN 47 — never over v30iot, which is the untrusted IoT segment.
# The source address comes from the kernel routing decision, so no explicit
# localAddress override is needed.
#
# Two parallel sessions, one per address family:
# - v4 session: peer 10.2.40.1, family IPv4 unicast only
# - v6 session: peer fd40::1, family IPv6 unicast only
# - v4 session: peer 10.2.47.1, family IPv4 unicast only
# - v6 session: peer fd02:47::1, family IPv6 unicast only
# This avoids the "v6 NLRIs over v4 transport" path, which requires
# bilateral extended-nexthop capability negotiation (RFC 5549/8950).
# Cilium 1.19 BGPv2 advertises the capability only for the IPv4 Unicast
@@ -74,14 +74,14 @@ spec:
peers:
- name: udm-v6
peerASN: 64512
# ULA — stable across ISP renumber events. UDM's br40 has
# fd40::1/64; nodes get fd40::<eui-64> via SLAAC on eth0.
peerAddress: fd40::1
# ULA — stable across ISP renumber events. UDM's br47 has
# fd02:47::1/64; nodes get fd02:47::<eui-64> via SLAAC on ingress0.
peerAddress: fd02:47::1
peerConfigRef:
name: bgp-peer-udm-v6
- name: udm-v4
peerASN: 64512
peerAddress: 10.2.40.1
peerAddress: 10.2.47.1
peerConfigRef:
name: bgp-peer-udm-v4
---
+87 -20
View File
@@ -12,11 +12,28 @@ machine:
# Left enabled they autoconfigure a ULA and an IPv6 default route from its
# RAs, which ties with mgmt0's and costs the cluster IPv6 egress.
# Disabling v6 on a VLAN parent does not affect the VLAN child.
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
net.ipv6.conf.enp87s0.disable_ipv6: "1"
net.ipv6.conf.uplink.disable_ipv6: "1"
net.ipv6.conf.mgmt.disable_ipv6: "1"
# pod0 is a closed L2 fabric between the three nodes: statically
# addressed, no gateway, nothing to learn from an RA.
net.ipv6.conf.pod0.accept_ra: "0"
# ingress0 keeps RAs, at the default accept_ra of 2: an IPv6 default
# router is a link-local address learned only from the RA, and with
# net.ipv6.conf.all.forwarding=1 — which Cilium sets — Linux ignores RAs
# entirely below 2. That is how mgmt0 has its IPv6 default today.
#
# The prefix is announced correctly, so SLAAC addresses this link the same
# way mgmt0 gets its fd40:: address: derived from the MAC, stable across
# reboots, nothing to allocate. Only IPv4 is declared here, and only
# because a second DHCP client is what broke the node's DNS record.
#
# Just the default route is suppressed, because mgmt0 owns it until the
# split flips it and two defaults at one metric tie. Dropping this line is
# what hands the IPv6 default over.
net.ipv6.conf.ingress0.accept_ra_defrtr: "0"
# That network also carries the mgmt VLAN's ARP. Answer ARP only on the
# link that owns the address, or the UDM learns the node IP against
# enp87s0's MAC and delivers IPv4 to a NIC Cilium is not attached to (#793).
# mgmt's MAC and delivers IPv4 to a NIC Cilium is not attached to (#793).
net.ipv4.conf.all.arp_ignore: "1"
net.ipv4.conf.default.arp_ignore: "1"
net.ipv4.conf.all.arp_announce: "2"
@@ -36,12 +53,15 @@ machine:
# stated once, here, by the machine that knows.
storage.renner.uno/ceph-osd: "true"
udev:
# Renames the SAN port (X710 port 1, matched by its burned-in MAC) to the
# role name at boot, before the network stack opens links — a rule applied
# to a RUNNING node cannot rename (Resource busy) and takes effect at the
# next reboot.
# Renames every port this node uses to its role at boot, matched by
# burned-in MAC — kernel names derive from PCI topology and shift when
# hardware moves. Shared manifests then name roles and only this file
# knows the hardware. A rule applied to a RUNNING node cannot rename
# (Resource busy) and takes effect at the next reboot.
rules:
- SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="58:47:ca:74:70:27", NAME="uplink"
- SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="58:47:ca:74:70:28", NAME="ceph-san"
- SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="58:47:ca:74:70:29", NAME="mgmt"
cluster:
allowSchedulingOnControlPlanes: true
---
@@ -83,20 +103,17 @@ provisioning:
grow: true
---
# X710 port 0 carries the switch trunk and takes no addressing itself — its
# untagged VLAN is a network this node has no business being on. Management
# and v30iot ride it as tagged VLANs.
# untagged VLAN is a network this node has no business being on. Management,
# pod, ingress and v30iot all ride it as tagged VLANs.
#
# Links are named by predictable kernel name, which derives from PCI topology
# and can still shift when hardware is added.
#
# LinkAliasConfig would pin these better, but aliasing the X710 ports by MAC
# and using the aliases as VLAN parents left the node with no address on any
# interface — the aliases were never created and every document referencing
# them silently did nothing. Prove any alias-based revision with
# `talosctl apply-config --mode=try` first; it rolls back on its own.
# Named by the udev rule above, not by LinkAliasConfig. Aliases are valid VLAN
# parents, but a selector matching zero links is skipped silently and Talos
# disables default DHCP machine-wide as soon as any new-style network document
# exists — so a silent miss leaves the node with no address at all. udev
# renames the link itself, so a missed match is visible as a missing link.
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f0np0
name: uplink
up: true
---
# Management, VLAN 40. Carries the node IP, etcd, the API and Ceph's public
@@ -105,7 +122,7 @@ apiVersion: v1alpha1
kind: VLANConfig
name: mgmt0
vlanID: 40
parent: enp2s0f0np0
parent: uplink
up: true
---
# DHCP, and the lease is not reserved in this repo — reservations live on the
@@ -134,9 +151,59 @@ apiVersion: v1alpha1
kind: VLANConfig
name: ens19
vlanID: 30
parent: enp2s0f0np0
parent: uplink
up: true
---
# Pod network, VLAN 48. Node-to-node only: the VXLAN overlay rides it once the
# kubelet node IP moves here, and nothing on it is routed off the switch.
#
# Statically addressed, no gateway, no DHCP. A lease would make the cluster's
# data path depend on the UDM being up in order to form, on a segment whose
# traffic never reaches the UDM. Declared per node, the way ceph-san's segment
# carries a declared range rather than a lease.
apiVersion: v1alpha1
kind: VLANConfig
name: pod0
vlanID: 48
parent: uplink
up: true
addresses:
- address: 10.2.48.11/24
- address: fd02:48::11/64
---
# Ingress network, VLAN 47. World-to-pod: the BGP session, the next hop for
# every advertised LoadBalancer VIP, and the masquerade source for pod egress.
# Publicly routed, which is what separates it from pod0.
#
# Statically addressed, not leased. A second DHCP client on the node announced
# the same hostname as mgmt0's and the UDM registered the node against this
# segment instead, so mgmt0's `srv.renner.uno` record disappeared and the
# lease's resolver displaced the management one. The UDM cannot register a
# network's clients selectively, so the client goes rather than the record.
# DHCP is off on VLAN 47 for the same reason.
#
# Both defaults live in table 47, not in main, so only the pod-source rules in
# site-homelab.yaml reach them. mgmt0 keeps main's default and with it every
# reply the node sends from its own addresses.
#
# The v6 gateway is the UDM's configured ULA on this segment, the same address
# BGP peers with. The RA-learned link-local would be the alternative and is not
# a value to write down. accept_ra_defrtr stays 0 for that reason: the RA
# default would land in main, where it does not belong.
apiVersion: v1alpha1
kind: VLANConfig
name: ingress0
vlanID: 47
parent: uplink
up: true
addresses:
- address: 10.2.47.11/24
routes:
- gateway: fd02:47::1
table: "47"
- gateway: 10.2.47.1
table: "47"
---
# Dedicated Ceph cluster network on X710 port 1, VLAN 49, untagged on the
# wire. OSD-to-OSD replication and nothing else. The udev rule in the machine
# section above renames the port to ceph-san at boot, so shared manifests name
+45 -13
View File
@@ -10,8 +10,13 @@ machine:
# Same for the physical ports on the untagged fallback network: left
# enabled they take an IPv6 default route from its RAs and cost the
# cluster IPv6 egress.
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
net.ipv6.conf.enp87s0.disable_ipv6: "1"
net.ipv6.conf.uplink.disable_ipv6: "1"
net.ipv6.conf.mgmt.disable_ipv6: "1"
# pod0 is a closed segment with no router, so it ignores RAs outright.
# ingress0 keeps RAs: it learns its IPv6 router and its address from them,
# and only the default route is held back — see k8s-1.yaml.
net.ipv6.conf.pod0.accept_ra: "0"
net.ipv6.conf.ingress0.accept_ra_defrtr: "0"
# Answer ARP only on the link that owns the address — see k8s-1.yaml (#793).
net.ipv4.conf.all.arp_ignore: "1"
net.ipv4.conf.default.arp_ignore: "1"
@@ -29,9 +34,11 @@ machine:
# Holds a Ceph OSD drive — see k8s-1.yaml.
storage.renner.uno/ceph-osd: "true"
udev:
# Renames the SAN port to the role name at boot — see k8s-1.yaml.
# Renames every port to its role at boot — see k8s-1.yaml.
rules:
- SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="58:47:ca:76:6f:73", NAME="uplink"
- SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="58:47:ca:76:6f:74", NAME="ceph-san"
- SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="58:47:ca:76:6f:75", NAME="mgmt"
cluster:
allowSchedulingOnControlPlanes: true
---
@@ -61,17 +68,14 @@ provisioning:
grow: true
---
# X710 port 0 carries the switch trunk and takes no addressing itself — the
# port's untagged VLAN is not one this node belongs on. Management and v30iot
# both ride it as tagged VLANs.
# port's untagged VLAN is not one this node belongs on. Management, pod,
# ingress and v30iot all ride it as tagged VLANs.
#
# Addressed by predictable kernel name, not by LinkAliasConfig. Aliases are
# valid VLAN parents, but a selector matching zero links is skipped silently
# and Talos disables default DHCP machine-wide as soon as any new-style network
# document exists — so a silent miss leaves the node with no address on any
# interface. See RUNBOOK-baremetal-conversion.md.
# Named by the udev rule above, not by LinkAliasConfig — see k8s-1.yaml and
# RUNBOOK-baremetal-conversion.md.
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f0np0
name: uplink
up: true
---
# Management, VLAN 40. Node IP, etcd, the API and Ceph's public network.
@@ -79,7 +83,7 @@ apiVersion: v1alpha1
kind: VLANConfig
name: mgmt0
vlanID: 40
parent: enp2s0f0np0
parent: uplink
up: true
---
apiVersion: v1alpha1
@@ -99,9 +103,37 @@ apiVersion: v1alpha1
kind: VLANConfig
name: ens19
vlanID: 30
parent: enp2s0f0np0
parent: uplink
up: true
---
# Pod network, VLAN 48. Node-to-node only, statically addressed, no gateway
# and no DHCP — see k8s-1.yaml.
apiVersion: v1alpha1
kind: VLANConfig
name: pod0
vlanID: 48
parent: uplink
up: true
addresses:
- address: 10.2.48.12/24
- address: fd02:48::12/64
---
# Ingress network, VLAN 47. World-to-pod, publicly routed, statically
# addressed — see k8s-1.yaml for why it is not leased.
apiVersion: v1alpha1
kind: VLANConfig
name: ingress0
vlanID: 47
parent: uplink
up: true
addresses:
- address: 10.2.47.12/24
routes:
- gateway: fd02:47::1
table: "47"
- gateway: 10.2.47.1
table: "47"
---
# Dedicated Ceph cluster network on X710 port 1, VLAN 49, untagged on the
# wire. OSD-to-OSD replication only, not routed off its own segment.
# Renamed to the role name by the udev rule above — see k8s-1.yaml.
+41 -6
View File
@@ -6,8 +6,13 @@ machine:
net.ipv6.conf.ens19.disable_ipv6: "1"
# Same for the physical ports on the untagged fallback network — see
# k8s-1.yaml.
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
net.ipv6.conf.enp87s0.disable_ipv6: "1"
net.ipv6.conf.uplink.disable_ipv6: "1"
net.ipv6.conf.mgmt.disable_ipv6: "1"
# pod0 is a closed segment with no router, so it ignores RAs outright.
# ingress0 keeps RAs: it learns its IPv6 router and its address from them,
# and only the default route is held back — see k8s-1.yaml.
net.ipv6.conf.pod0.accept_ra: "0"
net.ipv6.conf.ingress0.accept_ra_defrtr: "0"
# Answer ARP only on the link that owns the address — see k8s-1.yaml (#793).
net.ipv4.conf.all.arp_ignore: "1"
net.ipv4.conf.default.arp_ignore: "1"
@@ -25,9 +30,11 @@ machine:
# Holds a Ceph OSD drive — see k8s-1.yaml.
storage.renner.uno/ceph-osd: "true"
udev:
# Renames the SAN port to the role name at boot — see k8s-1.yaml.
# Renames every port to its role at boot — see k8s-1.yaml.
rules:
- SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="58:47:ca:74:72:d3", NAME="uplink"
- SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="58:47:ca:74:72:d4", NAME="ceph-san"
- SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="58:47:ca:74:72:d5", NAME="mgmt"
cluster:
allowSchedulingOnControlPlanes: true
---
@@ -58,14 +65,14 @@ provisioning:
---
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f0np0
name: uplink
up: true
---
apiVersion: v1alpha1
kind: VLANConfig
name: mgmt0
vlanID: 40
parent: enp2s0f0np0
parent: uplink
up: true
---
apiVersion: v1alpha1
@@ -83,9 +90,37 @@ apiVersion: v1alpha1
kind: VLANConfig
name: ens19
vlanID: 30
parent: enp2s0f0np0
parent: uplink
up: true
---
# Pod network, VLAN 48. Node-to-node only, statically addressed, no gateway
# and no DHCP — see k8s-1.yaml.
apiVersion: v1alpha1
kind: VLANConfig
name: pod0
vlanID: 48
parent: uplink
up: true
addresses:
- address: 10.2.48.13/24
- address: fd02:48::13/64
---
# Ingress network, VLAN 47. World-to-pod, publicly routed, statically
# addressed — see k8s-1.yaml for why it is not leased.
apiVersion: v1alpha1
kind: VLANConfig
name: ingress0
vlanID: 47
parent: uplink
up: true
addresses:
- address: 10.2.47.13/24
routes:
- gateway: fd02:47::1
table: "47"
- gateway: 10.2.47.1
table: "47"
---
# Dedicated Ceph cluster network — see k8s-1.yaml. The node holds NO address
# here on purpose; the link is handed whole to the OSD pod (host-device).
apiVersion: v1alpha1
@@ -20,3 +20,47 @@ machine:
validSubnets:
- 10.2.40.0/24
- fd40::/64
---
# Pod egress leaves over ingress0. Matching on the pod source prefix is what
# keeps the node's own traffic out of it: the node IP, the API VIP and apid all
# answer on mgmt0, and a reply that left over ingress0 would carry an mgmt0
# source onto VLAN 47.
#
# The routing decision runs before Cilium's masquerade hook, so the rule sees
# the pod address and the masquerade then rewrites it to ingress0's.
#
# Table 47 holds nothing but the two default routes, so the destination rules
# below have to run first: without them a pod's packet to a service or to a pod
# on another node also matches the source rule, finds only the default, and
# leaves for the UDM. Measured — in-cluster DNS stopped resolving.
#
# They send everything inside the cluster and the local networks back to main.
# The prefixes are Cilium's ipv4/ipv6NativeRoutingCIDR, which already mark what
# is not foreign traffic.
#
# Priorities are below main (32766) and clear of the rule Cilium installs at 9.
# The two families are separate rule tables, so the priorities need not match;
# the document name is the priority and must be unique.
apiVersion: v1alpha1
kind: RoutingRuleConfig
name: "990"
dst: fd00::/8
table: main
---
apiVersion: v1alpha1
kind: RoutingRuleConfig
name: "991"
dst: 10.0.0.0/8
table: main
---
apiVersion: v1alpha1
kind: RoutingRuleConfig
name: "1000"
src: fd10:244::/48
table: "47"
---
apiVersion: v1alpha1
kind: RoutingRuleConfig
name: "1001"
src: 10.244.0.0/16
table: "47"
+4 -4
View File
@@ -10,10 +10,10 @@
# session. The nodes have ~94G each with about 70G unused.
#
# The whole script runs here, flux and OpenBao included, so that what a rebuild
# rehearses is what a real recovery would execute. The unseal key is not baked
# in and not stored as a Secret: the operator streams all five secrets into an
# in-memory emptyDir at the moment the run asks for them, and they die with the
# Job without ever reaching etcd or a backup of it.
# rehearses is what a real recovery would execute. The OpenBao seal key is not
# baked in and not stored as a Secret here: the operator streams the secrets
# into an in-memory emptyDir at the moment the run asks for them, and they die
# with the Job without ever reaching this cluster's etcd or a backup of it.
apiVersion: v1
kind: Namespace
metadata:
+11 -2
View File
@@ -28,10 +28,19 @@ router bgp 64512
! advertise extended-nexthop for the IPv6 Unicast SAFI when v6 routes
! are carried over a v4 transport, so we run a native v6 session that
! carries v6 routes with v6 next-hops and skip the capability dance
! entirely. Dynamic neighbors — k8s nodes are SLAAC-addressed on mgmt
! LAN; the listen ranges cover the SLAAC suffix space.
! entirely. Dynamic neighbors — nodes are SLAAC-addressed, so the listen
! ranges cover the SLAAC suffix space rather than naming each peer.
!
! Both segments are listed while the ingress split lands (#576). The
! cluster peers from mgmt (40) today and moves to ingress (47), and a
! listen range only accepts a session, it does not open one — so having
! both costs nothing and means the move never has a window where the UDM
! is not listening where the nodes are calling from. Drop the 40 ranges
! once every session is established on 47.
bgp listen range 10.2.40.0/24 peer-group K8S
bgp listen range fd40::/64 peer-group K8S
bgp listen range 10.2.47.0/24 peer-group K8S
bgp listen range fd02:47::/64 peer-group K8S
!
address-family ipv4 unicast
neighbor K8S activate