Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5 f51eb8bf35 docs(runbook): drop the claim that a cold rebuild needs console access
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The rewrite earlier today stated, at the top and again in known gaps, that the
rebuild cannot be driven remotely: management is a tagged VLAN, a maintenance
mode node cannot configure VLANs, so it comes up unreachable and every machine
needs a keyboard.

That is wrong. A maintenance-mode node DHCPs untagged and is reachable at that
address — it is not on management, but it answers, and that is precisely the
address `apply-config --insecure` targets in Step 2. All three bare-metal
conversions were driven that way.

The claim came from #576 and was carried into this document without being
checked against what had actually happened days earlier in this repo. It is the
worst place to put an unverified constraint: a disaster-recovery runbook is read
when there is no capacity to discover that a stated prerequisite is fiction, and
"you need physical access to three machines" is exactly the kind of statement
that changes whether someone attempts a recovery at all.

What survives is narrower and true: MS-01 has no IPMI and nothing here does
PXE, so booting from USB is physical. Everything after the boot is remote.

Also removes the follow-on error from the first correction, which claimed the
switch port change on 2026-08-27 had fixed a reachability problem. There was no
reachability problem to fix. That change is real and useful — a maintenance mode
node now lands in management rather than the fallback network — but it did not
turn an impossible rebuild into a possible one.

