14 Commits

Author SHA1 Message Date
unkin-agent 979c188c34 Bump openbao-plugin-secrets-arrstack to 0.2.0 (#522)
## Why

Plugin 0.2.0 adds per-role method scoping; the binary must land on the vault nodes before the catalog sha256 bump in terraform-vault.

## How

- Bump the `openbao-plugin-secrets-arrstack` pin in `hieradata/roles/infra/storage/vault.yaml` from `0.1.0` to `0.2.0`.

Reviewed-on: #522
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 15:28:47 +10:00
unkin-agent 7e1f2c336c Install openbao-plugin-secrets-arrstack v0.1.0 (#521)
## Why

The new `openbao-plugin-secrets-arrstack` OpenBao secrets engine mints dynamic per-service arrproxy machine tokens for the arrstack (Sonarr/Radarr/Prowlarr) fleet. Its v0.1.0 RPM is now published in artifactapi (rpm-internal), so the OpenBao VM nodes can install the plugin binary into `/opt/openbao-plugins`.

## Changes

- Add `openbao-plugin-secrets-arrstack` pinned to `0.1.0` to `profiles::packages::include` in `hieradata/roles/infra/storage/vault.yaml`, matching the existing pinned sibling plugins (litellm, gpg, rancher, gitea, apptoken, netbox, ghp).

This PR only installs the RPM binary on the nodes. Catalog registration is handled separately by terraform-vault (`config/plugins/*.yaml`).

## Reference

Plugin binary sha256 (used by the terraform-vault catalog registration, separate PR):

```
f8ee60ca7ba14819976acb7dc4cfb6799e3e8da8f871d0bb2bd18d1d9e537972
```

Reviewed-on: #521
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-18 23:21:44 +10:00
unkin-agent 2a329058c0 vault: install openbao-plugin-secrets-ghp on OpenBao nodes (#520)
Why: step 1 of adding the ghp Vault secrets engine — the plugin binary must be on the OpenBao nodes before terraform-vault registers it in the catalog (the catalog sha256 pins this exact build).

How: pin `openbao-plugin-secrets-ghp` ensure 0.1.0 in hieradata/roles/infra/storage/vault.yaml profiles::packages::include, alongside the other secrets plugins.

Ordering: this merges + puppet-applies first; then the config-write policy; then the terraform-vault engine resources (terraform-vault#121).
Reviewed-on: #520
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-16 14:47:58 +10:00
unkinben 7102ef2b34 vault: move openbao plugin sockets off /tmp onto /run (#509)
## Why

The litellm secrets engine on the OpenBao cluster died with `rpc Unavailable / dial unix /tmp/pluginNNN: no such file` (terraform-vault#112), fixed only by a manual `sys/plugins/reload/backend`.

Root cause (post-incident log audit): go-plugin puts each plugin's control socket under the process TMPDIR — `/tmp/pluginNNN` — and `vault.service` runs without PrivateTmp. The daily `systemd-tmpfiles-clean` reaps aged `/tmp` files; bao ran a single systemd invocation for 3+ weeks, so the socket long outlived the `/tmp` cleanup age and got deleted out from under the still-running plugin process (no panic/OOM/signal/exit in the bao journal — the process was healthy, just unreachable). The risk is shared by every OpenBao plugin (gpg, rancher, gitea, ...), not just litellm.

Supersedes the earlier tmpfiles-exclude approach (#508, closed) with the permanent fix: move the sockets off `/tmp` entirely.

## Change

- Add a `vault.service` drop-in (`systemd::manage_dropin`) that sets `Environment=TMPDIR=/run/vault-plugins` and `RuntimeDirectory=vault-plugins` (mode 0700).
- Point plugin sockets at `/run` (tmpfs, no age-based cleanup); `RuntimeDirectory` creates/owns the dir per service start.
- Notify a vault service restart (module handles daemon-reload) so the new TMPDIR takes effect and plugins respawn with sockets under `/run`.

## Heads-up

Puppet rolls a **bao restart per node** when this lands (the drop-in notifies `Service['vault']`). With auto-unseal (this cluster runs `profiles::vault::unseal`) it is a rolling non-event; if any node relies on manual unseal it will come back **sealed** and need unsealing. Merge consciously / stagger if needed.

https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
Reviewed-on: #509
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-09 12:14:02 +10:00
unkinben d859daead4 vault: install netbox and apptoken OpenBao plugin RPMs on bao nodes (#519)
## Why

terraform-vault #115 registers `vault-plugin-secrets-netbox` in the OpenBao plugin catalog pinned to the released v0.1.0 binary's sha256. OpenBao only registers/mounts a plugin whose binary is physically present in the plugin_directory (`/opt/openbao-plugins`); the `openbao-plugin-secrets-netbox` RPM is published on rpm-internal but not yet installed on the bao nodes, so #115 fails to register until Puppet lays the binary down. The `openbao-plugin-secrets-apptoken` binary is likewise published but missing from the bao package list, deferred from the apptoken engine's earlier deploy follow-up.

## Changes

- Add `openbao-plugin-secrets-netbox` (pinned `0.1.0`) to the bao node `profiles::packages::include` list
- Add `openbao-plugin-secrets-apptoken` (pinned `0.1.0`) to the same list, alongside the existing gpg/litellm/rancher/gitea plugin RPMs
- Both install to `/opt/openbao-plugins`, matching the plugin_directory OpenBao reads and the sha256 registered in terraform-vault

## Ordering

This must apply (Puppet converge on the `roles::infra::storage::vault` nodes) BEFORE terraform-vault #115 is applied, otherwise #115's catalog registration fails on a missing binary.

Reviewed-on: #519
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-09 01:11:02 +10:00
unkinben 0fd01ac1a7 ceph: manage /etc/ceph/ceph.conf on osd and mon/mgr/mds hosts (#515)
## Why

`/etc/ceph/ceph.conf` on the prodnxsr ceph cluster (fsid `de96a98f`) is currently unmanaged and hand-maintained via the runbook (`profiles::ceph::client::manage_ceph_conf` is `false` on the k8s and incus roles). This brings it under Puppet, drives it from a single source of truth in hiera, and normalizes `public_network` to every ceph host's `/32`.

## How hosts are identified (osd-only vs mon/mgr/mds)

The authoritative, declarative signal is the role's `hiera_include` list (ENC `role` -> role hiera):

- **osd-only hosts** = `roles::infra::k8s::*` (prodnxsr0001-0008 and prodnxsr0014-0019): include `profiles::ceph::osd` + `::client`, NOT `::mds` -> `[global]` only.
- **mon/mgr/mds hosts** = `roles::infra::incus::node` (prodnxsr0009-0013): also include `profiles::ceph::mds` -> `[global]` + `[mds]` + `[mds.*]`.

The template keys on `profiles::ceph::client::render_mds_config`, set `true` only in the incus node role hiera (the single role that includes `profiles::ceph::mds`). The `is_ceph_mds` fact is a runtime fallback, not used here.

## Changes

- Add cluster topology as a single source of truth in `hieradata/common.yaml`: `cluster_public_ips` (all 19 ceph host `/32`s, 198.18.23.1-19), `mon_initial_members` (the 5 mons), and the `mds_instances` map (two mds daemons per mon/mgr/mds host).
- Rework `profiles/ceph/client.conf.erb` to render `[global]` always and the `[mds]`/`[mds.*]` sections only when `render_mds_config` is true, preserving the live two-space indent, exact key order, and blank-line separators between mds sections.
- Extend `profiles::ceph::client` with topology + tunable params (sample values as defaults) and gate `[mds]` on `render_mds_config`.
- Drop the hard `Package[ceph-common]` dependency when the class does not manage the package (cephadm / `profiles::packages` deliver it on the k8s and incus hosts; k8s hosts do not declare `ceph-common`, so an unconditional require would fail to compile).
- Enable `manage_ceph_conf: true` on `roles/infra/k8s.yaml`, `roles/infra/k8s/node.yaml`, and `roles/infra/incus/node.yaml`; add `render_mds_config: true` to the incus node role. prodnxsr0014-0019 run `roles::infra::k8s::compute` (confirmed live via PuppetDB; `is_ceph_osd` true, `is_ceph_mds` false), so they are already covered by `k8s.yaml` and get the `[global]`-only variant with no mds sections.

## Live-cluster safety review (please read before merge)

This touches a running storage cluster's config. The rendered content is byte-equivalent to the live file **except** for the one deliberate `public_network` normalization below.

- **Intended change:** `public_network` is normalized to all 19 ceph host `/32`s on every host (198.18.23.1-19). Adoption is now a **single `public_network`-line change on every cluster host**, no longer a no-op on the osd hosts:
  - osd hosts prodnxsr0001-0008 currently list `.1-.13` live -> first convergence adds `.14-.19`.
  - mon/mgr/mds hosts prodnxsr0009-0013 currently list only `.9-.13` live -> first convergence adds `.1-.8` and `.14-.19`.
  - prodnxsr0014-0019 (newly joined OSD hosts) get the file created with the full `[global]` variant.
- **Verified byte diff against the live files** (`/etc/ceph/ceph.conf` pulled from prodnxsr0008 and prodnxsr0009): each differs by **exactly one line** (the expanded `public_network`); on prodnxsr0009 the `[mds]` + `[mds.*]` sections and their blank-line separators are byte-identical to live. Rendering prodnxsr0009 with its current live `.9-.13` value reproduces the live file byte-for-byte, confirming the format match.
- RGW hosts (`roles::infra::ceph::rgw`, ausyd1nxvm2115-2119) include only `profiles::ceph::rgw` -> `profiles::ceph::conf`; they do not include `profiles::ceph::client`, so their variant is untouched and there is no duplicate `File[/etc/ceph/ceph.conf]`.

Rendered samples:

- osd-only (prodnxsr0008): `[global]` with all 19 `/32`s in `public_network`, nothing else.
- mon/mgr/mds (prodnxsr0009): the same `[global]` plus `[mds]` and `[mds.<host>-1|2]` for prodnxsr0009-0013, blank-line separated.

Reviewed-on: #515
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-09 00:00:20 +10:00
unkinben 62dff97c98 rke2: taint control-plane nodes NoSchedule (#517) 2026-08-08 23:19:12 +10:00
unkinben ff1a30823c fix: lock rke2-common and pin rke2 to 1.33.13~rke2r2 to unblock puppet (#516)
## Why

Every puppet run on k8s nodes (e.g. prodnxsr0002) fails on `Package[rke2-server]` and stops applying the rest of the catalog, so the nodes stop receiving all further package/config updates:

```
change from '1.33.4~rke2r1-1.el9' to '1.33.11~rke2r1' failed: Could not update:
dnf upgrade rke2-server-1.33.11~rke2r1 returned 1:
 Problem: problem with installed package rke2-common-1.33.13~rke2r2-0.el9.x86_64
  - package rke2-server-1.33.11~rke2r1 requires rke2-common = 1.33.11~rke2r1, but none of the providers can be installed
  - cannot install the best update candidate for package rke2-server
```

`rke2::install` versionlocks only `rke2-server`/`rke2-agent`, never their strict (`= version`) `rke2-common` dependency. `rke2-common` is served from the rolling `rancher-rke2-1.33-latest` channel, whose head is now `1.33.13~rke2r2`, so `rke2-common` drifted up to `1.33.13~rke2r2` while the pin (`#512`) sat at `1.33.11~rke2r1`. `dnf upgrade` cannot downgrade the newer `rke2-common` to satisfy the older server, so the transaction fails. This is the rolling-channel drift `#512` flagged as needing follow-up.

## How

- Versionlock `rke2-common` to the same `${rke2_version}~${rke2_release}` as the server/agent, so the rolling channel can no longer drift the dependency ahead of the pin.
- Bump `rke2_version` `1.33.11` -> `1.33.13` and `rke2_release` `rke2r1` -> `rke2r2` to match the current channel head and the already-drifted installed `rke2-common`, so the pinned server/agent, both versionlocks, and the preloaded airgap bundle resolve in one transaction.

Verified against the live artifactapi rke2 remote: `rke2-server-1.33.13~rke2r2-0.el9.x86_64.rpm` and the `v1.33.13+rke2r2` `rke2-images.linux-amd64.tar.zst` airgap bundle both serve HTTP 200.

Reviewed-on: #516
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-08 23:13:40 +10:00
unkinben 804ea06499 ceph: set OSD disk I/O scheduler to none via udev rule (#505)
## Why

Ceph OSDs manage their own I/O ordering, so the kernel scheduler on the backing disks just adds overhead. The original intent was to set those disks to the `noop` scheduler. The whole OSD fleet (k8s + incus nodes) runs AlmaLinux 9 on blk-mq kernels (5.14), where the equivalent of `noop` is `none`.

## Changes

- Add `profiles::ceph::osd_scheduler`, rendering a udev rule from the `ceph_osd_devices` fact (PR #504) that pins `queue/scheduler` to `none` on each OSD disk.
- Reload udev and trigger the matched block devices so the setting applies immediately; the udev rule keeps it set across reboots and device re-add.
- No-op when the fact is absent/empty, so VMs and non-OSD hosts are untouched.
- Include the class from `profiles::ceph::osd` so it lands only on OSD hosts.

https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
---------

Co-authored-by: BenVincent <benvin@main.unkin.net>
Reviewed-on: #505
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-08 22:33:29 +10:00
unkinben 0fdc7c97d8 rke2,puppet: enforce versionlock before package install (#514)
## Why

A package versionlock is only effective if it is applied **before** the package resource is evaluated. When the package is upgraded/installed first and locked afterwards, dnf can pull an unpinned version and the lock then pins whatever landed. This is the exact drift that let new RKE2 nodes boot 1.33.11 instead of the pinned version, and it also matters because a global `dnf update -y` autoupdate timer (`profiles::yum::autoupdater`) relies on the versionlock plugin already having the lock written.

## Changes

- `rke2::install` orders the versionlock ahead of the package: adds `before => Package["rke2-${node_type}"]` so the lock exists before install/upgrade.
- `profiles::puppet::agent` (RedHat branch) adds `before => Package[$use_package]` on the versionlock for the same reason.
- Leaves `profiles::gitea::runner` and `profiles::vault::server` unchanged: both already order the versionlock ahead of the package (`before => Package` / `before => Class['vault']`).
- Changes ordering only; locked versions (including the RKE2 pin) are untouched. No dependency cycle is introduced (packages hold no back-reference to the versionlock).

https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
Reviewed-on: #514
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-08 20:00:42 +10:00
unkinben 499251575d lldpd: run on physicals and expose neighbour topology as the lldp fact (#513)
## Why

LLDP is the only source of physical switch/port topology in the estate: which switch and which port each machine is cabled to exists nowhere else (not in intent YAML, not in any other fact, not in DHCP/DNS). NetBox needs it to record device interface -> switch/port connections. This installs and enables lldpd on physical hosts and exposes its neighbour data as a structured `lldp` fact for the terraform-infra pdbmux backfill to consume (linked issue below).

## Changes

- Add `profiles::lldpd`: install the `lldpd` package and enable+start the service (it ships disabled), the service subscribing to the package.
- Assign it physicals-only via `hiera_include` in `hieradata/virtual/physical.yaml` (`facts.virtual == 'physical'`), merged `unique` with the common `hiera_include`. VMs never receive it.
- Add the `lldp` custom fact (`modules/libs/lib/facter/lldp.rb`): parse `lldpctl -f json0` into a per-interface map of `{neighbor_chassis_name, neighbor_chassis_mac, neighbor_chassis_descr, neighbor_port_id, neighbor_port_descr, vlan_id, vlan_name}`, skipping interfaces with no neighbour. Confined to physical Linux hosts that have `lldpctl` and a live lldpd socket; returns an empty hash on any error so it can never break a puppet run.

json0 is used because it array-wraps every node regardless of cardinality, so one neighbour and many neighbours parse identically (plain `keyvalue` folds the neighbour SysName into the key path; plain `json` collapses single-element arrays into objects).

## Sample fact output (prodnxsr0019, enp1s0)

```json
{
  "enp1s0": {
    "neighbor_chassis_name":  "sg3429x-m2-02",
    "neighbor_chassis_mac":   "b8:fb:b3:a7:f9:5d",
    "neighbor_chassis_descr": "48-Port Gigabit Smart Managed Pro Switch",
    "neighbor_port_id":       "24",
    "neighbor_port_descr":    "two-gigabitEthernet 1/0/11",
    "vlan_id":                "201",
    "vlan_name":              "fabric_common_02"
  }
}
```

## Consumer

NetBox population of the switch/port cabling is tracked in terraform-infra: unkin/terraform-infra#7 (extend the pdbmux backfill / PR #6 to read this fact and emit the connection).

https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
Reviewed-on: #513
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-08 18:41:50 +10:00
unkinben 10854b6501 fix: preload correct rke2 airgap images to break CNI bootstrap deadlock (#512)
## Why

Four newly-provisioned el9_8 compute nodes (prodnxsr0014/0015/0018/0019) hang with canal's kube-flannel container in `ImagePullBackOff`: the flannel VXLAN overlay never comes up, so the node can't reach any in-cluster `198.18.200.x` LoadBalancer VIP.

Root cause is a stale airgap-preload version. The nodes boot rke2 **v1.33.11+rke2r1** from the rolling `latest/1.33` repo, whose canal manifest requests `hardened-flannel:v0.28.4` / `hardened-calico:v3.31.5` (build20260415). But `rke2::install` pinned the preloaded bundle to **v1.33.4+rke2r1** (flannel v0.27.2 / calico v3.30.2), so those images were never on disk. containerd then falls back to the `docker.io` mirror (artifactapi, `disable-default-registry-endpoint: true`), reachable only via the pod-overlay VIP that requires the very flannel image being pulled — a bootstrap deadlock. Older nodes (0001-0008) are versionlocked at 1.33.4 and still match their original bundle, so they were unaffected.

## Changes

- Bump pinned `rke2_version` `1.33.4` -> `1.33.11` so the versionlock, RPM `ensure`, and preloaded bundle all line up with the canal image tags the running binary requests. The default `rke2-images.linux-amd64.tar.zst` bundle already contains the canal CNI images (it is RKE2's default CNI), so no extra tarball is needed.
- Wire the airgap archive `source` to the `container_archive_source` class parameter (previously declared in the module but never consumed). The module keeps its generic upstream default; the artifactapi override (the pre-CNI-reachable source, same BGP/physical path the rke2 yum repos already use) lives in the k8s role hiera as `rke2::container_archive_source`.

Applies to servers and agents alike (`rke2::install` runs for both) and preloads `before => Service`, so the bundle lands before rke2 starts.

Notes:
- The `latest/1.33` repo is rolling, so the pinned version must be maintained as the repo advances; a follow-up to pin the yum channel to a fixed patch would remove the drift entirely.
- No terraform-artifactapi change is required. (If a canal-only preload were ever wanted, the github generic remote allowlist would need `rancher/rke2/.*/rke2-images-canal.linux-amd64.tar.zst$` added — but the default bundle already carries those images, so it is unnecessary.)

https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
Reviewed-on: #512
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-08 18:18:55 +10:00
benvin 895ca5b1cb fix: avoid anycast ips (#511)
must change management interfaces for new nodes to start from 21, since
.14 to .20 were used for various anycast services

really need ipam

---------

Co-authored-by: Ben Vincent <ben@unkin.net>
Reviewed-on: #511
2026-08-08 00:56:17 +10:00
benvin caf27e90bd chore: add new physicals (#510)
add prodnxsr0014-19, ocnfigure networking in preparation for ceph

---------

Co-authored-by: Ben Vincent <ben@unkin.net>
Reviewed-on: #510
2026-08-08 00:41:10 +10:00
24 changed files with 403 additions and 23 deletions
+39
View File
@@ -409,6 +409,45 @@ profiles::ceph::client::mons:
- 10.18.15.2
- 10.18.15.3
# de96a98f cluster topology (prodnxsr0001-0019) - single source of truth for
# /etc/ceph/ceph.conf rendered by profiles::ceph::client on the k8s (osd) and
# incus (mon/mgr/mds) roles. fsid/mons are overridden per-role in the role hiera.
# public_network is the /32 of every ceph host's ceph-public loopback.
profiles::ceph::client::cluster_public_ips:
- 198.18.23.1
- 198.18.23.2
- 198.18.23.3
- 198.18.23.4
- 198.18.23.5
- 198.18.23.6
- 198.18.23.7
- 198.18.23.8
- 198.18.23.9
- 198.18.23.10
- 198.18.23.11
- 198.18.23.12
- 198.18.23.13
- 198.18.23.14
- 198.18.23.15
- 198.18.23.16
- 198.18.23.17
- 198.18.23.18
- 198.18.23.19
profiles::ceph::client::mon_initial_members:
- prodnxsr0009
- prodnxsr0010
- prodnxsr0011
- prodnxsr0012
- prodnxsr0013
# two mds daemon instances per mon/mgr/mds host (rendered only where
# render_mds_config is true, i.e. the incus node role).
profiles::ceph::client::mds_instances:
prodnxsr0009: 2
prodnxsr0010: 2
prodnxsr0011: 2
prodnxsr0012: 2
prodnxsr0013: 2
profiles::ceph::conf::config:
global:
auth_client_required: 'cephx'
@@ -0,0 +1,13 @@
---
networking_loopback0_ip: 198.18.19.21 # management loopback
networking_loopback1_ip: 198.18.22.14 # ceph-cluster loopback
networking_loopback2_ip: 198.18.23.14 # ceph-public loopback
networking_1000_ip: 198.18.15.14 # 1gbe network
networking_2500_ip: 198.18.21.14 # 2.5gbe network
networking_1000_iface: enp1s0
networking_2500_iface: enp2s0
networking::interfaces:
"%{hiera('networking_1000_iface')}":
mac: a4:bb:6d:a4:e5:c1
"%{hiera('networking_2500_iface')}":
mac: c4:62:37:0d:50:03
@@ -0,0 +1,13 @@
---
networking_loopback0_ip: 198.18.19.22 # management loopback
networking_loopback1_ip: 198.18.22.15 # ceph-cluster loopback
networking_loopback2_ip: 198.18.23.15 # ceph-public loopback
networking_1000_ip: 198.18.15.15 # 1gbe network
networking_2500_ip: 198.18.21.15 # 2.5gbe network
networking_1000_iface: enp1s0
networking_2500_iface: enp2s0
networking::interfaces:
"%{hiera('networking_1000_iface')}":
mac: a4:bb:6d:a6:30:c4
"%{hiera('networking_2500_iface')}":
mac: c4:62:37:0d:4f:f4
@@ -0,0 +1,13 @@
---
networking_loopback0_ip: 198.18.19.23 # management loopback
networking_loopback1_ip: 198.18.22.16 # ceph-cluster loopback
networking_loopback2_ip: 198.18.23.16 # ceph-public loopback
networking_1000_ip: 198.18.15.16 # 1gbe network
networking_2500_ip: 198.18.21.16 # 2.5gbe network
networking_1000_iface: enp1s0
networking_2500_iface: enp2s0
networking::interfaces:
"%{hiera('networking_1000_iface')}":
mac: a4:bb:6d:9f:22:13
"%{hiera('networking_2500_iface')}":
mac: c4:62:37:0d:50:0c
@@ -0,0 +1,13 @@
---
networking_loopback0_ip: 198.18.19.24 # management loopback
networking_loopback1_ip: 198.18.22.17 # ceph-cluster loopback
networking_loopback2_ip: 198.18.23.17 # ceph-public loopback
networking_1000_ip: 198.18.15.17 # 1gbe network
networking_2500_ip: 198.18.21.17 # 2.5gbe network
networking_1000_iface: enp1s0
networking_2500_iface: enp2s0
networking::interfaces:
"%{hiera('networking_1000_iface')}":
mac: 8c:04:ba:9c:b6:08
"%{hiera('networking_2500_iface')}":
mac: c4:62:37:0d:50:12
@@ -0,0 +1,13 @@
---
networking_loopback0_ip: 198.18.19.25 # management loopback
networking_loopback1_ip: 198.18.22.18 # ceph-cluster loopback
networking_loopback2_ip: 198.18.23.18 # ceph-public loopback
networking_1000_ip: 198.18.15.18 # 1gbe network
networking_2500_ip: 198.18.21.18 # 2.5gbe network
networking_1000_iface: enp1s0
networking_2500_iface: enp2s0
networking::interfaces:
"%{hiera('networking_1000_iface')}":
mac: a4:bb:6d:a4:db:94
"%{hiera('networking_2500_iface')}":
mac: c4:62:37:0d:4f:fa
@@ -0,0 +1,13 @@
---
networking_loopback0_ip: 198.18.19.26 # management loopback
networking_loopback1_ip: 198.18.22.19 # ceph-cluster loopback
networking_loopback2_ip: 198.18.23.19 # ceph-public loopback
networking_1000_ip: 198.18.15.19 # 1gbe network
networking_2500_ip: 198.18.21.19 # 2.5gbe network
networking_1000_iface: enp1s0
networking_2500_iface: enp2s0
networking::interfaces:
"%{hiera('networking_1000_iface')}":
mac: a4:bb:6d:a4:56:11
"%{hiera('networking_2500_iface')}":
mac: c4:62:37:0d:50:00
+3 -1
View File
@@ -203,7 +203,9 @@ profiles::accounts::sysadmin::extra_groups:
- incus-admin
# manage cephfs mounts
profiles::ceph::client::manage_ceph_conf: false
profiles::ceph::client::manage_ceph_conf: true
# mon/mgr/mds host: render the [mds] + [mds.*] sections
profiles::ceph::client::render_mds_config: true
profiles::ceph::client::manage_ceph_package: false
profiles::ceph::client::manage_ceph_paths: false
profiles::ceph::client::fsid: 'de96a98f-3d23-465a-a899-86d3d67edab8'
+3 -1
View File
@@ -11,6 +11,8 @@ hiera_include:
# manage rke2
rke2::bootstrap_node: prodnxsr0001.main.unkin.net
rke2::join_url: https://join-k8s.service.consul:9345
# pull the airgap image bundle via artifactapi (host-reachable pre-CNI), not github directly
rke2::container_archive_source: 'https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github/rancher/rke2/releases/download'
rke2::manage_registries: true
rke2::registries:
docker.io:
@@ -90,7 +92,7 @@ profiles::packages::include:
profiles::selinux::setenforce::mode: disabled
profiles::ceph::client::manage_ceph_conf: false
profiles::ceph::client::manage_ceph_conf: true
profiles::ceph::client::manage_ceph_package: false
profiles::ceph::client::manage_ceph_paths: false
profiles::ceph::client::fsid: 'de96a98f-3d23-465a-a899-86d3d67edab8'
+3
View File
@@ -30,6 +30,9 @@ rke2::config_hash:
- '--node-monitor-period=4s'
protect-kernel-defaults: true
disable-kube-proxy: false
# taint the control-plane/etcd nodes so only tolerating workloads schedule here
node-taint:
- "node-role.kubernetes.io/control-plane=true:NoSchedule"
# configure consul service
consul::services:
+1 -1
View File
@@ -33,7 +33,7 @@ profiles::packages::include:
profiles::selinux::setenforce::mode: disabled
profiles::ceph::client::manage_ceph_conf: false
profiles::ceph::client::manage_ceph_conf: true
profiles::ceph::client::manage_ceph_package: false
profiles::ceph::client::manage_ceph_paths: false
profiles::ceph::client::fsid: 'de96a98f-3d23-465a-a899-86d3d67edab8'
+8
View File
@@ -40,3 +40,11 @@ profiles::packages::include:
ensure: '0.1.1'
openbao-plugin-secrets-gitea:
ensure: '0.1.0'
openbao-plugin-secrets-apptoken:
ensure: '0.1.0'
openbao-plugin-secrets-netbox:
ensure: '0.1.0'
openbao-plugin-secrets-ghp:
ensure: '0.1.0'
openbao-plugin-secrets-arrstack:
ensure: '0.2.0'
+5
View File
@@ -1,3 +1,8 @@
---
# physical hosts only (facts.virtual == 'physical'); merged 'unique' with the
# common hiera_include in profiles::base.
hiera_include:
- profiles::lldpd
profiles::packages::include:
"%{hiera('lm-sensors::package')}": {}
+104
View File
@@ -0,0 +1,104 @@
# frozen_string_literal: true
require 'facter'
require 'json'
# Exposes LLDP neighbour topology (switch/port each interface is cabled to) as
# the structured `lldp` fact, keyed by local interface. This is the only source
# of physical switch/port topology in the estate and feeds NetBox. Uses
# `lldpctl -f json0`: json0 wraps every node in an array regardless of
# cardinality, so one neighbour and many neighbours parse identically (plain
# `keyvalue` folds the neighbour's sysname into the key path, and plain `json`
# collapses single-element arrays into objects). Never raises: any error or a
# down daemon yields an empty hash so a puppet run can never break.
module LldpFact
SOCKETS = ['/run/lldpd.socket', '/var/run/lldpd.socket'].freeze
module_function
# First element of a json0 node (everything is array-wrapped), or the value
# itself if it is not an array; nil when absent.
def first(node)
node.is_a?(Array) ? node[0] : node
end
# Array form of a json0 node whatever its cardinality.
def list(node)
node.is_a?(Array) ? node : [node].compact
end
# Value string of a json0 leaf like [{ 'value' => 'x' }].
def leaf(node)
entry = first(node)
entry.is_a?(Hash) ? entry['value'] : entry
end
# Chassis MAC from its id list, preferring the entry typed 'mac'.
def chassis_mac(chassis)
ids = list(chassis['id'])
mac = ids.find { |id| id.is_a?(Hash) && id['type'] == 'mac' } || ids.first
mac.is_a?(Hash) ? mac['value'] : nil
end
# Topology record for one local interface, or nil when it has no neighbour.
def neighbour(iface)
chassis = first(iface['chassis'])
port = first(iface['port'])
return nil unless chassis && port
chassis_fields(chassis).merge(port_fields(port, first(iface['vlan'])))
end
def chassis_fields(chassis)
{
'neighbor_chassis_name' => leaf(chassis['name']),
'neighbor_chassis_mac' => chassis_mac(chassis),
'neighbor_chassis_descr' => leaf(chassis['descr'])
}
end
def port_fields(port, vlan)
port_id = first(port['id'])
vlan_h = vlan.is_a?(Hash) ? vlan : {}
{
'neighbor_port_id' => port_id.is_a?(Hash) ? port_id['value'] : port_id,
'neighbor_port_descr' => leaf(port['descr']),
'vlan_id' => vlan_h['vlan-id'],
'vlan_name' => vlan_h['value']
}
end
def interfaces(output)
lldp = first(JSON.parse(output)['lldp']) || {}
list(lldp['interface'])
end
# Map of local interface => topology record, skipping interfaces with no
# neighbour.
def collect(ifaces)
ifaces.each_with_object({}) do |iface, acc|
next unless iface.is_a?(Hash)
name = iface['name']
data = neighbour(iface)
acc[name] = data if name && data
end
end
def resolve
output = Facter::Core::Execution.execute('lldpctl -f json0 2>/dev/null', on_fail: nil)
return {} if output.to_s.empty?
collect(interfaces(output))
rescue StandardError
{}
end
end
Facter.add(:lldp) do
confine kernel: 'Linux'
confine { Facter.value(:is_virtual) == false }
confine { Facter::Core::Execution.which('lldpctl') }
confine { LldpFact::SOCKETS.any? { |path| File.exist?(path) } }
setcode { LldpFact.resolve }
end
+10 -3
View File
@@ -6,10 +6,17 @@ class rke2::install (
Stdlib::HTTPUrl $container_archive_source = $rke2::container_archive_source,
){
# versionlock rke2
# versionlock rke2 before install so the lock exists before any upgrade is attempted
yum::versionlock{"rke2-${node_type}":
ensure => present,
version => "${rke2_version}~${rke2_release}",
before => Package["rke2-${node_type}"],
}
# lock rke2-common (a strict = version dep) so the rolling latest channel can't drift it ahead of the pinned server/agent
yum::versionlock{'rke2-common':
ensure => present,
version => "${rke2_version}~${rke2_release}",
}
# install rke2
@@ -27,10 +34,10 @@ class rke2::install (
before => Service["rke2-${node_type}"],
}
# download required archive of containers
# preload the airgap bundle (has the default canal CNI images) so canal starts from disk, not the mirror VIP that needs flannel first
archive { '/var/lib/rancher/rke2/agent/images/rke2-images.linux-amd64.tar.zst':
ensure => present,
source => "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github/rancher/rke2/releases/download/v${rke2_version}%2B${rke2_release}/rke2-images.linux-amd64.tar.zst",
source => "${container_archive_source}/v${rke2_version}%2B${rke2_release}/rke2-images.linux-amd64.tar.zst",
require => [
Package["rke2-${node_type}"],
File['/var/lib/rancher/rke2/agent/images'],
+2 -2
View File
@@ -1,8 +1,8 @@
# rke2 params
class rke2::params (
Enum['server', 'agent'] $node_type = 'agent',
String $rke2_version = '1.33.4',
String $rke2_release = 'rke2r1',
String $rke2_version = '1.33.13',
String $rke2_release = 'rke2r2',
Stdlib::Absolutepath $config_file = '/etc/rancher/rke2/config.yaml',
Hash $config_hash = {},
Stdlib::HTTPSUrl $join_url = 'https://127.0.0.1:9345',
+31 -3
View File
@@ -2,6 +2,9 @@
class profiles::ceph::client (
String $fsid,
Array[Stdlib::Host] $mons,
# cluster topology (single source of truth: hieradata/common.yaml)
Array[Stdlib::Host] $cluster_public_ips,
Array[Stdlib::Host] $mon_initial_members,
Stdlib::Absolutepath $config_file = '/etc/ceph/ceph.conf',
Boolean $manage_ceph_conf = true,
Boolean $manage_ceph_package = true,
@@ -10,6 +13,24 @@ class profiles::ceph::client (
String $group = 'ceph',
Stdlib::Filemode $mode = '0644',
Hash $keyrings = {},
# [global] tunables (defaults match the live hand-maintained ceph.conf)
String $auth_client_required = 'cephx',
String $auth_cluster_required = 'cephx',
String $auth_service_required = 'cephx',
Boolean $mon_allow_pool_delete = true,
Boolean $ms_bind_ipv4 = true,
Boolean $ms_bind_ipv6 = false,
Integer $osd_crush_chooseleaf_type = 1,
Integer $osd_pool_default_min_size = 2,
Integer $osd_pool_default_size = 3,
Integer $osd_pool_default_pg_num = 128,
# mds config sections; only rendered on mon/mgr/mds hosts (render_mds_config)
Boolean $render_mds_config = false,
Hash[String, Integer] $mds_instances = {},
Hash $mds_common = {
'keyring' => '/var/lib/ceph/mds/ceph-$id/keyring',
'mds_standby_replay' => true,
},
) {
# dont run this on proxmox nodes
@@ -22,6 +43,13 @@ class profiles::ceph::client (
}
}
# only depend on the package when this class manages it; on the ceph
# hosts the package is delivered by cephadm / profiles::packages instead.
$config_require = $manage_ceph_package ? {
true => Package['ceph-common'],
default => undef,
}
# manage the ceph directory
if $manage_ceph_paths {
file { '/etc/ceph':
@@ -29,11 +57,11 @@ class profiles::ceph::client (
owner => $owner,
group => $group,
mode => $mode,
require => Package['ceph-common'],
require => $config_require,
}
}
# create a basic client config
# render /etc/ceph/ceph.conf from cluster topology in hiera
if $manage_ceph_conf {
file { $config_file:
ensure => file,
@@ -41,7 +69,7 @@ class profiles::ceph::client (
group => $group,
mode => $mode,
content => template('profiles/ceph/client.conf.erb'),
require => Package['ceph-common'],
require => $config_require,
}
}
+3
View File
@@ -2,6 +2,9 @@ class profiles::ceph::osd (
Boolean $ensure_running = true,
) {
# tune the I/O scheduler on the disks backing ceph OSDs
include profiles::ceph::osd_scheduler
if $ensure_running and $facts['is_ceph_osd'] {
$facts['ceph_services']['osd'].each |String $svc| {
service { $svc:
@@ -0,0 +1,32 @@
class profiles::ceph::osd_scheduler (
String[1] $scheduler = 'none',
) {
$devices = $facts['ceph_osd_devices']
# no-op where the fact is absent/empty (VMs, non-OSD hosts have no ceph PVs)
if $devices =~ Array[String[1], 1] {
# strip /dev/ so the rule matches the udev KERNEL sysname (e.g. sda)
$kernel_names = $devices.map |$dev| { regsubst($dev, '^.*/', '') }
$sysname_matches = $kernel_names.map |$name| { "--sysname-match=${name}" }
file { '/etc/udev/rules.d/60-ceph-osd-scheduler.rules':
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
content => template('profiles/ceph/osd-scheduler.rules.erb'),
notify => Exec['ceph-osd-scheduler-reload'],
}
# apply immediately; udev re-applies on reboot and device re-add
$trigger = "udevadm trigger --subsystem-match=block --action=change ${join($sysname_matches, ' ')}"
exec { 'ceph-osd-scheduler-reload':
command => "udevadm control --reload-rules && ${trigger}",
path => ['/usr/bin', '/bin', '/usr/sbin', '/sbin'],
refreshonly => true,
}
}
}
+33
View File
@@ -0,0 +1,33 @@
# profiles::lldpd
#
# Runs lldpd on physical hosts so each machine learns its switch/port topology
# via LLDP. The `lldp` fact exposes that neighbour data for NetBox. Assigned
# via hiera_include from hieradata/virtual/physical.yaml (physicals only); the
# lldpd.service ships disabled, so it is explicitly enabled and started here.
class profiles::lldpd (
Boolean $enabled = true,
String $package = 'lldpd',
String $service = 'lldpd',
){
if $enabled {
package { $package:
ensure => installed,
}
service { $service:
ensure => running,
enable => true,
subscribe => Package[$package],
}
} else {
service { $service:
ensure => stopped,
enable => false,
}
package { $package:
ensure => absent,
}
}
}
+2 -1
View File
@@ -60,10 +60,11 @@ class profiles::puppet::agent (
require => Yumrepo[$use_yumrepo],
}
# versionlock puppet-agent
# versionlock puppet-agent before install so the lock exists before any upgrade is attempted
yum::versionlock{$use_package:
ensure => $agent_versionlock_ensure,
version => $agent_versionlock_version,
before => Package[$use_package],
}
}
'Debian': {
+14 -9
View File
@@ -122,15 +122,6 @@ class profiles::vault::server (
]
}
# go-plugin creates each secrets plugin's control socket as /tmp/pluginNNN;
# systemd-tmpfiles-clean reaps files aged past the /tmp policy, severing the
# socket of a long-lived plugin and orphaning the still-running process
# (every request then fails: rpc Unavailable, dial unix /tmp/pluginNNN: no
# such file). Exclude the sockets from cleaning for all plugins.
systemd::tmpfile { 'openbao-plugin-sockets.conf':
content => "x /tmp/plugin*\n",
}
# ensure the vault audit log exists
file { $audit_log:
ensure => 'file',
@@ -139,6 +130,20 @@ class profiles::vault::server (
mode => '0600',
}
# go-plugin creates each secrets plugin's control socket under TMPDIR
# (/tmp/pluginNNN by default); systemd-tmpfiles-clean reaps aged /tmp files
# and severs the socket of a long-lived plugin, orphaning the process. Point
# TMPDIR at a per-start RuntimeDirectory on /run (tmpfs, no age cleanup).
systemd::manage_dropin { 'plugin-tmpdir.conf':
unit => 'vault.service',
service_entry => {
'RuntimeDirectory' => 'vault-plugins',
'RuntimeDirectoryMode' => '0700',
'Environment' => 'TMPDIR=/run/vault-plugins',
},
notify => Service['vault'],
}
service { 'vault':
ensure => true,
enable => true,
+27 -2
View File
@@ -1,3 +1,28 @@
[global]
fsid = <%= @fsid %>
mon_host = <%= @mons.join(' ') %>
auth_client_required = <%= @auth_client_required %>
auth_cluster_required = <%= @auth_cluster_required %>
auth_service_required = <%= @auth_service_required %>
fsid = <%= @fsid %>
mon_allow_pool_delete = <%= @mon_allow_pool_delete %>
mon_initial_members = <%= @mon_initial_members.join(',') %>
mon_host = <%= @mons.join(',') %>
ms_bind_ipv4 = <%= @ms_bind_ipv4 %>
ms_bind_ipv6 = <%= @ms_bind_ipv6 %>
osd_crush_chooseleaf_type = <%= @osd_crush_chooseleaf_type %>
osd_pool_default_min_size = <%= @osd_pool_default_min_size %>
osd_pool_default_size = <%= @osd_pool_default_size %>
osd_pool_default_pg_num = <%= @osd_pool_default_pg_num %>
public_network = <%= @cluster_public_ips.map { |ip| "#{ip}/32" }.join(',') %>
<% if @render_mds_config -%>
[mds]
keyring = <%= @mds_common['keyring'] %>
mds_standby_replay = <%= @mds_common['mds_standby_replay'] %>
<% @mds_instances.sort.each do |host, count| -%>
<% (1..count).each do |instance| -%>
[mds.<%= host %>-<%= instance %>]
host = <%= host %>
<% end -%>
<% end -%>
<% end -%>
@@ -0,0 +1,5 @@
# Managed by puppet (profiles::ceph::osd_scheduler).
# Set the I/O scheduler to <%= @scheduler %> on ceph OSD block devices.
<% @kernel_names.sort.each do |dev| -%>
ACTION=="add|change", SUBSYSTEM=="block", KERNEL=="<%= dev %>", ATTR{queue/scheduler}="<%= @scheduler %>"
<% end -%>