Compare commits

...
Author SHA1 Message Date
issue-fix-bot e0809aac20 fix: [alert] NetbirdRelayMemberDown (monitoring) (#603)
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
2026-08-26 05:48:17 +00:00
k.renner 0653361bc4 Merge pull request 'chore: remove the decommissioned Proxmox DNS server and its migration script' (#622) from drop-decommissioned-dns into master
ci/woodpecker/push/deploy-infrastructure Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline failed
Reviewed-on: k.renner/homelab#622
2026-08-26 05:20:29 +00:00
k.renner 7b2900e594 Merge pull request 'fix(opentofu): drop the last Proxmox VM and forget it from state' (#621) from tofu-drop-proxmox-vm into master
ci/woodpecker/push/deploy-infrastructure Pipeline failed
Reviewed-on: k.renner/homelab#621
2026-08-26 05:19:34 +00:00
claude-botandClaude Opus 5 56bb47e483 chore: remove the decommissioned Proxmox DNS server and its migration script
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The Proxmox DNS HA VIP (10.2.40.53) was decommissioned after it dead-ended
non-Ingress resolution and broke ~1374 Home Assistant entities on a pod restart
(2026-07-14). With Proxmox itself now gone, the implementation and everything
pointing at it are dead weight — and one piece was actively dangerous.

Removed:

  dns-server/                         the Go implementation, its systemd unit,
                                      and a 7.4 MB checked-in binary
  opentofu/scripts/remove_unifi_clients.sh
  the "Proxmox DNS (VIP)" Gatus check in the ansible role

remove_unifi_clients.sh is the one worth calling out. It instructs the reader
to deploy module.proxmox_dns and point UniFi DNS at 10.2.40.53 — precisely the
configuration that caused the outage. It also works by `tofu state rm`, which
orphans resources rather than removing them. It was already marked deprecated
in its own header; anyone who ignored that and followed it would have
reintroduced a known failure.

The Gatus check queried k8s-1.srv.renner.uno against 10.2.40.53 every 30s. It
belongs to the ansible gatus role, which targets a Proxmox LXC that no longer
exists, so nothing was running it — but the check itself asserts the decommis-
sioned server should answer, which is the opposite of the intended end state.

Deliberately kept: the three comments in bootstrap.sh, coredns-config and
k8s-gateway that record 10.2.40.53 as DECOMMISSIONED and say not to reintroduce
it. They carry the outage date and blast radius, and are the reason this keeps
not happening again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 07:18:23 +02:00
claude-botandClaude Opus 5 c1a6a2c6ca fix(opentofu): drop the last Proxmox VM and forget it from state
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Every infrastructure plan has failed since neptune was wiped. module.k8s_3 still
declared a Proxmox VM, so the bpg/proxmox provider tried to reach a hypervisor
that no longer exists:

  Error: failed to get resources list of type ("vm") for cluster:
  dial tcp 10.2.40.201:8006: connect: no route to host
    with module.k8s_3.proxmox_virtual_environment_vm.this

Deleting vm_152_k8s_3.tf alone does not fix that. Removing a resource from
configuration makes OpenTofu plan a destroy, and planning a destroy refreshes
the resource through the same unreachable provider.

A `removed` block with `lifecycle { destroy = false }` forgets the state entry
without contacting Proxmox. Nothing is orphaned: the guest, its disks and the
hypervisor underneath were all destroyed during the conversion. This is the
declarative equivalent of a state removal, reviewable in git rather than run by
hand against production state.

talos_image.tf goes with it. Its locals named the nocloud image for VM import
and had no consumer other than the VM module.

Deliberately kept for a follow-up: the proxmox provider block, the
proxmox_vm/proxmox_lxc/bpg_proxmox_container modules and sync_state_from_pve.py.
A provider with no resources is never contacted, so leaving it costs nothing,
and removing it should wait until a clean plan proves state holds no other
Proxmox resources. That check cannot be made from here — the S3 credentials
used for local runs have been revoked.

Verified with `tofu validate` and `tofu fmt -check` offline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 07:05:16 +02:00
k.renner 8173cf5517 Merge pull request 'chore: remove the Proxmox observability and access surface' (#620) from drop-pve-monitoring into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#620
2026-08-26 04:49:16 +00:00
claude-botandClaude Opus 5 d50be81ed1 chore: remove the Proxmox observability and access surface
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
No Proxmox remains. jupiter, saturn and neptune all run Talos on bare metal,
so everything that scraped, proxied, alerted on or rotated credentials for the
PVE API now points at hosts that answer nothing on :8006.

Two of these were not merely dead but actively failing on a schedule:

  gatus            an Infrastructure check on https://pve.renner.uno, every
                   60s, which can now only ever fail
  openbao rotation a Class-B CronJob rolling the pve-exporter@pve API token
                   against pve.apps.svc.cluster.local:8006

Removed:

  apps/pve/                       Service, EndpointSlice (10.2.40.201-203),
                                  IngressRoute for the PVE web UIs
  monitoring/pve-exporter-*       Deployment and its config ExternalSecret
  prometheus scrape configs       job_name pve and pve-node
  dashboards/proxmox-cluster.json and its ConfigMap generator entry
  image-automation/pve-exporter   image policy for an image nothing runs
  openbao/rotation/pve-exporter   the rotation CronJob
  openbao/policies/rotator-proxmox.hcl
  crowdsec pve-ui-polling-whitelist, which suppressed false bans on PVE UI
                                  API polling that no longer happens

Kept, with comments corrected: the insecure-backend ServersTransport, still
used by apps/file for the NAS web UI.

The gatus check leaves a numbering gap at "Infrastructure 2" rather than
renumbering 3 and 4. The labels are deliberately opaque and renumbering would
relabel the surviving checks, discarding their history for cosmetics.

Not touched: the opentofu Proxmox provider, VM and LXC modules,
vm_152_k8s_3.tf, sync_state_from_pve.py, and the ansible PVE inventory. Those
carry state implications and want a deliberate pass rather than folding into a
manifest cleanup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 06:46:40 +02:00
k.renner 357f8d79c2 Merge pull request 'chore(ci): drop the Talos image seeding step' (#612) from drop-talos-image-seeding into master
ci/woodpecker/push/deploy-infrastructure Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline failed
Reviewed-on: k.renner/homelab#612
2026-08-26 04:38:38 +00:00
k.renner 0d8f436466 Merge pull request 'fix(cilium): roll the agent DaemonSet when cilium-config changes' (#613) from cilium-reloader into master
Reviewed-on: k.renner/homelab#613
2026-08-26 04:37:21 +00:00
k.renner e33b0f584a Merge pull request 'fix(talos): install from the Image Factory metal installer' (#614) from talos-factory-installer into master
Reviewed-on: k.renner/homelab#614
2026-08-26 04:36:57 +00:00
claude-botandClaude Opus 5 f84df3b188 fix(talos): install from the Image Factory metal installer
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
machine.install.image was never set, so every node installed from the stock
ghcr.io/siderolabs/installer that talosctl gen config defaults to. Extensions
live in the installer image, which makes the schematic a property of the boot
media alone — nothing lands on disk. talosctl get extensions returns empty on
all three nodes.

Two extensions are missing as a result, and the cold-rebuild runbook explains
why each was chosen for bare metal:

  siderolabs/intel-ucode   CPU microcode. On a VM the hypervisor supplied it;
                           on bare metal nothing does except the BIOS.
  siderolabs/thunderbolt   Inert until machine.kernel.modules asks for it.
                           Present so enabling the MS-01 mesh later needs
                           neither a new image nor a reinstall — precisely the
                           property installing without it destroys.

Set in the shared cluster patch rather than per node: all three are bare metal
now and want the same image. Schematic a21cec8f is the bare-metal one from
RUNBOOK-cold-rebuild.md — same net.ifnames=1 as the Proxmox schematic, minus
qemu-guest-agent.

Config-only. install.image is read at install and upgrade time, so no running
node changes until it is upgraded; the three nodes stay without extensions
until a deliberate rolling upgrade is scheduled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:41:03 +02:00
claude-botandClaude Opus 5 e11ba99991 fix(cilium): roll the agent DaemonSet when cilium-config changes
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
Cilium reads datapath options once, at agent start, and the chart puts no
config checksum on the pod template. A changed ConfigMap therefore leaves every
agent running the previous datapath until something restarts it, and nothing
does — the DaemonSet is not rolled by a ConfigMap-only Helm change.

This already cost an outage. vlan-bpf-bypass landed in cilium-config and every
v30iot macvlan workload stayed broken until the agents were deleted by hand,
one node at a time (issue #592). The merge looked successful: HelmRelease
Ready, release upgraded, key present in the ConfigMap, and nothing working.

policyMapMax carries the same exposure. Its comment says "takes effect on agent
restart", which is documentation standing in for automation — as does the
equivalent note in the two bootstrap copies.

Reloader is already deployed and used across the repo for the same purpose.
Named per-ConfigMap rather than blanket, matching the existing convention.

The trade, stated plainly: any cilium-config change now rolls the CNI
cluster-wide, node by node. That is the same disruption a datapath change
already required; the difference is that it happens on its own rather than
being forgotten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:37:31 +02:00
claude-botandClaude Opus 5 43143847e9 chore(ci): drop the Talos image seeding step
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
The step pushed a nocloud Talos image onto each Proxmox node so the k8s VMs
could boot from it. No Proxmox node remains — neptune was the last one, and all
three control-plane nodes now boot the metal image from their own installed
disk.

Left in place the step would call the Proxmox storage API for a host that no
longer answers it, then sit through its 60x10s download-wait before giving up,
on every infrastructure apply.

The block's own comment already scheduled its removal: "the whole step goes away
once neptune is converted too."

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:35:46 +02:00
k.renner 1febebc55d Merge pull request 'fix(cilium): allow VLAN 30 through the eBPF datapath' (#611) from cilium-vlan-bypass into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#611
2026-08-26 00:27:35 +00:00
claude-botandClaude Opus 5 aade7b5d29 fix(cilium): allow VLAN 30 through the eBPF datapath
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 v30iot macvlan workload on a bare-metal node has been stuck in sandbox
creation since its node was converted. Cilium's datapath drops VLAN-tagged
frames unless bpf.vlanBypass lists the tag, and cilium-config carried no
vlan-bpf-bypass key at all.

Inside the Proxmox guests ens19 was an untagged virtio NIC on the v30iot
bridge, so nothing was tagged by the time Cilium saw it. On bare metal ens19 is
a VLAN 30 sub-interface of enp2s0f0np0, and the macvlan children's DHCP
exchange never reaches the wire.

Measured rather than inferred: cilium_drop_count_total on the affected node
reports ~123k INGRESS and ~1.1k EGRESS drops with reason "VLAN traffic
disallowed by VLAN filter", against none on the node that was still a VM, and
node_network_receive_packets_total for ens19 reads 0 there against 10 pkt/s on
the VM. Full analysis in issue #592.

The symptom hides its own cause. A tc-layer drop surfaces to an AF_PACKET
sender as ENOBUFS, so the CNI dhcp plugin reports "no buffer space available"
and "network is down" instead of a DHCP timeout.

VLAN 40 is deliberately not listed. mgmt0 rides the same parent and carries the
node IP, etcd and the API, and it passes today without a bypass entry, so
adding it would change a working control-plane datapath to fix an IoT one.

All three copies of the key move together — the HelmRelease, kubernetes/talos/
cilium.yaml, and the inline bootstrap manifest in patches/cilium-cni.yaml — so
a cold rebuild does not come up without it.

The runbook predicted this exact failure and is updated to record that it
happened, along with how to recognise it from the drop counters rather than the
misleading CNI error. music-assistant's comment claiming ens19 is "the v30iot
NIC" on every node is corrected; it is a NIC on the edge worker and a VLAN
sub-interface on the control-plane nodes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:26:41 +02:00
k.renner 02817ef45e Merge pull request 'feat(rook-ceph): restore replicapool and cephfs-data0 to size 3' (#610) from restore-replicapool-size into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#610
2026-08-26 00:14:05 +00:00
claude-botandClaude Opus 5 d376f318c2 feat(rook-ceph): restore replicapool and cephfs-data0 to size 3
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
These two sat at size 2 because of #434, not because of the conversion. With
three Crucial P3 QLC OSDs on three hosts, size 3 put every write on every
drive, so per-drive write volume equalled cluster write volume and all three
wore at an identical rate — 80/82/87% of rated endurance, projected to exhaust
together between Nov 2026 and Jan 2027. Replication is no defence against that,
because the wear was correlated by construction.

The OSDs are Samsung PM983 enterprise drives now, at 2-3% of rated endurance
with a far higher write budget. Wear-out is no longer the binding constraint
and the correlation argument no longer applies, so the pools return to the
durability the cluster is sized for.

The min_size 1 overrides go with them. They existed only because Ceph does not
lower min_size when size is reduced, which would have left the pools 2/2 and
blocked IO on any single OSD going down.

cephfs-metadata's comment loses the clause explaining that data0 sits at 2,
which stops being true here.

cephfs-ci stays at size 2: that pool holds throwaway CI workspaces and is at 2
rather than 1 only because an unreplicated pool makes `ceph osd ok-to-stop`
refuse for every OSD. Unrelated to #434.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:13:17 +02:00
k.renner bd5bc920d8 Merge pull request 'feat(rook-ceph): restore cephfs-metadata to size 3' (#609) from restore-cephfs-metadata-size into master
Reviewed-on: k.renner/homelab#609
2026-08-26 00:10:37 +00:00
claude-botandClaude Opus 5 b33c482ad3 feat(rook-ceph): restore cephfs-metadata to size 3
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
All three nodes are converted and the cluster is back to three OSDs across
three zones with 217 PGs active+clean, so the conversion window that justified
2/1 on this pool is over.

The min_size override goes with it. It existed only because Ceph does not
lower min_size when size is reduced, so the pool would have been 2/2 while a
host was out. At size 3 the default min_size of 2 is what the pool wants.

The comment loses its TEMPORARY banner and now states the standing rationale
and the condition under which the pool degrades to two copies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:08:38 +02:00
k.renner 0f68a1b344 Merge pull request 'feat(rook-ceph): re-add neptune's OSD on the PM983' (#605) from ceph-readd-neptune into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#605
2026-08-25 23:45:52 +00:00
k.renner bd6e101bfb Merge pull request 'feat(k8s-3): bare-metal config for neptune' (#604) from k8s-3-baremetal into master
Reviewed-on: k.renner/homelab#604
2026-08-25 23:45:38 +00:00
claude-botandClaude Opus 5 be0f891c73 feat(rook-ceph): re-add neptune's OSD on the PM983
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
neptune is converted and back in the cluster, so k8s-3 returns to the storage
fleet on its new PM983. All three nodes now name their OSD by /dev/disk/by-id,
and the storage fleet is whole again.

The comment generalises to all three nodes and records neptune's serials.
k8s-3 is the clearest evidence for by-id addressing: it enumerated the Kingston
as nvme0n1 on one boot and the Crucial as nvme0n1 on the very next one, so a
kernel-name reference had no stable meaning on that machine at all.

The old osd.1 lived on the Crucial that is now EPHEMERAL. That drive was wiped
during the conversion, so the OSD is unrecoverable and is purged from the CRUSH
map separately; this change only creates its replacement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:44:39 +02:00
claude-botandClaude Opus 5 1306d2ad89 feat(k8s-3): bare-metal config for neptune
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
neptune now runs Talos directly, so k8s-3 moves from the VM-era layout to the
multi-document form k8s-1 and k8s-2 already use. The old config selected
interfaces by busPath against virtio NICs and installed to /dev/vda; neither
exists on bare metal.

Disks are selected by serial rather than kernel name. That is not defensive
styling: Talos enumerated this machine's drives differently than Proxmox did,
and differently again between two consecutive boots — nvme0n1 was the Kingston
on one boot and the Crucial on the next. A device-name reference would have
installed onto the wrong drive.

  nvme  50026B7382B48B90  Kingston  system disk
  nvme  2326E6E8E67D      Crucial   EPHEMERAL, 83% of rated endurance
  nvme  S437NF0M501176    PM983     OSD, 3% used

Serials, interface names and drive health were read from the machine itself —
the Kingston and Crucial off the running Proxmox host before the wipe, and
confirmed again from Talos maintenance mode.

k8s-2.yaml loses the sentence claiming k8s-3 is still a Proxmox VM. No Proxmox
remains in the homelab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:43:15 +02:00
k.renner 1f15062b80 Merge pull request 'chore(rook-ceph): drop k8s-3 from the storage fleet for neptune's conversion' (#597) from ceph-drop-neptune into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#597
2026-08-25 22:54:19 +00:00
claude-botandClaude Opus 5 8e97253d6c chore(rook-ceph): drop k8s-3 from the storage fleet for neptune's conversion
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
neptune is the last node to convert to bare metal, and its OSD drive is swapped
in the same window. Removing k8s-3 from the explicit node list first means Rook
stops managing that OSD before the drive it lives on goes away, rather than
recreating it mid-reconcile and backfilling onto a disk that is about to be
pulled.

The device comment for k8s-3 goes with it: it described the PCI-passed-through
drive that node saw as a Proxmox guest, which stops being true here.

Ceph runs on two OSDs and size-3 pools stay degraded until the node rejoins.
cephfs-metadata is already at 2/1 for exactly this window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 00:47:52 +02:00
k.renner 44149d6363 Merge pull request 'feat(rook-ceph): re-add saturn's OSD, fix the bare-metal install sequence' (#595) from ceph-readd-saturn into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#595
2026-08-25 22:22:55 +00:00
claude-botandClaude Opus 5 78355b58de feat(rook-ceph): re-add saturn's OSD, fix the bare-metal install sequence
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
saturn is back on bare metal with the PM983 in place, so k8s-2 returns to the
storage fleet. Like k8s-1 it names the device by /dev/disk/by-id: the machine
has three NVMe drives and Talos enumerates them differently than Proxmox did,
so nvme0n1 there is the Kingston system disk.

The runbook prescribed applying the full per-node config in maintenance mode,
which hangs the install. A document referencing an interface that does not
exist yet leaves the node at STAGE: Installing with KUBELET n/a, the console
repeating

    k8s.NodeApplyController: error getting node: nodes "k8s-N" not found

which reads as a Kubernetes fault but is an install that never completed. On
saturn it was the ens19 VLAN document and the sysctl naming it. Three cold
installs hung; the identical config applied cleanly to the running node.
Wiping disks changes nothing.

Install now runs in two stages: a minimal network config plus EPHEMERAL, which
is one-shot and cannot be deferred, then the full config applied to the running
node behind --mode=try so a bad network config reverts itself.

Also corrects the claim that maintenance mode cannot be inspected. talosctl get
works there; --insecure is a subcommand flag and has to follow `get`, otherwise
cobra resolves the legacy `talosctl disks` command and rejects the flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 00:21:12 +02:00
k.renner 46129f2d4a Merge pull request 'chore: destroy the Ubuntu template VM with saturn's conversion' (#589) from remove-vm-605 into master
ci/woodpecker/push/deploy-infrastructure Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#589
2026-08-25 20:44:10 +00:00
claude-botandClaude Opus 5 a4885926e3 chore: destroy the Ubuntu template VM with saturn's conversion
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
VM 605 is pinned to saturn, so it cannot survive the conversion, and it has to
go before the node leaves the Proxmox cluster — afterwards the provider cannot
reach it and every plan errors.

Nothing uses it. Its purpose was cloning new Proxmox VMs, Proxmox is being
retired, and the one remaining VM definition sets template_name = null
explicitly rather than cloning from it.

The module default is changed to null for the same reason: it named a template
that no longer exists, which would only surface as an apply-time failure for
whoever created the next VM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 22:43:26 +02:00
k.renner ca324825a9 Merge pull request 'chore: destroy VM 151 and drop saturn from the Talos image seeding' (#588) from remove-vm-151 into master
ci/woodpecker/push/deploy-infrastructure Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#588
2026-08-25 20:16:24 +00:00
claude-botandClaude Opus 5 dd727769f6 chore: destroy VM 151 and drop saturn from the Talos image seeding
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
saturn is being converted to bare metal, so its VM definition goes and it
leaves the list of Proxmox nodes the pipeline seeds the nocloud image onto.

The apply must run while saturn is still a Proxmox cluster member. Once it
leaves, the provider cannot reach the node, the plan errors, and the only way
out is tofu state rm.

module.k8s_2 is referenced nowhere else in opentofu/, so this destroys exactly
one resource.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 22:15:12 +02:00
k.renner 562b2dcc32 Merge pull request 'feat(k8s-2): bare-metal config for saturn' (#587) from k8s-2-baremetal into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#587
2026-08-25 20:14:04 +00:00
claude-botandClaude Opus 5 ef6111bda3 feat(k8s-2): bare-metal config for saturn
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
Structurally identical to k8s-1.yaml; only serials, MACs and addresses differ.
Written from saturn's live hardware rather than assumed: the X710 port 1 MAC
came from the guest that currently owns it, since the host cannot see a
passed-through NIC.

  Kingston  50026B7382DA5F6E  system disk
  Crucial   2332E86B74BC      EPHEMERAL, retiring from osd.0 at 81% wear
  X710 p0   58:47:ca:76:6f:73 trunk, VLAN 40 + VLAN 30
  X710 p1   58:47:ca:76:6f:74 Ceph cluster network, 10.99.0.2/29

Links are addressed by kernel name rather than LinkAliasConfig, and both disks
by serial. Talos and Proxmox do not agree on NVMe enumeration order, and a
network document that fails to resolve takes the whole machine's DHCP with it.

Not applied by anything on merge — talos patches are applied by hand. Landing
it before the conversion so the file under review is the file that gets
installed.

Verified with talosctl validate --mode metal against v1.12.6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 22:12:11 +02:00
k.renner 269ccef5d2 Merge pull request 'chore(rook-ceph): drop k8s-2 from the storage fleet for saturn's conversion' (#586) from ceph-remove-k8s-2 into master
Reviewed-on: k.renner/homelab#586
2026-08-25 20:07:26 +00:00
claude-botandClaude Opus 5 ae6e013877 chore(rook-ceph): drop k8s-2 from the storage fleet for saturn's conversion
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
Removing the node from storage.nodes before purging its OSD is what makes the
purge stick. Under useAllNodes the operator rebuilt a purged OSD within one
reconcile and began backfilling onto the drive being replaced; listing nodes
explicitly exists so a node can be taken out first.

Re-add after the conversion naming the device by /dev/disk/by-id, as k8s-1
does. A bare device name is not safe on bare metal: Talos enumerates the three
NVMe drives in a different order than Proxmox, and nvme0n1 is the system disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 22:02:36 +02:00
k.renner 39533bce25 Merge pull request 'docs: runbook for converting a control-plane node to bare metal' (#584) from baremetal-conversion-runbook into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#584
2026-08-25 19:57:02 +00:00
claude-botandClaude Opus 5 5e80a11f15 docs: cite Proxmox's documented quorum recovery
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
pvecm expected 1 is the official recovery, documented verbatim in the storage
replication chapter of the Proxmox admin guide. Its caveat is about restoring
the missing node instead; here the missing node is deliberately gone for good,
so enforcing quorum is the correct branch rather than a workaround.

Also records that it must be run only once the node is alone -- corosync
rejects a value below the members it can see, which is why the attempt on the
current two-node cluster returned CS_ERR_INVALID_PARAM -- and that it is
runtime only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:56:35 +02:00
k.renner 4a2839b35f Merge pull request 'chore: bring immich back up, keep cephfs-metadata degraded until the end' (#585) from restore-after-jupiter into master
Reviewed-on: k.renner/homelab#585
2026-08-25 19:56:10 +00:00
claude-botandClaude Opus 5 5b47dc3271 chore: bring immich back up, keep cephfs-metadata degraded until the end
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
jupiter is converted and its OSD rebuilt, so the capacity constraint that took
immich down is gone with all three nodes back.

cephfs-metadata deliberately stays at size 2 / min_size 1. It has to be
degraded again for saturn and then neptune, and each transition re-replicates
the pool, so toggling it per node buys nothing but churn. Its comment said to
restore it once k8s-1 was back, which is now misleading about the plan; it
restores after the last conversion instead.

The immich comments lose their jupiter-specific framing — taking both
Deployments down is a pre-flight step for any single-node maintenance now, and
the runbook carries it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:53:33 +02:00
claude-botandClaude Opus 5 7c9fa97423 docs: runbook for converting a control-plane node to bare metal
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
Written from doing jupiter, with saturn and neptune still to go. The conversion
itself was straightforward; every failure came from state that outlived the VM,
so the runbook is weighted towards those rather than towards the happy path.

The three that cost real time:

A rebuilt node gets a new MAC and a new lease, and Ceph keeps its mon pinned to
the old address in the monmap, the mon-endpoints ConfigMap and the deployment's
--public-addr. The mon then cannot bind, the operator's next step wants to
restart a healthy mon, Ceph refuses because that would drop quorum, and it
retries every minute forever without reaching the storage reconcile. Fixing the
ConfigMap before the monmap and the deployment matters, or the operator
recreates the mon from the stale entry.

Talos stops running default DHCP for the whole machine as soon as one new-style
network document exists, and a LinkAliasConfig selector that matches nothing is
skipped silently. Together those turn a small config mistake into a node with no
address on any interface.

EPHEMERAL placement is honoured only on first provision, so it has to be right
in the first apply-config; afterwards it is ignored and relocating needs a wipe.

Also records the Proxmox quorum step that starts mattering at the second
conversion, the Secure Boot blocker, and that Talos and Proxmox enumerate the
same three NVMe drives in different orders — which is why the OSD is named by
by-id path rather than device name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:49:56 +02:00
k.renner 41e756107a Merge pull request 'feat(rook-ceph): re-add k8s-1, naming the PM983 by by-id path' (#580) from ceph-readd-k8s-1 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#580
2026-08-25 19:13:28 +00:00
claude-botandClaude Opus 5 ef6558fbb5 feat(rook-ceph): re-add k8s-1, naming the PM983 by by-id path
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
jupiter is back as a bare-metal Talos node, so its OSD returns to the storage
fleet on the new drive.

The device is named by /dev/disk/by-id rather than by bare device name, because
the bare name would be wrong here. Talos enumerates this machine's three NVMe
drives in a different order than Proxmox did, and nvme0n1 on k8s-1 is the
Kingston system disk:

  nvme0n1  50026B7382B48B86  Kingston  Talos system disk
  nvme1n1  2332E86B749A      Crucial   Talos EPHEMERAL (/var)
  nvme2n1  S437NX0M400426    PM983     this OSD

Rook would most likely have declined the system disk for carrying partitions,
but relying on that is luck rather than intent. The by-id path derives from
model and serial and does not depend on enumeration order.

k8s-2 and k8s-3 keep bare names: each guest sees exactly one block device,
the drive passed through from its host, so there is nothing to disambiguate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:12:28 +02:00
k.renner 7804dbe169 Merge pull request 'fix(k8s-1): address links by name, and give the node a rescue path' (#578) from k8s-1-network-fix into master
Reviewed-on: k.renner/homelab#578
2026-08-25 19:11:00 +00:00
claude-botandClaude Opus 5 e4883870b8 fix(k8s-1): address links by kernel name instead of by alias
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 first install of this node came up with no address on any interface and had
to be recovered from the console. This is the configuration that replaced it,
now running on jupiter.

The alias layer was the cause, but not for the reason it first appeared. Reading
the v1.12.6 source: aliases ARE supported as VLAN parents — link_config.go
resolves parent links through the alias map, and the field's own doc comment
says so. The structure was fine. What is not fine is the failure behaviour.

LinkAliasConfigController skips a selector that matches zero links with no log
of any kind, contradicting the doc comment on the type which claims an error is
returned. And permanent_addr comes from the ETHTOOL_GPERMADDR ioctl with no
fallback to the current address, so a driver that does not answer it yields an
empty string, an unmatched glob, and silence.

That alone would have been survivable. What made it total is that Talos stops
running default DHCP operators for the WHOLE machine as soon as a single
new-style link, VLAN or DHCP document exists — the gate is presence of a
document, not success of resolution. So the silent miss removed the only
configured path while the safety net was already disabled. There is no fail-open
option.

Aliases are worth having and should come back. The condition is that they are
verified rather than assumed: apply with `--mode=try`, which rolls back on its
own, and check that linkaliasspec exists and that permanentAddr is actually
populated before trusting it. A LinkConfig on the real kernel name alongside the
alias resolves regardless of whether the alias landed, and costs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:10:00 +02:00
k.renner 13a18b3a96 Merge pull request 'feat(k8s-1): put EPHEMERAL on the retired Crucial, not the system disk' (#575) from k8s-1-ephemeral-on-crucial into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#575
2026-08-25 18:33:09 +00:00
claude-botandClaude Opus 5 fe6d232b3a docs(k8s-1): correct the device counts and the second I226's name
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
Three NVMe drives are present, not two: the Crucial stays in the machine as
EPHEMERAL rather than being pulled.

The second I226 is enp90s0, not enp89s0. Adding that third drive renumbered
the PCI buses and renamed it, which is a concrete demonstration of why every
NIC in this file is matched by permanent MAC rather than by name or bus path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:30:04 +02:00
claude-botandClaude Opus 5 d99628053c feat(k8s-1): put EPHEMERAL on the retired Crucial, not the system disk
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 Crucial P3 that was this node's Ceph OSD stays in the machine rather than
being pulled, and takes over /var. At 87% of rated endurance it is finished as
an OSD, but everything EPHEMERAL holds — containerd images and snapshots, pod
logs, the Ceph mon store — is reconstructible, so the drive is consumable in
that role.

It also removes the constraint that keeps biting elsewhere: /var becomes
~931 GB instead of the 64 GB the VM-era zvol gave it. k8s-3 tripped
DiskPressure twice today the moment it absorbed another node's pods, with
image GC already at its floor and ~20 GB of unreclaimable overlayfs snapshots.
This node will not have that problem, and the same treatment is available to
saturn and neptune when they convert.

Talos only honours volume configuration when the volume does not yet exist, so
this must ship in the first apply-config; applied later it is silently ignored
and relocating EPHEMERAL then needs a disk wipe and a boot from install media.

The selector matches an exact serial. There are three NVMe drives in this
machine now and `disk.transport == 'nvme' && !system_disk` would match the
PM983 as well, which would hand Talos the OSD drive.

Verified with talosctl validate --mode metal against v1.12.6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:15:09 +02:00
k.renner 63fc1ce63e Merge pull request 'chore(immich): take server and machine-learning down for the jupiter window' (#570) from immich-window-scaledown into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#570
2026-08-25 15:36:48 +00:00
claude-botandClaude Opus 5 6c1f34e992 chore(immich): take server and machine-learning down for the jupiter window
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
With one of three nodes out for the bare-metal conversion, the remaining pair
cannot hold the workload: cluster CPU requests exceed two nodes' capacity by
roughly 200m once DaemonSets and unplaceable Ceph pods are discounted, and
k8s-3 sits ~5.8 GiB above the kubelet's eviction threshold with image GC
already at its floor.

immich is the right thing to stop rather than squeeze. machine-learning
reserves 500m against a 2m idle draw, but inference bursts hard, so cutting
its request would misrepresent what it needs the moment someone uploads
photos. Stopping it removes the reservation honestly, and takes its pods and
image pulls off k8s-3 for the duration.

The Postgres cluster stays up, so recovery is these two replica counts and
nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:36:09 +02:00
k.renner 9e9fdf44bd Merge pull request 'fix(rook-ceph): list OSD nodes explicitly and drop k8s-1' (#568) from ceph-exclude-k8s-1 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#568
2026-08-25 15:25:40 +00:00
claude-botandClaude Opus 5 b85107b95b fix(rook-ceph): list OSD nodes explicitly and drop k8s-1
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
useAllNodes makes it impossible to retire an OSD ahead of pulling its drive.
Purging osd.2 and deleting its deployment held for one reconcile: the operator
saw a node it was told to use, carrying a device with a BlueStore signature,
and rebuilt the OSD. Ceph then began backfilling onto the very drive being
replaced.

Listing nodes explicitly makes membership of the storage fleet a property of
this file rather than of which nodes happen to exist, so a node can be removed
before its disk is touched.

k8s-1 is out while jupiter's OSD is swapped for the PM983 and the node is
converted to bare metal. The cluster runs on two OSDs until it is re-added;
all pools are size 2 / min_size 1 and stay fully redundant across the pair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:24:47 +02:00
k.renner 2038cd34ae Merge pull request 'fix(crowdsec): request 50m CPU instead of the chart's 500m' (#567) from crowdsec-cpu-requests into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#567
2026-08-25 15:07:22 +00:00
48 changed files with 876 additions and 1781 deletions
-36
View File
@@ -128,42 +128,6 @@ steps:
- printf "Host *\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
- echo "$VAULT_PASS" > /tmp/.vault_pass && chmod 600 /tmp/.vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=/tmp/.vault_pass
- |
# Ensure Talos image exists on all Proxmox nodes via API.
#
# jupiter is NOT in this list: it runs Talos on bare metal and is no
# longer a Proxmox node, so the storage API returns nothing for it and
# the download-wait loop below would burn its full 60x10s before
# giving up. saturn and neptune still host k8s-2/k8s-3 as VMs and
# still need the nocloud image; bare-metal nodes boot the metal-amd64
# image from their own installed disk instead.
TALOS_VERSION="v1.12.6"
TALOS_SCHEMATIC="88e3752db032a08082972bd0adf16a80849e3870c3f95cf5b669936c5699e5ad"
TALOS_URL="https://factory.talos.dev/image/$TALOS_SCHEMATIC/$TALOS_VERSION/nocloud-amd64.raw"
TALOS_FILENAME="talos-$TALOS_VERSION-$(echo "$TALOS_SCHEMATIC" | cut -c1-12)-nocloud-amd64.raw"
API_TOKEN="$TF_VAR_proxmox_user!$TF_VAR_proxmox_token_id=$TF_VAR_proxmox_token_secret"
for node in saturn neptune; do
EXISTS=$(curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/content" \
-H "Authorization: PVEAPIToken=$API_TOKEN" 2>/dev/null | grep -c "$TALOS_FILENAME" || true)
if [ "$EXISTS" -gt 0 ]; then
echo "Talos image already exists on $node"
continue
fi
echo "Downloading Talos image to $node..."
curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/download-url" \
-H "Authorization: PVEAPIToken=$API_TOKEN" \
--data-urlencode "url=$TALOS_URL" \
--data-urlencode "content=import" \
--data-urlencode "filename=$TALOS_FILENAME" \
--max-time 600
echo "Waiting for download on $node..."
for i in $(seq 1 60); do
sleep 10
EXISTS=$(curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/content" \
-H "Authorization: PVEAPIToken=$API_TOKEN" 2>/dev/null | grep -c "$TALOS_FILENAME" || true)
if [ "$EXISTS" -gt 0 ]; then echo "Download complete on $node"; break; fi
done
done
- tofu init -upgrade -backend-config="access_key=$AWS_ACCESS_KEY_ID" -backend-config="secret_key=$AWS_SECRET_ACCESS_KEY"
- |
if [ -n "$$TOFU_STATE_RM" ]; then
@@ -253,22 +253,6 @@ endpoints:
{% endif %}
# Infrastructure
- name: Proxmox DNS (VIP)
group: infrastructure
url: 10.2.40.53
interval: 30s
dns:
query-name: k8s-1.srv.renner.uno
query-type: A
conditions:
- "[DNS_RCODE] == NOERROR"
- "[RESPONSE_TIME] < 500"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: PostgreSQL HA (VIP)
group: infrastructure
url: tcp://10.2.40.135:5432
-31
View File
@@ -1,31 +0,0 @@
# Proxmox DNS Server Configuration
# Proxmox API connection
proxmox:
host: "pve.renner.uno"
user: "root@pam"
# Token-based authentication (recommended)
token_name: "dns"
token_value: "your-token-secret-here"
# OR password-based authentication
# password: "your-password-here"
verify_ssl: false
# DNS server settings
dns_host: "0.0.0.0" # Listen on all interfaces
dns_port: 53 # Standard DNS port (requires root or CAP_NET_BIND_SERVICE)
# DNS suffix for VM/LXC hostnames
dns_suffix: ".renner.uno"
# TTL for DNS responses (in seconds)
dns_ttl: 300
# Cache settings
cache_ttl: 60 # How often to refresh the cache (in seconds)
# Upstream DNS server for non-Proxmox queries
upstream_dns: "10.2.40.1"
-15
View File
@@ -1,15 +0,0 @@
module proxmox-dns
go 1.21
require (
github.com/miekg/dns v1.1.58
gopkg.in/yaml.v3 v3.0.1
)
require (
golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/tools v0.17.0 // indirect
)
-16
View File
@@ -1,16 +0,0 @@
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-565
View File
@@ -1,565 +0,0 @@
package main
import (
"crypto/tls"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"gopkg.in/yaml.v3"
)
type Config struct {
Proxmox struct {
Host string `yaml:"host"`
User string `yaml:"user"`
TokenName string `yaml:"token_name"`
TokenValue string `yaml:"token_value"`
VerifySSL bool `yaml:"verify_ssl"`
} `yaml:"proxmox"`
DNSHost string `yaml:"dns_host"`
DNSPort int `yaml:"dns_port"`
DNSSuffix string `yaml:"dns_suffix"`
DNSTTL int `yaml:"dns_ttl"`
CacheTTL int `yaml:"cache_ttl"`
UpstreamDNS string `yaml:"upstream_dns"`
}
type DNSRecord struct {
IPv4 string
IPv6 string
Timestamp time.Time
}
type ProxmoxDNS struct {
config Config
cache map[string]*DNSRecord
cacheMux sync.RWMutex
client *http.Client
stopChan chan struct{}
nodes []string
nodesMux sync.RWMutex
}
func NewProxmoxDNS(configPath string) (*ProxmoxDNS, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config: %w", err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
// Set defaults
if config.DNSPort == 0 {
config.DNSPort = 53
}
if config.CacheTTL == 0 {
config.CacheTTL = 60
}
if config.DNSTTL == 0 {
config.DNSTTL = 300
}
if config.UpstreamDNS == "" {
config.UpstreamDNS = "10.2.40.1:53"
}
if !strings.Contains(config.UpstreamDNS, ":") {
config.UpstreamDNS += ":53"
}
return &ProxmoxDNS{
config: config,
cache: make(map[string]*DNSRecord),
nodes: []string{},
client: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
},
stopChan: make(chan struct{}),
}, nil
}
// isValidIPv6ForDNS checks if an IPv6 address is suitable for DNS records
func isValidIPv6ForDNS(ip net.IP) bool {
if ip == nil || ip.To16() == nil {
return false
}
// Reject loopback
if ip.IsLoopback() {
return false
}
// Reject link-local (fe80::/10) - THIS IS THE KEY FIX
if ip.IsLinkLocalUnicast() {
return false
}
// Reject multicast
if ip.IsMulticast() {
return false
}
// Reject unspecified
if ip.IsUnspecified() {
return false
}
// Accept global unicast and ULA (fc00::/7)
return true
}
func (p *ProxmoxDNS) queryProxmoxAPI(endpoint string) ([]byte, error) {
// Remove port from host if present
host := p.config.Proxmox.Host
if !strings.Contains(host, ":") {
host += ":8006"
}
url := fmt.Sprintf("https://%s/api2/json/%s", host, endpoint)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
auth := fmt.Sprintf("PVEAPIToken=%s!%s=%s",
p.config.Proxmox.User,
p.config.Proxmox.TokenName,
p.config.Proxmox.TokenValue)
req.Header.Set("Authorization", auth)
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}
func (p *ProxmoxDNS) discoverNodes() error {
data, err := p.queryProxmoxAPI("nodes")
if err != nil {
return fmt.Errorf("failed to discover nodes: %w", err)
}
var response struct {
Data []struct {
Node string `json:"node"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(data, &response); err != nil {
return fmt.Errorf("failed to parse nodes: %w", err)
}
p.nodesMux.Lock()
defer p.nodesMux.Unlock()
p.nodes = []string{}
for _, node := range response.Data {
if node.Status == "online" {
p.nodes = append(p.nodes, node.Node)
}
}
log.Printf("Discovered %d online nodes: %v", len(p.nodes), p.nodes)
return nil
}
func (p *ProxmoxDNS) refreshCache() {
log.Println("Refreshing DNS cache...")
// Discover nodes first
if err := p.discoverNodes(); err != nil {
log.Printf("Failed to discover nodes: %v", err)
return
}
newCache := make(map[string]*DNSRecord)
p.nodesMux.RLock()
nodes := append([]string{}, p.nodes...)
p.nodesMux.RUnlock()
for _, node := range nodes {
// Query VMs
p.refreshVMs(node, newCache)
// Query LXC containers
p.refreshLXC(node, newCache)
}
p.cacheMux.Lock()
p.cache = newCache
p.cacheMux.Unlock()
log.Printf("Cache refreshed: %d entries", len(newCache))
}
func (p *ProxmoxDNS) refreshVMs(node string, cache map[string]*DNSRecord) {
data, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/qemu", node))
if err != nil {
log.Printf("Failed to query VMs on %s: %v", node, err)
return
}
var response struct {
Data []struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(data, &response); err != nil {
log.Printf("Failed to parse VM data: %v", err)
return
}
for _, vm := range response.Data {
if vm.Status != "running" || vm.Name == "" {
continue
}
// Query agent network info
agentData, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/qemu/%d/agent/network-get-interfaces", node, vm.VMID))
if err != nil {
log.Printf("VM %s (ID %d): no guest agent data", vm.Name, vm.VMID)
continue
}
var agentResp struct {
Data struct {
Result []struct {
Name string `json:"name"`
IPAddresses []struct {
IPAddress string `json:"ip-address"`
IPAddressType string `json:"ip-address-type"`
} `json:"ip-addresses"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(agentData, &agentResp); err != nil {
continue
}
var ipv4, ipv6 string
for _, iface := range agentResp.Data.Result {
if iface.Name == "lo" {
continue
}
for _, ipAddr := range iface.IPAddresses {
if ipAddr.IPAddressType == "ipv4" && ipv4 == "" {
if !strings.HasPrefix(ipAddr.IPAddress, "127.") {
ipv4 = ipAddr.IPAddress
}
} else if ipAddr.IPAddressType == "ipv6" && ipv6 == "" {
ip := net.ParseIP(ipAddr.IPAddress)
if isValidIPv6ForDNS(ip) {
ipv6 = ipAddr.IPAddress
log.Printf(" VM %s: accepting IPv6 %s", vm.Name, ipv6)
} else if ip != nil {
log.Printf(" VM %s: rejecting IPv6 %s (link-local/loopback/multicast)", vm.Name, ipAddr.IPAddress)
}
}
}
// Stop after finding both on first non-lo interface
if ipv4 != "" && ipv6 != "" {
break
}
}
if ipv4 != "" || ipv6 != "" {
hostname := strings.ToLower(vm.Name)
// Strip DNS suffix from hostname if present
hostname = strings.TrimSuffix(hostname, p.config.DNSSuffix)
cache[hostname] = &DNSRecord{
IPv4: ipv4,
IPv6: ipv6,
Timestamp: time.Now(),
}
log.Printf("VM: %s -> IPv4=%s IPv6=%s", hostname, ipv4, ipv6)
}
}
}
func (p *ProxmoxDNS) refreshLXC(node string, cache map[string]*DNSRecord) {
data, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/lxc", node))
if err != nil {
log.Printf("Failed to query LXC on %s: %v", node, err)
return
}
var response struct {
Data []struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(data, &response); err != nil {
log.Printf("Failed to parse LXC data: %v", err)
return
}
for _, lxc := range response.Data {
if lxc.Status != "running" || lxc.Name == "" {
continue
}
// Query LXC interfaces
ifaceData, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/lxc/%d/interfaces", node, lxc.VMID))
if err != nil {
log.Printf("LXC %s (ID %d): failed to get interfaces", lxc.Name, lxc.VMID)
continue
}
var ifaceResp struct {
Data []struct {
Name string `json:"name"`
Inet string `json:"inet"`
Inet6 string `json:"inet6"`
} `json:"data"`
}
if err := json.Unmarshal(ifaceData, &ifaceResp); err != nil {
continue
}
var ipv4, ipv6 string
for _, iface := range ifaceResp.Data {
if iface.Name == "lo" {
continue
}
if iface.Inet != "" && ipv4 == "" {
// Format is "IP/CIDR"
parts := strings.Split(iface.Inet, "/")
if len(parts) > 0 && !strings.HasPrefix(parts[0], "127.") {
ipv4 = parts[0]
}
}
if iface.Inet6 != "" && ipv6 == "" {
parts := strings.Split(iface.Inet6, "/")
if len(parts) > 0 {
ip := net.ParseIP(parts[0])
if isValidIPv6ForDNS(ip) {
ipv6 = parts[0]
log.Printf(" LXC %s: accepting IPv6 %s", lxc.Name, ipv6)
} else if ip != nil {
log.Printf(" LXC %s: rejecting IPv6 %s (link-local/loopback/multicast)", lxc.Name, parts[0])
}
}
}
// Stop after finding both on first non-lo interface
if ipv4 != "" && ipv6 != "" {
break
}
}
if ipv4 != "" || ipv6 != "" {
hostname := strings.ToLower(lxc.Name)
// Strip DNS suffix from hostname if present
hostname = strings.TrimSuffix(hostname, p.config.DNSSuffix)
cache[hostname] = &DNSRecord{
IPv4: ipv4,
IPv6: ipv6,
Timestamp: time.Now(),
}
log.Printf("LXC: %s -> IPv4=%s IPv6=%s", hostname, ipv4, ipv6)
}
}
}
func (p *ProxmoxDNS) handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
m.Authoritative = true
for _, q := range r.Question {
name := strings.ToLower(strings.TrimSuffix(q.Name, "."))
qtype := dns.TypeToString[q.Qtype]
// Check if query is for our domain suffix
if !strings.HasSuffix(name, strings.TrimPrefix(p.config.DNSSuffix, ".")) {
// Forward to upstream
log.Printf("Query %s (%s) - forwarding to upstream", name, qtype)
p.forwardToUpstream(w, r)
return
}
// Extract hostname (remove suffix)
hostname := strings.TrimSuffix(name, p.config.DNSSuffix)
p.cacheMux.RLock()
record, found := p.cache[hostname]
p.cacheMux.RUnlock()
if !found {
// NXDOMAIN
m.SetRcode(r, dns.RcodeNameError)
log.Printf("Query %s (%s) - NXDOMAIN", name, qtype)
w.WriteMsg(m)
return
}
switch q.Qtype {
case dns.TypeA:
if record.IPv4 != "" {
rr := &dns.A{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: uint32(p.config.DNSTTL),
},
A: net.ParseIP(record.IPv4),
}
m.Answer = append(m.Answer, rr)
log.Printf("Query %s (A) -> %s", name, record.IPv4)
} else {
// NODATA (host exists but no A record)
m.Ns = append(m.Ns, p.getSOARecord())
log.Printf("Query %s (A) - NODATA", name)
}
case dns.TypeAAAA:
if record.IPv6 != "" {
// Parse and validate IPv6 again before returning
ip := net.ParseIP(record.IPv6)
if ip != nil && isValidIPv6ForDNS(ip) {
rr := &dns.AAAA{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET,
Ttl: uint32(p.config.DNSTTL),
},
AAAA: ip,
}
m.Answer = append(m.Answer, rr)
log.Printf("Query %s (AAAA) -> %s", name, record.IPv6)
} else {
// IPv6 is invalid (link-local etc)
m.Ns = append(m.Ns, p.getSOARecord())
log.Printf("Query %s (AAAA) - NODATA (invalid IPv6 in cache)", name)
}
} else {
// NODATA (host exists but no AAAA record)
m.Ns = append(m.Ns, p.getSOARecord())
log.Printf("Query %s (AAAA) - NODATA", name)
}
}
}
w.WriteMsg(m)
}
func (p *ProxmoxDNS) getSOARecord() dns.RR {
domain := strings.TrimPrefix(p.config.DNSSuffix, ".")
soa := &dns.SOA{
Hdr: dns.RR_Header{
Name: domain + ".",
Rrtype: dns.TypeSOA,
Class: dns.ClassINET,
Ttl: 300,
},
Ns: "ns." + domain + ".",
Mbox: "admin." + domain + ".",
Serial: uint32(time.Now().Unix()),
Refresh: 3600,
Retry: 600,
Expire: 86400,
Minttl: 300,
}
return soa
}
func (p *ProxmoxDNS) forwardToUpstream(w dns.ResponseWriter, r *dns.Msg) {
c := new(dns.Client)
c.Timeout = 2 * time.Second
resp, _, err := c.Exchange(r, p.config.UpstreamDNS)
if err != nil {
log.Printf("Failed to forward to upstream: %v", err)
m := new(dns.Msg)
m.SetRcode(r, dns.RcodeServerFailure)
w.WriteMsg(m)
return
}
w.WriteMsg(resp)
}
func (p *ProxmoxDNS) startCacheRefresher() {
ticker := time.NewTicker(time.Duration(p.config.CacheTTL) * time.Second)
defer ticker.Stop()
// Initial refresh
p.refreshCache()
for {
select {
case <-ticker.C:
p.refreshCache()
case <-p.stopChan:
return
}
}
}
func (p *ProxmoxDNS) Start() error {
// Start cache refresher
go p.startCacheRefresher()
// Start DNS server
dns.HandleFunc(".", p.handleDNSRequest)
addr := fmt.Sprintf("%s:%d", p.config.DNSHost, p.config.DNSPort)
log.Printf("Starting Proxmox DNS server on %s", addr)
log.Printf("Base domain: %s", p.config.DNSSuffix)
log.Printf("Upstream DNS: %s", p.config.UpstreamDNS)
server := &dns.Server{Addr: addr, Net: "udp"}
return server.ListenAndServe()
}
func main() {
configPath := flag.String("c", "/etc/proxmox-dns/config.yaml", "Path to configuration file")
flag.Parse()
server, err := NewProxmoxDNS(*configPath)
if err != nil {
log.Fatalf("Failed to create DNS server: %v", err)
}
log.Printf("Proxmox DNS Server starting...")
if err := server.Start(); err != nil {
log.Fatalf("Failed to start DNS server: %v", err)
}
}
Binary file not shown.
-27
View File
@@ -1,27 +0,0 @@
[Unit]
Description=Proxmox DNS Server
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/proxmox-dns
ExecStart=/usr/bin/python3 /opt/proxmox-dns/proxmox_dns.py -c /etc/proxmox-dns/config.yaml
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
+446
View File
@@ -0,0 +1,446 @@
# Runbook — converting a control-plane node from Proxmox VM to bare metal
Converts one MS-01 from "Proxmox host running a Talos VM" to "Talos on bare
metal", and swaps its Ceph OSD onto a Samsung PM983 at the same time. Written
after doing jupiter on 2026-08-25; saturn and neptune remain.
Everything here that reads like an over-specific warning is something that
actually happened. The conversion itself is straightforward — the failures were
all in state that outlived the VM.
## What the node ends up as
| | |
|---|---|
| System disk | Kingston OM8PGP41024Q-A0, selected by serial |
| EPHEMERAL (`/var`) | the retired Crucial P3, selected by serial |
| Ceph OSD | Samsung PM983, selected by `/dev/disk/by-id` |
| Management | VLAN 40 tagged on X710 port 0 |
| v30iot | VLAN 30 on the same port, named `ens19` |
| Ceph cluster net | X710 port 1, `10.99.0.x/29`, MTU 9000 |
## Before you start
- [ ] All three OSDs up, all mons in quorum, PGs `active+clean`. Do not start
from a degraded cluster; the window itself removes one of three.
- [ ] Cluster fits on two nodes. Check `kubectl describe node` allocation on the
two survivors. Take immich down (`kubernetes/apps/immich`, both
Deployments to `replicas: 0`) — it frees ~1000m against a shortfall of
roughly 200m and takes its image pulls off the remaining nodes.
- [ ] `cephfs-metadata` to size 2 / min_size 1 for the duration. At size 3 on
two hosts every PG is undersized with min_size 2, so one further OSD loss
blocks IO on every CephFS mount cluster-wide.
- [ ] Talos metal ISO on a USB stick. Schematic
`a21cec8f35ae32ab5e9a452e7b13500ca8776053930b8e182b9cb23eed186b64`
(`net.ifnames=1`, intel-ucode, thunderbolt; no qemu-guest-agent).
Verify by comparing partition offsets and sizes against the ISO with
`fdisk -l` — blkid labels lie after a rewrite.
- [ ] Physical access. Secure Boot must be turned off (see below), and that is
the one step nothing can do remotely.
## 1. Evacuate the OSD
```sh
ceph osd out <id>
```
Wait until no PGs are `backfilling` or `backfill_wait`. Do **not** wait for
`ceph osd safe-to-destroy` — pools at size 3 keep the OSD in their acting set
because three copies cannot be placed on two hosts, so it never returns clean.
Then remove it. The node must already be out of `cephcluster.yaml`'s
`storage.nodes` list, or the operator rebuilds the OSD within one reconcile and
starts backfilling onto the drive you are about to pull:
```sh
kubectl -n rook-ceph scale deployment rook-ceph-osd-<id> --replicas=0
ceph osd purge <id> --yes-i-really-mean-it
kubectl -n rook-ceph delete deployment rook-ceph-osd-<id>
```
## 2. Drain, and leave the cluster
```sh
kubectl drain <node> --ignore-daemonsets --delete-emptydir-data --timeout=300s
```
Single-replica workloads with `minAvailable: 1` PDBs will refuse eviction — that
is deliberate, they exist to surface the pod rather than let it be yanked. On
this cluster that is `influxdb-influxdb2-0`, `woodpecker-server` and the
CNPG primary. Move each one deliberately with `kubectl delete pod`; they
reschedule onto the survivors and their RBD volumes follow.
```sh
talosctl -n <node> reset --graceful --wipe-mode system-disk
kubectl delete node <node>
```
`--wipe-mode system-disk` and not the default `all`: the OSD drive is being
pulled anyway and wiping a terabyte of it is wasted time. `--graceful` leaves
etcd cleanly. Delete the Node object — it carries the `unschedulable` flag from
the drain, which the rebuilt node would otherwise inherit.
## 2a. Destroy the VM in OpenTofu — while the host is still a PVE node
Remove `opentofu/vm_15N_k8s_N.tf`, then apply. This **must** happen before the
host leaves the Proxmox cluster: afterwards the provider cannot reach the node,
the plan errors, and the only escape is `tofu state rm`, which is off the table.
Point `proxmox_api_url` at a host that will *survive* the conversion, not the one
being converted.
Manual triggers are currently broken by the Woodpecker v3.18.0 regression
(issue #582). Until that is resolved, run it locally:
```sh
AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=… AWS_REGION=fsn1 \
tofu -chdir=opentofu init -backend-config=<file>
… plan -target=module.k8s_N -var-file=<file> -out=<plan>
… apply <plan>
```
The `AWS_*` environment variables are required *in addition to*
`-backend-config`, or the S3 backend fails `HeadObject` with 403 even with
credentials that can read the object fine via the aws CLI.
## 2b. Proxmox quorum — matters from the second conversion onward
Two nodes remain after jupiter, with `expected_votes: 2` and quorum 2. Removing
one leaves the last node inquorate and `/etc/pve` read-only, which blocks the
final VM destroy. On the surviving node:
```sh
pvecm expected 1
```
This is the official recovery. Proxmox documents it in the storage replication
chapter (`/usr/share/pve-docs/chapter-pvesr.html`), verbatim:
> If you have no quorum, we strongly advise to fix this first and make the node
> operable again. Only if this is not possible at the moment, you may use the
> following command to enforce quorum on the current node: `# pvecm expected 1`
The caveat there is about restoring the missing node instead. Here the missing
node is deliberately gone for good, so enforcing quorum is the correct branch
rather than a workaround.
Run it only once the node is actually alone — attempting it while both nodes are
up fails with `CS_ERR_INVALID_PARAM`, since corosync will not set expected votes
below the members it can currently see. Which is exactly why it becomes valid
when one remains.
Runtime only; it does not survive a reboot, so re-run it if the survivor
restarts before its own conversion.
## 3. Hardware
Power the host off, then:
1. Kingston boot drive out of slot 1, into slot 2.
2. Crucial P3 out of slot 2 — it becomes EPHEMERAL, so put it in slot 3 rather
than setting it aside.
3. Slot 1 switch to **U.2**. Set it before the drive goes in; there is nothing
to record afterwards, the installed drive type implies the position.
4. PM983 onto the included 7mm M.2-to-U.2 adapter, into slot 1.
Boot the host's existing PVE once before touching Talos and check the drive:
```sh
nvme smart-log /dev/nvmeXn1 | grep -iE 'percentage_used|media_errors|critical'
nvme id-ns /dev/nvmeXn1 -H | grep -i 'in use' # want 512b data, 0 metadata
wipefs /dev/nvmeYn1 # Crucial: clear the old bluestore sig
ip link set <ceph-port> up && ethtool <ceph-port> | grep -i 'link detected'
```
This is the last revert point. A bad drive here means the Crucial goes back and
the VM is recreated from tofu with nothing lost. Once Talos writes to the
Kingston, PVE on that host is gone.
Relocating the boot drive is safe: `rpool` is referenced by NVMe EUI and the
UEFI entries by GPT PARTUUID, neither of which encodes a slot.
## 4. Secure Boot
MS-01 ships with Secure Boot on, and Proxmox satisfies it with a
Microsoft-signed shim. The Image Factory `metal-amd64.iso` is an unsigned UKI
and the firmware rejects it outright. **Turn Secure Boot off in the BIOS.**
A signed variant exists per schematic, but it is signed with SideroLabs' key
which firmware does not trust by default, and the installed system would have to
use the secureboot installer image too. That is a separate project.
## 5. Install
Boot the stick. Maintenance mode DHCPs untagged on every link; on these hosts
that lands on `192.168.8.x`, which is routable from the workstation. It DHCPs on
*every* link, so one machine answers on several addresses at once — they are the
same host, not several hosts. `get disks` on each confirms it.
Maintenance mode does serve `talosctl get`. `--insecure` is a flag on the
subcommand rather than a global, so it must come *after* `get` — placed before
it, cobra resolves to the legacy `talosctl disks` command and reports
`unknown flag: --insecure`:
```sh
talosctl -n <maintenance-ip> get disks --insecure
talosctl -n <maintenance-ip> get links --insecure
```
Verify serials and MACs here, on the machine itself, against the config about to
be applied.
Build the config from the **live cluster's secrets**, not a fresh
`talosctl gen config`, or the node forms a new cluster instead of joining:
```sh
talosctl -n <surviving-node> get mc v1alpha1 -o jsonpath='{.spec}' > cp.yaml
talosctl gen secrets --from-controlplane-config cp.yaml -o secrets.yaml
talosctl gen config homelab https://k8s.renner.uno:6443 \
--with-secrets secrets.yaml --output-types controlplane \
--config-patch @talos/patches/cluster.yaml \
--config-patch @<cilium additive half> \
--config-patch @talos/patches/registry-mirror.yaml \
--config-patch @talos/patches/k8s-N.yaml -o install.yaml
```
Confirm `cluster.id` matches the running cluster before applying.
Strip the generated `HostnameConfig` document — it collides with
`machine.network.hostname` from the per-node patch. `bootstrap.sh` does the same.
`cilium-cni.yaml` cannot be passed whole: its `$patch: delete` document fails
against a base with no `inlineManifests` (issue #581). Use only the additive
second document.
Validate:
```sh
talosctl validate --config install.yaml --mode metal
```
### Install minimal, complete on the running node
Do **not** apply the full per-node config in maintenance mode. A document that
references an interface which does not exist yet hangs the install: the node
sits at `STAGE: Installing` indefinitely with `KUBELET n/a`, `APISERVER n/a`,
and the console scrolling
```
k8s.NodeApplyController: error getting node: nodes "k8s-N" not found
```
That message is a symptom of an install that never finished, not a Kubernetes or
networking fault — the node holds a DHCP address and is reachable throughout. On
saturn the offenders were the `ens19` VLAN document and the sysctl naming it,
`net.ipv6.conf.ens19.disable_ipv6`. Three cold installs hung on it; the identical
config applied cleanly once the node was up. Wiping disks makes no difference —
it is not leftover state.
**Stage 1** — the smallest config that installs and joins, plus `EPHEMERAL`,
which is one-shot (section 7) and so cannot be deferred to stage 2:
```
machine.network.hostname, machine.install.diskSelector, certSANs, nodeLabels
LinkConfig <uplink>
VLANConfig mgmt0
DHCPv4Config mgmt0
VolumeConfig EPHEMERAL
```
```sh
talosctl apply-config --insecure -n <maintenance-ip> --file install-stage1.yaml
```
**Stage 2** — once the node is `Ready` and `talosctl etcd members` lists it,
apply the full config to the running node. `--mode=try` reverts on its own if the
network config is wrong, so it cannot strand the node; re-apply with
`--mode=auto` to persist.
```sh
talosctl -e <node-ip> -n <node-ip> apply-config --mode=try --timeout=3m --file install.yaml
talosctl -e <node-ip> -n <node-ip> apply-config --mode=auto --file install.yaml
```
Both applied without a reboot on saturn. Confirm `ens19`, `mgmt0` and the Ceph
link are up afterwards with `talosctl get links` and `get addresses`.
## 6. The network config will strand the node if it is wrong
Talos stops running default DHCP operators **for the entire machine** as soon as
a single new-style network document exists — the gate is presence of a document,
not success of resolution. If the configured path fails to materialise there is
no fallback on any interface, and recovery needs a console and install media.
Concretely: do not use `LinkAliasConfig` as a `VLANConfig` parent without
verifying it. Aliases *are* supported (confirmed in v1.12.6 source and by a
maintainer), but a selector matching zero links is skipped **silently**, and
`permanent_addr` comes from an ethtool ioctl with no fallback — if the driver
does not answer, the match is against an empty string and everything downstream
that references the alias does nothing.
Address links by predictable kernel name. To introduce aliases later, do it on a
node that is already up, with `talosctl apply-config --mode=try --timeout=5m`,
which rolls back on its own, and check `talosctl get linkaliasspec`.
Interface names derive from PCI topology and are stable — but not immune.
Adding a third NVMe to jupiter renumbered the buses and renamed the unused
second I226 from `enp89s0` to `enp90s0`.
## 7. EPHEMERAL placement is one-shot
```yaml
apiVersion: v1alpha1
kind: VolumeConfig
name: EPHEMERAL
provisioning:
diskSelector:
match: disk.serial == '<crucial serial>'
minSize: 50GiB
grow: true
```
Talos applies volume configuration **only when the volume does not yet exist**.
Applied afterwards it is silently ignored, and relocating EPHEMERAL then needs a
disk wipe and a fresh boot from install media. It must be in the first
`apply-config`.
Match by serial. `disk.transport == 'nvme' && !system_disk` matches the PM983 as
well, and losing that coin flip formats the OSD drive.
Verify afterwards:
```sh
talosctl -n <node> get volumestatus EPHEMERAL -o yaml | grep -E 'location|phase'
talosctl -n <node> get disks
```
## 8. The mon will not come back on its own
**This is the step that cost the most time on jupiter.** The rebuilt node has a
new MAC and therefore a new DHCP lease, but Ceph has its mon pinned to the old
address in three places. The mon cannot bind an address the host no longer has,
so it crashloops; the operator then tries to restart a *healthy* mon as its next
step, Ceph refuses because that would drop quorum, and it retries every 60
seconds forever without ever reaching the storage reconcile. Nothing else
progresses behind it.
Fix in this order — the order matters, because doing it the other way round has
the operator faithfully recreate the mon from the stale ConfigMap:
```sh
# 1. ConfigMap first
kubectl -n rook-ceph edit cm rook-ceph-mon-endpoints
# remove the dead mon from `data` and from `mapping`
# 2. then the monmap
ceph mon remove <id>
# 3. then the deployment — check it really carries the old address
kubectl -n rook-ceph get deploy rook-ceph-mon-<id> -o yaml | grep public-addr
kubectl -n rook-ceph delete deploy rook-ceph-mon-<id>
```
Rook then creates a **new** mon with the next free ID (jupiter's `c` came back
as `d`), on the correct address. It does not repair the old one.
Note mons use host ports under `network.provider: host`, so a replacement mon
cannot schedule while the broken one's deployment still holds 3300/6789 — the
canary sits `Pending` with "didn't have free ports" until the old deployment is
deleted.
## 9. Re-add the OSD
Add the node back to `cephcluster.yaml` under `storage.nodes`, naming the PM983
by `/dev/disk/by-id`:
```yaml
- name: k8s-N
devices:
- fullpath: /dev/disk/by-id/nvme-SAMSUNG_MZQLB960HAJR-00007_<serial>
```
**Not** a bare device name. Talos enumerates these drives in a different order
than Proxmox did — on jupiter `nvme0n1` is the Kingston *system* disk, and the
PM983 is `nvme2n1`. Rook would probably decline a partitioned system disk, but
that is luck rather than design.
Get the path from `talosctl -n <node> get disk <dev> -o yaml`, under `symlinks`.
## 10. Verify and restore
```sh
kubectl get nodes # Ready
talosctl -n <node> etcd members # 3 members
ceph status # 3 mons in quorum, 3 osds
ceph osd tree # OSD under the right zone/host
ceph device ls # PM983 backing the new OSD
```
Then undo the window changes: `cephfs-metadata` back to size 3, immich back to
1 replica, and `ceph config set osd osd_mclock_profile balanced` if it was
raised to `high_recovery_ops` to speed the evacuation.
The retired Crucial leaves a stale entry in `ceph device ls`. Remove it with
`ceph config-key rm device/<devid>`; the mgr's devicehealth view may lag.
## Known-stale things that will bite
- `bootstrap.sh` cannot generate a config at all right now (issue #581), so a
cold rebuild is broken independently of this work.
- Manual Woodpecker pipelines are broken (issue #582), so anything gated on
`PIPELINE == "..."` must be run another way.
- Cilium's eBPF datapath drops VLAN-tagged traffic unless `bpf.vlanBypass` is
set. **This happened** — every v30iot macvlan workload on a converted node
stayed in sandbox creation until `vlanBypass: [30]` was added (issue #592).
Only `ens19` is affected, and the reason is worth knowing: Cilium attaches to
devices that carry an address. `cilium-dbg status --verbose` lists `mgmt0`
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.
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
available` and `network is down` rather than a DHCP timeout, and the pod sits
in `ContainerCreating` behind `plugin type="macvlan" failed (add): error
calling DHCP.Allocate: no more tries`. Confirm it with
`cilium_drop_count_total{reason=~"VLAN.*"}` per node rather than by reading
the CNI logs, and check `node_network_receive_packets_total{device="ens19"}`
— it reads 0 on an affected node.
- The conversion drops the node's global IPv6 on `mgmt0`, and nothing in the
config says so. **This happened** (issue #603). Pods get only a ULA
(`fd10:244::/48`) and reach global IPv6 by being masqueraded to the node's
global address, so a node without one silently loses outbound IPv6 for every
pod scheduled onto it. The only in-cluster symptom is one line at Cilium
agent startup — `k8s-1` and `k8s-2` log it, `k8s-3` does not:
```
level=warn msg="IPv6 is enabled, but Cilium cannot find the IPv6 address
for this node" module=agent.controlplane.local-node-sync
```
It surfaced as `NetbirdRelayMemberDown` plus both v6 `blackbox-edge-wan`
probes going red the moment the blackbox exporter was rescheduled onto a
converted node. The relays themselves were healthy throughout.
Settle where the address comes from before converting the last VM. It
decides the fix and is not visible through kubectl — nodes publish only a
v4 `InternalIP`:
```sh
talosctl -n <unconverted> get addresses # while a VM is still up
talosctl -n <converted> get addresses
```
DHCPv6 means the converted nodes need a `DHCPv6Config` document for `mgmt0`
beside `DHCPv4Config`; SLAAC means they need RA acceptance instead and
`DHCPv6Config` alone would not help. Legacy `dhcp: true` requests DHCPv4
only, so SLAAC is the likelier source. Validate either with
`talosctl apply-config --mode=try`, per section 6.
Converting the last VM before this is settled leaves no IPv6 path at all:
`proxy-1` is the v6-only edge/relay member and goes from unmonitorable to
genuinely unreachable.
+1 -16
View File
@@ -19,7 +19,7 @@ metadata:
# Opaque-label map (maintainer reference only — NOT served publicly):
# services group infrastructure group
# Web Service 1 = Authentik Infrastructure 1 = PBS 2 (pbs2.renner.uno)
# Web Service 2 = Gitea Infrastructure 2 = Proxmox VE (pve.renner.uno)
# Web Service 2 = Gitea Infrastructure 2 = retired (was Proxmox VE)
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.40.1)
# Web Service 4 = Woodpecker CI Infrastructure 4 = Mosquitto MQTT broker
# Web Service 5 = Grafana
@@ -206,21 +206,6 @@ data:
conditions:
- "[STATUS] == any(200, 301, 302)"
- name: Infrastructure 2
group: infrastructure
url: https://pve.renner.uno
interval: 60s
client:
insecure-skip-verify: true
ui:
hide-hostname: true
hide-url: true
hide-port: true
hide-conditions: true
hide-errors: true
conditions:
- "[STATUS] == any(200, 301, 302)"
- name: Infrastructure 3
group: infrastructure
url: 10.2.40.1
@@ -6,6 +6,13 @@ metadata:
labels:
app.kubernetes.io/name: immich-machine-learning
spec:
# Scale this and immich-server to 0 for the duration of any single-node
# maintenance — cluster CPU requests exceed what two nodes can hold, and this
# pair frees ~1000m against a shortfall of roughly 200m. Stopping them is the
# right lever rather than lowering their requests: 500m reserved against a
# 2m idle draw looks like waste, but inference bursts hard and the request is
# honest about what it needs when someone uploads photos.
# See RUNBOOK-baremetal-conversion.md.
replicas: 1
strategy:
type: RollingUpdate
+3
View File
@@ -36,6 +36,9 @@ metadata:
labels:
app.kubernetes.io/name: immich-server
spec:
# Scaled to 0 alongside immich-machine-learning during single-node
# maintenance; see the note there. The CNPG Postgres cluster stays running,
# so recovery is just these two replica counts.
replicas: 1
strategy:
type: Recreate
@@ -2,7 +2,6 @@
# backend serving HTTPS with a self-signed / hostname-mismatched cert:
#
# apps/file — NAS web UI on a self-signed cert
# apps/pve — PVE on :8006 with per-node self-signed cert
#
# Replaces the nginx `backend-protocol: HTTPS` + `proxy-ssl-verify: off`
# annotation pair. Each Ingress that wants this behaviour sets
-1
View File
@@ -7,7 +7,6 @@ resources:
- file/
- gitea/
- ical-proxy/
- pve/
# woodpecker/ moved to its own dedicated `woodpecker` Kustomization
# (clusters/homelab/woodpecker.yaml) so its wp-pg CNPG cluster's barman
# dependency + Cluster health-gating don't contaminate this shared apps KS.
@@ -83,9 +83,6 @@ configMapGenerator:
- name: dashboard-power-flow
files:
- power-flow.json
- name: dashboard-proxmox-cluster
files:
- proxmox-cluster.json
- name: dashboard-pv-system-roi
files:
- pv-system-roi.json
@@ -1,239 +0,0 @@
{
"annotations": {"list": []},
"description": "Proxmox VE Cluster Overview",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 3}]}},
"overrides": []
},
"gridPos": {"h": 4, "w": 6, "x": 0, "y": 0},
"id": 1,
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(count by (id) (pve_up{id=~\"node/.*\"} == 1))", "refId": "A"}],
"title": "Online Nodes",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "blue", "value": null}]}},
"overrides": []
},
"gridPos": {"h": 4, "w": 6, "x": 6, "y": 0},
"id": 2,
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(count by (id) (pve_guest_info{type=\"qemu\", template=\"0\"}))", "refId": "A"}],
"title": "Total VMs",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "purple", "value": null}]}},
"overrides": []
},
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
"id": 3,
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(count by (id) (pve_guest_info{type=\"lxc\"}))", "refId": "A"}],
"title": "Total LXC Containers",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}},
"overrides": []
},
"gridPos": {"h": 4, "w": 5, "x": 15, "y": 0},
"id": 4,
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(count by (id) (pve_up{id=~\"(qemu|lxc)/.*\"} == 1))", "refId": "A"}],
"title": "Running Guests",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [{"options": {"0": {"color": "green", "index": 0, "text": "All Backed Up"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 1}, {"color": "red", "value": 5}]}},
"overrides": []
},
"gridPos": {"h": 4, "w": 4, "x": 20, "y": 0},
"id": 12,
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max(pve_not_backed_up_total)", "refId": "A"}],
"title": "Not Backed Up",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 60}, {"color": "red", "value": 80}]},
"unit": "percent"
},
"overrides": []
},
"gridPos": {"h": 6, "w": 8, "x": 0, "y": 4},
"id": 5,
"options": {"displayMode": "lcd", "minVizHeight": 10, "minVizWidth": 0, "orientation": "horizontal", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showUnfilled": true},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_cpu_usage_ratio{id=~\"node/.*\"}) * 100", "legendFormat": "{{id}}", "refId": "A"}],
"title": "Node CPU Usage",
"type": "bargauge"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 70}, {"color": "red", "value": 85}]},
"unit": "percent"
},
"overrides": []
},
"gridPos": {"h": 6, "w": 8, "x": 8, "y": 4},
"id": 6,
"options": {"displayMode": "lcd", "minVizHeight": 10, "minVizWidth": 0, "orientation": "horizontal", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showUnfilled": true},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_memory_usage_bytes{id=~\"node/.*\"}) / max by (id) (pve_memory_size_bytes{id=~\"node/.*\"}) * 100", "legendFormat": "{{id}}", "refId": "A"}],
"title": "Node Memory Usage",
"type": "bargauge"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 70}, {"color": "red", "value": 85}]},
"unit": "percent"
},
"overrides": []
},
"gridPos": {"h": 6, "w": 8, "x": 16, "y": 4},
"id": 7,
"options": {"displayMode": "lcd", "minVizHeight": 10, "minVizWidth": 0, "orientation": "horizontal", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showUnfilled": true},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_disk_usage_bytes{id=~\"storage/.*\"}) / max by (id) (pve_disk_size_bytes{id=~\"storage/.*\"}) * 100", "legendFormat": "{{id}}", "refId": "A"}],
"title": "Storage Usage",
"type": "bargauge"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "percent"
},
"overrides": []
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 10},
"id": 8,
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_cpu_usage_ratio{id=~\"node/.*\"}) * 100", "legendFormat": "{{id}}", "refId": "A"}],
"title": "Node CPU Over Time",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 10},
"id": 9,
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_memory_usage_bytes{id=~\"node/.*\"})", "legendFormat": "{{id}}", "refId": "A"}],
"title": "Node Memory Usage Over Time",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {"align": "auto", "cellOptions": {"type": "auto"}, "inspect": false},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "text", "value": null}]}
},
"overrides": [
{"matcher": {"id": "byName", "options": "Status"}, "properties": [{"id": "custom.cellOptions", "value": {"type": "color-background"}}, {"id": "mappings", "value": [{"options": {"0": {"color": "red", "index": 1, "text": "Stopped"}, "1": {"color": "green", "index": 0, "text": "Running"}}, "type": "value"}]}]},
{"matcher": {"id": "byName", "options": "CPU %"}, "properties": [{"id": "unit", "value": "percent"}, {"id": "custom.cellOptions", "value": {"mode": "gradient", "type": "gauge"}}, {"id": "min", "value": 0}, {"id": "max", "value": 100}]},
{"matcher": {"id": "byName", "options": "Memory %"}, "properties": [{"id": "unit", "value": "percent"}, {"id": "custom.cellOptions", "value": {"mode": "gradient", "type": "gauge"}}, {"id": "min", "value": 0}, {"id": "max", "value": 100}]}
]
},
"gridPos": {"h": 10, "w": 24, "x": 0, "y": 18},
"id": 10,
"options": {"footer": {"fields": "", "reducer": ["sum"], "show": false}, "showHeader": true, "sortBy": [{"desc": false, "displayName": "Name"}]},
"targets": [
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id, name, node, type) (pve_guest_info)", "format": "table", "instant": true, "refId": "A"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_cpu_usage_ratio{id=~\"(qemu|lxc)/.*\"}) * 100", "format": "table", "instant": true, "refId": "B"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_memory_usage_bytes{id=~\"(qemu|lxc)/.*\"}) / max by (id) (pve_memory_size_bytes{id=~\"(qemu|lxc)/.*\"}) * 100", "format": "table", "instant": true, "refId": "C"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_up{id=~\"(qemu|lxc)/.*\"})", "format": "table", "instant": true, "refId": "D"}
],
"title": "Guest Status",
"transformations": [
{"id": "seriesToColumns", "options": {"byField": "id"}},
{"id": "organize", "options": {"excludeByName": {"Time": true, "Time 1": true, "Time 2": true, "Time 3": true, "Time 4": true, "Value #A": true}, "indexByName": {"id": 0, "name": 1, "node": 2, "type": 3, "Value #D": 4, "Value #B": 5, "Value #C": 6}, "renameByName": {"Value #B": "CPU %", "Value #C": "Memory %", "Value #D": "Status", "id": "ID", "name": "Name", "node": "Node", "type": "Type"}}}
],
"type": "table"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "Bps"
},
"overrides": []
},
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 28},
"id": 11,
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (rate(pve_disk_read_bytes_total{id=~\"(qemu|lxc)/.*\"}[5m]))", "legendFormat": "{{id}} read", "refId": "A"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (rate(pve_disk_written_bytes_total{id=~\"(qemu|lxc)/.*\"}[5m]))", "legendFormat": "{{id}} write", "refId": "B"}
],
"title": "Guest Disk I/O",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 38,
"style": "dark",
"tags": ["homelab", "proxmox", "virtualization"],
"templating": {"list": []},
"time": {"from": "now-1h", "to": "now"},
"timepicker": {},
"timezone": "",
"title": "Proxmox VE Cluster",
"uid": "proxmox-cluster",
"version": 1,
"weekStart": ""
}
@@ -93,68 +93,8 @@ spec:
resources:
requests:
storage: 50Gi
# Scrape existing Proxmox infrastructure targets
# Additional scrape targets not covered by ServiceMonitors.
additionalScrapeConfigs:
# pve-exporter is a blackbox-style exporter: Prometheus passes
# ?target=<host> via relabel, and one in-cluster exporter
# instance queries each PVE node's API on the user's behalf.
# The targets list is the PVE node addresses; the actual
# scrape goes to pve-exporter.monitoring.svc:9221.
#
# Cluster collectors (pve_up, pve_guest_info, …) return the SAME
# cluster-wide resource set no matter which node answers, so they
# are scraped from a single node only. Scraping them from all three
# (the pre-2026-07 config) ingested every node/guest series 3x —
# identical `id`, differing `instance` — a real TSDB cardinality
# cost that dashboards had to paper over with max by (id).
#
# Target the pve Service (apps ns) rather than a hardcoded node IP:
# its EndpointSlice load-balances the PVE API across all three nodes,
# so cluster metrics keep flowing as long as ANY node is up, instead
# of pausing whenever .201 (jupiter) specifically is down. The
# exporter reaches the API on 8006 (verify_ssl:false, so the LB'd
# cert hostname mismatch is a non-issue).
- job_name: pve
static_configs:
- targets:
- "pve.apps.svc.cluster.local"
metrics_path: /pve
params:
cluster: ["1"]
node: ["0"]
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: pve-exporter.monitoring.svc.cluster.local:9221
# Node collectors (pve_version_info, replication status) are
# node-local and genuinely distinct per target, so every node is
# scraped — this is the split recommended by prometheus-pve-exporter
# upstream to avoid the cluster-collector duplication above.
# Targets are the node FQDNs, not raw IPs: the instance label then
# reads jupiter/neptune/saturn.renner.uno, and a renumbered node is
# picked up from DNS without editing this file.
- job_name: pve-node
static_configs:
- targets:
- "jupiter.renner.uno"
- "neptune.renner.uno"
- "saturn.renner.uno"
metrics_path: /pve
params:
cluster: ["0"]
node: ["1"]
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: pve-exporter.monitoring.svc.cluster.local:9221
# Gitea exposes /metrics anonymously on its main HTTP port. The
# `gitea_*` metrics power the gitea-overview dashboard. Gitea now runs
# in-cluster (StatefulSet in apps ns; LXC 114 retired), so scrape the
@@ -3,8 +3,6 @@ kind: Kustomization
resources:
- grafana-oauth-externalsecret.yaml
- alertmanager-smtp-externalsecret.yaml
- pve-exporter-config-externalsecret.yaml
- pve-exporter-deployment.yaml
- helmrelease-prometheus.yaml
- helmrelease-loki.yaml
- helmrelease-alloy-syslog.yaml
@@ -1,17 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: pve-exporter-config
namespace: monitoring
spec:
# 10m (was 1h) so an automated token rotation propagates within ~10m; Reloader
# then restarts pve-exporter. See kubernetes/infrastructure/openbao/rotation/pve-exporter.yaml.
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: pve-exporter-config # keeps the old Secret's name — consumers unchanged
dataFrom:
- extract:
key: apps/monitoring/pve-exporter-config
@@ -1,84 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: pve-exporter
namespace: monitoring
annotations:
# Reloader restarts pve-exporter when pve-exporter-config changes (token
# rotation) so it reloads pve.yml. See
# kubernetes/infrastructure/openbao/rotation/pve-exporter.yaml.
secret.reloader.stakater.com/reload: pve-exporter-config
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: pve-exporter
template:
metadata:
labels:
app.kubernetes.io/name: pve-exporter
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: pve-exporter
image: prompve/prometheus-pve-exporter:3.10.0 # {"$imagepolicy": "flux-system:pve-exporter"}
# Image ENTRYPOINT is already `pve_exporter`; pass flags only.
args:
- --config.file=/etc/pve-exporter/pve.yml
- --web.listen-address=0.0.0.0:9221
ports:
- name: http
containerPort: 9221
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 30
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /etc/pve-exporter
readOnly: true
# Python needs a writable temp dir; the rootfs is read-only.
- name: tmp
mountPath: /tmp
volumes:
- name: config
secret:
secretName: pve-exporter-config
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
metadata:
name: pve-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: pve-exporter
spec:
selector:
app.kubernetes.io/name: pve-exporter
ports:
- name: http
port: 9221
targetPort: http
@@ -25,8 +25,11 @@ spec:
# replaces the former hostNetwork attachment (see
# network-attachment.yaml) so the NODE no longer holds an address
# on the compromised IoT VLAN; MA gets its own leased IoT IP on
# net1 instead. master: ens19 is the v30iot NIC on every k8s-{1,2,3}/edge
# worker.
# 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
# bpf.vlanBypass to carry it (issue #592).
k8s.v1.cni.cncf.io/networks: music-assistant-iot
spec:
enableServiceLinks: false
-33
View File
@@ -1,33 +0,0 @@
# PVE node IPs are hardcoded here despite the general "IPs aren't static"
# rule for the homelab: corosync requires stable cluster member identity,
# so jupiter/neptune/saturn keep .201/.202/.203 across reboots and PVE
# upgrades. If a node is permanently renumbered, edit this file.
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: pve-nodes
namespace: apps
labels:
kubernetes.io/service-name: pve
app.kubernetes.io/name: pve
addressType: IPv4
ports:
- name: https
port: 8006
protocol: TCP
endpoints:
- addresses:
- 10.2.40.201
hostname: jupiter
conditions:
ready: true
- addresses:
- 10.2.40.202
hostname: neptune
conditions:
ready: true
- addresses:
- 10.2.40.203
hostname: saturn
conditions:
ready: true
-48
View File
@@ -1,48 +0,0 @@
# PVE (Proxmox cluster UI) on Traefik. Replaced the standard Ingress
# pattern because PVE's backend is per-node HTTPS on :8006 with a
# self-signed cert that has no IP SAN — Traefik's kubernetesIngress
# provider's service.serverstransport annotation doesn't resolve the
# apps/insecure-backend ServersTransport CRD, so TLS verification ran
# and rejected. IngressRoute's native services[].serversTransport field
# binds directly with no provider-qualifier guessing.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: pve-tls
namespace: apps
labels:
app.kubernetes.io/name: pve
spec:
secretName: pve-tls
issuerRef:
name: letsencrypt-cluster
kind: ClusterIssuer
dnsNames:
- pve.renner.uno
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: pve
namespace: apps
labels:
app.kubernetes.io/name: pve
annotations:
# Traefik does not populate IngressRoute.status.loadBalancer, so
# external-dns-unifi (traefik-proxy source) has no implicit target.
# Pin to the traefik Service VIP (kubernetes/infrastructure/traefik
# service.annotations io.cilium/lb-ipam-ips).
external-dns.alpha.kubernetes.io/target: "10.2.99.86,fd99::86"
spec:
entryPoints:
- websecure
routes:
- match: Host(`pve.renner.uno`)
kind: Rule
services:
- name: pve
port: 8006
scheme: https
serversTransport: insecure-backend
tls:
secretName: pve-tls
-6
View File
@@ -1,6 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- service.yaml
- endpointslice.yaml
- ingressroute.yaml
-15
View File
@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: pve
namespace: apps
labels:
app.kubernetes.io/name: pve
spec:
# No selector: endpoints managed manually via the sibling EndpointSlice
# so ingress-nginx fans out across the three Proxmox cluster members.
ports:
- name: https
port: 8006
targetPort: 8006
protocol: TCP
@@ -33,6 +33,24 @@ spec:
remediation:
retries: 3
values:
# Roll the agent DaemonSet when cilium-config changes. Cilium reads
# datapath options once, at agent start, and the chart puts no config
# checksum on the pod template — so a changed ConfigMap leaves every agent
# running the previous datapath until something restarts it. Nothing did.
#
# This is not hypothetical. vlan-bpf-bypass reached cilium-config and every
# v30iot macvlan workload stayed broken until the agents were deleted by
# hand, one node at a time (issue #592). policyMapMax has the same
# exposure, and its comment below says "takes effect on agent restart" —
# documentation standing in for automation.
#
# The trade: any cilium-config change now rolls the CNI cluster-wide,
# node by node. That is disruptive-ish, but it is what a datapath change
# already required — the difference is that it now happens on its own
# instead of being forgotten. Reloader is already deployed for exactly
# this pattern; named per-ConfigMap rather than blanket, as elsewhere.
annotations:
configmap.reloader.stakater.com/reload: cilium-config
# Which pod labels feed security-identity computation. Patterns are
# start-anchored regexes; a leading "!" excludes while keeping the default
# set. Woodpecker stamps every step pod with woodpecker-ci.org/task-uuid
@@ -73,6 +91,36 @@ spec:
# kubernetes/talos/cilium.yaml carries a third copy of the same key.
# Nothing enforces any of this; grep bpf-policy-map-max before changing it.
policyMapMax: 65536
# VLAN ids the eBPF datapath is allowed to pass. Without this it drops
# VLAN-tagged frames, accounted as "VLAN traffic disallowed by VLAN
# filter" in cilium_drop_count_total.
#
# 30 is v30iot, the macvlan parent for the shelly-manager, matter-server,
# otbr and music-assistant attachments. On bare metal it is a VLAN
# sub-interface of enp2s0f0np0; inside the former Proxmox guests it was
# an untagged virtio NIC, which is why this was not needed before.
#
# VLAN 40 (mgmt0) is deliberately NOT listed, and the asymmetry is the
# interesting part. Cilium attaches to devices that carry an address —
# `cilium-dbg status --verbose` lists enp87s0, enp2s0f0np0, enp2s0f1np1
# and mgmt0, but NOT ens19. mgmt0 holds the node IP, so Cilium owns it as
# a device and sees frames the 8021q layer has already untagged; nothing
# for a VLAN filter to match. ens19 is deliberately address-less (see
# talos/patches/k8s-1.yaml — host addresses on that L2 make node replies
# bypass the UDM), so Cilium never attaches to it and VLAN 30 frames only
# meet the filter on the shared parent, still tagged.
#
# So the fix belongs on 30 alone. Listing 40 would change nothing, and
# would touch the datapath carrying etcd and the API for no reason.
#
# A tc-layer drop surfaces to an AF_PACKET sender as ENOBUFS, so the
# symptom is the CNI dhcp plugin reporting "no buffer space available"
# rather than a DHCP timeout. See issue #592.
#
# Same three-copy problem as policyMapMax above: vlan-bpf-bypass in
# kubernetes/talos/cilium.yaml and kubernetes/talos/patches/cilium-cni.yaml
# must move with this list. Takes effect on agent restart.
vlanBypass: [30]
# Let pods reach the cluster's own LoadBalancer external IPs; Cilium
# redirects the socket directly to the backend pod instead of sending
# traffic out to the VIP and back (which fails with L2 announcements).
@@ -150,16 +150,3 @@ spec:
env:
- name: COLLECTIONS
value: "crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules"
config:
parsers:
s02-enrich:
pve-ui-polling-whitelist.yaml: |
name: krenner/pve-ui-polling-whitelist
description: "Suppress CrowdSec scenarios for the Proxmox UI's legitimate API polling on pve.renner.uno"
filter: "evt.Meta.log_type == 'http_access-log' && evt.Meta.target_fqdn == 'pve.renner.uno'"
whitelist:
reason: "Proxmox web UI polls /api2/json/cluster/* and /api2/json/nodes/<n>/{lxc,qemu}/<id>/status/current every ~2s; http-probing flagged it as scanning and banned the residential IPv6."
expression:
- "evt.Meta.http_path startsWith '/api2/json/cluster/'"
- "evt.Meta.http_path matches '^/api2/json/nodes/[^/]+/(lxc|qemu)/[0-9]+/status/current$'"
@@ -15,7 +15,6 @@ resources:
- netbird-ingress.yaml
- paperless-ngx.yaml
- protonmail-bridge.yaml
- pve-exporter.yaml
- mosquitto.yaml
- redis.yaml
- shelly-manager.yaml
@@ -1,23 +0,0 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: pve-exporter
namespace: flux-system
spec:
image: prompve/prometheus-pve-exporter
interval: 1h
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: pve-exporter
namespace: flux-system
spec:
imageRepositoryRef:
name: pve-exporter
filterTags:
pattern: '^(?P<ver>\d+\.\d+\.\d+)$'
extract: '$ver'
policy:
semver:
range: "3.x"
@@ -7,7 +7,7 @@ spec:
endpointSelector: {}
ingress:
# Intra-namespace — Prometheus ↔ Alertmanager ↔ Grafana ↔ Loki ↔
# alloy ↔ kube-state-metrics ↔ blackbox-exporter ↔ pve-exporter.
# alloy ↔ kube-state-metrics ↔ blackbox-exporter.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
@@ -1,53 +0,0 @@
# OpenBao policy: rotator-proxmox
#
# Class-B rotation for the pve-exporter Proxmox API token. Grants the
# proxmox-secret-rotator CronJob READ on its bootstrap credential (a PRIVILEGED
# Proxmox token allowed to delete/create tokens) and PATCH on the target
# pve-exporter-config path. Per-secret isolation (#213/#202).
#
# NOT applied by Flux. Apply ONCE with an admin token:
#
# bao policy write rotator-proxmox kubernetes/infrastructure/openbao/policies/rotator-proxmox.hcl
# bao write auth/kubernetes/role/rotator-proxmox \
# bound_service_account_names=proxmox-secret-rotator \
# bound_service_account_namespaces=openbao \
# policies=rotator-proxmox ttl=15m
#
# BOOTSTRAP CREDENTIAL (PROVISIONED — recorded here for rebuild/DR).
#
# A SEPARATE least-privilege Proxmox identity, NOT root@pam. PVE has no
# per-user ACL path (/access/users/<id> is rejected — see AccessControl.pm
# check_path), and the token endpoints authorise via a `userid-group` check.
# So the tightest possible scope is a group containing only the target user:
#
# pveum role add SecretRotator -privs "User.Modify"
# pveum user add secret-rotator@pve
# pveum group add secret-rotated
# pveum user modify pve-exporter@pve --groups secret-rotated
# pveum acl modify /access/groups/secret-rotated \
# --users secret-rotator@pve --roles SecretRotator
# pveum user token add secret-rotator@pve rotate --privsep 0
#
# This grants exactly one thing: managing API tokens of users in the
# secret-rotated group. Verified: no VM/storage visibility, and minting a
# root@pam token returns 403.
#
# bao kv put homelab/infrastructure/secret-rotators-bootstrap/proxmox \
# api-url=https://pve.apps.svc.cluster.local:8006 \
# auth='secret-rotator@pve!rotate=<secret>'
#
# api-url MUST be the in-cluster Service (backed by all three PVE nodes).
# The external name pve.renner.uno does NOT work from a pod — pods cannot
# hairpin to their own cluster's ingress LB and the Job hangs.
#
# Do NOT set token_no_default_policy=true (kv-v2 preflight needs the default policy).
# Bootstrap: the privileged Proxmox token + API URL.
path "homelab/data/infrastructure/secret-rotators-bootstrap/proxmox" {
capabilities = ["read"]
}
# Target: the pve-exporter config (holds the pve.yml with the token_value).
path "homelab/data/apps/monitoring/pve-exporter-config" {
capabilities = ["read", "patch"]
}
@@ -52,7 +52,6 @@ resources:
- crowdsec-bouncer-key.yaml
- netbird-ingress.yaml
- cloudflare.yaml
- pve-exporter.yaml
- external-dns.yaml
- flux-git.yaml
- home-assistant.yaml
@@ -1,91 +0,0 @@
---
# Class-B rotation of the pve-exporter Proxmox API token via the secret-rotator
# binary (proxmox-token mode). The RISKIEST rotator: Proxmox has no in-place
# token roll, so the binary DELETEs and recreates pve-exporter@pve!monitoring
# (same id, privsep=0) and patches OpenBao with a rendered pve.yml. That leaves
# a brief window with NO valid token — pve-exporter metrics gap until ESO(10m)+
# Reloader restart. Quarterly, off-hours: acceptable.
#
# privsep=0 makes the recreated token inherit pve-exporter@pve's own perms
# (PVEAuditor ACL is on the USER, not the token — verified), so no ACL re-grant.
# The bootstrap 'auth' is the least-privilege secret-rotator@pve!rotate identity
# (User.Modify scoped to the secret-rotated group), NOT root.
#
# api-url is the in-cluster Service https://pve.apps.svc.cluster.local:8006 (all
# three PVE nodes back it → node-failover). TLS verification is disabled for the
# PVE calls: the nodes present valid Let's Encrypt certs that fail only on
# hostname mismatch (Service DNS vs node FQDN); ureq has no skip-hostname-only
# option, and this matches the previous python's CERT_NONE. Do NOT use
# pve.renner.uno (pod hairpin off the ingress LB) or a single node FQDN (SPOF).
apiVersion: v1
kind: ServiceAccount
metadata:
name: proxmox-secret-rotator
namespace: openbao
labels:
app.kubernetes.io/name: pve-exporter
app.kubernetes.io/component: secret-rotator
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: proxmox-secret-rotate
namespace: openbao
labels:
app.kubernetes.io/name: pve-exporter
app.kubernetes.io/component: secret-rotator
spec:
suspend: false
schedule: "0 4 1 1,4,7,10 *" # quarterly, Europe/Berlin
timeZone: Europe/Berlin
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 5
startingDeadlineSeconds: 3600
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 300
ttlSecondsAfterFinished: 259200 # 3 d
template:
metadata:
labels:
app.kubernetes.io/name: pve-exporter
app.kubernetes.io/component: secret-rotator
spec:
serviceAccountName: proxmox-secret-rotator
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-07-30
args: ["proxmox-token"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: BAO_ADDR
value: http://openbao.openbao.svc.cluster.local:8200
- name: BAO_ROLE
value: rotator-proxmox
- name: BOOTSTRAP_PATH
value: homelab/infrastructure/secret-rotators-bootstrap/proxmox
- name: BAO_PATH
value: homelab/apps/monitoring/pve-exporter-config
- name: PVE_USER
value: pve-exporter@pve
- name: PVE_TOKENID
value: monitoring
resources:
requests:
cpu: 25m
memory: 16Mi
limits:
memory: 48Mi
@@ -160,19 +160,51 @@ spec:
- "10.2.40.0/24"
cluster:
- "10.99.0.0/29"
# Nodes are listed explicitly rather than useAllNodes, so that a node can be
# taken out of the storage fleet by removing it here. Under useAllNodes the
# operator re-provisions an OSD on any node whose device still carries a
# BlueStore signature, which makes purging an OSD ahead of pulling its drive
# impossible: the OSD is recreated within a reconcile and starts backfilling
# onto the drive being replaced.
#
# All three nodes are bare metal and see three NVMe drives each, so they name
# their OSDs by /dev/disk/by-id path. A bare name would be actively wrong:
# Talos enumerates these machines' drives in a different order than Proxmox
# did, and the kernel name lands on the Kingston SYSTEM disk or on EPHEMERAL
# depending on the boot. Rook would probably decline the system disk for
# carrying partitions, but that is luck rather than intent. The by-id path is
# derived from model and serial and does not depend on enumeration at all.
#
# k8s-3 demonstrated why: it enumerated the Kingston as nvme0n1 on one boot
# and the Crucial as nvme0n1 on the very next one.
#
# k8s-1 (jupiter)
# 50026B7382B48B86 Kingston — Talos system disk
# 2332E86B749A Crucial — Talos EPHEMERAL (/var)
# S437NX0M400426 PM983 — this OSD
#
# k8s-2 (saturn)
# 50026B7382DA5F6E Kingston — Talos system disk
# 2332E86B74BC Crucial — Talos EPHEMERAL (/var)
# S437NX0M403161 PM983 — this OSD
#
# k8s-3 (neptune)
# 50026B7382B48B90 Kingston — Talos system disk
# 2326E6E8E67D Crucial — Talos EPHEMERAL (/var)
# S437NF0M501176 PM983 — this OSD
storage:
useAllNodes: true
useAllNodes: false
useAllDevices: false
devices:
# On k8s-2/k8s-3 this is the NVMe drive PCI-passed-through from saturn
# and neptune, and it is the only block device the guest can see. k8s-1
# is bare metal on jupiter and sees two: the OSD drive in MS-01 slot 1
# (PCI 01:00.0) and the Talos system disk in slot 2 (58:00.0). Slot 1
# enumerates first, so nvme0n1 is still the OSD drive there — but that
# is enumeration order, not a guarantee. The system disk is protected
# from the other direction instead: talos/patches/k8s-1.yaml pins the
# installer to it by serial, and Rook skips a disk carrying partitions.
- name: nvme0n1
nodes:
- name: k8s-1
devices:
- fullpath: /dev/disk/by-id/nvme-SAMSUNG_MZQLB960HAJR-00007_S437NX0M400426
- name: k8s-2
devices:
- fullpath: /dev/disk/by-id/nvme-SAMSUNG_MZQLB960HAJR-00007_S437NX0M403161
- name: k8s-3
devices:
- fullpath: /dev/disk/by-id/nvme-SAMSUNG_MZQLB960HAJR-00007_S437NF0M501176
config:
osdsPerDevice: "1"
# Storage layer must never be evicted by kubelet under node pressure
@@ -5,31 +5,24 @@ metadata:
namespace: rook-ceph
spec:
failureDomain: host
# size 2, NOT 3. With 3 OSDs on 3 hosts, size=3 meant every write landed on
# every drive in the cluster: per-drive write volume equalled total cluster
# write volume, and all three drives wore at an identical rate and would have
# reached end-of-rated-life together (measured 2026-08-19: 80/82/87% endurance
# used, ~0.13 points/day, all three exhausted between Nov 2026 and Jan 2027 —
# see #434). Replication does not protect against that failure mode, because
# wear-out here is correlated by construction rather than independent.
# size 3. This pool sat at 2 while the OSDs were Crucial P3 QLC drives: with
# 3 OSDs on 3 hosts, size=3 put every write on every drive, so per-drive write
# volume equalled total cluster write volume and all three wore at an
# identical rate (measured 2026-08-19: 80/82/87% endurance used, ~0.13
# points/day, all three projected to exhaust between Nov 2026 and Jan 2027 —
# see #434). Replication does not protect against that, because the wear was
# correlated by construction rather than independent.
#
# size 2 cuts per-drive write volume by a third and desynchronises the wear.
# The OSDs are Samsung PM983 enterprise drives now, at 2-3% of rated
# endurance with a far higher write budget, so wear-out is no longer the
# binding constraint and the correlation argument no longer applies.
#
# min_size 1 is REQUIRED and deliberate. Ceph does not lower min_size when
# size is reduced — it was 2 here, and leaving it would make the pool 2/2,
# where any single OSD down (every rolling node reboot, every OSD restart)
# min_size is left at the size-3 default of 2. The explicit min_size 1
# override existed only because Ceph does not lower min_size when size is
# reduced, which would have left the pool 2/2 — where any single OSD down
# blocks IO for its PGs and hangs every RBD volume in the cluster.
#
# The trade this accepts: while the pool is degraded (one OSD down), a second
# drive failing loses data, and with all three drives at 80%+ endurance that
# window is not hypothetical. Mitigations that already exist: CNPG barman and
# Velero both ship offsite to Hetzner (opentofu/object_storage.tf), so the
# exposure is the RPO of those backups, not total loss. Keep node reboots
# serialised and wait for HEALTH_OK between them.
replicated:
size: 2
parameters:
min_size: "1"
size: 3
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
@@ -87,35 +80,26 @@ metadata:
name: cephfs
namespace: rook-ceph
spec:
# TEMPORARY — REVERT TO size 3 / no min_size OVERRIDE ONCE k8s-1 IS BACK.
# size 3 with no min_size override. This pool holds ~253 MiB against the data
# pool's 280 GiB, and losing it would destroy the whole filesystem rather
# than just the objects on the lost OSD, so it is the last pool that should
# ever run thin.
#
# The standing trade for this pool is size 3: it holds ~253 MiB against the
# data pool's 280 GiB, so it contributes nothing measurable to drive wear —
# the entire reason data0 dropped to 2 — while losing it would destroy the
# whole filesystem, not just the objects on the lost OSD. Paying 3x on a
# quarter of a gigabyte to keep that intact is the right trade in steady
# state.
#
# It does not hold while one of three hosts is out for the jupiter bare-metal
# conversion. size 3 on two remaining hosts leaves every PG undersized at two
# copies with min_size 2, so a single further OSD loss drops the pool below
# min_size and blocks IO on every CephFS mount cluster-wide. At 2/1 the same
# two copies are active+clean instead, and a further loss degrades rather
# than blocks — availability bought at the cost of accepting writes onto a
# single copy while degraded.
# It drops to 2/1 whenever one of the three hosts is out for longer than a
# reboot. size 3 on two remaining hosts leaves every PG undersized at two
# copies with min_size 2, so a single further OSD loss falls below min_size
# and blocks IO on every CephFS mount cluster-wide. At 2/1 the same two
# copies are active+clean instead, and a further loss degrades rather than
# blocks. See RUNBOOK-baremetal-conversion.md.
metadataPool:
replicated:
size: 2
parameters:
min_size: "1"
size: 3
dataPools:
- name: data0
# size 2 / min_size 1 for the same reasons as replicapool above — see
# the comment there for the full rationale and the accepted trade.
# size 3 for the same reasons as replicapool above — see the comment
# there for why this pool sat at 2 while the Crucials backed the OSDs.
replicated:
size: 2
parameters:
min_size: "1"
size: 3
metadataServer:
activeCount: 1
activeStandby: true
+7
View File
@@ -310,6 +310,13 @@ data:
# policy map (per endpoint). Keep in lockstep with patches/cilium-cni.yaml and
# values.bpf.policyMapMax in kubernetes/infrastructure/cilium/helmrelease.yaml.
bpf-policy-map-max: "65536"
# VLAN ids the eBPF datapath may pass; without this it drops VLAN-tagged
# frames. 30 is v30iot, the macvlan parent for the IoT workloads, and a VLAN
# sub-interface of enp2s0f0np0 on bare metal. VLAN 40 (mgmt0) rides the same
# parent but passes without an entry, so it is deliberately not listed.
# Keep in lockstep with patches/cilium-cni.yaml and values.bpf.vlanBypass in
# kubernetes/infrastructure/cilium/helmrelease.yaml. See issue #592.
vlan-bpf-bypass: "30"
# bpf-policy-stats-map-max specifies the maximum number of entries in global
# policy stats map
bpf-policy-stats-map-max: "65536"
+10
View File
@@ -331,6 +331,16 @@ cluster:
# inline manifest, so a lower value here means a cold rebuild comes up with
# a map that overflows until Flux reconciles.
bpf-policy-map-max: "65536"
# VLAN ids the eBPF datapath may pass; without this it drops
# VLAN-tagged frames. 30 is v30iot, the macvlan parent for the IoT
# workloads, and a VLAN sub-interface of enp2s0f0np0 on bare metal.
# VLAN 40 (mgmt0) rides the same parent but passes without an entry,
# so it is deliberately not listed. Keep in lockstep with
# values.bpf.vlanBypass in kubernetes/infrastructure/cilium/helmrelease.yaml
# and the third copy in kubernetes/talos/cilium.yaml — bootstrap installs
# from this inline manifest, so omitting it here breaks the IoT macvlan
# workloads until Flux reconciles. See issue #592.
vlan-bpf-bypass: "30"
# bpf-policy-stats-map-max specifies the maximum number of entries in global
# policy stats map
bpf-policy-stats-map-max: "65536"
+25
View File
@@ -5,6 +5,31 @@ machine:
- k8s-3.srv.renner.uno
- k8s.renner.uno
- 10.2.40.60
# Install from the Image Factory metal installer, not the stock
# ghcr.io/siderolabs/installer that `talosctl gen config` defaults to.
# Extensions live in the installer image, so with the stock one the schematic
# is only ever a property of the boot media and nothing lands on disk —
# `talosctl get extensions` returns empty on a node installed that way.
#
# What the schematic carries, and why each matters here:
#
# siderolabs/intel-ucode CPU microcode. On a VM the hypervisor supplied
# it; on bare metal nothing does except the BIOS.
# siderolabs/thunderbolt Inert until machine.kernel.modules asks for
# thunderbolt/thunderbolt_net. Present so enabling
# the MS-01 mesh later needs neither a new image
# nor a reinstall — which is exactly the property
# lost by installing without it.
#
# a21cec8f… is the bare-metal schematic described in RUNBOOK-cold-rebuild.md:
# same net.ifnames=1 as the Proxmox one (the predictable interface names the
# per-node VLAN parents match against), minus qemu-guest-agent.
#
# Only read at install and upgrade time. Changing it here does nothing to a
# running node until that node is upgraded. Bump the tag with the Talos
# version — it must match the running version, or the upgrade is a downgrade.
install:
image: factory.talos.dev/metal-installer/a21cec8f35ae32ab5e9a452e7b13500ca8776053930b8e182b9cb23eed186b64:v1.12.6
features:
rbac: true
stableHostname: true
+62 -34
View File
@@ -1,7 +1,7 @@
# k8s-1 runs Talos directly on jupiter's MS-01 hardware; k8s-2 and k8s-3 are
# still Proxmox VMs. Every machine-specific selector below differs from theirs
# for that reason: there are no virtio NICs, no PCI-passthrough layer, and both
# NVMe drives are visible to Talos at once.
# for that reason: there are no virtio NICs, no PCI-passthrough layer, and all
# four physical NICs and all three NVMe drives are visible to Talos at once.
#
# Networking uses the v1.12 multi-document config rather than the deprecated
# machine.network.interfaces. That is deliberate: the docs do not define what
@@ -9,9 +9,6 @@
# every link in one schema instead of straddling them.
machine:
network:
# Shared control-plane VIP 10.2.40.60 is configured as a Layer2VIPConfig
# below rather than machine.network.interfaces[].vip. Exposed via UniFi DNS
# as k8s.renner.uno.
hostname: k8s-1
sysctls:
# ens19 (VLAN 30, v30iot) is LINK ONLY — no host addressing. It exists as
@@ -24,10 +21,12 @@ machine:
# This disables v6 entirely on that link, including link-local.
net.ipv6.conf.ens19.disable_ipv6: "1"
install:
# Both NVMe drives are now ordinary block devices to Talos: the Kingston
# OM8PGP41024Q-A0 system disk and the Samsung PM983 that Rook claims as the
# Ceph OSD. Selecting the installer target by serial means it cannot land
# on the OSD drive regardless of which one the kernel enumerates first.
# Three NVMe drives are ordinary block devices to Talos here: the Kingston
# OM8PGP41024Q-A0 system disk, the Samsung PM983 that Rook claims as the
# Ceph OSD, and the retired Crucial P3 that carries EPHEMERAL (see the
# VolumeConfig below). Every one of them is selected by serial. Nothing in
# this file may use a broad selector: `disk.transport == 'nvme'` matches all
# three, and the failure mode is Talos taking the OSD drive.
diskSelector:
serial: 50026B7382B48B86
certSANs:
@@ -37,40 +36,70 @@ machine:
cluster:
allowSchedulingOnControlPlanes: true
---
# Physical NICs are aliased by permanent MAC. Both X710 ports bind i40e, so the
# driver selector k8s-2/k8s-3 use for their single passed-through port would
# match two links here. MAC matching is unambiguous and survives udev renaming.
# EPHEMERAL (/var) goes on the retired Crucial P3, not the system disk.
#
# That drive is at 87% of its rated endurance and was this node's Ceph OSD until
# the PM983 replaced it. /var is exactly the right role for a disk in that
# state: containerd's image and snapshot store, pod logs and the Ceph mon store
# are all reconstructible, so the drive is consumable here in a way it is not as
# an OSD. It also gives /var ~931 GB instead of the 64 GB the VM-era zvol had,
# which is the constraint that makes k8s-2 and k8s-3 trip DiskPressure whenever
# they absorb another node's pods.
#
# THIS ONLY TAKES EFFECT ON FIRST PROVISION. Talos applies volume configuration
# solely when the volume does not yet exist; a config applied after EPHEMERAL
# has been created elsewhere is silently ignored, and relocating it then
# requires wiping the disk and booting install media again.
#
# Matched by serial deliberately. `disk.transport == 'nvme' && !system_disk`
# would match the PM983 as well, and losing that coin flip means Talos formats
# the OSD drive.
apiVersion: v1alpha1
kind: LinkAliasConfig
name: lan0
selector:
match: glob("58:47:ca:74:70:27", mac(link.permanent_addr))
kind: VolumeConfig
name: EPHEMERAL
provisioning:
diskSelector:
match: disk.serial == '2332E86B749A'
minSize: 50GiB
grow: true
---
apiVersion: v1alpha1
kind: LinkAliasConfig
name: ceph0
selector:
match: glob("58:47:ca:74:70:28", mac(link.permanent_addr))
---
# lan0 is the switch trunk (X710 port 0). It carries no addressing of its own;
# VLAN 40 and VLAN 30 ride on top. Proxmox's vlan-aware vmbr0 did this tagging
# before, which is why the VM saw two already-untagged virtio NICs instead.
# X710 port 0 carries the switch trunk. It takes no addressing itself — the
# port's untagged VLAN is the 192.168.8.0/24 network, which this node has no
# business being on — so management and v30iot both ride it as tagged VLANs.
#
# Links are addressed by predictable kernel name. Those derive from PCI
# topology, not probe order — though not immune: adding the third NVMe
# renumbered the buses and moved the unused second I226 from enp89s0 to
# enp90s0. Neither name used here was affected.
#
# LinkAliasConfig would be the better way to pin these, and is worth
# reintroducing — but only once it is proven. Aliasing both X710 ports by
# permanent MAC and using the aliases as VLAN parents produced a node with no
# address on any interface: the aliases were never created, so every document
# referencing them did nothing, including the static address on port 1. Verify
# any alias-based revision with `talosctl apply-config --mode=try`, which rolls
# back on its own, before making it permanent.
apiVersion: v1alpha1
kind: LinkConfig
name: lan0
name: enp2s0f0np0
up: true
---
# Management, VLAN 40. Carries the node IP, etcd, the API and Ceph's public
# network. The k8s-1 VM reached 10.2.40.174 over this same physical port via
# Proxmox's v40srv VNet (vmbr0.40), so the switch trunks VLAN 40 here.
apiVersion: v1alpha1
kind: VLANConfig
name: mgmt0
vlanID: 40
parent: lan0
parent: enp2s0f0np0
up: true
---
apiVersion: v1alpha1
kind: DHCPv4Config
name: mgmt0
---
# Shared control-plane VIP, etcd-elected. Exposed via UniFi DNS as
# k8s.renner.uno.
apiVersion: v1alpha1
kind: Layer2VIPConfig
name: 10.2.40.60
@@ -80,21 +109,20 @@ link: mgmt0
# `master: ens19`, and k8s-2/k8s-3 still present the VLAN as a virtio NIC of
# that name. VLANConfig.name is free-form, so keeping it lets one set of NADs
# serve both VM and bare-metal nodes for as long as the cluster is mixed.
# Nothing else on this machine is called ens19: the physical NICs are
# enp2s0f0np0, enp2s0f1np1, enp87s0 and enp89s0.
apiVersion: v1alpha1
kind: VLANConfig
name: ens19
vlanID: 30
parent: lan0
parent: enp2s0f0np0
up: true
---
# Dedicated Ceph cluster network, VLAN 49 / 10.99.0.0/29, untagged on the wire
# on X710 port 1. Used by Rook for OSD<->OSD replication via
# CephCluster.spec.network.addressRanges.cluster. No default route.
# Dedicated Ceph cluster network on X710 port 1, VLAN 49 / 10.99.0.0/29,
# untagged on the wire. Used by Rook for OSD<->OSD replication via
# CephCluster.spec.network.addressRanges.cluster. Not routed off its own
# segment, and deliberately has no default route.
apiVersion: v1alpha1
kind: LinkConfig
name: ceph0
name: enp2s0f1np1
up: true
mtu: 9000
addresses:
+86 -19
View File
@@ -1,31 +1,98 @@
# k8s-2 runs Talos directly on saturn's MS-01 hardware. Structurally identical
# to k8s-1.yaml — see that file for the reasoning behind each choice; only the
# serials, MACs and addresses differ.
machine:
network:
hostname: k8s-2
# Shared control-plane VIP — see k8s-1.yaml for the busPath rationale.
interfaces:
- deviceSelector:
busPath: "0000:06:12.0"
dhcp: true
vip:
ip: 10.2.40.60
# v30iot: link only, no host addressing — see k8s-1.yaml.
- deviceSelector:
busPath: "0000:06:13.0"
dhcp: false
# See k8s-1.yaml for context on the dedicated Ceph cluster network.
- deviceSelector:
driver: i40e
addresses:
- 10.99.0.2/29
mtu: 9000
sysctls:
# v30iot NIC carries no host addressing — see k8s-1.yaml.
# ens19 (VLAN 30, v30iot) is LINK ONLY — no host addressing. It is the
# macvlan parent for the v30iot pod attachments; host addresses on that L2
# make node replies bypass the UDM and break every UDM-DNATed flow.
net.ipv6.conf.ens19.disable_ipv6: "1"
install:
disk: /dev/vda
# Three NVMe drives are visible on bare metal. Select the installer target
# by serial so it cannot land on the OSD drive or on EPHEMERAL, whatever
# order the kernel enumerates them in — Talos and Proxmox do not agree on
# that order.
diskSelector:
serial: 50026B7382DA5F6E
certSANs:
- k8s-2.srv.renner.uno
nodeLabels:
topology.kubernetes.io/zone: saturn
cluster:
allowSchedulingOnControlPlanes: true
---
# EPHEMERAL (/var) goes on the retired Crucial P3, not the system disk. That
# drive is at 81% of its rated endurance and is finished as an OSD, but
# everything EPHEMERAL holds is reconstructible, so it is consumable in this
# role. It also gives /var ~931 GB instead of the 64 GB the VM-era zvol had.
#
# ONLY TAKES EFFECT ON FIRST PROVISION. Talos applies volume configuration
# solely when the volume does not yet exist; applied later it is silently
# ignored and relocating needs a disk wipe and a boot from install media.
#
# Matched by serial deliberately: a broad selector such as
# `disk.transport == 'nvme' && !system_disk` would match the PM983 as well.
apiVersion: v1alpha1
kind: VolumeConfig
name: EPHEMERAL
provisioning:
diskSelector:
match: disk.serial == '2332E86B74BC'
minSize: 50GiB
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.
#
# 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.
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f0np0
up: true
---
# Management, VLAN 40. Node IP, etcd, the API and Ceph's public network.
apiVersion: v1alpha1
kind: VLANConfig
name: mgmt0
vlanID: 40
parent: enp2s0f0np0
up: true
---
apiVersion: v1alpha1
kind: DHCPv4Config
name: mgmt0
---
# Shared control-plane VIP, etcd-elected. Exposed via UniFi DNS as
# k8s.renner.uno.
apiVersion: v1alpha1
kind: Layer2VIPConfig
name: 10.2.40.60
link: mgmt0
---
# Named ens19 on purpose: the four v30iot macvlan NADs hard-code
# `master: ens19`, and k8s-3 still presents the VLAN as a virtio NIC of that
# name. VLANConfig.name is free-form, so one set of NADs serves both.
apiVersion: v1alpha1
kind: VLANConfig
name: ens19
vlanID: 30
parent: enp2s0f0np0
up: true
---
# Dedicated Ceph cluster network on X710 port 1, VLAN 49 / 10.99.0.0/29,
# untagged on the wire. OSD<->OSD replication only. Not routed off its own
# segment and deliberately has no default route.
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f1np1
up: true
mtu: 9000
addresses:
- address: 10.99.0.2/29
+69 -18
View File
@@ -1,31 +1,82 @@
# k8s-3 runs Talos directly on neptune's MS-01 hardware. Structurally identical
# to k8s-1.yaml — see that file for the reasoning behind each choice; only the
# serials and addresses differ. neptune was the last node converted, so all
# three control-plane nodes now share this layout and no Proxmox remains.
machine:
network:
hostname: k8s-3
# Shared control-plane VIP — see k8s-1.yaml for the busPath rationale.
interfaces:
- deviceSelector:
busPath: "0000:06:12.0"
dhcp: true
vip:
ip: 10.2.40.60
# v30iot: link only, no host addressing — see k8s-1.yaml.
- deviceSelector:
busPath: "0000:06:13.0"
dhcp: false
# See k8s-1.yaml for context on the dedicated Ceph cluster network.
- deviceSelector:
driver: i40e
addresses:
- 10.99.0.3/29
mtu: 9000
sysctls:
# v30iot NIC carries no host addressing — see k8s-1.yaml.
net.ipv6.conf.ens19.disable_ipv6: "1"
install:
disk: /dev/vda
# Three NVMe drives are visible on bare metal. Select the installer target
# by serial so it cannot land on the OSD drive or on EPHEMERAL, whatever
# order the kernel enumerates them in — Talos and Proxmox do not agree on
# that order.
diskSelector:
serial: 50026B7382B48B90
certSANs:
- k8s-3.srv.renner.uno
nodeLabels:
topology.kubernetes.io/zone: neptune
cluster:
allowSchedulingOnControlPlanes: true
---
# EPHEMERAL (/var) goes on the retired Crucial P3, not the system disk. That
# drive is at 83% of its rated endurance and is finished as an OSD, but
# everything EPHEMERAL holds is reconstructible, so it is consumable in this
# role. Available spare is still 100% and it reports no media errors.
#
# ONLY TAKES EFFECT ON FIRST PROVISION. Talos applies volume configuration
# solely when the volume does not yet exist; applied later it is silently
# ignored and relocating needs a disk wipe and a boot from install media.
#
# Matched by serial deliberately: a broad selector such as
# `disk.transport == 'nvme' && !system_disk` would match the PM983 as well.
apiVersion: v1alpha1
kind: VolumeConfig
name: EPHEMERAL
provisioning:
diskSelector:
match: disk.serial == '2326E6E8E67D'
minSize: 50GiB
grow: true
---
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f0np0
up: true
---
apiVersion: v1alpha1
kind: VLANConfig
name: mgmt0
vlanID: 40
parent: enp2s0f0np0
up: true
---
apiVersion: v1alpha1
kind: DHCPv4Config
name: mgmt0
---
# Shared control-plane VIP, held by whichever node wins the etcd campaign.
apiVersion: v1alpha1
kind: Layer2VIPConfig
name: 10.2.40.60
link: mgmt0
---
# v30iot: link only, no host addressing — see k8s-1.yaml.
apiVersion: v1alpha1
kind: VLANConfig
name: ens19
vlanID: 30
parent: enp2s0f0np0
up: true
---
# Dedicated Ceph cluster network — see k8s-1.yaml.
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f1np1
up: true
mtu: 9000
addresses:
- address: 10.99.0.3/29
+7 -2
View File
@@ -47,8 +47,13 @@ variable "tags" {
}
variable "template_name" {
type = string
default = "ubuntu-24-04-template"
type = string
# No default clone source. The ubuntu-24.04-template it used to name was
# deleted with saturn's conversion to bare metal, and Proxmox is being
# retired, so a default pointing at a template that does not exist would
# only fail at apply time. Set it explicitly if a clone source is ever
# wanted again.
default = null
}
variable "full_clone" {
+27
View File
@@ -0,0 +1,27 @@
# k8s-3 was the last Proxmox VM. neptune now runs Talos on bare metal, and its
# disks were wiped during the conversion, so the VM this module described does
# not exist in any form.
#
# `removed` rather than simply deleting vm_152_k8s_3.tf: dropping the resource
# from configuration alone makes OpenTofu plan a DESTROY, and planning a destroy
# refreshes the resource through the bpg/proxmox provider — which now has no
# endpoint to reach. That is exactly what has been failing every infrastructure
# plan since the conversion:
#
# Error: failed to get resources list of type ("vm") for cluster:
# dial tcp 10.2.40.201:8006: connect: no route to host
# with module.k8s_3.proxmox_virtual_environment_vm.this
#
# `lifecycle { destroy = false }` forgets the state entry without attempting to
# contact Proxmox. Nothing is orphaned by doing so: the guest, its disks and the
# hypervisor underneath it are all already gone.
#
# This block can be deleted once one plan has run cleanly with it — it is a
# one-shot instruction, not standing configuration.
removed {
from = module.k8s_3
lifecycle {
destroy = false
}
}
-52
View File
@@ -1,52 +0,0 @@
#!/bin/bash
# Script to help with UniFi client migration
#
# NOTE: This script is NO LONGER RECOMMENDED
# Instead, follow the migration guide in MIGRATION_QUICKSTART.md
#
# This script removes unifi_client resources from state, but the recommended
# approach is to let 'tofu apply' handle the removal automatically.
echo "⚠️ WARNING: This script is deprecated"
echo ""
echo "Please use the recommended migration path instead:"
echo " 1. Deploy DNS server: tofu apply -target=module.proxmox_dns"
echo " 2. Configure UniFi DNS to use 10.2.40.53"
echo " 3. Run full apply: tofu apply"
echo ""
echo "See MIGRATION_QUICKSTART.md for details."
echo ""
read -p "Do you still want to remove resources from state? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Cancelled. See MIGRATION_QUICKSTART.md for recommended approach."
exit 0
fi
set -e
echo "Removing unifi_client resources from OpenTofu state..."
echo "Note: VMs will keep their current network configuration"
echo ""
# List of all unifi_client resources to remove
RESOURCES=(
"module.cdns_srv_renner_uno.unifi_client.this[0]"
"module.fortress_renner_uno.unifi_client.this[0]"
"module.grafana_srv_renner_uno.unifi_client.this[0]"
"module.influxdb_srv_renner_uno.unifi_client.this[0]"
"module.kasm_srv_renner_uno.unifi_client.this[0]"
"module.mosquitto_srv_renner_uno.unifi_client.this[0]"
"module.netbox_srv_renner_uno.unifi_client.this[0]"
"module.ntp_srv_renner_uno.unifi_client.this[0]"
"module.paperless_srv_renner_uno.unifi_client.this[0]"
"module.uptime_kuma_srv_renner_uno.unifi_client.this[0]"
)
for resource in "${RESOURCES[@]}"; do
echo "Removing: $resource"
tofu state rm "$resource" || echo " (already removed or not found)"
done
echo ""
echo "Done! Run 'tofu plan' to verify."
-13
View File
@@ -1,13 +0,0 @@
locals {
talos_version = "v1.12.6"
# Image Factory schematic (predictable naming: net.ifnames=1). Keep in
# sync with TALOS_SCHEMATIC in .woodpecker/deploy-infrastructure.yml —
# that pipeline downloads the image, this local names it for VM import,
# and both MUST agree on the filename. The schematic hash is embedded in
# the filename so a schematic bump changes the name, forcing a fresh
# download instead of silently reusing a stale cached image (which would
# reintroduce net.ifnames=0 on the next VM (re)provision).
talos_schematic = "88e3752db032a08082972bd0adf16a80849e3870c3f95cf5b669936c5699e5ad"
talos_filename = "talos-${local.talos_version}-${substr(local.talos_schematic, 0, 12)}-nocloud-amd64.raw"
talos_image = "local:import/${local.talos_filename}"
}
-50
View File
@@ -1,50 +0,0 @@
module "k8s_2" {
source = "./modules/proxmox_vm"
name = "k8s-2.srv.renner.uno"
target_node = "saturn"
vmid = 151
memory = 49152
cores = 8
template_name = null
cloud_init = false
full_clone = false
bios = "ovmf"
machine = "q35"
boot = "order=virtio0"
agent_enabled = true
# Second NIC on v30iot so Cilium L2 announce can win ARP on the IoT
# VLAN from this node (previously only k8s-edge had v30iot, so any
# IoT-pool LB lease landing here was unreachable from devices).
extra_network_bridges = ["v30iot"]
disks = [
{
storage = "local-zfs"
size = "64G"
format = "raw"
slot = "virtio0"
file_id = local.talos_image
}
]
hostpci = [
{
mapping = "osd-ssd"
},
{
mapping = "x710-port1"
}
]
tags = "kubernetes;talos;controlplane"
ssh_public_keys = local.common_ssh_keys
vm_state = "running"
# Rebuild pipeline issues `-replace`; skip graceful shutdown so a
# non-responsive Talos guest can't hang the destroy for 30 minutes.
stop_on_destroy = true
}
-50
View File
@@ -1,50 +0,0 @@
module "k8s_3" {
source = "./modules/proxmox_vm"
name = "k8s-3.srv.renner.uno"
target_node = "neptune"
vmid = 152
memory = 49152
cores = 8
template_name = null
cloud_init = false
full_clone = false
bios = "ovmf"
machine = "q35"
boot = "order=virtio0"
agent_enabled = true
# Second NIC on v30iot so Cilium L2 announce can win ARP on the IoT
# VLAN from this node (previously only k8s-edge had v30iot, so any
# IoT-pool LB lease landing here was unreachable from devices).
extra_network_bridges = ["v30iot"]
disks = [
{
storage = "local-zfs"
size = "64G"
format = "raw"
slot = "virtio0"
file_id = local.talos_image
}
]
hostpci = [
{
mapping = "osd-ssd"
},
{
mapping = "x710-port1"
}
]
tags = "kubernetes;talos;controlplane"
ssh_public_keys = local.common_ssh_keys
vm_state = "running"
# Rebuild pipeline issues `-replace`; skip graceful shutdown so a
# non-responsive Talos guest can't hang the destroy for 30 minutes.
stop_on_destroy = true
}
-71
View File
@@ -1,71 +0,0 @@
resource "proxmox_virtual_environment_vm" "ubuntu_24_04_template" {
name = "ubuntu-24.04-template"
node_name = "saturn"
vm_id = 605
started = false
template = true
on_boot = false
keyboard_layout = "en-us"
bios = "seabios"
agent {
enabled = true
}
cpu {
cores = 2
type = "x86-64-v3"
}
memory {
dedicated = 2048
}
initialization {
datastore_id = "local-zfs"
ip_config {
ipv4 {
address = "dhcp"
}
}
user_account {
username = "ladmin"
keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress"
]
}
}
disk {
datastore_id = "local-zfs"
interface = "scsi0"
size = 32
file_format = "raw"
iothread = false
}
network_device {
bridge = "v40srv"
model = "virtio"
}
serial_device {}
scsi_hardware = "virtio-scsi-pci"
lifecycle {
ignore_changes = [
node_name,
started,
initialization,
disk,
cpu,
memory
]
}
}