#576 still carries the original claim and is flagged in known gaps as wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:25:50 +02:00
k.renner d1e072146f Merge pull request 'docs(runbook): rewrite the cold rebuild for bare metal' (#661) from runbook-cold-rebuild-baremetal into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#661
2026-08-27 08:11:24 +00:00
claude-botandClaude Opus 5 4678f73e9f docs(runbook): rewrite the cold rebuild for bare metal
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
Step 1 was "tofu: provision the VMs" and there are no VMs. The OpenTofu stack
has no node definitions left — only the uninstantiated proxmox_vm module beside
DNS, object storage and UniFi — so `tofu apply` provisions nothing and Step 2
then has no cluster to form. The document was half-updated: it knew k8s-1 was
bare metal but still treated k8s-2 and k8s-3 as Proxmox VMs, which stopped
being true at the neptune conversion.

That matters more than an ordinary stale doc, because it is the document
someone reaches for when everything is gone — exactly when there is no capacity
to discover that step one is fiction.

Step 1 splits into two unrelated halves: a tofu apply for platform resources
that do still exist, and physically booting three machines from install media.

Step 2 stops calling `bootstrap.sh talos`, which cannot generate a config
(#581), and carries the procedure that actually works — the one
RUNBOOK-baremetal-conversion.md uses. Both traps it hides are now stated: the
whole cilium-cni.yaml cannot be passed as a patch, and the generated
HostnameConfig collides with the per-node hostname. So is the two-stage apply:
a config referencing an interface that does not exist yet hangs the install at
STAGE: Installing with nothing timing out.

Scoped that claim rather than repeating "bootstrap.sh is dead" wholesale — the
flux and wait-apps phases are still used in Steps 3, 5 and 6 and work. Only
config generation is broken, and saying otherwise would have left the runbook
contradicting itself three sections later.

Adds the constraint that decides whether this is even attemptable remotely, at
the top and again in known gaps: management is a tagged VLAN, a maintenance
mode node cannot configure VLANs, and MS-01 has no IPMI. Every node needs a
keyboard and a monitor until #576 puts VLAN 40 native on the I226-V port. That
was true before today and undocumented.

Also records what changed under the runbook recently: the schematic now carries
i915, Ceph's cluster network is a Multus attachment rather than host
networking, and OpenBao runs three replicas that each need unsealing
individually — a sealed server is neither a voter nor a standby, so doing one
leaves the cluster short of quorum.

Disk verification before install is now explicit. Talos enumerates NVMe in an
order that is not stable across boots, the per-node configs select by serial for
that reason, and the failure mode is the installer taking the Ceph OSD drive.

Closes #660.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 10:09:50 +02:00
k.renner 5f8c0115b5 Merge pull request 'fix(monitoring): raise VeleroBackupStale from 30h to 60h' (#658) from velero-stale-60h into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#658
2026-08-27 07:36:12 +00:00
claude-botandClaude Opus 5 e2f3d67f79 fix(monitoring): raise VeleroBackupStale from 30h to 60h
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
At 30h a single missed cycle pages critical, and a single missed cycle is the
normal cost of touching the nodes. Velero moves data through pods, so draining
a node kills whatever upload is in flight and the schedule is lost for that day
— it is not retried. Backup windows run 03:15 to 08:45 UTC, so any rolling node
work overlapping them produced a guaranteed critical alert per affected
schedule.

That is noise about planned work, not signal about backups. It fired for eleven
schedules during the bare-metal Talos upgrade and again today.

60h still catches what the rule exists for. Two consecutive failures is no
longer explicable by one maintenance window. Nothing is hidden in the meantime:
VeleroBackupFailed fires on the first failure regardless, so the first cycle is
still visible — only the escalation to critical waits for a second cycle to
confirm it.

Calibration against the current state: the four stale schedules sit at 46.8 to
48.5 hours since their last success, so the new threshold leaves roughly twelve
hours — exactly until today's runs. If those fail too, the alert fires, which
is the intended behaviour rather than a gap.

Verified: the rule passes a server-side dry run, the expression returns nothing
at 60h, and the same query at a lower bound returns the four affected schedules
with their ages — so the selector is known to match rather than merely silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:35:09 +02:00
k.renner 7b0b664a46 Merge pull request 'chore(talos): move to v1.13.9' (#656) from talos-1-13-9 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#656
2026-08-27 06:57:58 +00:00
claude-botandClaude Opus 5 3abdeb2203 chore(talos): move to v1.13.9
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
Nodes run v1.12.11, which is the last 1.12 patch — v1.12.12 does not exist.
v1.13.9 is current.

The upgrade path is the documented one rather than a shortcut. Migration is
only tested between adjacent minor releases, and the recommendation is to go
to the latest patch of each intermediate minor. 1.12.11 -> 1.13.9 satisfies
both: one minor step, taken from the last patch of the version being left. No
intermediate hop is needed.

Kubernetes is not forced along. Talos 1.13's support matrix lists 1.36, 1.35,
1.34, 1.33, 1.32 and 1.31, and the cluster runs 1.35.2 — so it stays there
until `talosctl upgrade-k8s` moves it as a separate decision. This was the one
open question before the jump and it is answered from the matrix rather than
assumed.

Deprecations in 1.13 were checked against this cluster, not just read:

  .machine.env -> EnvironmentConfig          not used
  .machine.network.kubespan -> KubeSpanConfig not used
  EtcdConfigs/KubeletConfigs protobuf change  no consumer in this repo
  legacy upgrade API -> LifecycleService      still functional; `talosctl
                                              upgrade` keeps working, this
                                              only matters once it is removed

Both the five machine-config files here and the live config on k8s-1 were
checked for the first two, because the running config is generated from a base
this repo only patches and could carry fields the patches never mention.
Neither appears in either.

The removed interactive installer and the dropped `talosctl install` do not
apply — installation here is `talosctl upgrade --image` and Image Factory
media.

Schematic is unchanged; the factory image was verified to exist for the new
tag before committing. The cold-rebuild ISO URL moves with it.

Not applied to any node. Rolling it out is drain -> upgrade -> wait for Ceph
to return to 217 PGs active+clean, one node at a time, as in the 1.12.11 run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 08:56:20 +02:00
k.renner cdebaba2d3 Merge pull request 'chore(rook-ceph): return mon_data_avail_warn to the Ceph default' (#655) from ceph-mon-disk-warn-default into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#655
2026-08-27 06:40:38 +00:00
claude-botandClaude Opus 5 dd311f4b01 chore(rook-ceph): return mon_data_avail_warn to the Ceph default
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The override existed because the mons share the node's /var with containerd,
and on the VMs that was ~60 GB — 30% free measured image and snapshot churn
rather than anything about Ceph, so k8s-2 tripped MON_DISK_LOW whenever the
image cache filled and GC could not reclaim it (#434).

Bare metal removed the premise. /var is now ~931 GB per node at 7-8% used, so
30% free is a real reserve again and the ~106 MB mon database is irrelevant
against it. Reaching 70% would be a genuine signal about the node rather than
noise about the image cache. The comment still described the 60 GB VM /var and
ended with "BARE-METAL MIGRATION: revisit this", which is now done.

Set to 30 rather than deleted, deliberately. `ceph config` keeps a value that
only disappears from this file: `ceph config dump` currently shows
`mon advanced mon_data_avail_warn 20`, and removing the line would leave it
there indefinitely. That is the trap the bluestore settings below already
document, and the one that crash-looped an OSD during the network provider
change when public_network survived being deleted here.

mon_data_avail_crit stays at the 5% default, and CephMonDiskLow in
monitoring-rules still watches /var directly for the real risk, which is the
node filling rather than the mon growing.

kubectl diff against the live cluster shows the value and the generation bump,
nothing else. Verify after it applies with
`ceph config get mon mon_data_avail_warn` — 30 there is the proof the database
entry was overwritten and not merely edited in git.

Supersedes the remaining half of #503, which was closed as overtaken by the
bare-metal migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 08:37:46 +02:00
k.renner 36e683aee1 Merge pull request 'fix: [alert] TargetDown (default) (#635)' (#644) from alert-fix/issue-635 into master
Reviewed-on: k.renner/homelab#644
2026-08-27 06:35:54 +00:00
k.renner c34b423d38 Merge pull request 'chore(rook-ceph): give the SAN pool the whole /29 now the nodes are off it' (#654) from ceph-san-pool-widen into master
Reviewed-on: k.renner/homelab#654
2026-08-27 06:35:12 +00:00
k.renner 5fd0691ed2 Merge pull request 'fix(talos): stop the VLAN parents autoconfiguring on the fallback network' (#653) from no-ipv6-on-vlan-parents into master
Reviewed-on: k.renner/homelab#653
2026-08-27 06:34:51 +00:00
claude-botandClaude Opus 5 f0f0b0af9d chore(rook-ceph): give the SAN pool the whole /29 now the nodes are off it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The pool was .4-.6 because the nodes held .1-.3 for as long as Ceph needed
host networking to reach VLAN 49 — three addresses for three OSDs, with no
spare, during the one phase where things were actually moving.

The nodes have since given those addresses up: the link is only a macvlan
parent now and does not need one of its own. The reservation has nothing left
to avoid, so the pool takes .1 through .6.

Six for three is the headroom the migration could not afford. It leaves room
for a leaked allocation to sit until the whereabouts reconciler collects it —
the case where a node is lost hard enough that the CNI DEL never runs — and
room to add a second OSD per node without touching this file.

Keeping the pool inside the /29 rather than moving it somewhere roomier on the
segment stays deliberate. There is no router here, so an address outside the
/29 is unreachable from anything using one inside it, which is what would
break a future rolling change where migrated and un-migrated OSDs still have
to talk to each other. That was nearly the shape of this attachment and it
would have taken every pool down at the first OSD.

Inert for the running cluster: a NetworkAttachmentDefinition is read when a
pod is created, so the three existing OSDs keep .4, .5 and .6 until they next
restart for some other reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 08:03:46 +02:00
claude-botandClaude Opus 5 4c6c23377d fix(talos): stop the VLAN parents autoconfiguring on the fallback network
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The cluster had no IPv6 egress. Pods could not reach the IPv6 internet at all
while IPv4 from the same pod worked normally.

enp2s0f0np0 is nothing but the trunk parent for mgmt0 and ens19, and enp87s0
is unused. Both sit on the untagged fallback network, where by design nothing
is supposed to run — and both were autoconfiguring from its router
advertisements: an fd00::/64 ULA each, plus an IPv6 default route each.

That left four IPv6 default routes per node at identical priority 1024 —
mgmt0, enp2s0f0np0, enp87s0 and lo — of which only mgmt0 has a globally
routable source address. With nothing to break the tie, outbound IPv6 mostly
left through an interface whose only usable source was a ULA.

This is the same argument the file already makes for ens19, where host
addresses on a link that exists only as a macvlan parent are called "never
needed and actively harmful". It was simply never extended to the physical
ports.

How it surfaced: NetbirdRelayMemberDown and NetbirdPublicEdgeMemberDown had
been firing for 30 hours, reading as though the proxies were down. They were
not — every failing target is an IPv6 literal and the blackbox exporter is a
pod, so the probes were failing on cluster egress. The proxies answered IPv6
in 13 ms from a workstation on another VLAN throughout.

Applied to k8s-1 first and verified before rolling to the other two, because
the existing precedent disables v6 on a VLAN *child* while this targets VLAN
*parents*, and mgmt0 carries the address Ceph and Cilium use:

  mgmt0 kept 2003:d6:9718:3b02:… and fd40::…    unchanged
  fd00:: addresses                              2 -> 0
  IPv6 default routes                           4 -> 2 (mgmt0, lo)
  failing blackbox probes                       5 -> 0
  Ceph                                          217 PGs active+clean, no OSD restart

All three nodes are already patched live; this puts it in git so the next
apply-config does not undo it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 07:51:51 +02:00
k.renner f7f04281c0 Merge pull request 'fix: [alert] AlertmanagerClusterFailedToSendAlerts (monitoring) (#649)' (#650) from alert-fix/issue-649 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#650
2026-08-27 04:54:09 +00:00
k.renner c4a6d72cd6 Merge pull request 'fix: [alert] KubeStatefulSetReplicasMismatch (openbao) (#645)' (#648) from alert-fix/issue-645 into master
Reviewed-on: k.renner/homelab#648
2026-08-27 04:53:56 +00:00
k.renner 8c019e50d2 Merge pull request 'feat(monitoring): alert on OpenBao replicas being unavailable' (#647) from openbao-alerting into master
Reviewed-on: k.renner/homelab#647
2026-08-27 04:53:34 +00:00
security-bot c9e9830092 chore(image-automation): bump ghcr.io/music-assistant/server:2.10.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-27 01:17:36 +00:00
issue-fix-bot 7e825aff0f fix: [alert] AlertmanagerClusterFailedToSendAlerts (monitoring) (#649)
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
2026-08-26 23:45:38 +00:00
issue-fix-bot 781b849adb fix: [alert] KubeStatefulSetReplicasMismatch (openbao) (#645)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
2026-08-26 22:45:57 +00:00
claude-botandClaude Opus 5 af81045277 feat(monitoring): alert on OpenBao replicas being unavailable
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
There was no OpenBao alerting of any kind — no rule, no ServiceMonitor. That
is why today's seal went unnoticed for two hours: the k8s-1 Talos upgrade
sealed the single replica, the ClusterSecretStore went InvalidProviderConfig,
and nothing said so.

Three replicas make this more important rather than less. Before, a seal was a
visible outage that announced itself. Now the service survives one, so a
sealed replica is a quiet loss of redundancy that nothing surfaces until the
next drain takes quorum with it.

Watches pod readiness rather than a seal metric, because there is no seal
metric to watch. OpenBao is not scraped: no ServiceMonitor exists, and its
metrics endpoint needs a token or an explicit unauthenticated_metrics_access
telemetry setting, neither configured. Verified against the running
Prometheus — no bao_* or vault_* series at all. External Secrets exports
nothing usable either, so the ClusterSecretStore going NotReady is invisible.

Readiness carries the signal for a structural reason, not by luck: a sealed
server fails its readiness probe because it can neither serve requests nor act
as a raft standby. Sealed is therefore always not-Ready. The converse does not
hold — a crash or stuck scheduling fires the same alert — but those want a
human too.

Three rules:

  OpenBaoReplicaNotReady   < 3 ready for 10m, warning. The duration only
                           ignores ordinary rescheduling; a sealed replica
                           never recovers on its own.
  OpenBaoQuorumLost        < 2 ready for 5m, critical. Raft quorum of three
                           is two, so below that the store is down.
  OpenBaoMetricsMissing    absent() for 15m, warning. Both rules above are
                           threshold comparisons, so if the series disappears
                           they stop evaluating and the silence is
                           indistinguishable from health.

Verified against the running Prometheus rather than assumed: the selector
matches (the same query with `< 4` returns the series at 3), and the alerting
thresholds correctly return nothing at 3/3 ready. Testing only that it returns
nothing would not have caught a typo in the selector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:00:10 +02:00
k.renner 71d6bd3724 Merge pull request 'feat(openbao): run three raft replicas so a node drain is not an outage' (#646) from openbao-ha-3-replicas into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#646
2026-08-26 20:51:03 +00:00
claude-botandClaude Opus 5 0809823511 feat(openbao): run three raft replicas so a node drain is not an outage
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
OpenBao ran a single replica. Every node drain took the whole secret store
down with it: today the k8s-1 Talos upgrade sealed it, the ClusterSecretStore
went InvalidProviderConfig, and it stayed that way for two hours because
nothing was watching.

Three, not two. Raft needs a quorum and the quorum of two is two, so a
two-replica cluster tolerates no failures — it would stop the moment one pod
is sealed or rescheduled, which is worse than the single replica it replaced.
Three tolerates one.

What this buys: a drain seals the replica on that node, and raft elects a new
leader from the remaining standbys within seconds. The service does not go
down.

What it does not buy, stated plainly because it is easy to assume otherwise: a
sealed server can neither serve requests nor act as a standby, so that replica
stays out of the cluster until someone unseals it. Scaling removes the outage,
not the manual unseal. For rolling node maintenance the consequence matters —
a Talos upgrade drains all three nodes in turn, so unseal each replica as it
comes back, or the third drain takes the last unsealed one and quorum is lost
anyway.

retry_join is what makes a scaled replica join the existing cluster rather
than come up isolated and uninitialized; without it each new pod would need a
manual `bao operator raft join`. All three peers are listed on every node —
the stanza is a list of possible leaders, a node ignores its own entry, and
listing all three keeps the config identical across replicas regardless of
which pod currently leads. Addressed through the headless service so each
entry resolves to one specific pod.

The snapshot job moves from the plain service to openbao-active. With one
replica the plain service could only ever hit the one pod; with three it
selects any of them, and a raft snapshot has to be taken against the leader.

No affinity was added: the chart already applies a required podAntiAffinity on
kubernetes.io/hostname, so the replicas spread across the three nodes on their
own — verified against the running StatefulSet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:49:41 +02:00
issue-fix-bot 5155581fba fix: [alert] TargetDown (default) (#635)
ci/woodpecker/pr/ci-manifests-test Pipeline is pending
ci/woodpecker/pr/ci-scripts-test Pipeline is pending
ci/woodpecker/pr/pr-review Pipeline is pending
2026-08-26 20:45:37 +00:00
k.renner 96acff3456 Merge pull request 'chore(talos): drop the node addresses on the Ceph SAN' (#643) from ceph-san-cleanup into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#643
2026-08-26 20:18:18 +00:00
claude-botandClaude Opus 5 b5a1818896 chore(talos): drop the node addresses on the Ceph SAN
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 nodes held 10.99.0.1-.3/29 on enp2s0f1np1 only because reaching that link
required CephCluster.spec.network.provider: host, which is not per-daemon and
put every Ceph daemon on host networking. Rook now attaches the link to the
OSD pods through a Multus macvlan, and a macvlan parent only needs to be up —
it does not need an address of its own for its children to work.

By the rule this segment is built on — Ceph-internal traffic and nothing else,
no router, no other participants — the node has no business holding an address
there either. This is stricter than before, not looser.

The link keeps up: true and mtu: 9000. Both still matter: the parent must be
up for the macvlan children to exist, and the MTU is what the children
inherit.

Not in this commit, deliberately: widening the attachment's whereabouts pool
from .4-.6 to the full /29. That must not land before these addresses are
actually gone from the running nodes, or whereabouts could hand .1 to an OSD
while a node still holds it. Follow-up once these configs are applied and
verified.

Also records the one-time step that the manifests cannot express. Removing
addressRanges did not clear public_network and cluster_network from Ceph's
config database — the same trap the bluestore comment in the same file
describes. The first OSD of the phase A rollout came up on the pod network,
still had public_network 10.2.40.0/24 in the config database, found no
interface in it and crash-looped on "Failed to pick public address". Both keys
were removed by hand. A rebuild from scratch will not hit this because the
config database starts empty; another provider change on a live cluster will.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:17:15 +02:00
k.renner 1a94adb4c4 Merge pull request 'feat(rook-ceph): attach the cluster network via Multus (phase B of two)' (#642) from ceph-provider-phase-b into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#642
2026-08-26 20:08:31 +00:00
k.renner e9f3175b5b Merge pull request 'refactor(rook-ceph): drop the host network provider (phase A of two)' (#640) from ceph-provider-phase-a into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#640
2026-08-26 19:54:07 +00:00
claude-botandClaude Opus 5 a775b675d4 feat(rook-ceph): attach the cluster network via Multus (phase B of two)
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
Puts the OSDs back on the VLAN 49 link that phase A gave up, without putting
every Ceph daemon on host networking to get there.

The cluster network is an OSD concern. Per the Ceph documentation it carries
OSD heartbeat, replication and recovery, and "Ceph Monitors always operate on
the public network". Reaching it through addressRanges required
provider: host, which is not per-daemon — the mons went onto host networking
as a side effect, making a mon's address the node's DHCP lease.

That failure is documented rather than hypothetical. When a lease moved, the
mon could not bind ("Cannot assign requested address") and crash-looped. Rook
detected it and began a failover, but with three mons on three nodes each
holding the mon host ports the replacement could not be scheduled anywhere —
the canary sat Pending on "didn't have free ports". Two of three nodes hit it
during the Talos v1.12.11 upgrade; both needed hand intervention.

Selecting only `cluster` leaves `public` on the pod network, so mons, MDS,
MGR and the CSI pods keep Rook-managed identities and hold no host ports.
That removes the failure mode rather than working around it.

Isolation is not weakened. Rook's Multus prerequisites about hosts routing to
the Multus network apply to a Multus *public* network; selecting only
`cluster` means they do not apply here. VLAN 49 stays Ceph-internal traffic
and nothing else, with no router and no other participants.

MUST NOT be applied before phase A. Verified rather than assumed — kubectl
diff against the current cluster refuses it:

  spec.network.provider: Invalid value: "multus": network provider must be
  disabled (reverted to empty string) before a new provider is enabled

Second of the two rolling OSD restarts, bounded by the same two settings: one
OSD at a time, only once PGs are clean.

Follow-up, not in this commit: once this settles, the node addresses on VLAN
49 (10.99.0.1-.3) have no remaining purpose and should come out of the Talos
configs, after which the attachment's pool can take the whole /29 instead of
the three addresses it reserves today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:52:38 +02:00
claude-botandClaude Opus 5 a6dd1f4be2 refactor(rook-ceph): drop the host network provider (phase A of two)
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
Intermediate state, not a destination. san-network-attachment.yaml is already
in the cluster and stays unused until phase B sets provider: multus.

The detour is forced by Rook's own CRD validation, not chosen:

  rule:    self == '' || oldSelf == '' || self == oldSelf
  message: network provider must be disabled (reverted to empty string)
           before a new provider is enabled

host -> multus is rejected outright. It has to pass through empty, and an
empty provider means the default CNI — so addressRanges, which only takes
effect under provider: host, goes with it.

What this costs while it is in place: there is no cluster network. OSD
heartbeat, replication and recovery move onto the pod network and share it
with everything else instead of the dedicated X710 SFP+ link. Slower, and
every OSD restart pays that price in backfill. Not a correctness problem —
the OSDs can still reach one another throughout, which is the property that
matters, and the one a separate subnet would have broken.

The mons do not move here. Rook leaves existing mons on the network they were
created with; they change only on failover, which is deliberate and separate.

Bounded by the two settings that landed ahead of this: one OSD restarts at a
time, and only once PGs are clean again. Expect three sequential OSD restarts
with full recovery between them, and the same again in phase B.

kubectl diff against the live cluster shows provider host -> "" and the
generation bump. The removal of addressRanges does not appear there, because
this resource carries no managedFields and client-side diff cannot see a
field deletion. It is inert either way — Rook does not read addressRanges
without provider: host — but worth confirming after the apply rather than
assuming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:51:08 +02:00
k.renner 5d923c84e0 Merge pull request 'fix(rook-ceph): bound OSD restarts to one at a time, gated on healthy PGs' (#639) from ceph-osd-update-safety into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#639
2026-08-26 19:44:29 +00:00
claude-botandClaude Opus 5 c88ee0e9a0 fix(rook-ceph): bound OSD restarts to one at a time, gated on healthy PGs
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
Rook's defaults are wrong for a cluster this size, and nothing here had
overridden them:

  storage.osdMaxUpdatesInParallel   default 20
  upgradeOSDRequiresHealthyPGs      default false

Twenty parallel OSD restarts is a small fraction of a large cluster. Here it
is all three, at once, with no gate on PG recovery in between. Every PG goes
inactive and every pool — RBD volumes and both CephFS mounts — becomes
unreachable for the duration. Not a degraded cluster, a stopped one.

With three OSDs at size 3 there is no spare copy to absorb the second
restart, so the health gate between one OSD returning and the next going down
is the only thing separating a rolling change from an outage.

This binds any change that rewrites the OSD deployment spec, not only Ceph
version upgrades. The immediate reason is the pending network provider
switch, which is exactly such a change — but the exposure predates it and
would have applied to any of them.

Lands inert: `kubectl diff` against the live cluster shows the two added
fields and the generation bump, nothing else. No OSD deployment spec changes,
so nothing restarts.

Deliberately separate from the provider switch rather than bundled with it,
so the bound is in place and observed before the operation it exists to
bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:43:19 +02:00
k.renner b0a473d038 Merge pull request 'feat(rook-ceph): add the VLAN 49 attachment for Ceph's cluster network' (#638) from ceph-san-nad into master
Reviewed-on: k.renner/homelab#638
2026-08-26 19:41:39 +00:00
claude-botandClaude Opus 5 585ea36c9f fix(rook-ceph): keep the SAN pool on-link with the node addresses
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The pool was 10.99.1.0/24, a separate subnet on the same segment, chosen to
avoid carving up a /29 with only three addresses left. That choice would have
broken the migration it exists for.

OSDs move one at a time, so the cluster runs mixed: an OSD already on the
attachment has to reach an OSD still using its node's host address on
10.99.0.x. There is no router on this segment — by design, it carries
Ceph-internal traffic and nothing else — so a pool outside the /29 is
unreachable from the un-migrated side. The two OSDs would mark each other
down, during precisely the window that is supposed to be the safe one.

Moves the pool into 10.99.0.0/29 with range_start/range_end reserving .4-.6,
leaving .1-.3 to the nodes. That is one address per OSD with nothing spare,
which is sufficient rather than merely tolerable: osdMaxUpdatesInParallel
bounds the migration to one OSD at a time, so allocations grow 1, 2, 3 and
never overlap. Headroom returns after the switch is confirmed and the node
addresses come out of the Talos configs — then the pool takes the whole /29.

Verified: the embedded CNI JSON parses with the intended range and bounds;
the NAD passes a server-side dry run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:37:41 +02:00
claude-botandClaude Opus 5 296f79469e feat(rook-ceph): add the VLAN 49 attachment for Ceph's cluster network
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
Step 2 of moving Ceph's cluster network off host networking. Inert on its
own: a NetworkAttachmentDefinition does nothing until a pod references it,
and nothing will until cephcluster.yaml names it under
network.selectors.cluster.

Why this exists at all: `addressRanges` only takes effect under
`provider: host`, so carrying replication over the dedicated 10G link meant
setting a provider that is not per-daemon. It puts mons on host networking
too, which makes a mon's address the node's DHCP lease — and when that lease
moves the mon cannot bind. Three mons on three nodes then each hold the mon
host ports, so Rook cannot schedule the replacement it just decided to create
and the failover stalls. Both happened during the v1.12.11 upgrade, on two of
three nodes.

None of that serves the goal. Per the Ceph documentation the cluster network
carries OSD heartbeat, replication and recovery only — "Ceph Monitors always
operate on the public network". Attaching it here gives the OSDs the link and
leaves every other daemon on the pod network.

Its own subnet rather than the existing /29. The nodes hold 10.99.0.1-.3 for
as long as Ceph still uses host networking, which leaves exactly three
addresses for three OSDs — no headroom during the one phase where things
actually move, and a leaked allocation from a hard node loss would mean an
OSD without an address. OSDs never need to reach the node addresses, since
the cluster network is OSD-to-OSD, so the pool sits in its own range on the
same segment instead. A /24 for three addresses costs nothing: nothing else
can ever live on this segment, and it takes address count off the table when
OSDs are added.

After the provider switch is confirmed, the node addresses on VLAN 49 have no
remaining purpose and can come out of the Talos configs.

No gateway and no routes, because there is no router on this segment and
there must not be one. That also means no `sbr` chaining, unlike the v30iot
attachments — there is no DHCP-supplied default route to confine here.

whereabouts for IPAM: dhcp needs a server that must not exist on the SAN, and
host-local keeps state per node with no cluster-wide coordination, so two
nodes would hand out the same address on shared L2.

MTU 9000 matches the link. macvlan inherits it, but stating it means a change
to the parent cannot silently halve replication throughput with nothing in
the tree recording the expectation.

Verified: whereabouts from step 1 is running on all three nodes with its
binary installed next to macvlan; the NAD passes a server-side dry run; the
embedded CNI JSON parses and carries the intended master, mtu, ipam type and
range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:33:02 +02:00
k.renner f1f2c6ac6b Merge pull request 'feat(multus): add whereabouts IPAM for segments without a DHCP server' (#637) from whereabouts-ipam into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#637
2026-08-26 19:28:54 +00:00
claude-botandClaude Opus 5 ebd9625926 feat(multus): add whereabouts IPAM for segments without a DHCP server
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
Groundwork for moving Ceph's cluster network off host networking. Nothing
references whereabouts yet, so this lands inert: no existing attachment
changes, and the DaemonSet only installs a CNI binary that nothing invokes
until a NetworkAttachmentDefinition names it.

The Ceph SAN on VLAN 49 carries Ceph-internal traffic and nothing else, by
design. Neither IPAM already available covers it:

  dhcp        cni-dhcp-daemon works where a DHCP server is on the wire.
              There is none on the SAN and there must not be one.
  host-local  installed by cni-plugins-installer, but it keeps allocation
              state per node with no cluster-wide coordination. On a shared
              L2 segment two nodes hand out the same address.

Whereabouts coordinates through the Kubernetes API — allocations live in the
ippools and overlappingrangeipreservations CRDs. Nothing about assigning an
address touches the segment being addressed, which is the property that makes
it usable on an isolated SAN.

The DaemonSet runs hostNetwork and privileged. That is upstream's shape, and
it matches what multus-daemonset.yaml, cni-dhcp-daemon.yaml and
cni-plugins-installer.yaml already do: the pod writes a CNI binary and its
config into /opt/cni/bin and /etc/cni/net.d, which is node plumbing, and it
has to work before the attachment it serves exists. It is the fourth instance
of an existing exception, not a new one.

The reconciler is split out deliberately. It needs no host access — only the
API, where the allocations are — so it runs as an ordinary pod-network
Deployment. It matters more than it looks: the normal release path is the CNI
DEL when a pod goes away, which does not run when a node is lost hard, and on
a /29 a handful of leaked addresses is the difference between an OSD getting
one and not.

Vendored from upstream v0.9.4 with the image tag pinned — upstream ships
`:latest`. Tag rather than digest, matching the other images in this
directory. The three CRDs are vendored unmodified so a future upstream diff
stays readable. nodeslicepools is included because the ClusterRole references
it, though the node-slice controller itself is not deployed.

Verified: kustomize builds; all nine resources pass a server-side dry run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:24:52 +02:00
k.renner 6716f7dc50 Merge pull request 'chore(talos): move to v1.12.11 and add i915 to the schematic' (#634) from talos-1-12-11 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#634
2026-08-26 16:44:36 +00:00
claude-botandClaude Opus 5 d505503616 chore(talos): move to v1.12.11 and add i915 to the schematic
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 nodes run v1.12.6; v1.12.11 is the last patch of the 1.12 series. Upstream
tests migration only between adjacent minors and recommends going to the latest
patch of each intermediate minor, so 1.12.11 is the required stop before 1.13,
not an optional one.

Adds siderolabs/i915 while the image is being rebuilt anyway. Nothing requests
/dev/dri today — every manifest was checked, the only devices any workload asks
for are Ceph RBD and the NVMe disks by-id. It is included for the reason
thunderbolt already is: the extension set is fixed at install and upgrade time,
so adding it later would mean another rolling upgrade of all three nodes rather
than a config change. The MS-01's Alder Lake-P Iris Xe is present in hardware
either way.

New schematic 0f6fb8bd…, identical to a21cec8f… plus i915. Registered with the
Image Factory and the resulting installer image verified to exist for v1.12.11.

The absences are deliberate and now written down, so they are not re-derived as
gaps later: the X710 (i40e) and I226 (igc) NICs need no firmware extension —
intel-ice-firmware covers E810/ice only; mei is a prerequisite for Intel Arc
discrete GPUs and there are none; iscsi-tools, nvme-cli and util-linux-tools
have no consumer, since storage is Ceph RBD and device health comes from Ceph
rather than a node-level exporter.

Both runbooks move to the new schematic and version.

Note for whoever applies this: talosctl upgrade --image does NOT write
.machine.install.image, so this config has to be applied separately or the node
keeps pointing at whatever it was installed with. That is how the running nodes
ended up on ghcr.io/siderolabs/installer:v1.12.6 despite this file naming a
Factory image — which is also why talosctl get extensions is empty on all
three.

Kubernetes is untouched; talosctl upgrade does not move it. It stays on v1.35.2
and is a separate upgrade-k8s step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:33:37 +02:00
k.renner 2ba950497d Merge pull request 'feat(home-assistant): relay IoT-VLAN mDNS into HA's zeroconf as unicast' (#633) from mdns-relay into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline failed
Reviewed-on: k.renner/homelab#633
2026-08-26 15:46:41 +00:00
claude-botandClaude Opus 5 664bc72eb4 fix(home-assistant): confine the mDNS relay's receive sockets to net1
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
Both sockets bound the wildcard, so they accepted datagrams on eth0 as well as
net1. The namespace CNP grants every pod in home-assistant unrestricted access
to every other pod on every port, so any sibling pod could reach :5353 over the
cluster network and have records forwarded into HA's zeroconf cache. The
header comment described only the IoT-VLAN side of that.

so-bindtodevice=net1 confines delivery to datagrams that actually arrived on
the IoT VLAN interface. Binding to net1's address is still not possible — it is
DHCP-leased and unknown at exec time — but binding to the device gets the same
confinement without needing the address, and needs no capability: verified that
SO_BINDTODEVICE succeeds as uid 65534 with all capabilities dropped. Also
verified it costs no reception: 6 packets in 12s with the option, 6 in 12s
without.

Scope of what this fixes, stated exactly: the relay is not what makes HA's
zeroconf reachable from inside the cluster. HA binds :5353 on its pod address
(v4) and the wildcard (v6) — confirmed in /proc/net/udp and /proc/net/udp6 —
and the namespace CNP already permits any sibling pod to reach it directly.
This removes a second route to that listener, not the first.

Adds ipv6-v6only=1 on the v6 socket. The two containers share the pod's network
namespace, so a dual-stack v6 socket overlaps the v4 container's port. Measured
behaviour without it was no contention — both bound cleanly and the v4 receiver
kept taking packets throughout the v6 container's lifetime — so this is not a
latent bug being fixed, but the outcome depended on the kernel's bindv6only
default rather than on anything the manifest said. The roles are now explicit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:11:33 +02:00
claude-botandClaude Opus 5 14e45a540c fix(home-assistant): relay mDNS over IPv6 as well, not just IPv4
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 relay joined 224.0.0.251 only. mDNS is two protocols, and a socket joined
to one group hears nothing of the other, so every IPv6 announcement was
dropped — and a device that announces only over v6 would have stayed exactly
as invisible as before the relay existed.

Not hypothetical for this cluster: HA's zeroconf holds ff02::fb on eth0
alongside the v4 group, verified in /proc/net/igmp6. The listener was already
waiting for traffic the relay was never going to send it.

Adds a second socat container for the v6 group. Two processes rather than one
because socat carries a single address pair per invocation and the families
need different receive and send addresses. They fail independently and the
kubelet restarts each on its own.

Joining ff02::fb needs no address configuration on net1. The group is
link-local, so the kernel's automatic link-local address on the macvlan is
enough to receive on it; whether v30iot hands out global v6 addresses does not
affect reception.

The delivery Service becomes dual-stack (PreferDualStack with both families
listed, matching mosquitto-iot in this namespace), so each family is forwarded
over its own transport instead of folding v6 announcements into a v4 hop.
UDP4-SENDTO takes the A record and UDP6-SENDTO the AAAA from the same name.

Verified: the v6 socat address syntax parses and joins; a server-side dry run
allocates both families to the Service; all three manifests still apply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 16:54:43 +02:00
claude-botandClaude Opus 5 4965f53990 feat(home-assistant): relay IoT-VLAN mDNS into HA's zeroconf as unicast
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
HA's zeroconf listener joins 224.0.0.251 on eth0 and hears nothing. The HA
pod has received 0 multicast frames against 1.9M packets total. Two
independent reasons: multicast is link-local, so IoT-VLAN announcements never
reach the pod CIDR; and Cilium runs tunnel mode (VXLAN) with no
`enable-multicast`, so it does not replicate multicast between pods either.

The visible cost is that the ESPHome integration dials each device at the
address cached in its config entry and has no way to learn when that address
changes. A rotated lease produces `[Errno 113] Connect call failed` until
someone repoints the entry by hand. All eight ESPHome config entries were
created by `source=zeroconf` and now carry `discovery_keys: dhcp` — both
paths are inert today, so the addresses are frozen at whatever they were when
last discovered.

An avahi-style reflector cannot fix this. It receives on one interface and
re-emits as multicast on the other, and the second half is exactly what the
CNI drops. The published recipes for this all put avahi on the node, which
Talos does not permit.

So the relay bridges the last hop as unicast, which is the one direction the
CNI carries. It holds the multicast membership on v30iot through its own
macvlan and forwards each datagram to HA's :5353.

This works because python-zeroconf does not filter on how a packet arrived —
verified against _listener.py: datagram_received checks size, deduplicates,
and parses, with no test on destination address, source address, or source
port. Unicast mDNS is RFC 6762's QU mechanism, so this stays inside the
protocol.

Scope of the fix, stated plainly: passive reception only. RFC 6762 requires a
responder to re-announce when its address changes, which is the failure this
addresses, and new devices announcing themselves are seen too. Queries HA
sends still leave on eth0 and die there, so nothing that depends on
soliciting a response is restored.

No macvlan on the HA pod itself — that stays off-table, and the outcome since
it was removed supports keeping it that way. A separate relay was already the
recorded intent; this is that relay.

Shape follows shelly-manager-iot, which is the working precedent for L2
presence on v30iot: macvlan with DHCP IPAM, chained with sbr so the leased
default gateway cannot displace eth0's route. The forward path runs over eth0,
so that route has to survive.

Delivery target is a dedicated ClusterIP rather than the pod. socat resolves
its target once at start and cannot chase pod IPs; a ClusterIP is stable and
Cilium re-DNATs to the current backend. Kept separate from the
`home-assistant` Service, which fronts the nginx sidecar and is the XFF trust
anchor.

Exposure, since it is not zero: socat binds :5353 on all interfaces including
net1, because net1's address is DHCP-leased and unknown at exec time. Any host
on the IoT VLAN can send it a datagram that reaches HA's zeroconf cache — the
same trust already extended to real mDNS from that segment, which any device
there can equally forge. macvlan traffic bypasses Cilium policy, so the
namespace CNP does not constrain that interface. The container runs socat and
nothing else.

No CNP change needed: home-assistant-ns.yaml already allows intra-namespace
traffic on all ports.

Verified before committing: the socat address syntax joins the group and reads
in a loop without forking per datagram (UDP4-RECV, not UDP4-RECVFROM); it runs
as uid 65534 with no privileged operation; the image digest is pinned from the
registry; kustomize builds; all three manifests pass a server-side dry run.

Not yet verified, and only observable once deployed: that HA acts on the
forwarded records. The check is `discovery_keys` flipping back to zeroconf, or
a device surviving a lease change without hand-editing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:02:15 +02:00
k.renner e7c70ea368 Merge pull request 'fix(ansible): stop rotating netbird_proxy_token — Ansible does not own both ends' (#632) from netbird-rotate-migration into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#632
2026-08-26 11:04:55 +00:00
k.renner 4187aa91cb Merge pull request 'docs(runbook): record the half-open socket failure after a node move' (#630) from runbook-stale-sockets into master
Reviewed-on: k.renner/homelab#630
2026-08-26 11:04:38 +00:00
claude-botandClaude Opus 5 6ff5cb7d7f fix(ansible): stop rotating netbird_proxy_token — Ansible does not own both ends
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Rotated turn_secret and relay_secret in production; both are correct and stay
rotated. proxy_token was in the same set and should never have been: rotating
it locked every proxy out of the management server.

The misjudgement was specific. proxy_token appears in proxy.env on the server
AND in proxy.env on each proxy, both Ansible templates, which looked exactly
like the symmetry test the other two pass. It is not: the management server
never reads it from a file Ansible writes. The token is registered in the
management DATABASE, so rotating it changed only what the proxies present.

  management: proxy auth failed: rpc error: code = ResourceExhausted
              desc = too many failed authentication attempts   (~75/min, steady)
  proxy:      http: TLS handshake error ... unknown domain "home.renner.uno"

The second follows from the first — a proxy that cannot authenticate never
receives its domain configuration, so it rejects the domains it holds valid
certificates for.

Recovered by restoring the original proxy_token from the pre-rotation vault and
re-applying both roles. Auth failures went to zero and the domain errors
stopped. turn_secret and relay_secret keep their new values; both appear in
management.json.j2, so both ends really are Ansible's.

vault_netbird_proxy_token moves back into vault.yml. It was in
netbird-rotatable.yml, which this play rewrites wholesale — the next run would
have silently dropped it. Verified by re-running both deploys: changed=0.

Adds the test that would have caught this, in the play's own words: a secret is
safe to rotate here only if it appears in management.json.j2. Templating both
sides of a wire is not the same as owning both ends of a trust relationship.

Also corrects the shadow-key guard. vault.yml carried BOTH
vault_netbird_turn_secret and a bare netbird_turn_secret; the role default maps
one to the other, but a group var outranks a role default, so the bare key won
and rotating only the prefixed one would have changed nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:56:05 +02:00
claude-botandClaude Opus 5 c8b0d1cd4b docs(runbook): record the half-open socket failure after a node move
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
Anything holding a long-lived TCP session to a device outside the cluster keeps
a half-open socket when the node under it moves. The peer drops its side; the
pod never learns, because no RST arrives and no keepalive fires.

This cost real time after the conversion. Home Assistant held seven ESTABLISHED
sockets to ESPHome devices that were themselves reporting "not connected". It
logged nothing and retried nothing, because as far as it knew the connections
were fine — so the search went through logs that were empty by construction.
The one device Home Assistant did log was the only one genuinely offline.

Records the check that settles it in one command, reading /proc/net/tcp in the
pod, and notes that reloading the integration is the lighter fix than
restarting the pod.

Placed in "Verify and restore" rather than the known-stale list: it is something
to check before calling a conversion finished, not a trap in the tooling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:13:21 +02:00
k.renner 19462a3792 Merge pull request 'chore(ansible): remove monitoring-agent, which shipped to endpoints that are gone' (#629) from drop-monitoring-agent into master
ci/woodpecker/push/deploy-claude-workstation Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline failed
Reviewed-on: k.renner/homelab#629
2026-08-26 10:11:13 +00:00
k.renner 75710eab2e Merge pull request 'chore(ansible): remove dead plays and the 26 unreferenced vault keys' (#627) from ansible-drop-dead-plays into master
Reviewed-on: k.renner/homelab#627
2026-08-26 10:10:46 +00:00
claude-botandClaude Opus 5 b76a948e28 chore(ansible): remove monitoring-agent, which shipped to endpoints that are gone
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
common.yml applied monitoring-agent (Alloy) to every inventory host, pointing at
loki.srv.renner.uno:3100 and prometheus.srv.renner.uno:9090. Both moved into the
cluster and neither name resolves any more, so nas-1, claude-ws, netbird and the
proxies have been shipping logs and metrics nowhere.

Repointing them was the obvious fix and the wrong one. The cluster endpoints are
authenticated — loki.renner.uno returns 401 and prometheus.renner.uno sits behind
an Authentik forward-auth gate — while monitoring-agent has no auth support at
all. Making it work would mean per-host credentials, htpasswd entries in the
cluster secret, and some answer for remote-write past the gate. Nobody reads
those metrics: edge-1 is the host that matters, and edge-2 when it exists.

edge hosts are unaffected. install-edge-host.yml is self-contained and
deliberately never carried monitoring-agent; they ship via edge-log-shipper,
which already does this correctly — authenticated push to the cluster ingress
with a per-host credential.

install-netbird-monitoring.yml goes too; applying monitoring-agent to
netbird_server was its only content.

Comments referring to the role are corrected in install-edge-host.yml,
install-claude-workstation.yml, the claude-workstation role and its RUNBOOK.
Three more described the old srv Loki as "firewalled off the IoT VLAN" — it is
not firewalled, it is gone. The reason edge hosts use the authenticated ingress
is unchanged, but the stated reason was no longer true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:09:37 +02:00
claude-botandClaude Opus 5 9733796067 chore(ansible): drop the 26 vault keys nothing references
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
group_vars/all/vault.yml held 35 keys. Nine are referenced by the surviving
tree, all NetBird:

  netbird_oidc_client_id            vault_netbird_auth_client_secret
  netbird_oidc_client_secret        vault_netbird_datastore_key
  netbird_oidc_issuer               vault_netbird_proxy_token
  netbird_turn_secret               vault_netbird_relay_secret
                                    vault_netbird_turn_secret

The other 26 belonged to Proxmox, n8n, owncloud, the grafana and gitea OIDC
pairs, woodpecker, SNMP, cloudflared, the UniFi Poller account and both Home
Assistant tokens — services that now run in-cluster with their own credentials,
or no longer exist at all.

Edited YAML-aware rather than line-based: at least one value was a multi-line
block, which a line delete would have corrupted.

Verified the nine survivors are byte-identical, by comparing a normalised hash
of just those keys before and after, and again after re-encrypting:

  e9fbbcef4127b84289ebf32e3b99e59a68e96e275af3c8c17d8ad0414a08e790

Same hash all three times. Decryption happened in a 0600 file outside the repo,
shredded afterwards; no plaintext touched the working tree.

Removing a key here does NOT revoke the credential. Anything still live in its
target system needs revoking there — that is a separate exercise, and the Home
Assistant tokens in particular are unreferenced here but valid in HA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 11:26:54 +02:00
claude-botandClaude Opus 5 c75c7accd5 chore(ansible): remove two more plays whose hosts are gone
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Missed in the Proxmox removal. I built that list from a `hosts:` listing I had
truncated with `head -40`, so anything past the fortieth line was never
considered — a self-inflicted blind spot rather than a judgement call.

  install-postgres-monitoring.yml  authentik-db-1.srv.renner.uno and
                                   prometheus.srv.renner.uno, both NXDOMAIN
  configure-audiobookshelf.yml     audiobookshelf.srv.renner.uno is NXDOMAIN;
                                   audiobookshelf runs in-cluster

The postgres-exporter role goes with them — install-postgres-monitoring.yml was
its only consumer.

Found by cross-referencing every vault key against the surviving tree rather
than by looking at plays again: vault_postgres_exporter_password came back as
"still referenced", which was only true because a dead play still referenced it.

That check now leaves the group_vars/all vault holding nine keys anything uses,
all NetBird, and twenty-six nothing does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 11:21:41 +02:00
k.renner 98e4cb77bd Merge pull request 'chore(ansible): remove Proxmox and the guests that lived on it' (#625) from ansible-drop-pve into master
ci/woodpecker/cron/issue-fix Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#625
2026-08-26 08:56:13 +00:00
k.renner a790a2b13d Merge pull request 'feat(ansible): play to rotate the NetBird secrets Ansible owns' (#626) from ansible-rotate-netbird into master
Reviewed-on: k.renner/homelab#626
2026-08-26 08:55:59 +00:00
claude-botandClaude Opus 5 48130ea4f1 fix(ansible): drop the community.general dependency and harden the guard
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The play generated secrets with community.general.random_string. The CI image
(ci/images/ansible/Containerfile) installs only community.proxmox and
community.docker, so the play failed at its first task when run through the
project's own tooling. It resolved on a workstation that happened to have the
collection, which is exactly why verifying it there proved nothing.

Replaced with ansible.builtin.password, which needs no collection and produces
the same thing: 48 alphanumerics, generated without persisting (path /dev/null).

Also hardens the shadow-key guard. It piped `ansible-vault view` into `grep -c`
with failed_when: false, so a failed decrypt — wrong password, moved file —
sent empty input to grep, which reported zero matches. Indistinguishable from a
clean vault, meaning the guard would have waved the rotation through precisely
when it knew least. A preceding task now proves the vault is readable, and the
assertion requires both that proof and the zero count.

Corrects the openbao rotation path in the header comment; it is under
kubernetes/.

Caught by the review-bot on #626.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 10:23:06 +02:00
claude-botandClaude Opus 5 52899ecb64 fix(ci): remove update-all, which invoked a deleted playbook
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
.woodpecker/update-all.yml ran `ansible-playbook -i inventory/ plays/update.yml`
on a cron. This branch deletes that playbook, because every playbook it
imported targeted Proxmox hosts or guests that no longer exist, so the next
scheduled run would have failed outright.

Removing the pipeline rather than repointing it: there is nothing left for it
to update. Its imports were update-vm, update-lxc, update-authentik,
install-n8n, install-grist, install-music-assistant, update-docker and
update-pve-rolling — all removed here, all targeting hosts that no longer
resolve.

Swept every pipeline for references to deleted plays. The five still named
(install-claude-workstation, install-garage-synology, install-netbird-client,
install-netbird-proxy, install-netbird-server) all survive.

Also from the review:

  common.yml           `all:!pbs` was a no-op I introduced when simplifying the
                       old exclusion list — no pbs group exists. Now plain
                       `all`.
  qemu-guest-agent     dropped the `pve_cluster`/`proxmox_nodes`/
                       `proxmox_all_lxc` group guards; those groups are gone,
                       so the conditions were always true.

Caught by the review-bot on #625.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 10:17:36 +02:00
claude-botandClaude Opus 5 3c6278b144 fix(ansible): guard against vault.yml shadowing the rotated keys
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
group_vars/<group>/ files merge alphabetically and the last one wins.
netbird-rotatable.yml sorts before vault.yml, which still defines
vault_netbird_turn_secret, vault_netbird_relay_secret and
vault_netbird_proxy_token — verified, all three are present.

The rotation itself would have worked, since the plays pass the new values
explicitly and play vars outrank group vars. The damage came later: the next
ordinary run of install-netbird-server.yml or install-netbird-proxy.yml would
read the stale value from vault.yml and push it back out, breaking
server<->proxy auth. Those deploys run unattended on changes to the roles, so
it would have surfaced on something unrelated to secrets.

Adds a pre-flight check that counts — never prints — matching keys in vault.yml
and refuses to rotate while any remain, plus the one-time migration step in the
header.

Caught by the review-bot on #626.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:50:40 +02:00
claude-botandClaude Opus 5 b3f785645f feat(ansible): play to rotate the NetBird secrets Ansible owns
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Rotates the three shared secrets where Ansible controls both ends, so the two
sides cannot disagree:

  vault_netbird_turn_secret   turnserver.conf (coturn) + management.json, one
                              role, one host
  vault_netbird_relay_secret  management.json on the server + relay.env on the
                              proxies
  vault_netbird_proxy_token   proxy.env on both

Opt-in, because rotation is not idempotent and briefly interrupts the mesh:

  ansible-playbook plays/rotate-netbird-secrets.yml -e rotate_confirm=yes

New values are written to a dedicated encrypted file rather than edited into
the main vault. The plaintext is rendered at 0600 with a .new suffix, encrypted,
and only then moved into place, so a failure at any step cannot leave readable
secrets where the inventory would load them. The play asserts the installed
file carries the $ANSIBLE_VAULT header before proceeding.

Two are deliberately excluded and the play says why:

  vault_netbird_datastore_key       encrypts the datastore at rest; changing it
                                    without dump/restore makes existing data
                                    unreadable
  vault_netbird_auth_client_secret  its other half is in Authentik, so rotating
                                    only the consumer breaks login

These are Ansible's own credentials, not copies of the cluster's. OpenBao holds
separate credentials for some of the same systems, deliberately different values
— one consumer, one credential, independently revocable. This play does not
touch OpenBao and is not meant to.

Proxies roll one at a time so a relay stays reachable. The server is
reconfigured first, so there is a short window where a not-yet-updated proxy
fails to authenticate; clients reconnect and peer-to-peer tunnels are not torn
down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:01:18 +02:00
claude-botandClaude Opus 5 712a816f88 chore(ansible): remove Proxmox and the guests that lived on it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Nothing here had a target left. Every removal is backed by a probe rather than
an assumption:

  pve_cluster (jupiter/neptune/saturn)   run Talos; no SSH at all
  authentik-db-1/2, authentik-app-1/2    NXDOMAIN
  grafana.srv, vault.srv, paperless.srv  gone (grafana/paperless resolve but
                                         answer neither ping nor SSH)
  n8n.srv, grist.srv, wiki.srv           NXDOMAIN
  owncloud.srv, ical-proxy.srv, loki.srv NXDOMAIN
  proxmox_* dynamic groups               api.proxmox.yaml queried
                                         jupiter.renner.uno:8006

24 plays, 14 roles and the Proxmox dynamic inventory go. update.yml goes with
them: every playbook it imported is in that list, so the aggregate updater had
no fleet left to update.

Kept, because their hosts answer: netbird server and proxies, nas-1, edge-1 and
its capability groups, claude-ws. common.yml and install-node-exporter.yml lose
exclusion clauses naming groups that no longer exist.

NTP IS NO LONGER HIGHLY AVAILABLE, and this change records that rather than
causing it. ntp_cluster was jupiter (MASTER, prio 100), saturn (90), neptune
(80) and edge-1 (BACKUP, 70); the conversion took the first three. A single
VRRP member holds the address regardless of configured state, so fd40::123 is
still served — verified answering — but by one thin client with nothing behind
it. install-ntp-ha.yml still carries the four-host priority map, now describing
hosts that do not exist. Restoring redundancy needs a second chrony host that
is not a Talos node.

The gatus role is removed entirely, which supersedes the narrower edit in the
DNS cleanup that dropped its Proxmox DNS check.

Left alone: plays/archived/ still references pve_cluster, which is what an
archive is for. paperless-migration stays because an archived play uses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 08:37:43 +02:00
k.renner 6bf1cfae43 Merge pull request 'chore(opentofu): drop the spent removed block for module.k8s_3' (#624) from tofu-state-verify into master
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#624
2026-08-26 06:09:09 +00:00
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
claude-botandClaude Opus 5 dd727769f6 chore: destroy VM 151 and drop saturn from the Talos image seeding
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
saturn is being converted to bare metal, so its VM definition goes and it
leaves the list of Proxmox nodes the pipeline seeds the nocloud image onto.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The three that cost real time:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:24:47 +02:00
k.renner 2038cd34ae Merge pull request 'fix(crowdsec): request 50m CPU instead of the chart's 500m' (#567) from crowdsec-cpu-requests into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#567
2026-08-25 15:07:22 +00:00
claude-botandClaude Opus 5 315c9aeb46 fix(crowdsec): request 50m CPU instead of the chart's 500m
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
Draining k8s-1 for the jupiter conversion left three pods unschedulable:
k8s-2 and k8s-3 both sat at 100% of requested CPU while actually using 21-23%.
Cluster-wide the gap is 19,680m requested against 7,957m used.

crowdsec is the clearest single contributor. The chart defaults reserve 500m
for each agent and for the LAPI; measured draw is 1-2m. As a DaemonSet the
agent alone parks half a core on every node. Nothing here had ever set
resources, so this was an inherited default rather than a decision.

Only the request changes. Limits stay at the chart default, so a log flood can
still burst to 500m; what shrinks is the reservation the scheduler subtracts
from every node whether or not the process uses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:05:05 +02:00
k.renner 3e57ed5c2d Merge pull request 'feat(k8s-1): run Talos on bare-metal jupiter and swap the OSD to U.2' (#566) from jupiter-baremetal-u2 into master
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#566
2026-08-25 14:51:53 +00:00
claude-botandClaude Opus 5 6380f02ca4 feat(k8s-1): run Talos on bare-metal jupiter and swap the OSD to U.2
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
jupiter's Crucial P3 Plus OSD is at 87% endurance used, and reaching the
MS-01's only U.2-capable slot means moving the boot drive anyway. Doing the
bare-metal conversion in the same window avoids opening the machine twice and
throws away no work: the osd-ssd PCI mapping that a Proxmox-side swap would
need to be re-pointed at ceases to exist.

Networking uses the v1.12 multi-document config rather than the deprecated
machine.network.interfaces. The docs do not define what happens when one link
is configured by both schemas, so k8s-1 keeps every link in one of them. This
also buys VLANConfig.name, which is free-form: naming the VLAN 30 link ens19
keeps the four v30iot macvlan NADs working unchanged across VM and bare-metal
nodes, so the cluster can stay mixed without pinning those workloads.

Physical NICs are matched by permanent MAC, not driver — both X710 ports bind
i40e here, unlike the single passed-through port k8s-2/k8s-3 see.

cephfs-metadata drops to size 2 / min_size 1 for the duration. At size 3 on
two remaining hosts every PG sits undersized with min_size 2, so one further
OSD loss would block IO on every CephFS mount cluster-wide. This must go back
to size 3 once k8s-1 rejoins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 16:44:21 +02:00
k.renner 47c1170d51 Merge pull request 'perf(velero): run the restore-test monthly instead of weekly' (#565) from velero-restore-test-monthly into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#565
2026-08-25 13:42:55 +00:00
claude-botandClaude Opus 5 44aa0d6919 perf(velero): run the restore-test monthly instead of weekly
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 sweep restores every discovered Velero schedule, immich's ~180 GiB
library included. At size 2 that is ~360 GiB raw, ~120 GiB per OSD, and
measured on 2026-08-25 it was ~350 of the ~740 GB each drive wrote per
week — ~47% of the endurance budget, against a ~65 GB/day weekday
baseline.

That baseline is itself the result of the 2026-08-18..21 write-reduction
work (#518, #519, #520, CNPG checkpoint tuning, RAM-backed emptyDirs,
paperless data to RBD, CI workspaces to cephfs-ci), which cut per-drive
writes ~70% from ~195 GB/day. With the application tier down, the restore
test is now the single largest consumer of remaining QLC endurance; the
three OSD drives are at 81/82/87% used.

Monthly still exercises every restore path and cuts the sweep's share of
drive wear ~4x, moving per-drive weekly writes from ~740 GB to ~530 GB.

Cadence-only change:
- 0 8 * * 0 -> 0 8 1 * *, keeping the 08:00 UTC slot that clears the
  03:15-07:15 daily backup window (see the comment in the manifest).
- ttlSecondsAfterFinished 14 d -> 40 d, so the previous run is still
  inspectable when the next one fires.
- Comments and docs that state the cadence updated to match, including
  the BLUESTORE_SLOW_OP_ALERT runbook text, which tells the reader to
  check whether a stall fell inside the restore-test window.

No day-of-week form is available: Kubernetes parses cron with
robfig/cron's standard parser, whose day-of-week field allows only
`* / , - ?` (no Quartz `#` nth-weekday), and restricting both day fields
ORs them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 07:44:21 +02:00
k.renner a8a7aa9765 Merge pull request 'docs(zigbee2mqtt): record the WS90 ZCL reporting configuration' (#564) from ws90-reporting-config into master
ci/woodpecker/cron/e2e Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#564
2026-08-24 17:58:48 +00:00
claude-botandClaude Opus 5 c0791e2747 docs(zigbee2mqtt): record the WS90 ZCL reporting configuration
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The Wetterstation now carries a non-default reporting configuration: min
interval 5s on all ten reportable attributes, and tighter reportable-change
deadbands on temperature, humidity and pressure. None of it is expressible
in configuration.yaml - Zigbee2MQTT has no settings-schema field for
reporting configuration - so it lives only on the device and in Z2M's
database.db. This documents it and how to restore it.

min was 10s against an 8.8s transmit interval. Since min is a hold-off, every
change-triggered report was held past a transmit cycle and landed on the
next, so attributes reported at best every 17.6s and drifted out of phase.
The published stream showed no 8.8s periodicity at all. At min 5 each
attribute reports on its own transmit and the stream carries one publish per
cycle, measured at a mean gap of 8.69s. Publish volume fell from ~631/h to
~445/h because each publish is now a whole cycle rather than a fragment.

The deadbands come from the shared modernExtend helpers, which the WS90
definition composes without overrides. They are anti-chatter values sized for
battery-powered indoor sensors. Temperature was 1.00 C, giving a staircase
that moved about twice an hour; it is now 0.1 C, matching the resolution the
device actually reports. Pressure was 5 kPa - 50 hPa, which real barometric
pressure never traverses, so it only ever updated on the hourly heartbeat
that pressure_trend and weather_condition derive from. Note the same library
configures the identical ZCL attribute with a 5 hPa deadband in its older
reporting.ts helper, so the two code paths disagree by 10x.

Anything that makes Z2M re-run configure on the device reverts all of it, and
a fresh PVC will not have it. The restore commands are written out literally
rather than generated by a shell helper, because the nested quoting through
`kubectl exec -- sh -c` is where this goes wrong; each was executed and
verified as written, including the read-back, and re-applying is a no-op.

No behaviour change from this commit itself - the device was reconfigured at
runtime, this only records it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:15:22 +02:00
k.renner 2e00109f66 Merge pull request 'docs(zigbee2mqtt): the WS90 merge does not make the glitch detectable' (#561) from fix-ws90-merge-overclaim into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#561
2026-08-24 17:07:34 +00:00
security-bot 394d8995a6 chore(image-automation): bump woodpeckerci/woodpecker-server:v3.18.0
ci/woodpecker/cron/e2e Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-24 10:50:03 +00:00
security-bot 0685044ce6 chore(image-automation): bump woodpeckerci/woodpecker-agent:v3.18.0
Updates managed by image-automation-controller.
2026-08-24 10:50:01 +00:00
192 changed files with 2854 additions and 6402 deletions
-29
View File
@@ -128,35 +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
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 jupiter saturn neptune; do
EXISTS=$(curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/content" \
-H "Authorization: PVEAPIToken=$API_TOKEN" 2>/dev/null | grep -c "$TALOS_FILENAME" || true)
if [ "$EXISTS" -gt 0 ]; then
echo "Talos image already exists on $node"
continue
fi
echo "Downloading Talos image to $node..."
curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/download-url" \
-H "Authorization: PVEAPIToken=$API_TOKEN" \
--data-urlencode "url=$TALOS_URL" \
--data-urlencode "content=import" \
--data-urlencode "filename=$TALOS_FILENAME" \
--max-time 600
echo "Waiting for download on $node..."
for i in $(seq 1 60); do
sleep 10
EXISTS=$(curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/content" \
-H "Authorization: PVEAPIToken=$API_TOKEN" 2>/dev/null | grep -c "$TALOS_FILENAME" || true)
if [ "$EXISTS" -gt 0 ]; then echo "Download complete on $node"; break; fi
done
done
- tofu init -upgrade -backend-config="access_key=$AWS_ACCESS_KEY_ID" -backend-config="secret_key=$AWS_SECRET_ACCESS_KEY"
- |
if [ -n "$$TOFU_STATE_RM" ]; then
-32
View File
@@ -1,32 +0,0 @@
when:
- event: cron
evaluate: 'PIPELINE == "update-all"'
- event: manual
evaluate: 'PIPELINE == "update-all"'
timeout: 120
steps:
- name: update-all
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
PROXMOX_TOKEN_ID:
from_secret: proxmox_token_id
PROXMOX_TOKEN_SECRET:
from_secret: proxmox_token_secret
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/update.yml -v
+2 -2
View File
@@ -170,8 +170,8 @@ role keeps logs off local flash and monitors wear:
WAL/positions sit on `/run/alloy` (tmpfs), so the agent writes nothing to
the eMMC either. Net effect: no log writes hit local flash — the trade-off
is that logs are RAM-only and lost on reboot unless already shipped.
- **Loki endpoint:** the unauthenticated srv-network Loki
(`loki.srv.renner.uno:3100`) is firewalled off the IoT VLAN, so Alloy pushes
- **Loki endpoint:** the old srv-network Loki
(`loki.srv.renner.uno:3100`) no longer exists — Loki moved into the cluster, so Alloy pushes
to the authenticated cluster ingress `https://loki.renner.uno/loki/api/v1/push`
with basic-auth (`edge_loki_username` / `vault_edge_loki_password`).
**Delivery only works once the `edge-1` htpasswd line has reconciled into
-22
View File
@@ -1,22 +0,0 @@
plugin: community.proxmox.proxmox
user: root@pam
# token_id: ansible
# token_secret: provided via PROXMOX_TOKEN_SECRET env var
url: 'https://jupiter.renner.uno:8006'
validate_certs: true
want_facts: true
want_nodes: false
keyed_groups:
# proxmox_tags_parsed is an example of a fact only returned when 'want_facts=true'
- key: proxmox_tags_parsed
separator: "_"
prefix: proxmox
- key: proxmox_status
prefix: status
compose:
ansible_host: >-
(proxmox_lxc_interfaces | default([]) | selectattr('name', 'equalto', 'eth0') | map(attribute='inet') | list | first | default('') | split('/') | first)
if proxmox_vmtype == 'lxc' else
(proxmox_agent_interfaces | default([]) | selectattr('name', 'equalto', 'eth0') | map(attribute='ip-addresses') | list | first | list | first | default('') | split('/') | first)
ansible_user: "'root' if proxmox_vmtype == 'lxc' else 'ladmin'"
@@ -0,0 +1,13 @@
$ANSIBLE_VAULT;1.1;AES256
32373134633930343261336362373335623735656634313034393731393661643361346634623439
3561363963333533313264643036663731656465343162330a333964383462373934303766323533
63613065313063626261383236396363656565623538666565646661353766643133306334396561
3961376234623236620a616339326238366538333666316538396465666365373834313064653963
38623233616633383435373836613438333763343932636239386263306238376137643531373161
35386437313465643437343832623935353238316565366537646530323233363837656631383962
32653332333162623966366163633732356637323461613966313263313066643966373335313162
30306362346561633566316166313164323635356363643034663933363464663562343136646137
66643536363734316533663032643637356136363237376365613330633961616665323430633135
36323733633464306330666232663433616533373466633939353061663136666665323432373666
36393930333435333238306238373931393232393861333334643139306138653336633534626362
61323038303838396438
+28 -200
View File
@@ -1,201 +1,29 @@
$ANSIBLE_VAULT;1.1;AES256
32666334326535346466653862343263373365346261313136653366366664633436356536383437
6666326436366433373163353262383031333264633530390a346539316165633939346663623264
30653839376635626261323234326539313235623962306639396633343533363561376363363864
3165323130633061350a346233396530303737316361653337643264383230613635383932636133
61336561663264393831313364366665636166306437383835303332643934393062623163343861
31643063373432643531663538666363613331626564376539336435343161333237333036343664
31636635316631376563326637303337386337313330323838666562303463363361346531613363
64383062396132373536663832356266383430316465643934636537396466366335636131623339
34316166323730316463666465613734383339636264306431623535656362633739386362393134
30343837363361333666616332646131663738663737663434303437336130313133306236313739
36363731386163353136376230393262653334366239653032613034323333386433666262353364
30326364343735643739626563393661616238313265633566356639623639383138303934653737
64356139656163666263346539663733356239386333313763653434343163353436323937663034
35653236616135383465656536323738346662396366653435383337383233643232656435356335
63616138666662386337323865356439303961396138613965303465373766343939333933316664
35356561353366663361306336303339633362623332386330383533336461306139613232306363
62613731613835336338346236373636343463383433613938393865666165653935353830633966
61393266623961343665343136323235393764313461306138343334366631313531396663666136
34636436666238376138633461636438356461643461356139313437393736666338623038353933
34633032653930623065623639646231346133343231383032663438326164313136613361356232
38306565353737623532653231303963616336323563663137316139303136623234663432373661
37333431663233656635373433323461663164303137633831306232626465303761633934336461
65613534633665663565366634623362303964313862333936383032363137306336636563383435
35366666313065393463656430303436346334313930616166383861613830373962353536303866
39636165393861363161633734343838346338303036306639316231313738383831626639303833
62316336623135336462633439323533383931666563643839383236636134363466383561633363
64366466336362376234336463383639323165306631633437333639333263373837666565306163
34666162336166623831353436363831666561376562613732333938353638333134323133346435
61663261373433333635643761373465653031303236613465643931356433656161373463316331
34326163633037633737393338313931353130306134663839623332666365373663643330353864
36366535313032333262373038333161336230303165646133303261333839376432393435613434
39633830316236653962663464363637333433393665353066653230383339666130653135343332
32623165386535323832626536316665396564623064633564663437613930313665616163326637
61363538333761633264396430326539643833313830346565643937656137376464646664313636
38626165306661353631393031383063646463363031633435373735653838636634316634323738
38313832376265616631623761393666346133636135666664643661646130306237383134383934
39643561363962323261343236393630656435653261633933656262623737613130353531386438
39643335306436636238393734646238653465626232643161316539346435353130633138393863
63343965346361353036646561636339303063393033633162666639303166303265333233386137
38643835616131303263366539646130643630366139623032366532653031303336323463326263
37313566633032313931313565616131376635643030373833323838393633653531653664333032
33623566333731636462366133653837636238383832353362653230393164343135663062373438
61613832336633343038373565316431643432626532636435653438383536336166383436393035
66613037663739616337383136663034643433333933643862396330356533636439666536353338
30313761633961383337353030333862396164376337393338303565663963633662633134653362
30346563643537323431313137636364623231376364386364633739313438376663343339333330
64623663303535393262376437626637316532643139353262393231376135663161663862646666
65303463393966393633613964353132303863373565353131396435623634336364313864373465
61376335313830383534643431383966616565346466633633613933643231303732373938313434
38383731653938656331646239353330666266336566633232353835653239393232366634663165
31313766643539373133396563633637336133633034633236393336663064626630376632336264
63346136613165613063316264646561633430613630396263333562366430326432323264383233
30616631616535666631303838326330363339343463303831376531396164333539386263343334
64313363626363316266343735313263613038383464396564313336373831393936336237303361
65346161323961336631366263353161616562633533323036383366613637333832323466326535
36376661346133383461626461646237373531333532656239333566323433333139633966343465
36653130646636336534353331623062313863666637306462343139333031313234633636613232
64383663626566616465623938346531623362383839316361366566336166386461336634303339
63363931303866376462303634633033386332373564393932616638323764383630643839393630
38373533636561613131343138383765376332336434303930326630303735313462316666333861
37346336613339643466636665326466386664376438646666363537306533303036613135303230
65303733366163363964613135306538393234636262326639613439363063326237306337666564
32303161373661633231333662303565383134333737343530616631363337396333346261646636
38663439653435623236366437643334393432383636316664306530303133396561396435313936
38316637393365343936636438666461326436353636323537343363623337656536613662393333
32346638636532333266323036623932316265303134633265366634623336386138653165643662
35396637636136386366663762346236343565623133623336363530653039363166383534313632
39333162396534376335646664383362366663373531646132643462396131333932653464326532
39326633616335633030373463303665383261323763303863383566386536646136613362323034
35313064313533616262666439663266353136616466643562666131366636623166333030346333
62353539396131666434356238646439326337343239366234383866613065636232613863323931
38363339623634643433393135396264363835386135303061623964343832323936313536623634
34396531643839623932663565656535396337353934373462336231356133303335643931323131
33383030653362323830376435393134666339333036336333376539363033666666396534636131
64353663663633313966336439643334616332616536336238613162363435323938313636303930
66306135613735653437366164336237656331346365646265656362316639656162386239326632
34613431653433306633666632363639306266303361383230316539306161396566346137363133
30333131666530636264656263303639636161366134633964393834663436326539626264383034
34636134663833333764613237663233346363643566353166316230396235373261393739316237
38373466623635333432326531613637303839633033636166633364383964626134386261636536
64616432343936363664383532323936386136393332653131343533653633323639356234353363
62613631333864663435616633396537373035646238653565646535306163623262396136356663
63383537653231633766613231316666303830336334323738626138366434323164643465663034
64343965363738626638363733646136316338633161383330306363643461643664303734663863
38656135616165366438623237333161363234356538356466646266316136313832666161626566
65306234663338323263376437363961353033396532643132336261336662656136656530343532
61353736626665386437376230633336656663356361626437393932633765336439613665666632
34363763656164326565633433643836383234363962613535353230356461613133666232396336
32316264353535353162373833666365633266376634366362646264616161363634363138303832
30656465326562653430373962346438646138336364363833646165353636346161336336613565
37303931393865356136333431656264666331313165373037656535333832643662343434396337
37656263363161396438626437643839633834643265313963653337363463373530333930303163
34353163333461353763643631303763623034333665373235656531393561363432633433393137
63333438646565633533353466373930313831316132306662363831623732336435346334323331
32373736396239613839396535316166386637613435643637323932303239643339613737653761
63323062333430636161316437383035326531346466336263353761636661326363656631613063
62623863616533333036643563333463366432386232386436356632323863663862613466613931
35636538663263336333613664353934623338356262643832356163326161373166633833333838
36373537363934393137663462633833373364356230356136646639633737636130396231633730
61356536373064356665646335633435646164623164333964323135343536663162656564653961
63626432653439373938333039386661363330373136633831656230336237376436313664653633
61623830326330663235376534313135303566636131373835383534363730616433363536346666
38326532376338393462363430643334343137613363626132356133363534643336663063623136
66623261343932373662663134323538666334623764376131623530373065316338653535313165
35313337303563373737313963303134303435643563616561323963613565646466353162353637
62623961626630323432323133666636356531316362633331313630393761303030363736643163
63333565616232366665356535373632626366366532633036323839393266613730373331343765
39343132643734623162386162353438353165653537303033313439326132343036333739623832
30316132396532626236633665333162653536633134313339306661613066343937323032376235
64386161623639636361313865613536373338333931623438366338616338326536383232363564
30356139373865313432366337646366613361393262333336643535656438383562343763636236
38616263613834613030363033613839613334323964343436326136626565643838323064313037
34663964646636363038393134343634663637663030636339613864316165666265383632373637
32373063616130343762393832663030636334646263626236663730336135376461643134646337
63346534353264663031336164623330633737333766633662613863343431333138363337326536
38363937313632633537363261633133636266376534346430643230623663333464393262396564
37323632653036653336623636646537326636383865383338366138653463336638363965356536
30373664313863396237613363373236353966303862376561653562666231353930653233383733
38303261343436393261653436626136353237666633613166623035636466346539363436616364
36386438666165316137303932346435313736616434643337303038383166386135326132656265
64633866363532333465623135323761393837623934316561633637613433316334376138343764
64646262356631373865393363353465643733623535626434666639613965383239343736356231
31306135666539646234333934326664353137643261613131646364363739333035396437316362
62343531656234376438376266373932376233643630383836323032633537383961383537376631
38303062623032636264666534313266363835636132646664366162303430393161313964616633
32613063393965653438643766373434613237393736643635653465366533363535623932386433
38653530316364323033383330373433663865393837653466376666633434343062376432633138
64653365373062643431353533363764666339663334393432353466623062646632383163656365
63633365326565353730333164303237383766386135376630613234333834626335643738376339
31626161346562613439396263646361343464636136636663363766356263636630323366326131
37356166343031376635346433363335373137303933343539303832613363656334663964333462
31613531383166666261336439356434323263306364616536636333316131373930646262386366
33333032643464313165336265326130333637323461643131393437326535316164356665313636
61613237356331393463336431316539633133326334396134663035396531633538666331393132
64393134643832376230656332633633396333313464333066656461366335343131613038656535
32353037316132393936373834633535646631653464616331633830326131333361316464393762
34306136306633323766353235353663666539663036383636393335336263353134303634626131
31316334663733666530653330333263653135643539353439306165353339623032643633393632
37653636663164646438626638643630393135623934646166666635316532366264623165616131
64653736306431623639303564306431616263613966656563373861616339316466383761643439
64323836303162613931313534393436353464333330353635663034396631636563616464653530
34616164323362653231343433323338633930323035396630326461666461346338363039383737
38666431373633376536323739316132316231353738306632336537633938653535316530346634
65323937336635373736346438323133353333343235623463663264623838316239366165306535
30323136626561636537393066383539303233366262396538383563353730356537623266623038
33316135303833323663363733353634653763383166633663313465303838646266303264363339
30363637396161656332323333653931336661393566663232326466646563356436306332333532
35656331383661376538633664346537613161303135643937643161383034643532653961346533
31396237653234363832383437613433326165636262333236393334613437386638636535616634
34313330643139386232396332376135376332613333633938366533616435303232376366623237
37323535383932623631363431383165323338306338633066373937323361383336616265303234
32343132303366343866303837326566383865393764343130616366373536623766316238363430
31633734633438313065336431316161636465333166653765653962326636663065336532393731
33386139383466626565663666373664313030663132313233356136613435663265396332656633
33636436646565643838643930343938326231623738393661393738343062303661353362646465
31653335343638613238333636326337396438373263663666373263616662653634613036663364
66323639336233623538663061366462663766383539333432336363613938346263636436336430
36646639353163316661356636393034653934666563356335396631323166313532353361653630
61353261656431613135313965373061313038653237626231366332306432633538656462323739
38313831663861663738336564333731363937653663663031343261633538643535336264306230
31656365326261633730613765393861376662656137623832363832633266346135643133633331
39623836633337323934653464333731626361316638333565393435656132626631613739366535
37653266333432386334333461653231346630646435343230636531326338396566396466376532
32666632383430393064353861373033393538366266333339373436353932656333366264393936
39373938346633616534643734313934643438333965323133313037373737303964643337653135
31623038356263643438343635643530663362656630393164663634366461623464353532353239
38623662666235383139663162343836316331396266313062316639373034376337393563643739
65313564303962353061616265393135326266386266316538616430383966306163386533376137
36393565336435636535376639623931616435353532356438666663383961356638353333366139
63313962643638323862353738316233613862346532306631386536323261636235343565353563
64613465343662663034643138623865363862316131363461343636333864656132626166393836
32393737386166343630323861376562353337663030613263613664663938376337333533613432
33343064636365643831373135623466613738303164326562386430386339653831313536366532
32613430313461663662313966373238643934633938633764363365386139303337643533313961
61646131653638616365346236616330363566636261636362363635323566353562326239393530
33323934623530306436623963663735353337306162646638383934326530333163396165366139
61653339363166376266643733653536636639653939346666663834376361373533363231636433
35363665373031393231363161333036303239313032326134363432363034623762366637643830
32353132353961343830303562613964376234303461623263343735336238353565353238363438
61326164323265386431353130346364393466353334386336376139303036646634376331313561
39386331366337646634366635623066313432623537323164646263323532303465343735326364
33383162626139383232636362613831303832633562636136393138316334303832383666313963
37643835663065613236303338353435663830343637326563343665616532633830623035336534
39376536663937653939326261346263613265636363333531323931646633313837386238333435
33646339626462386635316230636135626330643630633664636364313539626332643331356334
32313736343031633631393662363638626137303331383263656335373966383335653133333932
34393861303665613633366461666664376466383437303065653938323166633562643834356139
32616564373731313134316661343839333830663338636132323265376133656337393632353931
35633531383438656461613135323134623134393062356661326435323065616331656638306432
62313130643763626336383762306535656238393961653537336465373064653432653665373362
63666366343866613235376535313835623261313531653265373636383030613930353530346265
64303531316239386630626565323966616535373936393766663063623464663864363338343466
33666237613532326661626461343166323162366532386638333835663838343865346266646533
66343533326562396331363137353631633965623561333963613565636264653031616339613232
39666362643739663837326432623462646233306239326463616232383236346236383132396637
34623332346533366362613465363263373339643033663264363332336463343337333466393866
33333363313331376634363431366636376466336131363330323530306162666434393764393563
35633530353839386362326262343635383434333338626563383463653263333430383465626535
63363933343365326665343166633939313739316231383938376166646331623663343161623865
36646630336337373865
30353730633631376539373334376531353263396339313464373538396231616166653738383161
6438656132616365646463653464396236313437323537620a363632643061333036353537626461
34393065613836343536383565303066326133363937386263653961663666643430663139653961
6462363235343063350a636165376232366636363233376637636231363839343738346134623562
63663839326435303964313730336436396365343163343366613966323938656263303166326435
65303931393233623064343763633161353161643635396365316362333137353732346366643162
36386132386638623333623839373161386330353635346331393735366662393935353264646439
36386539393432373765396562613033656339646532323864366230393638313161383363636636
32353266376563393239393734316461313566386233333164623135303132346164353165323338
62306333363838393236346435303339393639623231353232643966613163313134336265633636
64366539326639303362393933303465376532626663346431613563353032383037363532646637
38376266323537343437643635343634636164393230313632663533666434306134366261383230
66383135303565383763646331383338646339613539623835366438393635363832366134316531
63636466653239383231616139663231623636376564376234383762383066613030313937616533
33396135616236643034303236353230376434313365326633303961303232363766633161353434
64333832623535343733353462313862343934633363616237353331386264386632643538333939
34636437393934326131363236346636663665613537343134313636623439356665636663613938
37346634356164343537396363663730306232623964636635333333653738383538343236383535
66326462373964333234343435336630643930333433393034303439366631386666393330343238
61336237373236313533333732343064336637326636663433396362346631343165656163313561
30383331326231383539343639343130323731333432306630353835343338613861376631646264
66383539616663376237663234313431313064663566313566366664386439316161353135306338
35373962636537653535333935306564303235356232313463386237353737313736386463396164
62666165336539316565623634326562356434313833643833353635303361353937666663356263
35653665323736386263333437666432623332333936373938376637633463653930326466383139
38336239646135316565356564373731386662383234386433633632393165343331353835623437
30663138633430356334353832633434633939373337663437326263336537356633313763353337
64373764666430643433
@@ -1,39 +0,0 @@
$ANSIBLE_VAULT;1.1;AES256
32656264356562366161643464343065313163663461663038646531306532383532393238396566
3831646133656131336266346531323035313234663336380a356536313965383066363135343739
32663032373165643931663762333036393430333437366630326562653034636464336166313730
6666366634346664330a643063343061623465393436656461303862336431393931643638333735
36663561626134333639326635373735623739366366396564653763656661663866623639323233
63326130303065663265356136343266613966333230653935343836343461623535363161346439
64653130383539363933636437643035306536376439326539666662383064636337363265356135
39623537303165356635373666643634383731333236643061343033376466643762373636333834
65626435353563623938363566626330356662366666666263363038646334323731393631356531
37346230303134326135633033393162333839346664363062633736363861343435623331343762
36643266333965323861633836336234333839306330343961653636623439656432613662303865
33313438316236653461346264373338393539326438666337346162326163313837626364623461
37353135323866326536303138363165396339306237393064366335386339303238373563373064
62646430313131373964346466373164306432363361626661653531356261313233616563353465
33353661663930333037636637353234663231633339333436393362393363653066376337376364
61663035396563643665353162356466623032643938353933393930656162386631376132383461
65383265623136646261393863336635323634336136376534323231383930383632306161393136
66366563633737613931633233666336363166313930613963396636633761633638323465306332
65333764343762643133613036353937386338303336656530366433333031323336366165346435
62626533363961636361306664616638396538646530633730373636643131303636333036326534
65616361353435346161383366626334386536376630326334353837396161356637356431316364
31643636326262383362336535346237666465363165643830613932336362623635366138303336
66376362386531313063336266623033653736616430386635656134616461643238373831333062
62343964343032366434316365626232656337613832313263353237353336643735353464656262
66313765393332393663303562393438376139373565623734653432626134376630343863353061
31383530643264643330313136356638646265663831363637363464636634356639313936336437
33393961363930616435623864303732633564303937343638373761653761666339373433373432
39653333396138383332353465396639346535666536373232653536393163353835613239386530
62666536633636363035613063643965623735366333386335363066326238323835663535333162
36613366326137303564653463393665666337363664363563643361373766663331333363326533
36353265616261613232633833616362363433313562303536326537346662383239343965313964
36353534666535313239316166343163343132323134313266653537646136383262626233376332
31616166366234653632373732633061373362336137303831353763373166643031313466656434
64623362663762316465633564633462363866373762383937386362623462333461666462613336
64393633336137663631386562366339646464363433396366303466336561653938386639393563
33613936356336346134323830366236326231653465373438623433613634656666386162336331
32316637383637336266656637343639613233623739303438323033333935663261373230626661
6635353534313662646665613438336264303835633564303166
@@ -1,6 +0,0 @@
---
# n8n group variables
# Maps vault secrets to role defaults
n8n_encryption_key: "{{ vault_n8n_encryption_key }}"
n8n_postgres_password: "{{ vault_n8n_postgres_password }}"
@@ -1,2 +0,0 @@
---
ansible_user: root
@@ -1,3 +0,0 @@
---
# Docker hosts - tagged with 'docker' in Proxmox
docker_monitoring_enabled: true
@@ -1,8 +0,0 @@
---
# --- NTP (see plays/install-ntp-ha.yml) ---
# The PVE hosts hold the 10.2.40.4 VIP via keepalived on the VLAN 40 bridge
# sub-interface. This role disappears when these nodes are rebuilt as bare-metal
# Talos, which cannot run chrony or keepalived — edge-1 is being brought into
# ntp_cluster to take over.
ntp_keepalived_enabled: true
ntp_keepalived_interface: "vmbr1.40"
@@ -1,20 +0,0 @@
$ANSIBLE_VAULT;1.1;AES256
66356134353363643130323632626530653863306635396432353539623731346138323635336632
3536633563333231373333323564373763363936356535630a333135623336613832313833626531
34646137633633353266653536363934303939626564353036343033643462373439643139353065
6539383436343233300a396339356562363832373962306337663464363961346335656130653235
38646563656538373530643531643964633934623465643065626266396130643966343166313937
65663965613937323634323436386130653633656261653065306365316135343531653737663138
65303339323137653362383766343361643837356531643339613431333238613564343563333761
30616530613133633762306533363134383265313031343230313764633238626565623935313134
38323435653264643534366366313862646161373434623135366164323236363733653533656233
34336233396465626264666637363532646562306237363533616461613764306433366639623562
30393234396238353038356636373632326130306431663439316363646530636563666364316139
34326333363536343963393833616465303636323166643638623061353964303265393733366234
33373435643237636536363364373830353833346235333534626561343465373236643165646664
39396233363531393835656463666662616633643566383061323161663061323831356337333337
65653336663735633536353263316634373639313238343365353833646666353131656530373361
32643133353737366465633635663339666138323035633537653462303532366632626262373532
32643132363062623033356134356135346361343234656636663936336338303230616665326630
65353231356538633034626332633831396362373733646539313834386535666631613463656639
373864663230353339666137653835656630
@@ -1,11 +0,0 @@
---
# Loki server - central log aggregation host
# Enable syslog receiver for remote devices (Synology NAS, etc.)
# Synology DSM's "IETF" format actually sends BSD/RFC 3164 format: <pri>MMM DD HH:MM:SS host app: msg
# Using raw format to accept messages and parse them with regex
alloy_syslog_receiver_enabled: true
alloy_syslog_receiver_port: 1514
alloy_syslog_receiver_protocol: "udp"
alloy_syslog_receiver_format: "raw"
alloy_stability_level: "experimental"
@@ -1,8 +0,0 @@
---
# Paperless server specific configuration
# Extra scrape targets for Alloy
alloy_extra_scrape_targets:
- name: "paperless"
address: "127.0.0.1:9199"
job: "paperless"
@@ -1,7 +0,0 @@
---
# Wiki server configuration
# Home Assistant documentation generation (disabled - see HA Wiki Documentation project)
homeassistant_docs_enabled: false
homeassistant_url: "http://10.2.30.11:8123"
homeassistant_token: "{{ vault_homeassistant_token }}"
+24 -35
View File
@@ -1,31 +1,14 @@
# Reached by name, like every other host in this file. These three were the last
# entries still addressed by IPv4 literal; authentik, netbird, proxy, nas-1,
# grafana and edge-1 have always used their FQDN.
# Hosts are reached by name. An address in an inventory is allocator state
# standing in for identity, and it pins access to the legacy family -- the names
# resolve IPv6-first, which is the primary path on this network. DNS is the
# authoritative source for where a host is; this file says which host, not where
# it currently sits.
#
# An address in an inventory is allocator state standing in for identity, and it
# pins access to the legacy family -- the names resolve IPv6-first, which is the
# primary path on this network. DNS is the authoritative source for where a host
# is; this file should say which host, not where it currently sits.
#
# The short inventory_hostname is kept deliberately: plays key on it
# (ntp_servers[inventory_hostname] in install-ntp-ha.yml, node names in opentofu,
# the prometheus targets), so only ansible_host changes.
[pve_cluster]
jupiter ansible_host=jupiter.renner.uno ansible_user=root
neptune ansible_host=neptune.renner.uno ansible_user=root
saturn ansible_host=saturn.renner.uno ansible_user=root
[authentik_db]
authentik-db-1.srv.renner.uno ansible_user=root
authentik-db-2.srv.renner.uno ansible_user=root
[authentik:children]
authentik_db
authentik_app
[authentik_app]
authentik-app-1.srv.renner.uno ansible_user=root
authentik-app-2.srv.renner.uno ansible_user=root
# Gone from this file, and why:
# pve_cluster (jupiter/neptune/saturn) run Talos on bare metal; no SSH at all
# authentik_db / authentik_app authentik runs in-cluster (CNPG)
# grafana runs in-cluster
# Their hosts no longer resolve, except the three that resolve to Talos nodes.
[netbird_server]
netbird.renner.uno ansible_user=root
@@ -38,9 +21,6 @@ proxy-2.renner.uno ansible_user=root
[synology_nas]
nas-1.renner.uno ansible_user=ladmin ansible_python_interpreter=/usr/bin/python3
[grafana]
grafana.srv.renner.uno ansible_user=ladmin
# Every bare-metal edge thin client. Gets the host-generic roles only:
# flash-wear handling, the log shipper, the MQTT bridge, the VLAN
# sub-interface. Nothing here assumes any peripheral is attached.
@@ -71,12 +51,21 @@ zwave_gateway
heating_can
power_meter
# NTP servers. Includes edge-1 so the role survives the PVE hosts being
# retired for bare-metal Talos, which cannot run chrony or keepalived itself.
# See plays/install-ntp-ha.yml for why keepalived is scoped more narrowly
# than chrony within this group.
# NTP servers. edge-1 was added here so the role would survive the PVE hosts
# being retired for bare-metal Talos, which cannot run chrony or keepalived
# itself. That retirement has now happened, so edge-1 is the ONLY member.
#
# THIS IS NO LONGER HIGHLY AVAILABLE. The group was jupiter (MASTER, prio 100),
# saturn (90), neptune (80) and edge-1 (BACKUP, 70); the first three are gone.
# A single VRRP member takes the address regardless of its configured state, so
# fd40::123 is still served — verified answering — but by one thin client with
# no failover behind it. install-ntp-ha.yml still carries the four-host
# ntp_servers priority map, which now describes hosts that do not exist.
#
# Restoring redundancy needs a second chrony host that is not a Talos node.
# See plays/install-ntp-ha.yml for why keepalived is scoped more narrowly than
# chrony within this group.
[ntp_cluster:children]
pve_cluster
edge_host
# Off-site Debian VM (Synology VMM on nas-2, 10.3.40.0/24) running Claude Code.
-48
View File
@@ -1,48 +0,0 @@
---
# Backup Authentik PostgreSQL database for K8s migration
# Creates a pg_dump on the primary DB host
#
# Usage (via CI):
# ansible-playbook -i inventory/ plays/backup-authentik-db.yml
#
# Output: /tmp/authentik-backup.sql on the primary DB host
- name: Backup Authentik PostgreSQL database
hosts: authentik-db-1.srv.renner.uno
become: true
tasks:
- name: Create database backup
become_user: postgres
command: >
pg_dump
--clean --if-exists
--no-owner --no-privileges
--format=custom
--file=/tmp/authentik-backup.dump
{{ authentik_db_name | default('authentik') }}
changed_when: true
- name: Make backup readable
file:
path: /tmp/authentik-backup.dump
mode: "0644"
- name: Get backup size
stat:
path: /tmp/authentik-backup.dump
register: backup_stat
- name: Report backup size
debug:
msg: "Backup created: {{ (backup_stat.stat.size / 1024 / 1024) | round(1) }} MB"
- name: Fetch backup to controller
fetch:
src: /tmp/authentik-backup.dump
dest: /tmp/authentik-backup.dump
flat: true
- name: Clean up remote backup
file:
path: /tmp/authentik-backup.dump
state: absent
-59
View File
@@ -1,59 +0,0 @@
---
# Dump Paperless-ngx DB from the legacy VM to a local file on the
# controller (CI runner), where a downstream pipeline step consumes it
# to produce a Barman seed for the CNPG `bootstrap.recovery` path.
# Data + media tarballs are handled separately by `backup-paperless.yml`.
- name: Dump Paperless DB from legacy VM to CI runner
hosts: paperless.srv.renner.uno
become: true
vars:
paperless_db_name: paperless
paperless_db_user: paperless
tasks:
- name: Find running paperless DB container
ansible.builtin.shell: >
docker ps --filter "label=com.docker.compose.service=db"
--format '{{ "{{" }}.Names{{ "}}" }}' | head -1
register: paperless_db_container
changed_when: false
- name: Fail if DB container not found
ansible.builtin.fail:
msg: "Could not find paperless DB container (compose service=db)"
when: paperless_db_container.stdout | trim == ""
- name: Dump DB to /tmp on legacy host
ansible.builtin.shell: >
docker exec -i {{ paperless_db_container.stdout | trim }}
pg_dump --clean --if-exists --no-owner --no-privileges --format=custom
-U {{ paperless_db_user }} {{ paperless_db_name }}
> /tmp/paperless-backup.dump
args:
executable: /bin/bash
changed_when: true
- name: Make dump readable
ansible.builtin.file:
path: /tmp/paperless-backup.dump
mode: "0644"
- name: Report dump size
ansible.builtin.stat:
path: /tmp/paperless-backup.dump
register: dump_stat
- name: Show dump size
ansible.builtin.debug:
msg: "Dump created: {{ (dump_stat.stat.size / 1024 / 1024) | round(1) }} MB"
- name: Fetch dump to controller
ansible.builtin.fetch:
src: /tmp/paperless-backup.dump
dest: /tmp/paperless-backup.dump
flat: true
- name: Remove remote dump
ansible.builtin.file:
path: /tmp/paperless-backup.dump
state: absent
-95
View File
@@ -1,95 +0,0 @@
---
- name: Check for Distribution Upgrades
hosts: proxmox_all_qemu:&status_running:!proxmox_noansible
become: true
gather_facts: true
ignore_unreachable: true
tasks:
- name: Check Ubuntu Release Availability
ansible.builtin.command: do-release-upgrade -c
register: ubuntu_check
changed_when: false
failed_when: false
when:
- ansible_distribution is defined
- ansible_distribution == 'Ubuntu'
- name: Determine Upgrade Status
ansible.builtin.set_fact:
upgrade_needed: >-
{%- if ansible_distribution is not defined -%}
unknown
{%- elif ansible_distribution == 'Ubuntu' -%}
{%- if ubuntu_check.stdout is defined and 'New release' in ubuntu_check.stdout -%}
true
{%- else -%}
false
{%- endif -%}
{%- elif ansible_distribution == 'Debian' -%}
{%- if ansible_distribution_major_version | int < 12 -%}
true
{%- else -%}
false
{%- endif -%}
{%- else -%}
false
{%- endif -%}
current_version_str: "{{ ansible_distribution }} {{ ansible_distribution_version }}"
target_version_str: >-
{%- if ansible_distribution is not defined -%}
Unknown
{%- elif ansible_distribution == 'Ubuntu' -%}
{%- if ubuntu_check.stdout is defined and 'New release' in ubuntu_check.stdout -%}
{{ (ubuntu_check.stdout | regex_findall("New release '([^']+)'") | first) | default('Unknown') }}
{%- else -%}
-
{%- endif -%}
{%- elif ansible_distribution == 'Debian' -%}
{%- if ansible_distribution_major_version | int < 12 -%}
Debian 12 (Bookworm)
{%- else -%}
-
{%- endif -%}
{%- else -%}
-
{%- endif -%}
when: ansible_distribution is defined
- name: Group hosts by status
ansible.builtin.group_by:
key: "upgrade_{{ upgrade_needed | trim }}"
when: upgrade_needed is defined
- name: Generate Report
hosts: localhost
gather_facts: false
tasks:
- name: Display Upgrade Candidates
ansible.builtin.debug:
msg: "{{ report.splitlines() }}"
vars:
report: |
DISTRIBUTION UPGRADE REPORT
===========================
REQUIRES UPGRADE
================
{{ '%-30s' | format('HOST') }} {{ '%-20s' | format('CURRENT') }} {{ 'TARGET' }}
{{ '------------------------------' }} {{ '--------------------' }} {{ '--------------------' }}
{% for host in groups['upgrade_true'] | default([]) %}
{{ '%-30s' | format(host) }} {{ '%-20s' | format(hostvars[host]['current_version_str']) }} {{ hostvars[host]['target_version_str'] }}
{% endfor %}
{% if groups['upgrade_true'] is not defined or groups['upgrade_true'] | length == 0 %}
(No hosts require upgrade)
{% endif %}
UP TO DATE
==========
{{ '%-30s' | format('HOST') }} {{ 'CURRENT' }}
{{ '------------------------------' }} {{ '--------------------' }}
{% for host in groups['upgrade_false'] | default([]) %}
{{ '%-30s' | format(host) }} {{ hostvars[host]['current_version_str'] }}
{% endfor %}
-8
View File
@@ -1,8 +0,0 @@
- name: Ensure debian-12 template exists on saturn
hosts: saturn
become: true
tasks:
- name: Download debian-12 template
command: pveam download local debian-12-standard_12.12-1_amd64.tar.zst
args:
creates: /var/lib/vz/template/cache/debian-12-standard_12.12-1_amd64.tar.zst
+2 -8
View File
@@ -1,22 +1,16 @@
---
- name: Install Custom CA Certificates
hosts: all:!proxmox_noansible
hosts: all
become: true
roles:
- certificates
- name: Base System Hardening & Common Configuration
hosts: all:!pve:!pbs:!proxmox_noansible:!proxmox_nodes:!pve_cluster
hosts: all
become: true
ignore_unreachable: true
gather_facts: true
vars:
# Monitoring agent endpoints
loki_endpoint: "http://loki.srv.renner.uno:3100"
prometheus_remote_write_endpoint: "http://prometheus.srv.renner.uno:9090/api/v1/write"
roles:
- common
- qemu-guest-agent
- monitoring-agent
@@ -1,44 +0,0 @@
---
# Configure Audiobookshelf permissions
# Fixes upload issues by ensuring audiobookshelf user can write to storage directories
- name: Configure Audiobookshelf
hosts: audiobookshelf.srv.renner.uno
become: true
tasks:
- name: Ensure audiobookshelf user exists
ansible.builtin.user:
name: audiobookshelf
system: true
create_home: false
state: present
- name: Set ownership on audiobooks directory
ansible.builtin.file:
path: /audiobooks
owner: audiobookshelf
group: audiobookshelf
mode: "0755"
state: directory
recurse: false
- name: Set ownership on audiobooks content recursively
ansible.builtin.command:
cmd: chown -R audiobookshelf:audiobookshelf /audiobooks
changed_when: true
- name: Set ownership on metadata directory
ansible.builtin.file:
path: /metadata
owner: audiobookshelf
group: audiobookshelf
mode: "0755"
state: directory
recurse: true
- name: Restart audiobookshelf service
ansible.builtin.systemd:
name: audiobookshelf
state: restarted
enabled: true
@@ -1,40 +0,0 @@
---
# Configure Grafana to use Authentik for authentication
#
# Prerequisites:
# 1. Create OAuth2/OpenID application in Authentik:
# - Name: Grafana
# - Slug: grafana
# - Client type: Confidential
# - Redirect URIs: https://dash.renner.uno/login/generic_oauth
# - Signing Key: authentik Self-signed Certificate
#
# 2. Add to vault (ansible/inventory/group_vars/all/vault.yml):
# vault_grafana_authentik_client_id: "<client-id-from-authentik>"
# vault_grafana_authentik_client_secret: "<client-secret-from-authentik>"
#
# 3. (Optional) Create groups in Authentik:
# - "Grafana Admins" - gets Admin role
# - "Grafana Editors" - gets Editor role
# - Others get Viewer role
- name: Configure Grafana Authentik Authentication
hosts: grafana.srv.renner.uno
become: yes
roles:
- grafana-authentik
post_tasks:
- name: Display configuration status
debug:
msg: |
Grafana Authentik OAuth configured!
Users can now login at: {{ grafana_root_url }}
Role mapping:
- Members of "Grafana Admins" -> Admin
- Members of "Grafana Editors" -> Editor
- All others -> Viewer
If auto_login is enabled, users will be redirected to Authentik automatically.
@@ -1,68 +0,0 @@
---
# Set sync=disabled on the zvols backing the K8s control-plane VM root disks
# (k8s-1/2/3, VMIDs 150/151/152) to eliminate ZFS fsync amplification on the
# shared local-zfs pool. Trades a small durability window for major fsync
# latency reduction on etcd's WAL writes:
#
# - etcd's WAL fsync returns immediately; ZFS still flushes via txgs
# (default ~5s)
# - Worst case on hard power loss: up to ~5s of writes lost per CP member
# - With 3-member quorum and Velero backups, this is acceptable for homelab
# - The whole zvol is sync=disabled (Talos OS, containerd, kubelet
# ephemeral, etcd WAL all share it), but etcd is the dominant fsync user;
# other writers tolerate small loss windows
#
# Each PVE host owns exactly one CP zvol (jupiter→150, saturn→151, neptune→152).
# Auto-discovery handles whatever the host has — no need to update if a CP
# VM ever moves.
#
# Idempotent: skips zvols that already have sync=disabled.
#
# Run manually against freshly-created CP zvols so they get the property
# before Talos starts writing (previously wired into the now-removed
# rebuild-k8s pipeline between `recreate-vms` and `talos-bootstrap`).
- name: Set sync=disabled on K8s CP VM zvols
hosts: pve_cluster
become: yes
gather_facts: no
tasks:
- name: Discover CP-VM zvols on this host
shell: |
set -o pipefail
zfs list -H -t volume -o name | grep -E "/vm-(150|151|152)-disk-[0-9]+$" || true
args:
executable: /bin/bash
register: cp_zvols
changed_when: false
- name: Read current sync property
shell: "zfs get -H -o value sync {{ item }}"
register: current_sync
changed_when: false
loop: "{{ cp_zvols.stdout_lines }}"
when: cp_zvols.stdout_lines | length > 0
- name: Set sync=disabled where not already
shell: "zfs set sync=disabled {{ item.item }}"
when:
- cp_zvols.stdout_lines | length > 0
- item.stdout | trim != "disabled"
loop: "{{ current_sync.results | default([]) }}"
loop_control:
label: "{{ item.item }}"
- name: Verify sync=disabled
shell: "zfs get -H -o value sync {{ item }}"
register: verify_sync
changed_when: false
failed_when: verify_sync.stdout | trim != "disabled"
loop: "{{ cp_zvols.stdout_lines }}"
when: cp_zvols.stdout_lines | length > 0
- name: Summary
debug:
msg: >-
{{ inventory_hostname }}: tuned {{ cp_zvols.stdout_lines | length }}
zvol(s) [{{ cp_zvols.stdout_lines | join(', ') }}]
@@ -1,53 +0,0 @@
---
# Configure Proxmox VE Node Host Networking
#
# Fixes two defects found by live inspection on 2026-07-19:
#
# 1. All three nodes had no IPv6 address on any external interface. FRR
# enables ipv6 forwarding for the OSPFv3 corosync mesh, and with the
# kernel default accept_ra=1 the nodes silently discarded the Router
# Advertisements the gateway sends on VLAN 40. Fixed with accept_ra=2.
#
# 2. saturn's vmbr0 sits at MTU 1500 while its own uplink and both peer
# nodes are at 9000, so large frames between saturn's guests and the
# other nodes' guests blackhole instead of fragmenting.
#
# The sysctl fix is non-disruptive and applies on every run. The MTU fix is
# staged as a pending PVE network change and only applied when explicitly
# requested, because applying it reloads networking on the node and briefly
# interrupts the Talos k8s VM and its Ceph OSD.
#
# Safe (converges IPv6, stages the MTU change without applying it):
# ansible-playbook -i inventory/ plays/configure-pve-node-network.yml
#
# Applying the staged MTU change, one node at a time:
# ansible-playbook -i inventory/ plays/configure-pve-node-network.yml \
# --limit saturn -e pve_node_network_apply_pending=true
#
# See wiki: pve-ipv6-accept-ra-forwarding-defect,
# pve-saturn-vmbr0-mtu-asymmetry
- name: Configure PVE node host networking
hosts: pve_cluster
become: yes
serial: 1
roles:
- role: pve-node-network
post_tasks:
- name: Summarise node IPv6 state
ansible.builtin.command: ip -6 addr show dev vmbr1.40 scope global
register: pve_node_network_v6_summary
changed_when: false
failed_when: false
- name: Display node IPv6 state
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}:
{{ pve_node_network_v6_summary.stdout_lines
| select('search', 'inet6')
| map('trim')
| list
| default(['no global IPv6'], true) }}
-20
View File
@@ -1,20 +0,0 @@
---
- name: Setup PostgreSQL HA Cluster
hosts: authentik_db
become: true
vars:
# Set this in inventory or pass as extra var.
# For initial setup, we define authentik-db-1 as primary.
postgres_primary_host: "authentik-db-1.srv.renner.uno"
roles:
- common
- postgres-simple-ha
- name: Setup Authentik Stateless App
hosts: authentik_app
become: true
roles:
- common
- docker-install
- authentik-stateless
+1 -1
View File
@@ -10,7 +10,7 @@
- name: Deploy the Claude Code workstation
hosts: claude_workstation
become: true
# qemu-guest-agent + monitoring-agent (Alloy -> Loki/Prometheus) are applied
# qemu-guest-agent is applied
# to every inventory host by the umbrella plays/common.yml, same as for the
# netbird-proxy host — run that against this host too (see RUNBOOK Step 5).
roles:
+4 -3
View File
@@ -18,9 +18,10 @@
#
# Self-contained: `certificates` and `common` are the parts of plays/common.yml
# that apply to an edge host, carried here so bootstrapping one is a single
# unrestricted run over the group. Not carried over: qemu-guest-agent (bare
# metal, not VMs) and monitoring-agent (Debian-oriented; these hosts ship logs
# via edge-log-shipper).
# unrestricted run over the group. Not carried over: qemu-guest-agent, since
# these are bare metal rather than VMs. Log shipping is edge-log-shipper's job
# and lives below — it pushes to the authenticated cluster ingress with a
# per-host credential, which is the only Loki these hosts can reach.
#
# Order is dependency order: edge-flash-wear before docker, so the journal is
# volatile before containers start logging into it.
-8
View File
@@ -1,8 +0,0 @@
---
- name: Install and Configure Gatus
hosts: proxmox_gatus
become: true
roles:
- role: common
- role: docker-install
- role: gatus
-8
View File
@@ -1,8 +0,0 @@
---
- name: Install and Configure Grist
hosts: grist
become: true
roles:
- role: common
- role: docker-install
- role: grist
-8
View File
@@ -1,8 +0,0 @@
---
- name: Install and Configure ical-proxy
hosts: proxmox_ical_proxy
become: true
roles:
- role: common
- role: docker-install
- role: ical-proxy
-19
View File
@@ -1,19 +0,0 @@
---
# Deploy MkDocs wiki from Obsidian vault
#
# Usage:
# ansible-playbook -i inventory plays/install-mkdocs-wiki.yml
#
- name: Deploy MkDocs Wiki
hosts: proxmox_wiki
become: true
pre_tasks:
- name: Ensure wiki group exists
ansible.builtin.group:
name: wiki
system: true
state: present
roles:
- role: mkdocs-wiki
@@ -1,8 +0,0 @@
---
- name: Install and Configure Music Assistant
hosts: proxmox_music_assistant
become: true
roles:
- role: common
- role: docker-install
- role: music-assistant
-8
View File
@@ -1,8 +0,0 @@
---
- name: Install and Configure n8n
hosts: n8n
become: true
roles:
- role: common
- role: docker-install
- role: n8n
@@ -1,38 +0,0 @@
---
# Deploy monitoring agent to NetBird server (external datacenter)
# Uses NetBird mesh IPs to reach Loki and Prometheus
# Usage: ansible-playbook -i inventory/ plays/setup-netbird-monitoring.yml
- name: Deploy Monitoring Agent to NetBird Server
hosts: netbird_server
become: yes
gather_facts: yes
vars:
# Use ARM64 binary for Hetzner ARM server
alloy_arch: "linux-arm64"
# Monitoring endpoints via NetBird mesh
loki_endpoint: "http://loki.nb.renner.uno:3100"
prometheus_remote_write_endpoint: "http://prometheus.nb.renner.uno:9090/api/v1/write"
# NetBird server labels
alloy_extra_labels:
environment: "production"
datacenter: "hetzner"
type: "netbird-server"
service: "netbird"
roles:
- monitoring-agent
post_tasks:
- name: Verify Alloy agent is running
systemd:
name: alloy
state: started
register: alloy_status
- name: Display agent status
debug:
msg: "Monitoring agent deployed to {{ inventory_hostname }} via NetBird mesh"
@@ -1,41 +0,0 @@
---
# Install Node Exporter and Chrony Exporter on Proxmox VE hosts
# Usage: ansible-playbook -i inventory/ plays/install-node-exporter-pve.yml
- name: Install Exporters on PVE Cluster
hosts: pve_cluster
become: yes
gather_facts: yes
roles:
- node-exporter
- chrony-exporter
post_tasks:
- name: Verify node-exporter is running
uri:
url: "http://127.0.0.1:9100/metrics"
status_code: 200
register: node_exporter_health
retries: 3
delay: 5
until: node_exporter_health.status == 200
- name: Verify chrony-exporter is running
uri:
url: "http://127.0.0.1:9123/metrics"
status_code: 200
register: chrony_exporter_health
retries: 3
delay: 5
until: chrony_exporter_health.status == 200
- name: Verify chrony metrics include stratum
uri:
url: "http://127.0.0.1:9123/metrics"
return_content: yes
register: chrony_metrics
- name: Check chrony exporter output
debug:
msg: "Chrony stratum metrics available: {{ 'chrony_tracking_stratum' in chrony_metrics.content }}"
+1 -1
View File
@@ -3,7 +3,7 @@
# Usage: ansible-playbook -i inventory/ plays/install-node-exporter.yml
- name: Install Node Exporter
hosts: all:!pve_cluster
hosts: all
become: yes
gather_facts: yes
-8
View File
@@ -1,8 +0,0 @@
---
- name: Install and Configure ownCloud Infinite Scale
hosts: proxmox_owncloud
become: true
roles:
- role: common
- role: docker-install
- role: owncloud
@@ -1,62 +0,0 @@
---
# Setup PostgreSQL monitoring user and exporter
# Usage: ansible-playbook -i inventory/ plays/setup-postgres-monitoring.yml
- name: Create PostgreSQL monitoring user on primary
hosts: authentik-db-1.srv.renner.uno
become: yes
vars_files:
- ../inventory/group_vars/all/vault.yml
tasks:
- name: Install psycopg2 for Ansible PostgreSQL modules
apt:
name: python3-psycopg2
state: present
- name: Create postgres_exporter user
become_user: postgres
community.postgresql.postgresql_user:
name: postgres_exporter
password: "{{ vault_postgres_exporter_password }}"
role_attr_flags: "NOSUPERUSER,NOCREATEDB,NOCREATEROLE"
state: present
- name: Grant pg_monitor role to exporter user
become_user: postgres
community.postgresql.postgresql_privs:
database: postgres
type: group
objs: pg_monitor
roles: postgres_exporter
grant_option: no
- name: Grant connect on authentik database
become_user: postgres
community.postgresql.postgresql_privs:
database: authentik
type: database
privs: CONNECT
roles: postgres_exporter
- name: Deploy postgres-exporter to Prometheus server
hosts: prometheus.srv.renner.uno
become: yes
vars_files:
- ../inventory/group_vars/all/vault.yml
vars:
postgres_exporter_db_host: "10.2.40.135" # PostgreSQL HA VIP
postgres_exporter_db_user: "postgres_exporter"
postgres_exporter_db_password: "{{ vault_postgres_exporter_password }}"
postgres_exporter_db_name: "postgres"
roles:
- postgres-exporter
post_tasks:
- name: Verify postgres-exporter is running
uri:
url: "http://127.0.0.1:9187/metrics"
status_code: 200
register: exporter_health
retries: 3
delay: 5
until: exporter_health.status == 200
-33
View File
@@ -1,33 +0,0 @@
---
# Deploy monitoring agents to Proxmox VE nodes
# Usage: ansible-playbook -i inventory/ plays/setup-pve-monitoring.yml
- name: Deploy Monitoring Agents to Proxmox Nodes
hosts: pve_cluster
become: yes
gather_facts: yes
vars:
# Monitoring endpoints
loki_endpoint: "http://loki.srv.renner.uno:3100"
prometheus_remote_write_endpoint: "http://prometheus.srv.renner.uno:9090/api/v1/write"
# PVE-specific labels
alloy_extra_labels:
environment: "production"
datacenter: "homelab"
type: "hypervisor"
roles:
- monitoring-agent
post_tasks:
- name: Verify Alloy agent is running
systemd:
name: alloy
state: started
register: alloy_status
- name: Display agent status
debug:
msg: "Monitoring agent deployed to {{ inventory_hostname }}"
-7
View File
@@ -1,7 +0,0 @@
---
- name: Install HashiCorp Vault
hosts: vault.srv.renner.uno
become: true
roles:
- common
- vault
+250
View File
@@ -0,0 +1,250 @@
---
# Rotate the NetBird shared secrets that Ansible owns BOTH ends of.
#
# Deliberately opt-in. Rotation is not idempotent and it interrupts the mesh
# briefly, so this play refuses to run unless invoked explicitly:
#
# ansible-playbook plays/rotate-netbird-secrets.yml -e rotate_confirm=yes
#
# ONE-TIME MIGRATION REQUIRED BEFORE FIRST USE. The three keys below currently
# live in group_vars/all/vault.yml. They must be deleted from there, because
# group_vars files merge alphabetically and vault.yml sorts after
# netbird-rotatable.yml — leaving them would let a later ordinary deploy
# overwrite a freshly rotated secret with the old one. The play checks for this
# and refuses to run until it is done:
#
# ansible-vault edit inventory/group_vars/all/vault.yml
# # delete vault_netbird_turn_secret, vault_netbird_relay_secret,
# # and netbird_turn_secret
#
# That last one is easy to miss and breaks the rotation silently. vault.yml
# carried BOTH vault_netbird_turn_secret and a bare netbird_turn_secret. The
# role default is `netbird_turn_secret: "{{ vault_netbird_turn_secret }}"`, but
# a group var outranks a role default — so the bare key wins, and rotating only
# the vault_-prefixed one changes nothing that reaches the template. The check
# below matches both spellings for that reason.
#
# The first rotation then populates netbird-rotatable.yml with fresh values, so
# nothing needs copying across.
#
# WHAT IS ROTATED, and why each is safe to rotate from here:
#
# vault_netbird_turn_secret coturn's `user=netbird:<secret>` in
# turnserver.conf and the management server's
# Turn.Secret/Password in management.json. Both
# templates, one role, one host — the two ends
# cannot disagree across a run.
# vault_netbird_relay_secret management.json on the server and relay.env on
# the proxies. Two hosts, both managed here.
#
# The test for "safe to rotate here" is whether the value appears in
# management.json.j2. If the management server does not read it from a file
# Ansible writes, Ansible does not own both ends, whatever else it templates.
#
# WHAT IS NOT, and must not be added:
#
# vault_netbird_proxy_token LOOKS symmetric — it is in proxy.env on both
# the server and the proxies, all Ansible
# templates — but the management server does not
# validate it from any of them. It is registered
# in the management DATABASE. Rotating it changes
# only what the proxies present, so every proxy
# is locked out: "proxy auth failed: too many
# failed authentication attempts", and the
# proxies stop receiving their domain
# configuration. Learned by doing it.
#
# vault_netbird_datastore_key encrypts NetBird's datastore at rest. Changing
# it without re-encrypting the store makes the
# existing data unreadable. Rotating this needs a
# dump/restore, not a template rewrite.
# vault_netbird_auth_client_secret the other half lives in Authentik. Ansible
# only writes the consumer side, so rotating here
# breaks login until Authentik is changed too.
#
# These are Ansible's OWN credentials, not a copy of the cluster's. OpenBao
# carries credentials for some of the same systems (see
# kubernetes/infrastructure/openbao/rotation/), and those are deliberately DIFFERENT
# values: one consumer, one credential, independently revocable. Rotating here
# must not touch OpenBao and does not invalidate anything the cluster holds —
# that is the point of them being distinct, not drift to be reconciled.
#
# Corollary: if a credential ever has to be shared between the two, that is the
# bug. Mint a second one instead.
#
# There IS a brief interruption. The server is reconfigured before the proxies,
# so relay/proxy auth fails for the seconds between the two plays. Clients
# reconnect; existing peer-to-peer tunnels are not torn down.
- name: Generate and store new NetBird shared secrets
hosts: localhost
connection: local
gather_facts: false
vars:
rotatable_vault: "{{ playbook_dir }}/../inventory/group_vars/all/netbird-rotatable.yml"
pre_tasks:
- name: Require explicit confirmation
ansible.builtin.assert:
that:
- rotate_confirm | default('') == 'yes'
fail_msg: >-
Refusing to rotate without -e rotate_confirm=yes. This changes live
credentials and briefly interrupts the NetBird mesh.
success_msg: Confirmed; rotating NetBird shared secrets.
- name: Verify the vault password is available before touching anything
ansible.builtin.command:
cmd: "{{ playbook_dir }}/../vault-password.sh"
register: _vault_pw_check
changed_when: false
no_log: true
# Files inside group_vars/<group>/ are merged ALPHABETICALLY and the last
# one wins. netbird-rotatable.yml sorts before vault.yml, so if vault.yml
# still defines these keys it silently overrides everything this play
# writes. The rotation itself would still work — the plays below pass the
# new values explicitly, and play vars outrank group vars — but the NEXT
# ordinary run of install-netbird-server.yml or install-netbird-proxy.yml
# would read the stale value from vault.yml and push it back out, breaking
# server<->proxy auth. The deploy pipelines run unattended, so that would
# land on an unrelated change to those roles.
#
# Counting matches rather than printing them: the value never reaches
# stdout, only the key name does.
# Proving the vault is readable FIRST. Without this, a failed decrypt (wrong
# password, moved file) sends empty input to grep, which then reports zero
# matches — indistinguishable from a clean vault, so the guard below would
# wave the rotation through precisely when it knows least.
- name: Prove vault.yml is readable before trusting the key check
ansible.builtin.shell:
cmd: >-
set -o pipefail;
ansible-vault view
--vault-password-file {{ playbook_dir }}/../vault-password.sh
{{ playbook_dir }}/../inventory/group_vars/all/vault.yml
| wc -l
executable: /bin/bash
register: _vault_lines
changed_when: false
- name: Check vault.yml does not still define the rotated keys
ansible.builtin.shell:
cmd: >-
ansible-vault view
--vault-password-file {{ playbook_dir }}/../vault-password.sh
{{ playbook_dir }}/../inventory/group_vars/all/vault.yml
| grep -cE '^(vault_)?netbird_(turn_secret|relay_secret):'
register: _stale_keys
changed_when: false
# grep exits 1 on "no matches", which is the good case here.
failed_when: false
- name: Refuse to rotate while vault.yml still owns these keys
ansible.builtin.assert:
that:
- _vault_lines.stdout | int > 0
- _stale_keys.stdout | int == 0
fail_msg: >-
vault.yml still defines {{ _stale_keys.stdout }} of the keys this play
rotates. group_vars files merge alphabetically and vault.yml sorts
last, so it would override netbird-rotatable.yml on the next deploy
and revert the secrets. Remove vault_netbird_turn_secret,
netbird_turn_secret and vault_netbird_relay_secret from vault.yml
first (one-time migration), then re-run.
success_msg: vault.yml does not shadow the rotated keys.
tasks:
# 48 alphanumerics. coturn's shared secret and the relay secret are both
# opaque strings; length is the only property that matters.
#
# ansible.builtin.password, NOT community.general.random_string: the CI
# image (ci/images/ansible/Containerfile) installs only community.proxmox
# and community.docker, so anything from community.general fails at this
# task when the play runs through the project's own tooling — even though
# it resolves fine on a workstation that happens to have the collection.
#
# `/dev/null` as the path makes the lookup generate without persisting;
# the value exists only in this fact.
- name: Generate replacement secrets
ansible.builtin.set_fact:
new_turn_secret: "{{ lookup('ansible.builtin.password', '/dev/null length=48 chars=ascii_letters,digits') }}"
new_relay_secret: "{{ lookup('ansible.builtin.password', '/dev/null length=48 chars=ascii_letters,digits') }}"
no_log: true
- name: Render the new values to a temporary file
ansible.builtin.copy:
dest: "{{ rotatable_vault }}.new"
mode: "0600"
content: |
---
# Generated by plays/rotate-netbird-secrets.yml. Do not hand-edit —
# the next rotation overwrites this file wholesale.
vault_netbird_turn_secret: "{{ new_turn_secret }}"
vault_netbird_relay_secret: "{{ new_relay_secret }}"
no_log: true
# Encrypting the whole file rather than inline !vault strings: these three
# values are always rewritten together, so per-value encryption buys nothing
# and makes the write non-atomic.
- name: Encrypt the new file
ansible.builtin.command:
cmd: >-
ansible-vault encrypt
--vault-password-file {{ playbook_dir }}/../vault-password.sh
{{ rotatable_vault }}.new
changed_when: true
# Move into place only after encryption succeeded, so a failure above never
# leaves plaintext where the inventory would load it.
- name: Install the encrypted file
ansible.builtin.command:
cmd: "mv {{ rotatable_vault }}.new {{ rotatable_vault }}"
changed_when: true
- name: Confirm the installed file is encrypted
ansible.builtin.command:
cmd: "head -1 {{ rotatable_vault }}"
register: _vault_header
changed_when: false
failed_when: "'$ANSIBLE_VAULT' not in _vault_header.stdout"
- name: Apply the new secrets to the NetBird server
hosts: netbird_server
become: true
vars:
netbird_turn_secret: "{{ hostvars['localhost']['new_turn_secret'] }}"
netbird_relay_secret: "{{ hostvars['localhost']['new_relay_secret'] }}"
roles:
- netbird-server
- name: Apply the new secrets to the NetBird proxies
hosts: netbird_proxy
become: true
# One at a time so at least one relay stays reachable while the other
# restarts. The server is already on the new secret at this point, so a
# proxy is broken only between its own template write and its restart.
serial: 1
vars:
netbird_relay_secret: "{{ hostvars['localhost']['new_relay_secret'] }}"
roles:
- netbird-proxy
- name: Report what still needs doing by hand
hosts: localhost
connection: local
gather_facts: false
tasks:
- name: Remaining manual steps
ansible.builtin.debug:
msg:
- "Rotated: turn_secret and relay_secret."
- "Commit inventory/group_vars/all/netbird-rotatable.yml — it is encrypted."
- "NOT rotated: datastore_key (needs dump/restore), auth_client_secret (Authentik side)."
- "OpenBao is unaffected by design — its NetBird credentials are separate ones."
-10
View File
@@ -1,10 +0,0 @@
---
- name: Update Authentik HA
hosts: authentik_app
become: true
serial: 1
vars:
authentik_path: /home/ladmin
roles:
- authentik-update
-13
View File
@@ -1,13 +0,0 @@
---
- name: Update Docker Containers
hosts: proxmox_docker
become: true
gather_facts: false
vars:
# Define the root directory where your docker-compose files live.
# You can override this per-host in host_vars if they differ.
docker_compose_dir: "/opt"
roles:
- docker-update
-32
View File
@@ -1,32 +0,0 @@
---
- name: Update LXC Containers
hosts: proxmox_all_lxc:!proxmox_noansible
become: true
gather_facts: true
tasks:
- name: Update APT Cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
when: ansible_pkg_mgr == 'apt'
- name: Upgrade all packages
ansible.builtin.apt:
upgrade: dist
when: ansible_pkg_mgr == 'apt'
register: apt_upgrade
- name: Check if reboot required
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_required_file
when: ansible_pkg_mgr == 'apt'
- name: Reboot if required
ansible.builtin.reboot:
msg: "Rebooting due to system updates"
when:
- ansible_pkg_mgr == 'apt'
- reboot_required_file.stat.exists
- apt_upgrade.changed
-9
View File
@@ -1,9 +0,0 @@
---
- name: Proxmox VE Rolling Update
hosts: proxmox_nodes # Replace with your inventory group name
become: true
serial: 1 # CRITICAL: Only update one node at a time
any_errors_fatal: true
roles:
- pve-update
-9
View File
@@ -1,9 +0,0 @@
---
- name: Update Proxmox VMs (Linux)
hosts: proxmox_all_qemu:&status_running:!proxmox_noansible:!proxmox_eol
become: true
serial: 1
gather_facts: false # We gather manually after checking connection
roles:
- vm-update
-27
View File
@@ -1,27 +0,0 @@
---
- name: Full Infrastructure Update
hosts: localhost
gather_facts: false
tasks:
- name: Banner
debug:
msg: "Starting Full Infrastructure Update Sequence"
- import_playbook: update-vm.yml
- import_playbook: update-lxc.yml
- import_playbook: update-authentik.yml
# Re-render docker-compose templates before pulling updates
# (immich decommissioned — migrated to in-cluster deployment, see kubernetes/apps/immich/)
- import_playbook: install-n8n.yml
- import_playbook: install-grist.yml
- import_playbook: install-music-assistant.yml
- import_playbook: update-docker.yml
- import_playbook: update-pve-rolling.yml
- name: Update Summary
hosts: localhost
gather_facts: false
tasks:
- name: Done
debug:
msg: "Full Infrastructure Update Completed Successfully"
@@ -1,18 +0,0 @@
---
authentik_path: /home/ladmin
authentik_db_host: "authentik-db.renner.uno"
authentik_db_user: "authentik"
authentik_db_name: "authentik"
authentik_db_password: "changeMeInVault"
authentik_secret_key: "changeMeInVault"
authentik_tag: "latest" # or "2025.10.x"
authentik_port_http: 9000
authentik_port_https: 9443
authentik_email_host: "smtp.example.com"
authentik_email_port: 587
authentik_email_user: "user"
authentik_email_password: "password"
authentik_email_use_tls: true
authentik_email_from: "authentik@example.com"
@@ -1,5 +0,0 @@
---
- name: Restart Authentik
community.docker.docker_compose_v2:
project_src: "{{ authentik_path }}"
state: restarted
@@ -1,42 +0,0 @@
---
- name: Ensure Authentik directory exists
ansible.builtin.file:
path: "{{ authentik_path }}"
state: directory
owner: "{{ ansible_user | default('root') }}"
group: "{{ ansible_user | default('root') }}"
- name: Get latest Authentik release if no tag specified
ansible.builtin.uri:
url: https://api.github.com/repos/goauthentik/authentik/releases/latest
return_content: true
register: github_response
when: authentik_tag is not defined
- name: Set target version fact
set_fact:
authentik_version: "{{ github_response.json.tag_name | regex_replace('^version/', '') if authentik_tag is not defined else authentik_tag }}"
- name: Get Docker group ID
ansible.builtin.shell: "getent group docker | cut -d: -f3"
register: docker_gid_result
changed_when: false
- name: Deploy .env configuration
ansible.builtin.template:
src: .env.j2
dest: "{{ authentik_path }}/.env"
mode: '0600'
notify: Restart Authentik
- name: Deploy Docker Compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ authentik_path }}/docker-compose.yml"
notify: Restart Authentik
- name: Ensure Authentik is started
community.docker.docker_compose_v2:
project_src: "{{ authentik_path }}"
state: present
pull: always
@@ -1,38 +0,0 @@
PG_PASS={{ authentik_db_password }}
PG_HOST={{ authentik_db_host }}
PG_USER={{ authentik_db_user }}
PG_DB={{ authentik_db_name }}
AUTHENTIK_SECRET_KEY={{ authentik_secret_key }}
AUTHENTIK_ERROR_REPORTING__ENABLED={{ authentik_error_reporting_enabled | default('true') }}
AUTHENTIK_EMAIL__HOST={{ authentik_email_host }}
AUTHENTIK_EMAIL__PORT={{ authentik_email_port }}
AUTHENTIK_EMAIL__USERNAME={{ authentik_email_username }}
AUTHENTIK_EMAIL__PASSWORD={{ authentik_email_password }}
AUTHENTIK_EMAIL__USE_TLS={{ authentik_email_use_tls | lower }}
AUTHENTIK_EMAIL__USE_SSL={{ authentik_email_use_ssl | lower }}
AUTHENTIK_EMAIL__TIMEOUT={{ authentik_email_timeout }}
AUTHENTIK_EMAIL__FROM={{ authentik_email_from }}
AUTHENTIK_LOG_LEVEL={{ authentik_log_level | default('info') }}
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS={{ authentik_listen_trusted_proxy_cidrs | default('10.0.0.0/8,172.16.0.0/12') }}
AUTHENTIK_TAG={{ authentik_tag | default(authentik_version) }}
AUTHENTIK_ERROR_REPORTING__ENABLED=true
AUTHENTIK_EMAIL__HOST={{ authentik_email_host }}
AUTHENTIK_EMAIL__PORT={{ authentik_email_port }}
AUTHENTIK_EMAIL__USERNAME={{ authentik_email_user }}
AUTHENTIK_EMAIL__PASSWORD={{ authentik_email_password }}
AUTHENTIK_EMAIL__USE_TLS={{ authentik_email_use_tls }}
AUTHENTIK_EMAIL__FROM={{ authentik_email_from }}
AUTHENTIK_PORT_HTTP={{ authentik_port_http }}
AUTHENTIK_PORT_HTTPS={{ authentik_port_https }}
AUTHENTIK_CSRF_TRUSTED_ORIGINS=https://authentik.renner.uno,https://auth.renner.uno,https://authentik-app-1.srv.renner.uno,https://authentik-app-2.srv.renner.uno
# HA specific config:
# Disable Redis (Use Postgres)
AUTHENTIK_REDIS__HOST=""
# Worker needs to use shared storage for media
# Assuming nfs/glusterfs or just volume syncing?
# Or maybe just use local volume if media uploads ar rare?
# For true stateless, external S3 storage is recommended:
# AUTHENTIK_STORAGE__MEDIA__BACKEND="aws"
# AUTHENTIK_STORAGE__MEDIA__S3__BUCKET="..."
# For now, local volume (will not sync across nodes without NFS).
@@ -1,41 +0,0 @@
services:
server:
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-latest}
restart: unless-stopped
command: server
env_file:
- .env
environment:
AUTHENTIK_REDIS__HOST: ""
AUTHENTIK_POSTGRESQL__HOST: ${PG_HOST}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_LISTEN__METRICS: "0.0.0.0:9300"
volumes:
- ./media:/media
- ./custom-templates:/templates
- ./certs:/certs
ports:
- "${AUTHENTIK_PORT_HTTP:-9000}:9000"
- "${AUTHENTIK_PORT_HTTPS:-9443}:9443"
- "9300:9300"
worker:
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-latest}
restart: unless-stopped
command: worker
user: "1000:{{ docker_gid_result.stdout }}"
env_file:
- .env
environment:
AUTHENTIK_REDIS__HOST: ""
AUTHENTIK_POSTGRESQL__HOST: ${PG_HOST}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./media:/media
- ./certs:/certs
- ./custom-templates:/templates
@@ -1,47 +0,0 @@
---
- name: Get latest Authentik release
ansible.builtin.uri:
url: https://api.github.com/repos/goauthentik/authentik/releases/latest
return_content: true
register: github_response
- name: Set target version
ansible.builtin.set_fact:
target_version: "{{ github_response.json.tag_name | regex_replace('^version/', '') }}"
- name: Display target version
ansible.builtin.debug:
msg: "Updating Authentik to version {{ target_version }}"
- name: Update AUTHENTIK_TAG in .env
ansible.builtin.lineinfile:
path: "{{ authentik_path | default('/home/ladmin') }}/.env"
regexp: '^AUTHENTIK_TAG='
line: "AUTHENTIK_TAG={{ target_version }}"
create: true
- name: Pull new images
ansible.builtin.command:
cmd: docker compose pull
chdir: "{{ authentik_path | default('/home/ladmin') }}"
- name: Pull Outpost images
ansible.builtin.shell: |
docker pull ghcr.io/goauthentik/proxy:{{ target_version }}
docker pull ghcr.io/goauthentik/ldap:{{ target_version }}
docker pull ghcr.io/goauthentik/radius:{{ target_version }}
changed_when: true
- name: Apply updates
ansible.builtin.command:
cmd: docker compose up -d --force-recreate
chdir: "{{ authentik_path | default('/home/ladmin') }}"
- name: Restart worker to trigger outpost update
ansible.builtin.command:
cmd: docker compose restart worker
chdir: "{{ authentik_path | default('/home/ladmin') }}"
- name: Prune unused images
ansible.builtin.command:
cmd: docker image prune -f
+1 -2
View File
@@ -169,8 +169,7 @@ Merge the PR, then trigger the pipeline (Woodpecker → Run → `PIPELINE ==
"deploy-claude-workstation"`), or push a change under the role's paths. CI SSHes
to `10.3.40.60` over the tunnel and applies `common`, `netbird-client`, and this
role. Also run the umbrella `plays/common.yml` against the new host once, so it
picks up `qemu-guest-agent` and `monitoring-agent` (Alloy → Loki/Prometheus)
like every other inventory host.
picks up `qemu-guest-agent` like every other inventory host.
---
@@ -3,8 +3,8 @@
# Debian 12 VM on the off-site nas-2 (Synology VMM). See README for the manual
# bootstrap (VM creation, Netbird route, secret material) that precedes this.
#
# common.yml already applies `common`, `qemu-guest-agent` and `monitoring-agent`
# (Alloy -> Loki/Prometheus) to every inventory host, so this role owns only the
# common.yml already applies `common` and `qemu-guest-agent`
# to every inventory host, so this role owns only the
# Claude-specific surface: toolchain, the two credential-isolated users, the
# read-only creds, notes sync, the unattended timers, and egress lockdown.
@@ -1,8 +1,8 @@
---
# Journal -> Loki shipping.
#
# The srv-network Loki (loki.srv.renner.uno:3100, no auth) is firewalled off
# the IoT VLAN; the only endpoint these hosts can reach is the authenticated
# The srv-network Loki (loki.srv.renner.uno:3100) no longer exists — it moved
# into the cluster. The only endpoint these hosts can reach is the authenticated
# cluster ingress. edge_loki_password comes from the vault.
edge_alloy_config_file: /etc/alloy/edge.alloy
@@ -7,8 +7,8 @@
// Loki. Its own WAL/positions live on /run (tmpfs, see edge-alloy.service),
// so this agent writes nothing to the eMMC either.
//
// The srv-network Loki (loki.srv.renner.uno:3100, unauthenticated) is not
// reachable from the IoT VLAN; the only reachable endpoint is the cluster
// The srv-network Loki (loki.srv.renner.uno:3100) no longer exists — it moved
// into the cluster. The only reachable endpoint is the cluster
// ingress, which enforces Traefik basic-auth — hence the credential below.
loki.relabel "journal" {
-15
View File
@@ -1,15 +0,0 @@
---
gatus_version: "v5.35.0"
gatus_port: 8080
gatus_data_dir: "/opt/gatus"
# SMTP configuration (override in vault)
gatus_smtp_host: ""
gatus_smtp_port: 587
gatus_smtp_from: ""
gatus_smtp_username: ""
gatus_smtp_password: ""
gatus_alert_email: ""
# Webhook URL for outage notifications (override in vault or group_vars)
gatus_webhook_url: ""
-5
View File
@@ -1,5 +0,0 @@
---
- name: Restart Gatus
community.docker.docker_compose_v2:
project_src: "{{ gatus_data_dir }}"
state: restarted
-29
View File
@@ -1,29 +0,0 @@
---
- name: Install required system packages
ansible.builtin.apt:
name:
- python3-docker
state: present
update_cache: true
- name: Create Gatus data directory
ansible.builtin.file:
path: "{{ gatus_data_dir }}"
state: directory
mode: '0755'
- name: Copy Gatus configuration files
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ gatus_data_dir }}/{{ item.dest }}"
mode: '0644'
loop:
- { src: 'config.yaml.j2', dest: 'config.yaml' }
- { src: 'docker-compose.yml.j2', dest: 'docker-compose.yml' }
notify: Restart Gatus
- name: Start Gatus via Docker Compose
community.docker.docker_compose_v2:
project_src: "{{ gatus_data_dir }}"
state: present
pull: always
@@ -1,282 +0,0 @@
storage:
type: sqlite
path: /data/gatus.db
ui:
title: Homelab Status
description: Service health monitoring for renner.uno
{% if gatus_smtp_host %}
alerting:
email:
host: {{ gatus_smtp_host }}
port: {{ gatus_smtp_port }}
from: {{ gatus_smtp_from }}
username: {{ gatus_smtp_username }}
password: {{ gatus_smtp_password }}
to: {{ gatus_alert_email }}
default-alert:
enabled: true
failure-threshold: 3
success-threshold: 2
send-on-resolved: true
{% if gatus_webhook_url %}
custom:
url: {{ gatus_webhook_url }}
method: POST
body: |
{
"service": "[ALERT_TRIGGERED_OR_RESOLVED]",
"description": "[ENDPOINT_NAME] - [RESULT_ERRORS]",
"status": "[ALERT_TRIGGERED_OR_RESOLVED]"
}
default-alert:
enabled: true
failure-threshold: 3
success-threshold: 2
send-on-resolved: true
{% endif %}
{% endif %}
endpoints:
# Web Services
- name: ownCloud
group: services
url: https://owncloud.renner.uno/ocs/v2.php/cloud/capabilities?format=json
interval: 60s
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 2000"
- "[BODY].ocs.meta.status == ok"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: ONLYOFFICE
group: services
url: https://office.renner.uno/healthcheck
interval: 60s
conditions:
- "[STATUS] == 200"
- "[BODY] == true"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: Authentik
group: services
url: https://auth.renner.uno/-/health/ready/
interval: 60s
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 2000"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: Gitea
group: services
url: https://gitea.renner.uno/api/v1/settings/api
interval: 60s
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 2000"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: Immich
group: services
url: https://immich.renner.uno/api/server/ping
interval: 60s
conditions:
- "[STATUS] == 200"
- "[BODY].res == pong"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: Woodpecker CI
group: services
url: https://ci.renner.uno/healthz
interval: 60s
conditions:
- "[STATUS] == any(200, 204)"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: Grafana
group: services
url: https://dash.renner.uno/api/health
interval: 60s
conditions:
- "[STATUS] == 200"
- "[BODY].database == ok"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: Home Assistant
group: services
url: https://home.renner.uno
interval: 60s
conditions:
- "[STATUS] == any(200, 301, 302)"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: Paperless
group: services
url: https://file.renner.uno
interval: 60s
conditions:
- "[STATUS] == any(200, 301, 302)"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: Audiobookshelf
group: services
url: https://audiobooks.renner.uno/healthcheck
interval: 60s
conditions:
- "[STATUS] == 200"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: Jellyfin
group: services
url: https://media.renner.uno/health
interval: 60s
conditions:
- "[STATUS] == 200"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: Wiki
group: services
url: https://wiki.renner.uno
interval: 60s
conditions:
- "[STATUS] == 200"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: Music Assistant
group: services
url: https://music.renner.uno
interval: 60s
conditions:
- "[STATUS] == any(200, 301, 302)"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: InfluxDB
group: services
url: https://influx.renner.uno/health
interval: 60s
conditions:
- "[STATUS] == 200"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: PBS (pbs.obg.renner.uno)
group: infrastructure
url: https://pbs.obg.renner.uno:8007
interval: 60s
client:
insecure-skip-verify: true
conditions:
- "[STATUS] == any(200, 301, 302)"
- name: PBS 2 (pbs2.renner.uno)
group: infrastructure
url: https://pbs2.renner.uno:8007
interval: 60s
client:
insecure-skip-verify: true
conditions:
- "[STATUS] == any(200, 301, 302)"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: Proxmox VE
group: infrastructure
url: https://pve.renner.uno
interval: 60s
client:
insecure-skip-verify: true
conditions:
- "[STATUS] == any(200, 301, 302)"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% 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
interval: 30s
conditions:
- "[CONNECTED] == true"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
@@ -1,17 +0,0 @@
services:
gatus:
container_name: gatus
image: twinproduction/gatus:{{ gatus_version }}
ports:
- "{{ gatus_port }}:8080"
volumes:
- {{ gatus_data_dir }}/config.yaml:/config/config.yaml:ro
- {{ gatus_data_dir }}/data:/data
- /etc/localtime:/etc/localtime:ro
restart: always
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
@@ -1,28 +0,0 @@
---
# Grafana Authentik OAuth Configuration
# Authentik OAuth settings (must be set in vault)
# Add to ansible/inventory/group_vars/all/vault.yml:
# vault_grafana_authentik_client_id: "<client-id>"
# vault_grafana_authentik_client_secret: "<client-secret>"
grafana_authentik_client_id: "{{ vault_grafana_authentik_client_id | default('') }}"
grafana_authentik_client_secret: "{{ vault_grafana_authentik_client_secret | default('') }}"
# Authentik URLs
authentik_base_url: "https://auth.renner.uno"
grafana_authentik_auth_url: "{{ authentik_base_url }}/application/o/authorize/"
grafana_authentik_token_url: "{{ authentik_base_url }}/application/o/token/"
grafana_authentik_api_url: "{{ authentik_base_url }}/application/o/userinfo/"
# Grafana settings
grafana_root_url: "https://dash.renner.uno"
grafana_config_dir: "/etc/grafana"
# Role mapping from Authentik groups
grafana_role_attribute_path: "contains(groups, 'Grafana Admins') && 'Admin' || contains(groups, 'Grafana Editors') && 'Editor' || 'Viewer'"
# Allow users to sign up automatically
grafana_oauth_allow_sign_up: true
# Auto login (skip Grafana login page, go directly to Authentik)
grafana_oauth_auto_login: false
@@ -1,5 +0,0 @@
---
- name: Restart grafana
systemd:
name: grafana-server
state: restarted
@@ -1,48 +0,0 @@
---
# Configure Grafana to use Authentik for OAuth authentication
- name: Verify Authentik credentials are configured
assert:
that:
- grafana_authentik_client_id | length > 0
- grafana_authentik_client_secret | length > 0
fail_msg: |
Authentik OAuth credentials not configured!
Please add to ansible/inventory/group_vars/all/vault.yml:
vault_grafana_authentik_client_id: "<client-id-from-authentik>"
vault_grafana_authentik_client_secret: "<client-secret-from-authentik>"
To create the OAuth application in Authentik:
1. Go to https://auth.renner.uno/if/admin/
2. Applications > Providers > Create
3. Select "OAuth2/OpenID Provider"
4. Name: Grafana, Slug: grafana
5. Client type: Confidential
6. Redirect URIs: https://grafana.renner.uno/login/generic_oauth
6. Redirect URIs: https://dash.renner.uno/login/generic_oauth
7. Copy Client ID and Secret to vault
- name: Ensure Grafana provisioning directory exists
file:
path: "{{ grafana_config_dir }}/provisioning"
state: directory
owner: grafana
group: grafana
mode: '0755'
- name: Deploy Authentik OAuth configuration
template:
src: grafana-auth.ini.j2
dest: "{{ grafana_config_dir }}/grafana.ini"
owner: root
group: grafana
mode: '0640'
backup: yes
notify: Restart grafana
- name: Ensure Grafana service is running
systemd:
name: grafana-server
state: started
enabled: yes
@@ -1,26 +0,0 @@
# Grafana Authentik OAuth Configuration
# Managed by Ansible - Do not edit manually
[server]
root_url = {{ grafana_root_url }}
[auth]
# Disable login form if using auto_login
disable_login_form = {{ grafana_oauth_auto_login | lower }}
[auth.generic_oauth]
enabled = true
name = Authentik
icon = signin
allow_sign_up = {{ grafana_oauth_allow_sign_up | lower }}
auto_login = {{ grafana_oauth_auto_login | lower }}
client_id = {{ grafana_authentik_client_id }}
client_secret = {{ grafana_authentik_client_secret }}
scopes = openid profile email
auth_url = {{ grafana_authentik_auth_url }}
token_url = {{ grafana_authentik_token_url }}
api_url = {{ grafana_authentik_api_url }}
role_attribute_path = {{ grafana_role_attribute_path }}
role_attribute_strict = false
allow_assign_grafana_admin = true
use_pkce = true
-27
View File
@@ -1,27 +0,0 @@
---
# Grist configuration
grist_version: "latest"
grist_port: 8484
grist_data_dir: "/opt/grist"
# Public URL (required for proper link generation)
grist_public_url: "https://grist.renner.uno"
# Session secret (override in vault!)
grist_session_secret: "CHANGE_ME_IN_VAULT"
# Default admin email
grist_default_email: "admin@renner.uno"
# Sandbox flavor: gvisor for security, unsandboxed for compatibility
grist_sandbox_flavor: "gvisor"
grist_default_locale: "de"
# OIDC Authentication (Authentik)
grist_oidc_enabled: true
grist_oidc_issuer: "https://auth.renner.uno/application/o/grist/"
grist_oidc_client_id: ""
grist_oidc_client_secret: ""
grist_oidc_sp_profile_name_attr: "name"
grist_oidc_sp_profile_email_attr: "email"
grist_oidc_ignore_email_verified: true
-5
View File
@@ -1,5 +0,0 @@
---
- name: Restart Grist
community.docker.docker_compose_v2:
project_src: "{{ grist_data_dir }}"
state: restarted
-35
View File
@@ -1,35 +0,0 @@
---
- name: Install required system packages
ansible.builtin.apt:
name:
- python3-docker
state: present
update_cache: true
- name: Create Grist data directory
ansible.builtin.file:
path: "{{ grist_data_dir }}"
state: directory
mode: '0755'
- name: Create Grist persist directory
ansible.builtin.file:
path: "{{ grist_data_dir }}/persist"
state: directory
mode: '0755'
- name: Copy Grist configuration files
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ grist_data_dir }}/{{ item.dest }}"
mode: '0644'
loop:
- { src: '.env.j2', dest: '.env' }
- { src: 'docker-compose.yml.j2', dest: 'docker-compose.yml' }
notify: Restart Grist
- name: Start Grist via Docker Compose
community.docker.docker_compose_v2:
project_src: "{{ grist_data_dir }}"
state: present
pull: always
-29
View File
@@ -1,29 +0,0 @@
# Grist Environment Configuration
# Managed by Ansible - Do not edit manually
# Application URL
APP_HOME_URL={{ grist_public_url }}
# Security
GRIST_SESSION_SECRET={{ grist_session_secret }}
GRIST_SANDBOX_FLAVOR={{ grist_sandbox_flavor }}
# Default admin
GRIST_DEFAULT_EMAIL={{ grist_default_email }}
{% if grist_oidc_enabled and grist_oidc_client_id %}
# OIDC Authentication (Authentik)
GRIST_OIDC_IDP_ENABLED=true
GRIST_OIDC_IDP_ISSUER={{ grist_oidc_issuer }}
GRIST_OIDC_IDP_CLIENT_ID={{ grist_oidc_client_id }}
GRIST_OIDC_IDP_CLIENT_SECRET={{ grist_oidc_client_secret }}
GRIST_OIDC_SP_PROFILE_NAME_ATTR={{ grist_oidc_sp_profile_name_attr }}
GRIST_OIDC_SP_PROFILE_EMAIL_ATTR={{ grist_oidc_sp_profile_email_attr }}
GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED={{ grist_oidc_ignore_email_verified | lower }}
{% endif %}
# Locale
GRIST_DEFAULT_LOCALE={{ grist_default_locale }}
# Telemetry (disabled)
GRIST_TELEMETRY_LEVEL=off
@@ -1,20 +0,0 @@
version: "3.8"
services:
grist:
container_name: grist
image: gristlabs/grist:{{ grist_version }}
env_file:
- .env
ports:
- "{{ grist_port }}:8484"
volumes:
- {{ grist_data_dir }}/persist:/persist
- /etc/localtime:/etc/localtime:ro
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8484/status"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
-20
View File
@@ -1,20 +0,0 @@
---
- name: Ensure install directory exists
file:
path: /opt/ical-proxy
state: directory
mode: '0755'
- name: Checkout ical-proxy repository
git:
repo: 'https://github.com/konairius/ical-proxy.git'
dest: /opt/ical-proxy
version: main
force: yes
register: git_checkout
- name: Deploy ical-proxy with docker compose
community.docker.docker_compose_v2:
project_src: /opt/ical-proxy
state: present
build: always
@@ -1,32 +0,0 @@
---
# MkDocs Wiki configuration
# Source directory (Obsidian vault path on control node)
mkdocs_source_dir: "{{ lookup('env', 'HOME') }}/Notes/Work & Dev/Homelab"
# Installation directory on target
mkdocs_install_dir: /opt/mkdocs-wiki
mkdocs_build_dir: /var/www/wiki
# Service user
mkdocs_user: wiki
mkdocs_group: wiki
# Python virtual environment
mkdocs_venv_dir: "{{ mkdocs_install_dir }}/venv"
# Web server port (for development/testing)
mkdocs_serve_port: 8000
# Build options
mkdocs_strict_mode: false
# Sync schedule (cron)
mkdocs_sync_enabled: true
mkdocs_sync_schedule: "*/15 * * * *" # Every 15 minutes
# Home Assistant documentation generation
homeassistant_docs_enabled: true
homeassistant_url: "http://10.2.30.11:8123"
# homeassistant_token: set in vault as vault_homeassistant_token
homeassistant_docs_dir: "{{ mkdocs_install_dir }}/docs/Services/Home Assistant Auto"
@@ -1,477 +0,0 @@
#!/usr/bin/env python3
"""
Home Assistant Documentation Generator
Queries the Home Assistant WebSocket API to get entity, device, and area registries,
then generates room-organized Markdown documentation.
Usage:
python generate-ha-docs.py --url http://10.2.30.11:8123 --token <token> --output /path/to/docs
"""
import argparse
import json
import os
import sys
from datetime import datetime
from collections import defaultdict
try:
import requests
from websocket import create_connection
except ImportError:
print("Error: Required libraries missing. Install with: pip install requests websocket-client")
sys.exit(1)
class HomeAssistantDocs:
def __init__(self, url: str, token: str):
self.url = url.rstrip('/')
self.token = token
self.headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json',
}
self.msg_id = 1
# Registries
self.areas = {} # area_id -> area_name
self.devices = {} # device_id -> device_info
self.entities = {} # entity_id -> entity_info
self.states = {} # entity_id -> state
self.automations = []
def _ws_call(self, ws, msg_type: str, **kwargs) -> dict:
"""Make WebSocket API call."""
msg = {"id": self.msg_id, "type": msg_type, **kwargs}
self.msg_id += 1
ws.send(json.dumps(msg))
result = json.loads(ws.recv())
if not result.get("success", True):
print(f"Warning: WS call {msg_type} failed: {result}")
return result.get("result", [])
def fetch_all_data(self):
"""Fetch all registries via WebSocket API."""
ws_url = self.url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
print(f"Connecting to WebSocket at {ws_url}...")
ws = create_connection(ws_url)
# Wait for auth_required
auth_req = json.loads(ws.recv())
if auth_req.get("type") != "auth_required":
raise Exception(f"Unexpected message: {auth_req}")
# Authenticate
ws.send(json.dumps({"type": "auth", "access_token": self.token}))
auth_result = json.loads(ws.recv())
if auth_result.get("type") != "auth_ok":
raise Exception(f"Authentication failed: {auth_result}")
print("Authenticated. Fetching registries...")
# Fetch area registry
areas = self._ws_call(ws, "config/area_registry/list")
for area in areas:
self.areas[area["area_id"]] = {
"name": area["name"],
"aliases": area.get("aliases", []),
"floor_id": area.get("floor_id"),
"icon": area.get("icon"),
}
print(f" Found {len(self.areas)} areas")
# Fetch device registry
devices = self._ws_call(ws, "config/device_registry/list")
for device in devices:
self.devices[device["id"]] = {
"name": device.get("name_by_user") or device.get("name") or "Unknown",
"manufacturer": device.get("manufacturer"),
"model": device.get("model"),
"area_id": device.get("area_id"),
"via_device_id": device.get("via_device_id"),
"disabled_by": device.get("disabled_by"),
}
print(f" Found {len(self.devices)} devices")
# Fetch entity registry
entities = self._ws_call(ws, "config/entity_registry/list")
for entity in entities:
self.entities[entity["entity_id"]] = {
"name": entity.get("name") or entity.get("original_name"),
"device_id": entity.get("device_id"),
"area_id": entity.get("area_id"), # Entity can have its own area
"platform": entity.get("platform"),
"disabled_by": entity.get("disabled_by"),
"hidden_by": entity.get("hidden_by"),
"entity_category": entity.get("entity_category"),
}
print(f" Found {len(self.entities)} entities")
ws.close()
# Fetch states via REST API (more complete)
print("Fetching states...")
resp = requests.get(f"{self.url}/api/states", headers=self.headers, timeout=30)
resp.raise_for_status()
for state in resp.json():
self.states[state["entity_id"]] = state
print(f" Found {len(self.states)} states")
# Extract automations from states
for entity_id, state in self.states.items():
if entity_id.startswith("automation."):
self.automations.append({
"id": entity_id.replace("automation.", ""),
"alias": state.get("attributes", {}).get("friendly_name", entity_id),
"entity_id": entity_id,
"state": state,
})
print(f" Found {len(self.automations)} automations")
def get_entity_area(self, entity_id: str) -> str:
"""Get the area for an entity (from entity or device)."""
entity = self.entities.get(entity_id, {})
# Entity's own area takes precedence
if entity.get("area_id"):
return entity["area_id"]
# Fall back to device's area
device_id = entity.get("device_id")
if device_id:
device = self.devices.get(device_id, {})
if device.get("area_id"):
return device["area_id"]
return None
def get_automation_areas(self, automation: dict) -> set:
"""Extract areas referenced by an automation from its name and entity_id patterns."""
areas = set()
# Try to match automation alias to areas
alias = automation.get("alias", "").lower()
# Map area names to area_ids
for area_id, area_info in self.areas.items():
area_name = area_info["name"].lower()
# Check if room name appears in automation name
# Handle common patterns like "EG Küche", "Küche", "kuche"
normalized_area = area_name.replace("ü", "u").replace("ö", "o").replace("ä", "a")
normalized_alias = alias.replace("ü", "u").replace("ö", "o").replace("ä", "a")
# Match full area name or last part (e.g., "Küche" from "EG Küche")
parts = area_name.split()
if area_name in alias or normalized_area in normalized_alias:
areas.add(area_id)
elif len(parts) > 1 and parts[-1] in alias:
areas.add(area_id)
return areas
def generate_room_doc(self, area_id: str, area_info: dict) -> str:
"""Generate documentation for a single room/area."""
area_name = area_info["name"]
# Find all entities in this area
entities_in_area = []
for entity_id, entity in self.entities.items():
if self.get_entity_area(entity_id) == area_id:
# Skip disabled, hidden, or diagnostic entities
if entity.get("disabled_by") or entity.get("hidden_by"):
continue
if entity.get("entity_category") in ("diagnostic", "config"):
continue
entities_in_area.append(entity_id)
# Find devices in this area
devices_in_area = []
for device_id, device in self.devices.items():
if device.get("area_id") == area_id and not device.get("disabled_by"):
devices_in_area.append((device_id, device))
# Find automations that affect this area
automations_for_area = []
for auto in self.automations:
if area_id in self.get_automation_areas(auto):
automations_for_area.append(auto)
# Build document
lines = [
f"# {area_name}",
"",
f"Last Updated: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
"",
]
# Devices section
if devices_in_area:
lines.extend([
"## Devices",
"",
"| Device | Manufacturer | Model |",
"|--------|--------------|-------|",
])
for device_id, device in sorted(devices_in_area, key=lambda x: x[1]["name"]):
name = device["name"].replace("|", "\\|")
manufacturer = (device.get("manufacturer") or "-").replace("|", "\\|")
model = (device.get("model") or "-").replace("|", "\\|")
lines.append(f"| {name} | {manufacturer} | {model} |")
lines.append("")
# Group entities by domain
domain_entities = defaultdict(list)
for entity_id in entities_in_area:
domain = entity_id.split(".")[0]
domain_entities[domain].append(entity_id)
# Entities by type
if domain_entities:
lines.extend([
"## Entities",
"",
])
# Preferred order for domains
domain_order = ["light", "switch", "climate", "cover", "fan", "media_player",
"sensor", "binary_sensor", "number", "select", "button"]
for domain in domain_order + sorted(set(domain_entities.keys()) - set(domain_order)):
if domain not in domain_entities:
continue
entity_ids = domain_entities[domain]
domain_title = domain.replace("_", " ").title()
lines.extend([
f"### {domain_title}",
"",
"| Entity | State |",
"|--------|-------|",
])
for entity_id in sorted(entity_ids):
state = self.states.get(entity_id, {})
name = state.get("attributes", {}).get("friendly_name", entity_id)
state_val = state.get("state", "unknown")
# Add unit if present
unit = state.get("attributes", {}).get("unit_of_measurement", "")
if unit:
state_val = f"{state_val} {unit}"
# Truncate long values
if len(state_val) > 30:
state_val = state_val[:27] + "..."
name = name.replace("|", "\\|")
state_val = state_val.replace("|", "\\|")
lines.append(f"| {name} | {state_val} |")
lines.append("")
# Automations section
if automations_for_area:
lines.extend([
"## Automations",
"",
"Automations that control devices in this room:",
"",
"| Automation | Status | Last Triggered |",
"|------------|--------|----------------|",
])
for auto in sorted(automations_for_area, key=lambda x: x.get("alias", "")):
alias = auto.get("alias", "Unnamed automation")
state_obj = auto.get("state", {})
is_on = state_obj.get("state", "unknown")
last_triggered = state_obj.get("attributes", {}).get("last_triggered", "Never")
if last_triggered and last_triggered not in ("Never", None):
try:
dt = datetime.fromisoformat(last_triggered.replace("Z", "+00:00"))
last_triggered = dt.strftime("%Y-%m-%d %H:%M")
except:
pass
else:
last_triggered = "Never"
alias = alias.replace("|", "\\|")
lines.append(f"| {alias} | {is_on} | {last_triggered} |")
lines.append("")
if not devices_in_area and not domain_entities and not automations_for_area:
lines.extend([
"*No devices, entities, or automations assigned to this area.*",
"",
])
return "\n".join(lines)
def generate_index(self) -> str:
"""Generate index page with all rooms overview."""
lines = [
"# Home Assistant - Rooms",
"",
f"Last Updated: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
"",
"Auto-generated documentation organized by room/area.",
"",
"## Rooms Overview",
"",
"| Room | Devices | Entities | Automations |",
"|------|---------|----------|-------------|",
]
for area_id in sorted(self.areas.keys(), key=lambda x: self.areas[x]["name"]):
area_name = self.areas[area_id]["name"]
# Count devices
device_count = sum(1 for d in self.devices.values()
if d.get("area_id") == area_id and not d.get("disabled_by"))
# Count entities
entity_count = sum(1 for e_id in self.entities
if self.get_entity_area(e_id) == area_id
and not self.entities[e_id].get("disabled_by")
and not self.entities[e_id].get("hidden_by")
and self.entities[e_id].get("entity_category") not in ("diagnostic", "config"))
# Count automations
auto_count = sum(1 for a in self.automations if area_id in self.get_automation_areas(a))
# Create wiki link
safe_name = area_name.replace(" ", "%20")
lines.append(f"| [[{area_name}]] | {device_count} | {entity_count} | {auto_count} |")
lines.extend([
"",
"## Unassigned Entities",
"",
])
# Find entities without an area
unassigned = []
for entity_id in self.entities:
if not self.get_entity_area(entity_id):
entity = self.entities[entity_id]
if entity.get("disabled_by") or entity.get("hidden_by"):
continue
if entity.get("entity_category") in ("diagnostic", "config"):
continue
unassigned.append(entity_id)
if unassigned:
lines.append(f"There are {len(unassigned)} entities not assigned to any room.")
lines.extend([
"",
"<details>",
"<summary>Show unassigned entities</summary>",
"",
])
for entity_id in sorted(unassigned)[:50]: # Limit
state = self.states.get(entity_id, {})
name = state.get("attributes", {}).get("friendly_name", entity_id)
lines.append(f"- `{entity_id}` - {name}")
if len(unassigned) > 50:
lines.append(f"- ... and {len(unassigned) - 50} more")
lines.extend([
"",
"</details>",
"",
])
else:
lines.append("All entities are assigned to rooms.")
return "\n".join(lines)
def generate_automations_doc(self) -> str:
"""Generate all automations overview."""
lines = [
"# All Automations",
"",
f"Last Updated: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
"",
f"Total: {len(self.automations)} automations",
"",
"| Automation | Status | Rooms | Last Triggered |",
"|------------|--------|-------|----------------|",
]
for auto in sorted(self.automations, key=lambda x: x.get("alias", "")):
alias = auto.get("alias", "Unnamed")
state_obj = auto.get("state", {})
is_on = state_obj.get("state", "?")
last_triggered = state_obj.get("attributes", {}).get("last_triggered", "Never")
if last_triggered and last_triggered not in ("Never", None):
try:
dt = datetime.fromisoformat(last_triggered.replace("Z", "+00:00"))
last_triggered = dt.strftime("%Y-%m-%d %H:%M")
except:
pass
else:
last_triggered = "Never"
areas = self.get_automation_areas(auto)
room_names = ", ".join(self.areas.get(a, {}).get("name", a) for a in areas) or "-"
if len(room_names) > 30:
room_names = room_names[:27] + "..."
alias = alias.replace("|", "\\|")
room_names = room_names.replace("|", "\\|")
lines.append(f"| {alias} | {is_on} | {room_names} | {last_triggered} |")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description='Generate Home Assistant documentation by room')
parser.add_argument('--url', required=True, help='Home Assistant URL')
parser.add_argument('--token', required=True, help='Long-lived access token')
parser.add_argument('--output', required=True, help='Output directory for markdown files')
args = parser.parse_args()
# Create output directory
os.makedirs(args.output, exist_ok=True)
print(f"Connecting to Home Assistant at {args.url}...")
ha = HomeAssistantDocs(args.url, args.token)
try:
ha.fetch_all_data()
# Generate index
index_path = os.path.join(args.output, "Home Assistant Auto.md")
with open(index_path, 'w') as f:
f.write(ha.generate_index())
print(f"Generated {index_path}")
# Generate room pages
for area_id, area_info in ha.areas.items():
filename = f"{area_info['name']}.md"
filepath = os.path.join(args.output, filename)
with open(filepath, 'w') as f:
f.write(ha.generate_room_doc(area_id, area_info))
print(f"Generated {filepath}")
# Generate all automations page
auto_path = os.path.join(args.output, "All Automations.md")
with open(auto_path, 'w') as f:
f.write(ha.generate_automations_doc())
print(f"Generated {auto_path}")
print(f"\nDone! Generated documentation for {len(ha.areas)} rooms.")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()
@@ -1,10 +0,0 @@
---
- name: Rebuild MkDocs
ansible.builtin.command:
cmd: "{{ mkdocs_venv_dir }}/bin/mkdocs build --site-dir {{ mkdocs_build_dir }}"
chdir: "{{ mkdocs_install_dir }}"
- name: Reload Caddy
ansible.builtin.systemd:
name: caddy
state: reloaded
-160
View File
@@ -1,160 +0,0 @@
---
- name: Install system dependencies
ansible.builtin.apt:
name:
- python3
- python3-pip
- python3-venv
- rsync
- caddy
state: present
update_cache: true
- name: Create wiki user
ansible.builtin.user:
name: "{{ mkdocs_user }}"
group: "{{ mkdocs_group }}"
system: true
shell: /usr/sbin/nologin
home: "{{ mkdocs_install_dir }}"
create_home: false
- name: Create directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ mkdocs_user }}"
group: "{{ mkdocs_group }}"
mode: "0755"
loop:
- "{{ mkdocs_install_dir }}"
- "{{ mkdocs_install_dir }}/docs"
- "{{ mkdocs_build_dir }}"
- name: Sync documentation from control node
ansible.posix.synchronize:
src: "{{ mkdocs_source_dir }}/"
dest: "{{ mkdocs_install_dir }}/docs/"
delete: true
use_ssh_args: true
rsync_opts:
- "--exclude=.obsidian"
- "--exclude=.git"
- "--exclude=*.sync-conflict-*"
- "--exclude=mkdocs.yml"
- "--rsync-path=rsync"
- name: Copy mkdocs.yml to install directory
ansible.builtin.copy:
src: "{{ mkdocs_source_dir }}/mkdocs.yml"
dest: "{{ mkdocs_install_dir }}/mkdocs.yml"
owner: "{{ mkdocs_user }}"
group: "{{ mkdocs_group }}"
mode: "0644"
- name: Update mkdocs.yml docs_dir
ansible.builtin.lineinfile:
path: "{{ mkdocs_install_dir }}/mkdocs.yml"
regexp: "^docs_dir:"
line: "docs_dir: docs"
insertafter: "^site_url:"
notify: Rebuild MkDocs
- name: Create Python virtual environment
ansible.builtin.pip:
name:
- pip
- setuptools
- wheel
virtualenv: "{{ mkdocs_venv_dir }}"
virtualenv_command: python3 -m venv
state: latest
- name: Install MkDocs and plugins
ansible.builtin.pip:
name:
- mkdocs
- mkdocs-material
- mkdocs-roamlinks-plugin
- mkdocs-static-i18n
- requests
- websocket-client
virtualenv: "{{ mkdocs_venv_dir }}"
state: latest
# Home Assistant documentation generation
- name: Create HA docs directory
ansible.builtin.file:
path: "{{ homeassistant_docs_dir }}"
state: directory
owner: "{{ mkdocs_user }}"
group: "{{ mkdocs_group }}"
mode: "0755"
when: homeassistant_docs_enabled | default(false)
- name: Copy HA docs generator script
ansible.builtin.copy:
src: generate-ha-docs.py
dest: "{{ mkdocs_install_dir }}/generate-ha-docs.py"
owner: "{{ mkdocs_user }}"
group: "{{ mkdocs_group }}"
mode: "0755"
when: homeassistant_docs_enabled | default(false)
- name: Generate Home Assistant documentation
ansible.builtin.command:
cmd: >
{{ mkdocs_venv_dir }}/bin/python {{ mkdocs_install_dir }}/generate-ha-docs.py
--url "{{ homeassistant_url }}"
--token "{{ homeassistant_token }}"
--output "{{ homeassistant_docs_dir }}"
when:
- homeassistant_docs_enabled | default(false)
- homeassistant_token is defined
changed_when: true
ignore_errors: true
- name: Build MkDocs site
ansible.builtin.command:
cmd: "{{ mkdocs_venv_dir }}/bin/mkdocs build --site-dir {{ mkdocs_build_dir }}"
chdir: "{{ mkdocs_install_dir }}"
changed_when: true
- name: Set ownership on build directory
ansible.builtin.file:
path: "{{ mkdocs_build_dir }}"
state: directory
owner: "{{ mkdocs_user }}"
group: "{{ mkdocs_group }}"
recurse: true
- name: Configure Caddy for wiki
ansible.builtin.template:
src: Caddyfile.j2
dest: /etc/caddy/Caddyfile
owner: root
group: root
mode: "0644"
notify: Reload Caddy
- name: Create sync script
ansible.builtin.template:
src: sync-wiki.sh.j2
dest: "{{ mkdocs_install_dir }}/sync-wiki.sh"
owner: "{{ mkdocs_user }}"
group: "{{ mkdocs_group }}"
mode: "0755"
- name: Configure cron for sync
ansible.builtin.cron:
name: "Sync and rebuild MkDocs wiki"
minute: "*/15"
job: "{{ mkdocs_install_dir }}/sync-wiki.sh >> /var/log/wiki-sync.log 2>&1"
user: root
when: mkdocs_sync_enabled
- name: Ensure Caddy is running
ansible.builtin.systemd:
name: caddy
state: started
enabled: true
@@ -1,19 +0,0 @@
:80 {
root * {{ mkdocs_build_dir }}
file_server
encode gzip
# Handle clean URLs
try_files {path} {path}/ {path}.html /index.html
# Security headers
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
}
log {
output file /var/log/caddy/wiki-access.log
}
}
@@ -1,38 +0,0 @@
#!/bin/bash
# Sync wiki from source and rebuild
# This script is called by cron
set -e
LOCK_FILE="/tmp/wiki-sync.lock"
SOURCE_HOST="{{ hostvars['localhost']['ansible_hostname'] | default('workstation') }}"
SOURCE_DIR="{{ mkdocs_source_dir }}"
INSTALL_DIR="{{ mkdocs_install_dir }}"
BUILD_DIR="{{ mkdocs_build_dir }}"
VENV_DIR="{{ mkdocs_venv_dir }}"
# Prevent concurrent runs
exec 200>"$LOCK_FILE"
flock -n 200 || { echo "Another sync is running"; exit 0; }
echo "[$(date)] Starting wiki sync..."
# Sync docs (this requires SSH key access from wiki server to source)
# For now, we rely on Ansible to do the sync
# rsync -avz --delete --exclude='.obsidian' --exclude='.git' \
# "${SOURCE_HOST}:${SOURCE_DIR}/" "${INSTALL_DIR}/docs/"
{% if homeassistant_docs_enabled | default(false) and homeassistant_token is defined %}
# Generate Home Assistant documentation
echo "[$(date)] Generating Home Assistant documentation..."
"${VENV_DIR}/bin/python" "${INSTALL_DIR}/generate-ha-docs.py" \
--url "{{ homeassistant_url }}" \
--token "{{ homeassistant_token }}" \
--output "{{ homeassistant_docs_dir }}" || echo "Warning: HA docs generation failed"
{% endif %}
# Rebuild site
cd "${INSTALL_DIR}"
"${VENV_DIR}/bin/mkdocs" build --site-dir "${BUILD_DIR}" --quiet
echo "[$(date)] Wiki sync complete"
@@ -1,59 +0,0 @@
---
# Monitoring Agent Configuration (Grafana Alloy)
# Enable/disable agent deployment
monitoring_agent_enabled: true
# Version and download
alloy_version: "1.13.2"
alloy_arch: "linux-amd64"
alloy_download_url: "https://github.com/grafana/alloy/releases/download/v{{ alloy_version }}/alloy-{{ alloy_arch }}.zip"
alloy_bin_path: "/usr/local/bin/alloy"
# Directories
alloy_config_dir: "/etc/alloy"
alloy_config_file: "{{ alloy_config_dir }}/config.alloy"
alloy_data_dir: "/var/lib/alloy"
# Endpoints - MUST be configured in group_vars
loki_endpoint: "http://loki.srv.renner.uno:3100"
prometheus_remote_write_endpoint: "http://prometheus.srv.renner.uno:9090/api/v1/write"
# Collection settings
alloy_collect_journal: true
alloy_collect_syslog: true
alloy_collect_node_metrics: true
alloy_collect_docker: "{{ docker_socket_exists | default(false) }}"
# Docker container monitoring (cAdvisor)
# Set to true on hosts that should have cAdvisor deployed
docker_monitoring_enabled: false
# Scrape interval
alloy_scrape_interval: "30s"
# Labels applied to all metrics/logs
alloy_extra_labels:
environment: "production"
datacenter: "homelab"
# Systemd service
alloy_user: "alloy"
alloy_group: "alloy"
# Agent port for self-metrics
alloy_http_listen_port: 12345
# Extra scrape targets for custom exporters
# Format: list of {name: "...", address: "host:port", job: "..."}
alloy_extra_scrape_targets: []
# Syslog receiver (for receiving logs from remote devices like Synology NAS)
# Enable this on the central log collection host (e.g., loki.srv.renner.uno)
alloy_syslog_receiver_enabled: false
alloy_syslog_receiver_port: 1514 # Use non-privileged port, Synology can specify port
alloy_syslog_receiver_protocol: "udp" # udp or tcp
alloy_syslog_receiver_format: "rfc5424" # rfc3164, rfc5424, or raw (requires experimental)
# Stability level for experimental features (e.g., raw syslog format)
alloy_stability_level: "generally-available" # generally-available, public-preview, experimental
@@ -1,17 +0,0 @@
---
- name: Restart alloy
systemd:
name: alloy
state: restarted
daemon_reload: yes
when: monitoring_agent_enabled | bool
- name: Reload systemd
systemd:
daemon_reload: yes
- name: Restart cadvisor
systemd:
name: cadvisor
state: restarted
daemon_reload: yes
@@ -1,170 +0,0 @@
---
# Monitoring Agent (Grafana Alloy) Installation and Configuration
# This role is designed to be included in common.yml for all hosts
- name: Skip if monitoring agent is disabled
meta: end_host
when: not (monitoring_agent_enabled | bool)
- name: Install dependencies
apt:
name:
- unzip
- curl
state: present
update_cache: yes
when: ansible_facts['os_family'] == 'Debian'
- name: Check if Docker socket exists
stat:
path: /var/run/docker.sock
register: docker_socket
- name: Set Docker collection fact
set_fact:
alloy_collect_docker: "{{ docker_socket.stat.exists }}"
- name: Deploy cAdvisor container for Docker metrics
when: docker_monitoring_enabled | bool and docker_socket.stat.exists
block:
- name: Create cAdvisor systemd service
copy:
dest: /etc/systemd/system/cadvisor.service
mode: '0644'
content: |
[Unit]
Description=cAdvisor Container Monitoring
After=docker.service
Requires=docker.service
[Service]
Type=simple
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f cadvisor
ExecStart=/usr/bin/docker run --rm \
--name=cadvisor \
--privileged \
--device=/dev/kmsg \
--volume=/:/rootfs:ro \
--volume=/var/run:/var/run:ro \
--volume=/sys:/sys:ro \
--volume=/var/lib/docker/:/var/lib/docker:ro \
--volume=/dev/disk/:/dev/disk:ro \
--volume=/cgroup:/cgroup:ro \
--publish=127.0.0.1:8080:8080 \
gcr.io/cadvisor/cadvisor:v0.49.1 \
--store_container_labels=true
ExecStop=/usr/bin/docker stop cadvisor
[Install]
WantedBy=multi-user.target
notify:
- Reload systemd
- Restart cadvisor
- name: Enable and start cAdvisor service
systemd:
name: cadvisor
state: started
enabled: yes
daemon_reload: yes
- name: Create alloy user
user:
name: "{{ alloy_user }}"
system: yes
shell: /usr/sbin/nologin
create_home: no
groups: "{{ 'docker' if alloy_collect_docker else omit }}"
append: yes
- name: Create directories
file:
path: "{{ item }}"
state: directory
owner: "{{ alloy_user }}"
group: "{{ alloy_group }}"
mode: '0755'
loop:
- "{{ alloy_config_dir }}"
- "{{ alloy_data_dir }}"
- name: Check if Alloy is already installed
stat:
path: "{{ alloy_bin_path }}"
register: alloy_binary
- name: Get installed Alloy version
command: "{{ alloy_bin_path }} --version"
register: installed_version
changed_when: false
failed_when: false
when: alloy_binary.stat.exists
- name: Download Alloy
get_url:
url: "{{ alloy_download_url }}"
dest: "/tmp/alloy.zip"
mode: '0644'
when: not alloy_binary.stat.exists or (installed_version.stdout is defined and alloy_version not in installed_version.stdout)
- name: Unzip Alloy
unarchive:
src: "/tmp/alloy.zip"
dest: "/tmp"
remote_src: yes
when: not alloy_binary.stat.exists or (installed_version.stdout is defined and alloy_version not in installed_version.stdout)
- name: Find Alloy binary in extracted files
find:
paths: "/tmp"
patterns: "alloy-{{ alloy_arch }}"
file_type: file
register: alloy_extracted_binary
when: not alloy_binary.stat.exists or (installed_version.stdout is defined and alloy_version not in installed_version.stdout)
- name: Install Alloy binary from extracted file
copy:
src: "{{ alloy_extracted_binary.files[0].path }}"
dest: "{{ alloy_bin_path }}"
mode: '0755'
remote_src: yes
when:
- not alloy_binary.stat.exists or (installed_version.stdout is defined and alloy_version not in installed_version.stdout)
- alloy_extracted_binary.files is defined and alloy_extracted_binary.files | length > 0
notify: Restart alloy
- name: Create Alloy configuration
template:
src: config.alloy.j2
dest: "{{ alloy_config_file }}"
owner: "{{ alloy_user }}"
group: "{{ alloy_group }}"
mode: '0644'
notify: Restart alloy
- name: Create Alloy systemd service
template:
src: alloy.service.j2
dest: /etc/systemd/system/alloy.service
mode: '0644'
notify:
- Reload systemd
- Restart alloy
- name: Enable and start Alloy service
systemd:
name: alloy
state: started
enabled: yes
daemon_reload: yes
- name: Clean up downloaded files
file:
path: "{{ item }}"
state: absent
loop:
- "/tmp/alloy.zip"
- "/tmp/alloy-{{ alloy_arch }}"
ignore_errors: yes
@@ -1,37 +0,0 @@
[Unit]
Description=Grafana Alloy Monitoring Agent
Documentation=https://grafana.com/docs/alloy/latest/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ alloy_user }}
Group={{ alloy_group }}
ExecStart={{ alloy_bin_path }} run \
--server.http.listen-addr=0.0.0.0:{{ alloy_http_listen_port }} \
--storage.path={{ alloy_data_dir }} \
{% if alloy_stability_level is defined and alloy_stability_level != "generally-available" %}
--stability.level={{ alloy_stability_level }} \
{% endif %}
{{ alloy_config_file }}
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=10s
LimitNOFILE=65536
{% if ansible_virtualization_type is defined and ansible_virtualization_type != 'lxc' %}
# Security hardening (disabled in LXC containers due to namespace restrictions)
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{ alloy_data_dir }} /var/log
ReadOnlyPaths=/var/run/docker.sock
PrivateTmp=yes
{% endif %}
# Allow reading journal
SupplementaryGroups=systemd-journal{% if alloy_collect_docker %} docker{% endif %}
[Install]
WantedBy=multi-user.target
@@ -1,266 +0,0 @@
// Grafana Alloy Configuration
// Host: {{ inventory_hostname }}
// Generated by Ansible
// =====================================================
// Logging Configuration (Loki)
// =====================================================
{% if alloy_collect_journal %}
// Collect systemd journal logs
loki.source.journal "journal" {
forward_to = [loki.write.default.receiver]
labels = {
job = "systemd-journal",
host = "{{ inventory_hostname }}",
{% for key, value in alloy_extra_labels.items() %}
{{ key }} = "{{ value }}",
{% endfor %}
}
relabel_rules = loki.relabel.journal.rules
}
loki.relabel "journal" {
forward_to = []
rule {
source_labels = ["__journal__systemd_unit"]
target_label = "unit"
}
rule {
source_labels = ["__journal_priority_keyword"]
target_label = "level"
}
rule {
source_labels = ["__journal__hostname"]
target_label = "hostname"
}
}
{% endif %}
{% if alloy_collect_syslog %}
// Collect syslog files
local.file_match "syslog" {
path_targets = [
{__path__ = "/var/log/syslog", job = "syslog"},
{__path__ = "/var/log/auth.log", job = "auth"},
{__path__ = "/var/log/kern.log", job = "kernel"},
]
}
loki.source.file "syslog" {
targets = local.file_match.syslog.targets
forward_to = [loki.write.default.receiver]
tail_from_end = true
}
{% endif %}
{% if alloy_collect_docker %}
// Docker container logs
discovery.docker "containers" {
host = "unix:///var/run/docker.sock"
}
discovery.relabel "docker" {
targets = discovery.docker.containers.targets
rule {
source_labels = ["__meta_docker_container_name"]
target_label = "container"
regex = "/(.*)"
}
rule {
source_labels = ["__meta_docker_container_label_com_docker_compose_service"]
target_label = "service"
}
rule {
source_labels = ["__meta_docker_container_label_com_docker_compose_project"]
target_label = "project"
}
}
loki.source.docker "docker" {
host = "unix:///var/run/docker.sock"
targets = discovery.relabel.docker.output
forward_to = [loki.process.docker.receiver]
labels = {
job = "docker",
host = "{{ inventory_hostname }}",
{% for key, value in alloy_extra_labels.items() %}
{{ key }} = "{{ value }}",
{% endfor %}
}
}
loki.process "docker" {
forward_to = [loki.write.default.receiver]
stage.json {
expressions = {
log = "log",
stream = "stream",
time = "time",
}
}
stage.labels {
values = {
stream = "",
}
}
stage.output {
source = "log"
}
}
{% endif %}
{% if alloy_syslog_receiver_enabled %}
// Syslog receiver for remote devices (Synology NAS, network devices, etc.)
loki.source.syslog "syslog_receiver" {
listener {
address = "0.0.0.0:{{ alloy_syslog_receiver_port }}"
protocol = "{{ alloy_syslog_receiver_protocol }}"
syslog_format = "{{ alloy_syslog_receiver_format }}"
labels = {
job = "syslog-remote",
}
}
{% if alloy_syslog_receiver_format == "raw" %}
forward_to = [loki.process.syslog_receiver.receiver]
}
// Parse raw syslog messages (BSD/RFC 3164 format)
// Synology's "IETF" format actually sends: <priority>MMM DD HH:MM:SS hostname app[pid]: message
// Example: <14>Mar 1 09:02:47 nas-1 Connection: User [pve] from...
loki.process "syslog_receiver" {
forward_to = [loki.write.default.receiver]
stage.regex {
expression = "^\\w+\\s+\\d+\\s+\\d+:\\d+:\\d+\\s+(?P<hostname>\\S+)\\s+(?P<app>[^:\\[]+)(?:\\[\\d+\\])?:\\s*(?P<message>.*)$"
}
stage.labels {
values = {
hostname = "",
app = "",
}
}
stage.output {
source = "message"
}
}
{% else %}
forward_to = [loki.write.default.receiver]
}
{% endif %}
{% endif %}
// Write logs to Loki
loki.write "default" {
endpoint {
url = "{{ loki_endpoint }}/loki/api/v1/push"
}
external_labels = {
host = "{{ inventory_hostname }}",
{% for key, value in alloy_extra_labels.items() %}
{{ key }} = "{{ value }}",
{% endfor %}
}
}
// =====================================================
// Metrics Configuration (Prometheus)
// =====================================================
{% if alloy_collect_node_metrics %}
// Node metrics (CPU, memory, disk, network)
prometheus.exporter.unix "node" {
// Using default settings for comprehensive node metrics
}
prometheus.scrape "node" {
targets = prometheus.exporter.unix.node.targets
forward_to = [prometheus.remote_write.default.receiver]
scrape_interval = "{{ alloy_scrape_interval }}"
job_name = "node"
}
{% endif %}
{% if alloy_collect_docker and docker_monitoring_enabled %}
// Docker/cAdvisor metrics - scrape from cAdvisor container
prometheus.scrape "cadvisor" {
targets = [
{"__address__" = "127.0.0.1:8080"},
]
forward_to = [prometheus.relabel.cadvisor.receiver]
scrape_interval = "{{ alloy_scrape_interval }}"
job_name = "cadvisor"
}
// Relabel cAdvisor metrics to add host label
prometheus.relabel "cadvisor" {
forward_to = [prometheus.remote_write.default.receiver]
rule {
target_label = "host"
replacement = "{{ inventory_hostname }}"
}
}
{% endif %}
// Self-monitoring metrics
prometheus.exporter.self "alloy" {}
prometheus.scrape "alloy_self" {
targets = prometheus.exporter.self.alloy.targets
forward_to = [prometheus.remote_write.default.receiver]
scrape_interval = "{{ alloy_scrape_interval }}"
job_name = "alloy"
}
{% if alloy_extra_scrape_targets | length > 0 %}
// Extra scrape targets (custom exporters)
{% for target in alloy_extra_scrape_targets %}
prometheus.scrape "{{ target.name }}" {
targets = [
{"__address__" = "{{ target.address }}"},
]
forward_to = [prometheus.relabel.extra_{{ target.name }}.receiver]
scrape_interval = "{{ alloy_scrape_interval }}"
job_name = "{{ target.job }}"
}
prometheus.relabel "extra_{{ target.name }}" {
forward_to = [prometheus.remote_write.default.receiver]
rule {
target_label = "host"
replacement = "{{ inventory_hostname }}"
}
}
{% endfor %}
{% endif %}
// Remote write to Prometheus
prometheus.remote_write "default" {
endpoint {
url = "{{ prometheus_remote_write_endpoint }}"
}
external_labels = {
host = "{{ inventory_hostname }}",
{% for key, value in alloy_extra_labels.items() %}
{{ key }} = "{{ value }}",
{% endfor %}
}
}
// =====================================================
// Agent HTTP Server (for health checks and metrics)
// =====================================================
// Agent serves metrics on port {{ alloy_http_listen_port }}
@@ -1,16 +0,0 @@
---
# Music Assistant configuration
music_assistant_version: "latest"
music_assistant_data_dir: "/opt/music-assistant"
# Network ports (host networking required for mDNS/uPnP)
music_assistant_web_port: 8095
music_assistant_stream_port: 8097
music_assistant_slimproto_port: 3483
# Log level: debug, info, warning, error
music_assistant_log_level: "info"
# Optional: Path to local music library (mount point inside container)
# Set to empty string to disable local music provider
music_assistant_media_path: ""
@@ -1,5 +0,0 @@
---
- name: Restart Music Assistant
community.docker.docker_compose_v2:
project_src: "{{ music_assistant_data_dir }}"
state: restarted
@@ -1,39 +0,0 @@
---
- name: Install required system packages
ansible.builtin.apt:
name:
- python3-docker
state: present
update_cache: true
- name: Create Music Assistant data directory
ansible.builtin.file:
path: "{{ music_assistant_data_dir }}"
state: directory
mode: '0755'
- name: Create Music Assistant data subdirectory
ansible.builtin.file:
path: "{{ music_assistant_data_dir }}/data"
state: directory
mode: '0755'
- name: Create media directory if specified
ansible.builtin.file:
path: "{{ music_assistant_media_path }}"
state: directory
mode: '0755'
when: music_assistant_media_path | length > 0
- name: Copy Music Assistant Docker Compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ music_assistant_data_dir }}/docker-compose.yml"
mode: '0644'
notify: Restart Music Assistant
- name: Start Music Assistant via Docker Compose
community.docker.docker_compose_v2:
project_src: "{{ music_assistant_data_dir }}"
state: present
pull: always
@@ -1,28 +0,0 @@
services:
music-assistant:
container_name: music-assistant
image: ghcr.io/music-assistant/server:{{ music_assistant_version }}
# Host networking is REQUIRED for mDNS/uPnP discovery of players
network_mode: host
environment:
- LOG_LEVEL={{ music_assistant_log_level }}
volumes:
- {{ music_assistant_data_dir }}/data:/data
- /etc/localtime:/etc/localtime:ro
{% if music_assistant_media_path | length > 0 %}
- {{ music_assistant_media_path }}:/media:ro
{% endif %}
# Required capabilities for proper audio streaming
cap_add:
- SYS_ADMIN
- DAC_READ_SEARCH
# Security: limit capabilities to what's needed
security_opt:
- apparmor:unconfined
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:{{ music_assistant_web_port }}/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
-36
View File
@@ -1,36 +0,0 @@
---
# n8n configuration
n8n_version: "2.11.0"
n8n_port: 5678
n8n_data_dir: "/opt/n8n"
# Public URL (required for webhooks)
n8n_public_url: "https://n8n.renner.uno"
n8n_protocol: "https"
# Encryption key for credentials (MUST be set in vault!)
n8n_encryption_key: "CHANGE_ME_IN_VAULT"
# Timezone
n8n_timezone: "Europe/Vienna"
# PostgreSQL configuration
n8n_db_type: "postgresdb"
n8n_postgres_user: "n8n"
n8n_postgres_password: "CHANGE_ME_IN_VAULT"
n8n_postgres_db: "n8n"
n8n_postgres_version: "16"
# Execution settings
n8n_executions_data_save_on_error: "all"
n8n_executions_data_save_on_success: "all"
n8n_executions_data_save_manual_executions: true
n8n_executions_data_prune: true
n8n_executions_data_max_age: 336 # 14 days in hours
# Node environment
n8n_node_env: "production"
# Default admin (optional, for initial setup)
n8n_default_user_email: ""
n8n_default_user_password: ""
-5
View File
@@ -1,5 +0,0 @@
---
- name: Restart n8n
community.docker.docker_compose_v2:
project_src: "{{ n8n_data_dir }}"
state: restarted
-40
View File
@@ -1,40 +0,0 @@
---
- name: Install required system packages
ansible.builtin.apt:
name:
- python3-docker
state: present
update_cache: true
- name: Create n8n data directory
ansible.builtin.file:
path: "{{ n8n_data_dir }}"
state: directory
mode: '0755'
- name: Create n8n subdirectories
ansible.builtin.file:
path: "{{ n8n_data_dir }}/{{ item.path }}"
state: directory
mode: '0755'
owner: "{{ item.owner | default('root') }}"
group: "{{ item.group | default('root') }}"
loop:
- { path: 'data', owner: '1000', group: '1000' } # n8n runs as user 'node' (UID 1000)
- { path: 'postgres' }
- name: Copy n8n configuration files
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ n8n_data_dir }}/{{ item.dest }}"
mode: '0644'
loop:
- { src: '.env.j2', dest: '.env' }
- { src: 'docker-compose.yml.j2', dest: 'docker-compose.yml' }
notify: Restart n8n
- name: Start n8n via Docker Compose
community.docker.docker_compose_v2:
project_src: "{{ n8n_data_dir }}"
state: present
pull: always
-42
View File
@@ -1,42 +0,0 @@
# n8n Environment Configuration
# Managed by Ansible - Do not edit manually
# Node environment
NODE_ENV={{ n8n_node_env }}
# Timezone
GENERIC_TIMEZONE={{ n8n_timezone }}
TZ={{ n8n_timezone }}
# Public URL and webhooks
N8N_HOST=0.0.0.0
N8N_PORT=5678
N8N_PROTOCOL={{ n8n_protocol }}
WEBHOOK_URL={{ n8n_public_url }}/
# Encryption key for credentials (critical - do not change after initial setup)
N8N_ENCRYPTION_KEY={{ n8n_encryption_key }}
# Database configuration (PostgreSQL)
DB_TYPE={{ n8n_db_type }}
DB_POSTGRESDB_HOST=postgres
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_DATABASE={{ n8n_postgres_db }}
DB_POSTGRESDB_USER={{ n8n_postgres_user }}
DB_POSTGRESDB_PASSWORD={{ n8n_postgres_password }}
# Execution settings
EXECUTIONS_DATA_SAVE_ON_ERROR={{ n8n_executions_data_save_on_error }}
EXECUTIONS_DATA_SAVE_ON_SUCCESS={{ n8n_executions_data_save_on_success }}
EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS={{ n8n_executions_data_save_manual_executions | lower }}
EXECUTIONS_DATA_PRUNE={{ n8n_executions_data_prune | lower }}
EXECUTIONS_DATA_MAX_AGE={{ n8n_executions_data_max_age }}
{% if n8n_default_user_email and n8n_default_user_password %}
# Default admin user (only used on first startup)
N8N_DEFAULT_USER_EMAIL={{ n8n_default_user_email }}
N8N_DEFAULT_USER_PASSWORD={{ n8n_default_user_password }}
{% endif %}
# Telemetry (disabled)
N8N_DIAGNOSTICS_ENABLED=false
@@ -1,39 +0,0 @@
services:
postgres:
container_name: n8n-postgres
image: postgres:{{ n8n_postgres_version }}
restart: always
environment:
- POSTGRES_USER={{ n8n_postgres_user }}
- POSTGRES_PASSWORD={{ n8n_postgres_password }}
- POSTGRES_DB={{ n8n_postgres_db }}
volumes:
- {{ n8n_data_dir }}/postgres:/var/lib/postgresql/data
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ n8n_postgres_user }} -d {{ n8n_postgres_db }}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
n8n:
container_name: n8n
image: docker.n8n.io/n8nio/n8n:{{ n8n_version }}
restart: always
depends_on:
postgres:
condition: service_healthy
env_file:
- .env
ports:
- "{{ n8n_port }}:5678"
volumes:
- {{ n8n_data_dir }}/data:/home/node/.n8n
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:5678/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
-23
View File
@@ -1,23 +0,0 @@
---
# ownCloud Infinite Scale (oCIS) configuration
owncloud_version: "8.0.1"
owncloud_port: 9200
owncloud_data_dir: "/opt/owncloud"
owncloud_storage_dir: "/mnt/data/owncloud"
# Public URL
owncloud_url: "https://owncloud.renner.uno"
# Admin user (override password in vault!)
owncloud_admin_password: "{{ vault_owncloud_admin_password }}"
# OIDC Authentication (Authentik)
owncloud_oidc_enabled: true
owncloud_oidc_issuer: "https://auth.renner.uno/application/o/owncloud/"
owncloud_oidc_client_id: "{{ vault_owncloud_oidc_client_id }}"
owncloud_oidc_client_secret: "{{ vault_owncloud_oidc_client_secret }}"
# ONLYOFFICE
owncloud_onlyoffice_enabled: true
owncloud_onlyoffice_url: "https://office.renner.uno"
owncloud_onlyoffice_secret: "{{ vault_owncloud_wopi_secret | default('') }}"
-5
View File
@@ -1,5 +0,0 @@
---
- name: Restart ownCloud
community.docker.docker_compose_v2:
project_src: "{{ owncloud_data_dir }}"
state: restarted
-90
View File
@@ -1,90 +0,0 @@
---
- name: Install required system packages
ansible.builtin.apt:
name:
- python3-docker
state: present
update_cache: true
- name: Create ownCloud data directory
ansible.builtin.file:
path: "{{ owncloud_data_dir }}"
state: directory
mode: '0755'
- name: Create ownCloud config directory
ansible.builtin.file:
path: "{{ owncloud_data_dir }}/config"
state: directory
owner: "1000"
group: "1000"
mode: '0755'
- name: Create ownCloud data storage directory
ansible.builtin.file:
path: "{{ owncloud_storage_dir }}/data"
state: directory
owner: "1000"
group: "1000"
mode: '0755'
- name: Create ONLYOFFICE directories
ansible.builtin.file:
path: "{{ owncloud_data_dir }}/{{ item }}"
state: directory
mode: '0755'
loop:
- onlyoffice/data
- onlyoffice/logs
when: owncloud_onlyoffice_enabled | default(false)
- name: Copy ownCloud configuration files
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ owncloud_data_dir }}/{{ item.dest }}"
mode: '0644'
loop:
- { src: '.env.j2', dest: '.env' }
- { src: 'docker-compose.yml.j2', dest: 'docker-compose.yml' }
notify: Restart ownCloud
- name: Create proxy config directory
ansible.builtin.file:
path: "{{ owncloud_data_dir }}/config/proxy"
state: directory
owner: "1000"
group: "1000"
mode: '0755'
- name: Copy proxy role assignment config
ansible.builtin.template:
src: proxy.yaml.j2
dest: "{{ owncloud_data_dir }}/config/proxy.yaml"
owner: "1000"
group: "1000"
mode: '0644'
notify: Restart ownCloud
- name: Copy role assignment configuration
ansible.builtin.template:
src: role_assignment.json.j2
dest: "{{ owncloud_data_dir }}/config/role_assignment.json"
owner: "1000"
group: "1000"
mode: '0644'
notify: Restart ownCloud
- name: Copy CSP configuration
ansible.builtin.template:
src: csp.yaml.j2
dest: "{{ owncloud_data_dir }}/config/csp.yaml"
owner: "1000"
group: "1000"
mode: '0644'
notify: Restart ownCloud
- name: Start ownCloud via Docker Compose
community.docker.docker_compose_v2:
project_src: "{{ owncloud_data_dir }}"
state: present
pull: always
-32
View File
@@ -1,32 +0,0 @@
# ownCloud Infinite Scale Configuration
# Managed by Ansible - Do not edit manually
OCIS_URL={{ owncloud_url }}
OCIS_INSECURE=true
OCIS_LOG_LEVEL=warn
# Admin credentials
IDM_ADMIN_PASSWORD={{ owncloud_admin_password }}
# Proxy configuration (behind Caddy reverse proxy)
PROXY_HTTP_ADDR=0.0.0.0:9200
PROXY_TLS=false
NATS_NATS_HOST=0.0.0.0
GATEWAY_GRPC_ADDR=0.0.0.0:9142
{% if owncloud_oidc_enabled and owncloud_oidc_client_id %}
# OIDC Authentication (Authentik)
PROXY_OIDC_ISSUER={{ owncloud_oidc_issuer }}
OCIS_OIDC_ISSUER={{ owncloud_oidc_issuer }}
PROXY_OIDC_REWRITE_WELLKNOWN=true
WEB_OIDC_CLIENT_ID={{ owncloud_oidc_client_id }}
PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD=jwt
PROXY_USER_OIDC_CLAIM=preferred_username
PROXY_USER_CS3_CLAIM=username
PROXY_AUTOPROVISION_ACCOUNTS=true
PROXY_ROLE_ASSIGNMENT_DRIVER=oidc
PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM=groups
WEB_OIDC_SCOPE=openid profile email groups
OCIS_EXCLUDE_RUN_SERVICES=idp
PROXY_CSP_CONFIG_FILE_LOCATION=/etc/ocis/csp.yaml
{% endif %}
@@ -1,40 +0,0 @@
directives:
child-src:
- "'self'"
connect-src:
- "'self'"
- "https://auth.renner.uno"
- "https://raw.githubusercontent.com"
- "https://office.renner.uno"
default-src:
- "'none'"
font-src:
- "'self'"
- "data:"
frame-ancestors:
- "'self'"
- "https://owncloud.renner.uno"
- "https://office.renner.uno"
frame-src:
- "'self'"
- "blob:"
- "https://auth.renner.uno"
- "https://office.renner.uno"
img-src:
- "'self'"
- "data:"
- "blob:"
- "https://raw.githubusercontent.com"
manifest-src:
- "'self'"
media-src:
- "'self'"
object-src:
- "'self'"
- "blob:"
script-src:
- "'self'"
- "'unsafe-inline'"
style-src:
- "'self'"
- "'unsafe-inline'"
@@ -1,72 +0,0 @@
services:
ocis:
container_name: ocis
image: owncloud/ocis:{{ owncloud_version }}
env_file:
- .env
ports:
- "{{ owncloud_port }}:9200"
volumes:
- {{ owncloud_data_dir }}/config:/etc/ocis
- {{ owncloud_storage_dir }}/data:/var/lib/ocis
- /etc/localtime:/etc/localtime:ro
restart: always
entrypoint:
- /bin/sh
command: ["-c", "ocis init || true; ocis server"]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9200/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
{% if owncloud_onlyoffice_enabled is defined and owncloud_onlyoffice_enabled %}
ocis-collaboration:
container_name: ocis-collaboration
image: owncloud/ocis:{{ owncloud_version }}
command: ["collaboration", "server"]
environment:
COLLABORATION_GRPC_ADDR: 0.0.0.0:9301
COLLABORATION_HTTP_ADDR: 0.0.0.0:9300
MICRO_REGISTRY: nats-js-kv
MICRO_REGISTRY_ADDRESS: ocis:9233
COLLABORATION_WOPI_SRC: http://ocis-collaboration:9300
COLLABORATION_APP_NAME: OnlyOffice
COLLABORATION_APP_PRODUCT: OnlyOffice
COLLABORATION_APP_ADDR: {{ owncloud_onlyoffice_url }}
COLLABORATION_APP_ICON: {{ owncloud_onlyoffice_url }}/web-apps/apps/documenteditor/main/resources/img/favicon.ico
COLLABORATION_APP_INSECURE: "true"
COLLABORATION_CS3API_DATAGATEWAY_INSECURE: "true"
COLLABORATION_LOG_LEVEL: info
OCIS_URL: {{ owncloud_url }}
volumes:
- {{ owncloud_data_dir }}/config:/etc/ocis
- /etc/localtime:/etc/localtime:ro
restart: always
depends_on:
ocis:
condition: service_healthy
onlyoffice:
condition: service_healthy
onlyoffice:
container_name: onlyoffice
image: onlyoffice/documentserver:latest
ports:
- "9980:80"
environment:
- JWT_ENABLED=false
- WOPI_ENABLED=true
volumes:
- {{ owncloud_data_dir }}/onlyoffice/data:/var/www/onlyoffice/Data
- {{ owncloud_data_dir }}/onlyoffice/logs:/var/log/onlyoffice
- /etc/localtime:/etc/localtime:ro
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/healthcheck"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
{% endif %}
@@ -1,11 +0,0 @@
role_assignment:
driver: oidc
oidc_role_mapper:
role_claim: groups
role_mapping:
- role_name: admin
claim_value: ocis-admin
- role_name: spaceadmin
claim_value: ocis-spaceadmin
- role_name: user
claim_value: ocis-user

Some files were not shown because too many files have changed in this diff Show More