Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5 4938f8f5a2 chore(opentofu): drop the spent removed block for module.k8s_3
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The removal has been applied:

  Apply complete! Resources: 0 added, 0 changed, 0 destroyed, 1 forgotten.

`tofu state list` now returns no Proxmox resources at all, and a full plan with
refresh enabled produces no Proxmox errors — the "no route to host" against
10.2.40.201:8006 that failed every infrastructure plan since the conversion is
gone.

The block was always one-shot. Left in place it refers to a state entry that no
longer exists, which is harmless but misleading: it reads as pending work.

Worth recording for the next time this shape comes up, because it cost a failed
merge to learn. A `removed` block alone does not fix an unreachable provider:
OpenTofu still refreshes the resource before it can forget it, and that refresh
is exactly what cannot succeed. It takes one run with `-refresh=false` to
process. `-target` does not help — targeting does not reach `removed` blocks, so
a targeted plan reports "No changes" while the state entry sits there untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 08:07:45 +02: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
43 changed files with 313 additions and 1702 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.
#
# Only nodes still running Proxmox belong in this list. jupiter and
# saturn run Talos on bare metal now, so the storage API returns
# nothing for them and the download-wait loop below would burn its full
# 60x10s per node before giving up. Bare-metal nodes boot the
# metal-amd64 image from their own installed disk instead, and the
# whole step goes away once neptune is converted too.
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 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
+82 -11
View File
@@ -172,12 +172,22 @@ 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.
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 is effectively write-only — `talosctl get` has no `--insecure`
and `version` returns "API is not implemented in maintenance mode". You cannot
inspect disks or links before applying. Verify serials and MACs from the host's
PVE beforehand, in step 3.
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:
@@ -202,13 +212,58 @@ Strip the generated `HostnameConfig` document — it collides with
against a base with no `inlineManifests` (issue #581). Use only the additive
second document.
Validate, then apply:
Validate:
```sh
talosctl validate --config install.yaml --mode metal
talosctl apply-config --insecure -n <maintenance-ip> --file install.yaml
```
### 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
@@ -334,7 +389,23 @@ The retired Crucial leaves a stale entry in `ceph device ls`. Remove it with
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 silently drops VLAN-tagged traffic unless
`bpf.vlanBypass` is set. Both `mgmt0` and `ens19` are VLAN interfaces on a
converted node. Not observed on jupiter, but it is the first place to look if
the v30iot macvlan workloads misbehave there.
- 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.
+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
@@ -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
@@ -167,34 +167,44 @@ spec:
# impossible: the OSD is recreated within a reconcile and starts backfilling
# onto the drive being replaced.
#
# nvme0n1 on k8s-2/k8s-3 is the NVMe drive PCI-passed-through from saturn and
# neptune, and is the only block device those guests can see, so a bare device
# name is unambiguous there.
#
# k8s-1 is bare metal and sees three NVMe drives, so it names its OSD by
# /dev/disk/by-id path instead. A bare name would be actively wrong: Talos
# enumerates this machine's drives in a different order than Proxmox did, and
# nvme0n1 here is the Kingston SYSTEM disk. Rook would probably decline it for
# 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.
#
# nvme0n1 50026B7382B48B86 Kingston — Talos system disk
# nvme1n1 2332E86B749A Crucial — Talos EPHEMERAL (/var)
# nvme2n1 S437NX0M400426 PM983 — this OSD
# 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: false
useAllDevices: false
nodes:
# k8s-2 is absent while saturn's OSD drive is swapped and the node is
# converted to bare metal. RE-ADD IT once the PM983 is in and the node has
# rejoined, naming the device by /dev/disk/by-id as k8s-1 does — bare
# metal sees three NVMe drives and the bare name is not the OSD.
- name: k8s-3
devices:
- name: nvme0n1
- 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,39 +80,26 @@ metadata:
name: cephfs
namespace: rook-ceph
spec:
# TEMPORARY — RESTORE TO size 3 / no min_size OVERRIDE ONCE SATURN AND
# NEPTUNE HAVE BOTH BEEN CONVERTED TO BARE METAL. It stays at 2/1 across all
# three conversions rather than being toggled per node: each change
# re-replicates the pool, and the window reopens with the next node anyway.
# 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. 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.
#
# See RUNBOOK-baremetal-conversion.md.
# 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
+1 -2
View File
@@ -1,7 +1,6 @@
# 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. k8s-3 is still a Proxmox VM and keeps the
# VM-era layout until neptune is converted.
# serials, MACs and addresses differ.
machine:
network:
hostname: k8s-2
+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" {
-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_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
]
}
}