Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d04b09310f | ||
|
|
dd050d811d | ||
|
|
c4bb35173d | ||
|
|
a9df3ea9ce | ||
|
|
2549535961 | ||
|
|
60f231f839 | ||
|
|
b03d0652c2 | ||
|
|
4e040c5a2d | ||
|
|
900bc73635 | ||
|
|
a65109fee0 | ||
|
|
477b79cdc6 | ||
|
|
55ea98c035 | ||
|
|
6a50ab1daa | ||
|
|
f51eb8bf35 | ||
|
|
d1e072146f | ||
|
|
4678f73e9f | ||
|
|
205d7835c3 | ||
|
|
5f8c0115b5 | ||
|
|
e2f3d67f79 | ||
|
|
7b0b664a46 | ||
|
|
3abdeb2203 | ||
|
|
cdebaba2d3 | ||
|
|
dd311f4b01 | ||
|
|
36e683aee1 | ||
|
|
c34b423d38 | ||
|
|
5fd0691ed2 | ||
|
|
f0f0b0af9d | ||
|
|
4c6c23377d | ||
|
|
f7f04281c0 | ||
|
|
c4a6d72cd6 | ||
|
|
8c019e50d2 | ||
|
|
c9e9830092 | ||
|
|
781b849adb | ||
|
|
af81045277 | ||
|
|
5155581fba |
@@ -101,7 +101,7 @@ steps:
|
||||
bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- name: alert-triage-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.218
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
# Builds the CI tooling images (and the secret-rotator runtime image) with
|
||||
# kaniko and pushes them to the Gitea container registry.
|
||||
#
|
||||
# The kaniko image is NOT Google's. Google archived
|
||||
# GoogleContainerTools/kaniko on 2025-06-03, but the original authors forked
|
||||
# it to chainguard-dev/kaniko and kept releasing — the pin here is well past
|
||||
# the v1.24.0 the Google repo ended on. The fork ships no binaries or images
|
||||
# of its own, so these come from gitlab-ci-utils, which builds that fork for
|
||||
# amd64 and arm64 under versioned tags. A generic `:debug` tag exists there
|
||||
# and is deliberately unused; everything else in this repo is pinned too.
|
||||
#
|
||||
# The executor path, flags and busybox layout are unchanged from Google's
|
||||
# image, which is why the swap touched nothing but the `image:` lines (#552).
|
||||
#
|
||||
# Credential: ci_images_token — a PAT scoped to `package: read+write` and
|
||||
# NOTHING else (verified: 403 on /user/repos, /admin/users and the issues
|
||||
# API; 200 on the /v2/ registry endpoints kaniko uses). It replaces the
|
||||
@@ -25,7 +36,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: build-ansible
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/ansible
|
||||
depends_on: []
|
||||
# Only rebuild when THIS image's source (or the pipeline file) changed, or
|
||||
@@ -53,7 +64,7 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/ci-ansible:latest
|
||||
|
||||
- name: build-k8s
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/k8s
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -77,7 +88,7 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
|
||||
|
||||
- name: build-tofu
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/tofu
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -108,7 +119,7 @@ steps:
|
||||
# Runtime image, not CI tooling: a static musl binary in a FROM-scratch image,
|
||||
# used as the base for secret-rotation CronJobs. Replaces per-job inline Python.
|
||||
- name: build-secret-rotator
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: secret-rotator
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -137,7 +148,7 @@ steps:
|
||||
# container, but nothing about it is zigbee-specific. Exists so tools come from
|
||||
# the image rather than an `apk add` on every container start.
|
||||
- name: build-alpine-jq
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/alpine-jq
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -166,7 +177,7 @@ steps:
|
||||
# critical path of every node rebuild. The download now happens here, once,
|
||||
# where the sha256 is checked — see the Containerfile for the full rationale.
|
||||
- name: build-cni-plugins
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/cni-plugins
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -195,7 +206,7 @@ steps:
|
||||
# every paperless pod start. The openssl CLI is genuinely absent from the alpine
|
||||
# base, so this one needs an image rather than a busybox substitute.
|
||||
- name: build-alpine-openssl
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/alpine-openssl
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -224,7 +235,7 @@ steps:
|
||||
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
|
||||
# builds the same way and lands in the same registry so the audit sees it.
|
||||
- name: build-heating-can
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/heating-can
|
||||
depends_on: []
|
||||
when:
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
# consumer pins to ci-claude:<version> and open a PR. A human
|
||||
# reviews and merges it; nothing auto-merges.
|
||||
#
|
||||
# TAG SCHEME — deliberately version tags (ci-claude:2.1.218), NOT the dated
|
||||
# TAG SCHEME — deliberately version tags (ci-claude:<claude-version>), NOT the dated
|
||||
# tags the other ci-* images use. The tag encodes the thing that changes here
|
||||
# (the Claude version), which makes "did it change?" a trivial, race-free
|
||||
# string compare and keeps the weekly cron from opening no-op PRs. The other
|
||||
@@ -84,14 +84,24 @@ steps:
|
||||
- |
|
||||
set -eu
|
||||
mkdir -p "$$CI_WORKSPACE/.ci-refresh"
|
||||
# Files that pin the image. Keep this list in sync with the consumers.
|
||||
CONSUMERS="
|
||||
.woodpecker/security-scan.yml
|
||||
.woodpecker/alert-monitor.yml
|
||||
.woodpecker/update-audit.yml
|
||||
.woodpecker/pr-review.yml
|
||||
.woodpecker/issue-fix.yml
|
||||
"
|
||||
# Files that pin the image, DISCOVERED rather than listed. This used to
|
||||
# be a hardcoded list carrying the instruction "keep this in sync with
|
||||
# the consumers" — and it was not: e2e.yml and ci-scripts-test.yml were
|
||||
# missing, so they sat on a version no bump could ever reach. A list
|
||||
# that must be maintained by hand is the bug; deriving it removes the
|
||||
# failure mode instead of correcting one instance of it.
|
||||
#
|
||||
# Matches a version-pinned reference only, which is what excludes this
|
||||
# file: its own steps run ci-claude:latest, and :latest never needs
|
||||
# rewriting. That exclusion is load-bearing — do not write a literal
|
||||
# ci-claude version anywhere in THIS file, not even in prose, or it
|
||||
# adds itself to the consumer set and the sed below rewrites it. The
|
||||
# tag-scheme note at the top says <claude-version> for that reason.
|
||||
CONSUMERS=$$(grep -rlE 'ci-claude:[0-9]' .woodpecker)
|
||||
# An empty list would make the grep below read stdin and hang forever
|
||||
# rather than fail, so it is a hard stop.
|
||||
[ -n "$$CONSUMERS" ] || { echo "No ci-claude consumers found — refusing to continue."; exit 1; }
|
||||
echo "consumers: $$CONSUMERS"
|
||||
NEW=$$(npm view @anthropic-ai/claude-code version)
|
||||
case "$$NEW" in
|
||||
[0-9]*.[0-9]*.[0-9]*) : ;;
|
||||
@@ -130,7 +140,7 @@ steps:
|
||||
# --build-arg to the resolved version (not "latest") guarantees the tag and
|
||||
# the image content agree even if a new release lands mid-run.
|
||||
- name: build
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/claude
|
||||
depends_on: [decide]
|
||||
environment:
|
||||
@@ -176,13 +186,10 @@ steps:
|
||||
echo "Branch $$BRANCH already exists — a PR for $$NEW is already open. Nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
CONSUMERS="
|
||||
.woodpecker/security-scan.yml
|
||||
.woodpecker/alert-monitor.yml
|
||||
.woodpecker/update-audit.yml
|
||||
.woodpecker/pr-review.yml
|
||||
.woodpecker/issue-fix.yml
|
||||
"
|
||||
# Same discovery as the decide step — see the comment there for why this
|
||||
# is not a maintained list.
|
||||
CONSUMERS=$$(grep -rlE 'ci-claude:[0-9]' .woodpecker)
|
||||
[ -n "$$CONSUMERS" ] || { echo "No ci-claude consumers found — refusing to continue."; exit 1; }
|
||||
# Exact-string swap of the current tag for the new one, consumers only.
|
||||
for f in $$CONSUMERS; do
|
||||
sed -i "s#ci-claude:$$CUR#ci-claude:$$NEW#g" "$$f"
|
||||
@@ -198,7 +205,7 @@ steps:
|
||||
{
|
||||
printf 'Automated bump of the ci-claude agent image from `%s` to `%s`.\n\n' "$$CUR" "$$NEW"
|
||||
printf 'The refresh pipeline built and pushed `ci-claude:%s` (and `:latest`) from the\n' "$$NEW"
|
||||
printf 'latest published `@anthropic-ai/claude-code`. This rewires the 5 pipelines that\n'
|
||||
printf 'latest published `@anthropic-ai/claude-code`. This rewires the %s pipelines that\n' "$$(printf '%s\n' $$CONSUMERS | grep -c .)"
|
||||
printf 'run the image so CI stops running a stale Claude Code.\n\n'
|
||||
printf 'Merging is a human decision — review, then merge to roll it out.\n'
|
||||
} > /tmp/pr-body.md
|
||||
|
||||
@@ -17,6 +17,6 @@ when:
|
||||
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.218
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ steps:
|
||||
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
|
||||
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
|
||||
# test from rotting (it exercises the exact injection shapes).
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.218
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ steps:
|
||||
- bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- name: issue-fix-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.218
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -65,7 +65,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: pr-review-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.218
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -42,7 +42,7 @@ steps:
|
||||
# this step is skipped — otherwise we'd fork-bomb PRs.
|
||||
when:
|
||||
- branch: master
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.218
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -30,7 +30,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: update-audit-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.218
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -51,7 +51,11 @@ kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste key
|
||||
```
|
||||
|
||||
**Manual unseal is required after every pod restart** (node drain, upgrade,
|
||||
crash): `kubectl -n openbao exec -it openbao-0 -- bao operator unseal`.
|
||||
crash), **per replica** — the server runs three raft peers (see
|
||||
`infrastructure/openbao/helmrelease.yaml`), and a sealed one is neither a
|
||||
voter nor a standby, so it stays out of the cluster until unsealed:
|
||||
`kubectl -n openbao exec -it openbao-<N> -- bao operator unseal`, same key for
|
||||
all three. Three peers mean one sealed replica is survivable; two are not.
|
||||
Accepted for now; auto-unseal needs a second trust root (transit/KMS) and is
|
||||
explicitly out of scope. Symptom of sealed: ESO stores go NotReady, synced
|
||||
Secrets keep their last value (they are real k8s Secrets — pods keep running).
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Proposal — separate the four traffic classes onto their own VLANs
|
||||
|
||||
Status: draft. The question this started with is **answered** — from the Cilium
|
||||
source and flag reference, not from a test — and the answer changes the shape
|
||||
of the proposal rather than confirming it.
|
||||
|
||||
Supersedes the framing in issue #576, which described this as "give node
|
||||
management its own NIC, and Ceph public its own VLAN". That was two of four
|
||||
classes and one of them turned out already solved.
|
||||
|
||||
## The four classes
|
||||
|
||||
| class | what it is | where it goes today |
|
||||
|---|---|---|
|
||||
| **Ceph replication** | OSD-to-OSD heartbeat, replication, recovery | VLAN 49, its own X710 port — **separated** |
|
||||
| **cluster-internal** | pod-to-pod east-west, including Ceph client I/O | pod network, VXLAN over `mgmt0` |
|
||||
| **cluster-external** | ingress to LoadBalancer VIPs, egress to LAN and internet | `mgmt0` |
|
||||
| **management** | Talos API, etcd, kube-apiserver, kubelet | `mgmt0` |
|
||||
|
||||
Three of four share one interface. That is not a design decision — until the
|
||||
bare-metal conversion there was one NIC to work with.
|
||||
|
||||
Ceph client I/O living on the pod network is correct rather than a leftover:
|
||||
that traffic *is* cluster-internal. Ceph is done.
|
||||
|
||||
## Principle
|
||||
|
||||
**Each class gets its own VLAN. Which physical interface a VLAN lands on is a
|
||||
separate, changeable decision.**
|
||||
|
||||
Defining classes by VLAN means the separation survives recabling, port failures
|
||||
and capacity changes, and it does not require one port per class. Defining them
|
||||
by interface would freeze a capacity decision into the architecture.
|
||||
|
||||
## The finding that shapes everything else
|
||||
|
||||
The original open question was whether `nodePort.directRoutingDevice` moves the
|
||||
next-hop of Cilium's direct routes, or only selects an interface. It is
|
||||
answerable from the source, and the answer is: **only the interface.**
|
||||
|
||||
`installDirectRoute` takes the next-hop as a parameter:
|
||||
|
||||
```go
|
||||
func installDirectRoute(log *slog.Logger, prefix netip.Prefix, nodeIP net.IP,
|
||||
skipUnreachable bool) (routeSpec *netlink.Route, err error)
|
||||
```
|
||||
|
||||
and `nodeUpdate` supplies it as `newNode.GetNodeIP(false)`. `GetNodeIP` returns,
|
||||
in priority order, `NodeInternalIP` → `NodeExternalIP` → anything else. The
|
||||
same `newIP4` is what tunnel mode uses as the VXLAN endpoint.
|
||||
|
||||
`NodeInternalIP` comes from the Kubernetes Node object. `--ipv4-node` looks like
|
||||
an override but is not one: `initFromConfig()` calls `SetNodeInternalIP` and
|
||||
`initFromK8s()` runs **after** it and overwrites the value. In a Kubernetes
|
||||
cluster there is no path by which Cilium's node IP differs from the kubelet's
|
||||
`InternalIP`.
|
||||
|
||||
`--direct-routing-device` is documented as "Device name used to connect nodes in
|
||||
direct routing mode" — a device, not an address.
|
||||
|
||||
**So cluster-internal traffic is welded to the node IP, and the node IP is the
|
||||
kubelet InternalIP.** Two consequences:
|
||||
|
||||
- The class cannot be moved off whatever VLAN the node IP lives on.
|
||||
- **Routing mode is irrelevant to this problem.** Tunnel mode sources the VXLAN
|
||||
from the node IP; native mode uses it as the direct-route next-hop. Native
|
||||
routing buys nothing here, so the disruptive, non-rolling step this proposal
|
||||
previously contained is simply gone.
|
||||
|
||||
## Consequence: draw the line the other way
|
||||
|
||||
If cluster-internal cannot leave the node IP, then separation has to come from
|
||||
moving *management services* off it — and each one has to be checked
|
||||
individually, because they do not share a mechanism.
|
||||
|
||||
| management service | can it leave the node IP? | lever |
|
||||
|---|---|---|
|
||||
| etcd | **yes** | `cluster.etcd.advertisedSubnets` (+ `listenSubnets`, which defaults to it) |
|
||||
| kube-apiserver endpoint | **yes** | already a VIP — `Layer2VIPConfig` with a `link:` field |
|
||||
| Talos API | **n/a** | `apid` answers on every node address (measured); a client and firewall choice, not a bind setting |
|
||||
| kubelet | **no** | it *is* the node IP, by definition |
|
||||
|
||||
So four VLANs are reachable, but the boundary does not fall where the class
|
||||
names suggest: **kubelet stays with cluster-internal.** That is the honest
|
||||
result, and it is worth stating plainly rather than filing kubelet under
|
||||
management and hoping.
|
||||
|
||||
The remaining classes keep the levers established earlier:
|
||||
|
||||
- **cluster-external ingress** — `CiliumBGPNodeConfigOverride.spec.bgpInstances[].peers[].localAddress`.
|
||||
For eBGP the session source is the next-hop, so pinning the session pins
|
||||
where return traffic arrives. The session to the UDM has been running for
|
||||
months.
|
||||
- **cluster-external egress** — eBPF masquerading follows the output device, so
|
||||
this is a default-route decision plus `enable-masquerade-to-route-source`
|
||||
(today `false`).
|
||||
- **Ceph replication** — Multus macvlan on VLAN 49, done.
|
||||
|
||||
## The general escape hatch
|
||||
|
||||
Ceph is separated because its OSDs got a second interface and stopped using the
|
||||
pod network for replication. That is the *only* mechanism that fully detaches a
|
||||
workload from the node IP, and it generalises: anything that must not ride the
|
||||
node IP gets its own `NetworkAttachmentDefinition`.
|
||||
|
||||
It is also expensive per workload — an attachment, an IPAM pool, a pod spec
|
||||
change. Worth it for Ceph replication. Not worth it for east-west traffic in
|
||||
general.
|
||||
|
||||
## Recommended mapping
|
||||
|
||||
Two ways to arrange this, and they differ only in which VLAN gets relabelled:
|
||||
|
||||
**A — move the node IP to a new cluster VLAN.** VLAN 40 keeps etcd, the API VIP
|
||||
and Talos API. Cleanest match to the class names, but it changes every node's
|
||||
identity, which is the one address the whole cluster is administered through.
|
||||
|
||||
**B (recommended) — leave the node IP on VLAN 40 and move management off it.**
|
||||
VLAN 40 becomes the cluster VLAN; a new VLAN carries etcd and the API VIP.
|
||||
Identical end state, and no node ever changes its InternalIP.
|
||||
|
||||
Under B:
|
||||
|
||||
| VLAN | carries |
|
||||
|---|---|
|
||||
| 49 | Ceph replication |
|
||||
| 40 (relabelled *cluster*) | pod east-west, kubelet |
|
||||
| new *management* | etcd, kube-apiserver VIP, Talos API by convention |
|
||||
| new *external* | LoadBalancer ingress, egress default route |
|
||||
|
||||
The property the user asked for — management carries only management traffic —
|
||||
holds under B. The label moves; the property is what matters.
|
||||
|
||||
## Migration shape
|
||||
|
||||
Every step is rolling and reversible. The routing-mode change is gone.
|
||||
|
||||
1. **New VLANs** on the switch, trunked to the X710. Nothing uses them.
|
||||
2. **Addresses** on the new VLANs via Talos, one node at a time, `--mode=try`.
|
||||
Still unused; this only proves the L2 works.
|
||||
3. **etcd** to the management VLAN via `advertisedSubnets`. One node at a time,
|
||||
waiting for a healthy quorum between each.
|
||||
4. **API VIP** to the management VLAN via `Layer2VIPConfig.link`. DNS for
|
||||
`k8s.renner.uno` follows.
|
||||
5. **cluster-external**: BGP `localAddress`, default route, masquerade flag.
|
||||
Reversible by reverting the override; failure is visible immediately in
|
||||
`cilium-dbg shell -- bgp/peers`.
|
||||
6. Verify VLAN 40 by what is *absent* from it, not by what still works.
|
||||
|
||||
Step 3 is the one that deserves care — etcd is the only step where a mistake
|
||||
costs quorum rather than connectivity.
|
||||
|
||||
## What this does not buy
|
||||
|
||||
Management currently shares a 10 Gb link and is not contended. Nothing observed
|
||||
today is slow because of this. The case for the split is that traffic classes
|
||||
with different failure modes and security postures should not be
|
||||
indistinguishable — not that something is broken.
|
||||
|
||||
## Sources and measurements
|
||||
|
||||
Source, Cilium v1.20.1 (the running version):
|
||||
|
||||
- `pkg/datapath/linux/node.go` — `installDirectRoute(…, nodeIP net.IP, …)`,
|
||||
fed from `newIP4 = newNode.GetNodeIP(false)`; `createDirectRouteSpec` rejects
|
||||
a next-hop that is not directly reachable.
|
||||
- `pkg/node/types/node.go` — `GetNodeIP` prefers `NodeInternalIP`.
|
||||
- `pkg/node/sync/local_node_sync.go` — `initFromConfig()` applies
|
||||
`--ipv4-node`, `initFromK8s()` runs afterwards and overwrites it from the
|
||||
Kubernetes Node.
|
||||
- `cilium-agent` flag reference — `--direct-routing-device`: "Device name used
|
||||
to connect nodes in direct routing mode".
|
||||
|
||||
Talos:
|
||||
|
||||
- Multihoming guide — `cluster.etcd.advertisedSubnets`; `listenSubnets`
|
||||
defaults to it. kube-apiserver and Talos API listen addresses are not
|
||||
covered there.
|
||||
- `kubernetes/talos/patches/k8s-{1,2,3}.yaml` — `Layer2VIPConfig` for
|
||||
`10.2.40.60` with `link: mgmt0`.
|
||||
|
||||
Measured on the cluster:
|
||||
|
||||
- `apid` answered on a node address that is not the InternalIP, so the Talos
|
||||
API is not bound to one address.
|
||||
- k8s-1 currently holds one routable v4 address plus the VIP on `mgmt0`; its
|
||||
InternalIP had changed since the last session, which is why nothing here
|
||||
names an address as fixed.
|
||||
- Cilium runs `routing-mode: tunnel` with `enable-masquerade-to-route-source`
|
||||
unset and no direct-routing device configured.
|
||||
|
||||
Not verified: whether the kube-apiserver's `--advertise-address` follows the
|
||||
node IP independently of the VIP. It does not affect the recommendation, since
|
||||
clients reach the API through the VIP either way.
|
||||
@@ -1,10 +1,25 @@
|
||||
# Cold-rebuild runbook — homelab cluster from scratch
|
||||
|
||||
Rebuild the entire cluster from **this repo + backups only**, assuming nothing
|
||||
in the cluster survives (all VMs gone, Ceph gone, in-cluster Gitea gone).
|
||||
in the cluster survives (all three nodes wiped, Ceph gone, in-cluster Gitea
|
||||
gone).
|
||||
|
||||
Order: **tofu → talos → flux → openbao → apps.**
|
||||
|
||||
All three nodes are bare-metal MS-01 machines. Nothing provisions them: there
|
||||
are no VMs to recreate, and OpenTofu owns only DNS, object storage and UniFi.
|
||||
Step 1 is therefore two unrelated halves — a `tofu apply` for the platform
|
||||
resources, and physically booting three machines from install media.
|
||||
|
||||
**Someone has to be at the machines to boot them from USB** — MS-01 has no IPMI
|
||||
and nothing here does PXE. Everything after that is remote.
|
||||
|
||||
A node in maintenance mode configures no VLANs and DHCPs untagged, so it comes
|
||||
up on whatever untagged network its ports carry — not on management, which is
|
||||
a tagged VLAN on the X710 trunk. It is reachable there: that is the address
|
||||
`apply-config --insecure` talks to in Step 2, and it is how all three
|
||||
bare-metal conversions were driven. Find the leases on the UDM.
|
||||
|
||||
## The circular-bootstrap problem this runbook exists to break
|
||||
|
||||
In steady state Flux's `GitRepository flux-system` pulls manifests from the
|
||||
@@ -61,12 +76,21 @@ These must exist outside the cluster or the rebuild cannot complete:
|
||||
`openbao-daily` offsite copy. In a total-loss rebuild the offsite Velero
|
||||
copy is the one that survives.
|
||||
- [ ] **Tooling:** `talosctl`, `kubectl`, `flux`, `tofu`, `bao`, plus the
|
||||
OpenTofu backend + Proxmox/UniFi credentials the `opentofu/` stack needs.
|
||||
OpenTofu backend + UniFi credentials the `opentofu/` stack needs. No
|
||||
Proxmox credentials — there is no Proxmox any more.
|
||||
- [ ] **Install media** written and to hand: the Image Factory ISO named in
|
||||
Step 1, on a USB stick, for all three machines.
|
||||
- [ ] **Someone at the machines to boot them from USB.** No IPMI, no PXE. A
|
||||
monitor is useful for reading boot errors but is not required to find the
|
||||
node afterwards — it DHCPs into management on its own (see the note at
|
||||
the top). Everything past the boot is remote.
|
||||
|
||||
## Step 1 — tofu: provision the VMs + supporting infra
|
||||
## Step 1a — tofu: DNS, object storage, UniFi
|
||||
|
||||
Recreate the Talos VMs and the platform resources OpenTofu owns (Proxmox VMs,
|
||||
UniFi DNS records incl. `k8s.renner.uno` / node A records, etc.).
|
||||
OpenTofu no longer provisions any node. The `proxmox_vm` module still exists in
|
||||
the tree but nothing instantiates it, so `tofu apply` here creates only the
|
||||
platform resources: UniFi DNS records including `k8s.renner.uno` and the node A
|
||||
records, the Hetzner object storage, and the NAT hairpin rules.
|
||||
|
||||
```sh
|
||||
cd opentofu
|
||||
@@ -74,44 +98,119 @@ tofu init -upgrade -backend-config="access_key=..." -backend-config="secret_key=
|
||||
tofu apply
|
||||
```
|
||||
|
||||
In CI this is `.woodpecker/deploy-infrastructure.yml` (manual, `TOFU_APPLY=true`),
|
||||
which also pre-seeds the Talos image onto every Proxmox node. Wait until the
|
||||
node FQDNs (`k8s-{1,2,3}.srv.renner.uno`) resolve and the VMs are up before
|
||||
Step 2 — `bootstrap.sh` polls DNS but will not create the records.
|
||||
Run this first regardless, because Step 2 needs `k8s.renner.uno` and the node
|
||||
FQDNs to resolve — the control-plane endpoint is baked into every generated
|
||||
config, and the nodes are addressed by name.
|
||||
|
||||
**k8s-1 is not a VM and OpenTofu does not create it.** jupiter runs Talos on
|
||||
bare metal, so this step only provisions k8s-2 (saturn) and k8s-3 (neptune).
|
||||
Before Step 2, boot jupiter from the bare-metal image below so it comes up in
|
||||
maintenance mode and answers on `k8s-1.srv.renner.uno` — `bootstrap.sh` treats
|
||||
all three nodes identically from there, since `apply-config --insecure` does
|
||||
not care whether the target is a VM or bare metal.
|
||||
## Step 1b — boot all three machines from install media
|
||||
|
||||
```
|
||||
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.12.11/metal-amd64.iso
|
||||
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.13.9/metal-amd64.iso
|
||||
```
|
||||
|
||||
That schematic is deliberately not the one `.woodpecker/deploy-infrastructure.yml`
|
||||
seeds onto the Proxmox nodes. It keeps the same `net.ifnames=1` kernel argument
|
||||
— without it the predictable interface names that `talos/patches/k8s-1.yaml`
|
||||
matches its VLAN parents against do not exist — and differs in three ways:
|
||||
Write it to a USB stick and boot each MS-01 from it. Each comes up in
|
||||
maintenance mode with a DHCP address on an untagged network — not on
|
||||
management, which is tagged. Read the leases off the UDM; those addresses are
|
||||
what Step 2 applies the first config to.
|
||||
|
||||
- drops `siderolabs/qemu-guest-agent`, which has nothing to talk to on bare metal
|
||||
- adds `siderolabs/intel-ucode`, since no hypervisor is supplying microcode now
|
||||
- adds `siderolabs/thunderbolt`, unused so far and inert until
|
||||
`machine.kernel.modules` requests `thunderbolt`/`thunderbolt_net`. It is
|
||||
present only so enabling the MS-01 Thunderbolt mesh later does not require a
|
||||
new image and a reinstall. Note its udev rule auto-authorises any Thunderbolt
|
||||
device attached to the machine once those modules are loaded.
|
||||
The schematic `0f6fb8bd…` carries `net.ifnames=1` — without it the predictable
|
||||
interface names that `talos/patches/k8s-{1,2,3}.yaml` match their VLAN parents
|
||||
against do not exist, and the node ends up with no address on any interface.
|
||||
Beyond that it carries:
|
||||
|
||||
- `siderolabs/intel-ucode` — no hypervisor supplies microcode here
|
||||
- `siderolabs/i915` — the Alder Lake-P Iris Xe. Nothing requests `/dev/dri`
|
||||
today; present so wanting hardware transcoding later is a config change
|
||||
rather than another rolling reinstall
|
||||
- `siderolabs/thunderbolt` — inert until `machine.kernel.modules` requests
|
||||
`thunderbolt`/`thunderbolt_net`, present for the same reason. Note its udev
|
||||
rule auto-authorises any Thunderbolt device attached once those modules load
|
||||
- no `qemu-guest-agent`, which has nothing to talk to on bare metal
|
||||
|
||||
Verify the disks before going further. Talos enumerates NVMe in an order that
|
||||
is not stable across boots, and the per-node configs select by serial for
|
||||
exactly that reason — the failure mode is the installer taking the Ceph OSD
|
||||
drive. `talosctl -n <maintenance-ip> --insecure get disks` should show three
|
||||
NVMe devices whose serials match the `diskSelector` and `VolumeConfig` entries
|
||||
in `talos/patches/k8s-N.yaml`.
|
||||
|
||||
## Step 2 — talos: form the cluster + get kubeconfig
|
||||
|
||||
**Do not use `kubernetes/bootstrap.sh talos`.** That phase cannot generate a
|
||||
config: it passes `talos/patches/cilium-cni.yaml` whole, and that file's
|
||||
`$patch: delete` document fails against a base with no `inlineManifests`
|
||||
(#581). Its other phases — `flux`, `wait-apps` — are still used, in Steps 3, 5
|
||||
and 6; only config generation is broken.
|
||||
|
||||
The procedure below is what actually works, and is the same one
|
||||
`RUNBOOK-baremetal-conversion.md` uses.
|
||||
|
||||
### Generate the configs
|
||||
|
||||
This is a genuinely new cluster — nothing survives to copy secrets from, so
|
||||
generate them fresh. (In a *partial* rebuild, where one node still runs, pull
|
||||
secrets off it instead with `talosctl get mc v1alpha1` + `gen secrets
|
||||
--from-controlplane-config`, or the new node forms its own cluster rather than
|
||||
joining.)
|
||||
|
||||
```sh
|
||||
kubernetes/bootstrap.sh talos
|
||||
talosctl gen secrets -o secrets.yaml
|
||||
|
||||
for N in 1 2 3; do
|
||||
talosctl gen config homelab https://k8s.renner.uno:6443 \
|
||||
--with-secrets secrets.yaml --output-types controlplane \
|
||||
--config-patch @talos/patches/cluster.yaml \
|
||||
--config-patch @<cilium additive half> \
|
||||
--config-patch @talos/patches/registry-mirror.yaml \
|
||||
--config-patch @talos/patches/k8s-$N.yaml -o install-k8s-$N.yaml
|
||||
done
|
||||
```
|
||||
|
||||
Generates + applies Talos configs to `k8s-{1,2,3}`, bootstraps etcd/Kubernetes,
|
||||
writes `~/.talos/config` + `~/.kube/config`, and stages both into
|
||||
`kubernetes/access-staging/` for later phases. Ends when all 3 nodes are `Ready`.
|
||||
Two things the generator gets wrong for this repo, both of which will bite
|
||||
silently:
|
||||
|
||||
- **`cilium-cni.yaml` cannot be passed whole** — extract only its additive
|
||||
second document. See issue #581.
|
||||
- **Strip the generated `HostnameConfig`** — it collides with
|
||||
`machine.network.hostname` from the per-node patch.
|
||||
|
||||
Validate each before applying:
|
||||
|
||||
```sh
|
||||
talosctl validate --config install-k8s-$N.yaml --mode metal
|
||||
```
|
||||
|
||||
### Apply in two stages
|
||||
|
||||
Do **not** apply the full per-node config in maintenance mode. A document that
|
||||
references an interface which does not exist yet hangs the install: the node
|
||||
sits at `STAGE: Installing` indefinitely with `KUBELET n/a` and `APISERVER n/a`,
|
||||
and nothing times out. Concretely, the `ens19` VLAN document must be removed
|
||||
from the first-stage config — its parent exists, but the install stalls anyway.
|
||||
|
||||
```sh
|
||||
# Stage 1: minimal config, from the maintenance-mode address off the console
|
||||
talosctl apply-config --insecure -n <maintenance-ip> --file install-k8s-$N-stage1.yaml
|
||||
# Stage 2: once the node is up on management, apply the complete config
|
||||
talosctl -n k8s-$N.srv.renner.uno apply-config --mode=try --timeout=5m --file install-k8s-$N.yaml
|
||||
```
|
||||
|
||||
`--mode=try` rolls back on its own if the node becomes unreachable, which is the
|
||||
only safety net there is for a network change that can strand a machine with no
|
||||
IPMI. Use it for every network-affecting apply, then make it permanent once
|
||||
proven.
|
||||
|
||||
### Bootstrap etcd, then collect access
|
||||
|
||||
Bootstrap **exactly one** node:
|
||||
|
||||
```sh
|
||||
talosctl -n k8s-1.srv.renner.uno bootstrap
|
||||
talosctl -n k8s-1.srv.renner.uno kubeconfig .
|
||||
```
|
||||
|
||||
Ends when all three nodes are `Ready`. `scripts/import-cluster-access.sh` is the
|
||||
supported way to get the kubeconfig into place afterwards.
|
||||
|
||||
## Step 3 — flux: bootstrap GitOps **from the GitHub mirror** (break-glass)
|
||||
|
||||
@@ -149,8 +248,8 @@ Secrets, and every secret-consuming app stays Pending. This step is **manual**
|
||||
(the unseal key lives in `pass`, off-cluster by design).
|
||||
|
||||
```sh
|
||||
# 1. Wait for the OpenBao pod to exist (sealed, 0/1 Ready is expected).
|
||||
kubectl -n openbao get pod openbao-0
|
||||
# 1. Wait for the OpenBao pods to exist (all three sealed, 0/1 Ready is expected).
|
||||
kubectl -n openbao get pods
|
||||
|
||||
# 2. Restore the most recent raft snapshot. Source it from the snapshot PVC
|
||||
# (infrastructure/openbao/snapshot-cronjob.yaml) or, in a total-loss rebuild,
|
||||
@@ -158,8 +257,12 @@ kubectl -n openbao get pod openbao-0
|
||||
kubectl -n openbao cp <snapshot>.snap openbao-0:/tmp/restore.snap
|
||||
kubectl -n openbao exec -it openbao-0 -- bao operator raft snapshot restore /tmp/restore.snap
|
||||
|
||||
# 3. Unseal with the key from pass (threshold 1).
|
||||
# 3. Unseal with the key from pass (threshold 1) — EVERY replica, same key.
|
||||
# A sealed server is neither a raft voter nor a standby, so unsealing only
|
||||
# openbao-0 leaves the three-peer cluster one node short of quorum.
|
||||
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste homelab/openbao/unseal-key
|
||||
kubectl -n openbao exec -it openbao-1 -- bao operator unseal # same key
|
||||
kubectl -n openbao exec -it openbao-2 -- bao operator unseal # same key
|
||||
```
|
||||
|
||||
Verify the store recovers before moving on:
|
||||
@@ -170,7 +273,9 @@ kubectl -n <ns> get externalsecret # want: SecretSynced
|
||||
```
|
||||
|
||||
> **Manual unseal is required after every OpenBao pod restart**, not just
|
||||
> during rebuilds. Details + rationale: `MIGRATION-sops-openbao.md`.
|
||||
> during rebuilds, and for *each* replica that restarts — a rolling node
|
||||
> drain seals all three in turn. Details + rationale:
|
||||
> `MIGRATION-sops-openbao.md`.
|
||||
|
||||
## Step 5 — apps: let the rest reconcile
|
||||
|
||||
@@ -225,9 +330,28 @@ it: repo → **Settings → Repository → Mirror Settings** in Gitea, or
|
||||
in-cluster commits made **after** Flux/Gitea broke are not on the mirror
|
||||
(Gitea couldn't push them if Gitea itself was down). This is the intended
|
||||
recovery point, but be aware of it when reconciling drift.
|
||||
- `bootstrap.sh` has no `openbao` phase — Step 4 is manual because the unseal
|
||||
key must not live in CI. See issue #187 for the open question of whether to
|
||||
wire a semi-automated OpenBao restore.
|
||||
- **Booting is physical; the rest is not.** MS-01 has no IPMI and nothing here
|
||||
does PXE, so someone has to put a USB stick in three machines. From there it
|
||||
is remote — a maintenance-mode node DHCPs untagged and is reachable at that
|
||||
address, which is how all three bare-metal conversions were driven.
|
||||
#576 claims a freshly booted node "comes up unreachable and must be installed
|
||||
from a console". That is not what happens and has never been observed here.
|
||||
- **`bootstrap.sh talos` is dead** — it cannot generate a config (#581), and
|
||||
Step 2 no longer uses it. Its `flux` and `wait-apps` phases are still used in
|
||||
Steps 3, 5 and 6 and work; do not read this as the whole script being gone.
|
||||
It has also never had an `openbao` phase — Step 4 is manual because the
|
||||
unseal key must not live in CI; see issue #187 on whether to wire a
|
||||
semi-automated OpenBao restore.
|
||||
- **OpenBao seals on every restart and each replica must be unsealed
|
||||
individually.** Three raft replicas mean the service survives losing one, but
|
||||
a sealed server is neither a voter nor a standby — unsealing only one leaves
|
||||
the cluster short of quorum. Step 4 covers this; it is repeated here because
|
||||
it is the step most likely to be half-done under pressure.
|
||||
- Ceph's cluster network is a Multus attachment on VLAN 49 with whereabouts
|
||||
IPAM, not host networking. Nothing in the rebuild needs special handling for
|
||||
it — the nodes hold no address on that segment — but a rebuilt cluster will
|
||||
not have the whereabouts allocations from before, which is harmless since
|
||||
they are assigned fresh.
|
||||
- warn-and-continue waits in the `flux` phase can mask a genuinely broken
|
||||
Ceph/infra bring-up; always read the phase output rather than trusting the
|
||||
exit code.
|
||||
|
||||
@@ -12,6 +12,7 @@ resources:
|
||||
- prometheusrule-netbird-availability.yaml
|
||||
- prometheusrule-netbird-backup.yaml
|
||||
- prometheusrule-netbird-ingress.yaml
|
||||
- prometheusrule-openbao.yaml
|
||||
- prometheusrule-velero.yaml
|
||||
- prometheusrule-woodpecker-rotation.yaml
|
||||
# CNPG backup health. Seven Postgres clusters ship to Hetzner and nothing
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: openbao
|
||||
namespace: monitoring
|
||||
labels:
|
||||
# kube-prometheus-stack's Prometheus resource selects on this label;
|
||||
# without it the rule is created but never loaded into Prometheus.
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
groups:
|
||||
- name: openbao.rules
|
||||
rules:
|
||||
# WHY THIS WATCHES POD READINESS RATHER THAN A SEAL METRIC
|
||||
#
|
||||
# OpenBao is not scraped: there is no ServiceMonitor for it, and its
|
||||
# metrics endpoint needs either a token or an explicit
|
||||
# `unauthenticated_metrics_access` telemetry setting, neither of which
|
||||
# is configured. Confirmed against Prometheus — no bao_* or vault_*
|
||||
# series exist. External Secrets exports nothing usable either, so the
|
||||
# ClusterSecretStore going NotReady is invisible as a metric.
|
||||
#
|
||||
# kube-state-metrics readiness carries the signal anyway, and does so
|
||||
# for a structural reason rather than by luck: a sealed OpenBao fails
|
||||
# its readiness probe, because a sealed server can neither serve
|
||||
# requests nor act as a raft standby. Sealed is therefore always
|
||||
# not-Ready. The converse does not hold — a crash or a stuck
|
||||
# scheduling would also fire this — but both want a human too, so the
|
||||
# imprecision costs nothing.
|
||||
- alert: OpenBaoReplicaNotReady
|
||||
# A replica that is sealed never recovers on its own, so the
|
||||
# duration is only there to ignore ordinary rescheduling: a healthy
|
||||
# pod moving between nodes is Ready again in well under a minute.
|
||||
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 3
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "OpenBao has {{ $value }} of 3 replicas ready"
|
||||
description: >-
|
||||
An OpenBao replica has been unavailable for 10 minutes. The usual
|
||||
cause is a sealed replica after a node drain — every restart
|
||||
seals it, and it stays out of the raft cluster until unsealed by
|
||||
hand. The service is still up on the remaining replicas, so
|
||||
nothing is broken yet, but redundancy is reduced and a second
|
||||
drain would cost quorum. Check `kubectl -n openbao get pods` and
|
||||
`bao status` in the not-ready pod; unseal with the key from
|
||||
`pass` (homelab/openbao/unseal-key).
|
||||
|
||||
- alert: OpenBaoQuorumLost
|
||||
# Raft quorum of a three-node cluster is two. At one ready replica
|
||||
# the cluster cannot elect a leader and the secret store is down:
|
||||
# the ClusterSecretStore goes InvalidProviderConfig and no
|
||||
# ExternalSecret can refresh.
|
||||
#
|
||||
# Shorter window than the warning above because this is an outage,
|
||||
# not a degradation — but not zero, so a rolling restart that
|
||||
# briefly dips does not page.
|
||||
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 2
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "OpenBao below raft quorum — {{ $value }} of 3 replicas ready"
|
||||
description: >-
|
||||
Fewer than two OpenBao replicas are ready, so raft cannot hold
|
||||
quorum and the secret store is unavailable. ExternalSecrets stop
|
||||
refreshing; existing Secrets keep their last values, so failures
|
||||
surface only when something rotates or a new secret is needed.
|
||||
Unseal the sealed replicas (key in `pass`,
|
||||
homelab/openbao/unseal-key) — each one individually, since a
|
||||
sealed server cannot be a standby.
|
||||
|
||||
- alert: OpenBaoMetricsMissing
|
||||
# Guards the two alerts above. Both are threshold comparisons, so if
|
||||
# kube-state-metrics stops exporting this series — the StatefulSet
|
||||
# deleted, the namespace gone, kube-state-metrics itself broken —
|
||||
# they silently stop evaluating and the silence looks identical to
|
||||
# health. This makes that state say something.
|
||||
expr: absent(kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"})
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "No readiness metric for the OpenBao StatefulSet"
|
||||
description: >-
|
||||
kube-state-metrics has not reported
|
||||
kube_statefulset_status_replicas_ready for the openbao
|
||||
StatefulSet in 15 minutes. Either the StatefulSet is gone or
|
||||
kube-state-metrics is not scraping — and while this is absent,
|
||||
OpenBaoReplicaNotReady and OpenBaoQuorumLost cannot fire at all.
|
||||
@@ -40,17 +40,31 @@ spec:
|
||||
description: "A Velero backup run for {{ $labels.schedule }} completed with Failed or PartiallyFailed status in the last hour. Inspect with `kubectl -n velero get backup.velero.io -l velero.io/schedule-name={{ $labels.schedule }}` and the velero pod logs."
|
||||
|
||||
- alert: VeleroBackupStale
|
||||
# Daily schedule with no successful backup in over 30h: covers
|
||||
# the case where multiple consecutive runs failed (Garage flapping
|
||||
# over the backup window) without the per-event alerts being
|
||||
# acted on.
|
||||
# Daily schedule with no successful backup in over 60h: covers the
|
||||
# case where consecutive runs failed without the per-event alerts
|
||||
# being acted on.
|
||||
#
|
||||
# 60h, not 30h. At 30h a single missed cycle pages, 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 simply lost for that day — it is not
|
||||
# retried. Rolling node work between 03:15 and 08:45 UTC therefore
|
||||
# produced a guaranteed critical alert for every schedule it
|
||||
# overlapped, which is noise about planned work rather than a signal
|
||||
# about backups.
|
||||
#
|
||||
# 60h still catches what this is for. Two consecutive failures is no
|
||||
# longer explicable by one maintenance window, and the per-event
|
||||
# VeleroBackupFailed alert fires on the first failure regardless — so
|
||||
# nothing is hidden, only the escalation to critical is deferred
|
||||
# until a second cycle confirms it.
|
||||
expr: |
|
||||
time() - velero_backup_last_successful_timestamp{schedule!=""} > 30 * 3600
|
||||
time() - velero_backup_last_successful_timestamp{schedule!=""} > 60 * 3600
|
||||
and
|
||||
velero_backup_last_successful_timestamp{schedule!=""} > 0
|
||||
for: 10m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Velero {{ $labels.schedule }} has no successful backup in 30h+"
|
||||
description: "Last successful backup for {{ $labels.schedule }} was over 30 hours ago. With a daily schedule that means at least one full cycle missed. RPO is degrading."
|
||||
summary: "Velero {{ $labels.schedule }} has no successful backup in 60h+"
|
||||
description: "Last successful backup for {{ $labels.schedule }} was over 60 hours ago. With a daily schedule that is at least two consecutive cycles missed, which one maintenance window does not explain. RPO is degrading."
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
{
|
||||
"annotations": {"list": []},
|
||||
"description": "NVMe throughput, latency and temperature per node. Every panel joins node_disk_* to node_nvme_info and node_uname_info on purpose: device names are NOT stable across nodes (nvme0 is the Kingston system disk on k8s-1 and k8s-3, but the Crucial on k8s-2), so a legend keyed on the device alone compares different hardware. Endurance/wear is deliberately absent — see the note at the bottom and issue #549.",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Which physical drive is which, resolved live rather than written down. The OSD column comes from ceph_disk_occupation, so it follows Rook instead of restating a device list that would go stale the next time a drive is swapped.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "thresholds"},
|
||||
"custom": {"align": "auto", "cellOptions": {"type": "auto"}, "inspect": false},
|
||||
"mappings": [],
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "text", "value": null}]}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 9, "w": 24, "x": 0, "y": 0},
|
||||
"id": 1,
|
||||
"options": {"cellHeight": "sm", "footer": {"countRows": false, "fields": "", "reducer": ["sum"], "show": false}, "showHeader": true, "sortBy": [{"desc": false, "displayName": "node"}]},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"expr": "node_nvme_info * on (instance) group_left(nodename) node_uname_info",
|
||||
"format": "table",
|
||||
"instant": true,
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Drive inventory",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "organize",
|
||||
"options": {
|
||||
"excludeByName": {"Time": true, "Value": true, "__name__": true, "cntlid": true, "container": true, "endpoint": true, "instance": true, "job": true, "namespace": true, "pod": true, "service": true, "domainname": true, "machine": true, "release": true, "sysname": true, "version": true, "state": true},
|
||||
"indexByName": {},
|
||||
"renameByName": {"nodename": "node", "device": "device", "model": "model", "serial": "serial", "firmware_revision": "firmware"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "table"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Read and write throughput per drive.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
|
||||
"mappings": [],
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||
"unit": "Bps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 9},
|
||||
"id": 2,
|
||||
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"expr": "label_replace(rate(node_disk_read_bytes_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
|
||||
"legendFormat": "read {{nodename}} {{model}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"expr": "label_replace(rate(node_disk_written_bytes_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
|
||||
"legendFormat": "write {{nodename}} {{model}}",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Throughput",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Completed operations per second. Read together with throughput this separates many small operations from few large ones.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
|
||||
"mappings": [],
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||
"unit": "iops"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 9},
|
||||
"id": 3,
|
||||
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"expr": "label_replace(rate(node_disk_reads_completed_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
|
||||
"legendFormat": "read {{nodename}} {{model}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"expr": "label_replace(rate(node_disk_writes_completed_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
|
||||
"legendFormat": "write {{nodename}} {{model}}",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "IOPS",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Mean service time per operation, derived as accumulated time divided by completed operations. This is an average and will not show a tail — a drive can look healthy here while a small fraction of operations stall. The line disappears while a device is idle, because the denominator is zero; that is absence of traffic, not absence of a problem.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
|
||||
"mappings": [],
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 18},
|
||||
"id": 4,
|
||||
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"expr": "label_replace(rate(node_disk_read_time_seconds_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]) / rate(node_disk_reads_completed_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
|
||||
"legendFormat": "read {{nodename}} {{model}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"expr": "label_replace(rate(node_disk_write_time_seconds_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]) / rate(node_disk_writes_completed_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
|
||||
"legendFormat": "write {{nodename}} {{model}}",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Mean operation latency",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Fraction of wall-clock time the device had at least one operation in flight, and the number of operations queued. Utilisation near 100% on an NVMe drive does not mean saturation the way it does on a spindle — these devices serve many operations in parallel — so read it alongside the queue depth.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
|
||||
"mappings": [],
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||
"unit": "percentunit"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": {"id": "byRegexp", "options": "queue .*"},
|
||||
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"}]
|
||||
}
|
||||
]
|
||||
},
|
||||
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 18},
|
||||
"id": 5,
|
||||
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"expr": "label_replace(rate(node_disk_io_time_seconds_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
|
||||
"legendFormat": "busy {{nodename}} {{model}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"expr": "label_replace(node_disk_io_now{device=~\"nvme[0-9]+n[0-9]+\"}, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
|
||||
"legendFormat": "queue {{nodename}} {{model}}",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Utilisation and queue depth",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Composite temperature reported by each controller, read from hwmon in sysfs. This is the one health value available without an admin ioctl, which is why it is the only SMART-adjacent series on this dashboard.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "dashed"}},
|
||||
"mappings": [],
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 70}, {"color": "red", "value": 80}]},
|
||||
"unit": "celsius"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 27},
|
||||
"id": 6,
|
||||
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"expr": "label_replace(node_hwmon_temp_celsius{chip=~\"nvme_nvme[0-9]+\", sensor=\"temp1\"}, \"nvmedev\", \"$1\", \"chip\", \"nvme_(nvme[0-9]+)\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
|
||||
"legendFormat": "{{nodename}} {{model}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Controller temperature",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Ceph's own view of the OSDs, for comparison with the block-layer numbers above. A divergence between these and the device latency is the interesting case: it points at Ceph rather than at the hardware.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
|
||||
"mappings": [],
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||
"unit": "ms"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 27},
|
||||
"id": 7,
|
||||
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"expr": "ceph_osd_apply_latency_ms",
|
||||
"legendFormat": "apply {{ceph_daemon}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"expr": "ceph_osd_commit_latency_ms",
|
||||
"legendFormat": "commit {{ceph_daemon}}",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Ceph OSD latency",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 36},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"code": {"language": "plaintext", "showLineNumbers": false, "showMiniMap": false},
|
||||
"content": "### What this dashboard cannot tell you\n\n**Endurance is missing.** There is no wear, no written-bytes total, no power-on hours and no media-error count here, because no exporter in this cluster produces them. NVMe reports those over an admin ioctl that needs elevated privileges, and temperature is the only health value the kernel publishes through sysfs. Tracked as **#549** — do not read a healthy dashboard as a healthy drive.\n\n**The three roles are not interchangeable.** Each node carries a Kingston OM8PGP41024Q as the Talos system disk, a Samsung PM983 that Rook claims as the Ceph OSD, and a Crucial P3 that was retired from Ceph and now carries the EPHEMERAL volume. The Crucial drives were replaced *because* they were worn, so the drive with the least endurance left is the one holding container state — and it is the one nothing is watching.\n\n**Device names are not identity.** `nvme0` is the Kingston on k8s-1 and k8s-3 and the Crucial on k8s-2. Every panel here resolves the model through `node_nvme_info` for that reason; a query written against a bare device name compares different hardware and will keep doing so silently.",
|
||||
"mode": "markdown"
|
||||
},
|
||||
"title": "Gaps and caveats",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
"refresh": "1m",
|
||||
"schemaVersion": 39,
|
||||
"tags": ["storage", "nvme", "ceph"],
|
||||
"templating": {"list": []},
|
||||
"time": {"from": "now-6h", "to": "now"},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Disk Health",
|
||||
"uid": "disk-health",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -7,9 +7,12 @@ namespace: monitoring
|
||||
# the kube-prometheus-stack Grafana sidecar watches for.
|
||||
#
|
||||
# To add a dashboard:
|
||||
# 1. Copy the ported JSON to this directory as <name>.json
|
||||
# 1. Copy the JSON to this directory as <name>.json
|
||||
# 2. Add a configMapGenerator entry below referencing the file
|
||||
# 3. Tick off the row in ../DASHBOARD-MIGRATION.md
|
||||
#
|
||||
# There used to be a third step, ticking a row off ../DASHBOARD-MIGRATION.md.
|
||||
# That file no longer exists anywhere in the repo — the port it tracked is
|
||||
# finished — so following the instruction was impossible.
|
||||
#
|
||||
# Dashboard datasource UIDs must match K8s Grafana datasources:
|
||||
# - prometheus (kube-prom-stack default, in-cluster)
|
||||
@@ -35,6 +38,9 @@ configMapGenerator:
|
||||
- name: dashboard-blackbox-overview
|
||||
files:
|
||||
- blackbox-overview.json
|
||||
- name: dashboard-disk-health
|
||||
files:
|
||||
- disk-health.json
|
||||
- name: dashboard-gitea-overview
|
||||
files:
|
||||
- gitea-overview.json
|
||||
|
||||
@@ -55,7 +55,7 @@ spec:
|
||||
# stdlib only; Flux keeps the tag in lockstep via the shared image
|
||||
# policy) — no extra dependency.
|
||||
- name: reconcile-publish-ip
|
||||
image: ghcr.io/music-assistant/server:2.9.13 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.0 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
@@ -65,7 +65,7 @@ spec:
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: music-assistant
|
||||
image: ghcr.io/music-assistant/server:2.9.13 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.0 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
env:
|
||||
- name: LOG_LEVEL
|
||||
value: info
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
# * host namespaces + hostPath volumes: denied for every step pod (neither
|
||||
# kaniko nor the buildx plugin needs them).
|
||||
# * privileged containers: denied EXCEPT the allow-listed buildx plugin image.
|
||||
# Verified non-breaking: this repo's own builds use rootless kaniko
|
||||
# (gcr.io/kaniko-project/executor) which needs none of the above; only the
|
||||
# external buildx plugin needs privileged, and it is allow-listed by image ref.
|
||||
# Verified non-breaking: this repo's own builds use rootless kaniko, which needs
|
||||
# none of the above; only the external buildx plugin needs privileged, and it is
|
||||
# allow-listed by image ref. The kaniko image moved off gcr.io to the maintained
|
||||
# chainguard-dev fork in #552 — no policy change was needed, because nothing
|
||||
# here matches on the kaniko image, only on the buildx one.
|
||||
#
|
||||
# ValidatingAdmissionPolicy is GA / in-tree on the cluster's Kubernetes v1.35 —
|
||||
# no external admission controller (Kyverno/Gatekeeper) is introduced.
|
||||
|
||||
@@ -203,16 +203,16 @@ metadata:
|
||||
labels:
|
||||
app.kubernetes.io/part-of: homelab-backup
|
||||
spec:
|
||||
# Daily at 06:45 local (next free stagger slot). All nine PVCs live
|
||||
# in the home-assistant namespace regardless of which app/ dir owns
|
||||
# Daily at 06:45 local (next free stagger slot). The app PVCs live in
|
||||
# the home-assistant namespace regardless of which app/ dir owns
|
||||
# their manifest (matter-server, otbr, whisper, speech-to-phrase,
|
||||
# zone-configurator all set `namespace: home-assistant` via their
|
||||
# Kustomization, not per-resource): home-assistant-config (.storage
|
||||
# entity registry, recorder DB, secrets.yaml), esphome-data,
|
||||
# shelly-manager-data, matter-server-data, otbr-data, whisper-data,
|
||||
# speech-to-phrase-models, speech-to-phrase-train,
|
||||
# zone-configurator-config. No CNPG cluster in this namespace, so no
|
||||
# label exclusion is needed. On restore, home-assistant-config has its
|
||||
# speech-to-phrase-models, speech-to-phrase-train, zigbee2mqtt-data,
|
||||
# zwavejs-store, zone-configurator-config. On restore,
|
||||
# home-assistant-config has its
|
||||
# own path (the HA pod's `seed` initContainer pulling the native HAOS
|
||||
# backup from Garage, see the home-assistant Kustomization comment in
|
||||
# kubernetes/clusters/homelab/home-assistant.yaml); layering a Velero
|
||||
@@ -229,6 +229,20 @@ spec:
|
||||
storageLocation: default
|
||||
ttl: 720h0m0s
|
||||
snapshotMoveData: true
|
||||
# The ha-pg CNPG cluster now runs in this namespace (it did not when
|
||||
# this schedule was written — the old comment here still claimed
|
||||
# "no CNPG cluster in this namespace"). Its PVC ha-pg-1 carries the
|
||||
# cnpg.io/cluster label and was being swept in: a crash-consistent
|
||||
# snapshot of a running Postgres data directory is not a restorable
|
||||
# backup, the same trap the wordpress-daily comment below describes.
|
||||
# ha-pg is covered by Barman — daily base backup at 02:50 UTC
|
||||
# (kubernetes/apps/home-assistant/ha-pg-scheduled-backup.yaml) plus
|
||||
# continuous WAL archiving — so this exclusion removes a redundant
|
||||
# 20 GiB upload, matching paperless/authentik/immich/trmnl above.
|
||||
labelSelector:
|
||||
matchExpressions:
|
||||
- key: cnpg.io/cluster
|
||||
operator: DoesNotExist
|
||||
---
|
||||
apiVersion: velero.io/v1
|
||||
kind: Schedule
|
||||
|
||||
@@ -47,26 +47,27 @@ spec:
|
||||
# (paperless + audiobookshelf + immich + ... all clone at once).
|
||||
cephConfig:
|
||||
mon:
|
||||
# Ceph's 30% default assumes the mon has a filesystem to itself, where
|
||||
# 30% free is a meaningful reserve. Here the mons live on
|
||||
# dataDirHostPath /var/lib/rook — the node's /var, shared with ~29 GB
|
||||
# of containerd image and snapshot storage — and the mon DB itself is
|
||||
# ~106 MB. So the default measures container churn, not Ceph: k8s-2
|
||||
# trips MON_DISK_LOW whenever the image cache fills, and image GC does
|
||||
# not clear it (most of /var/lib/containerd is overlayfs snapshots held
|
||||
# by running containers, which GC cannot reclaim — see #434).
|
||||
# 20% of a 60 GB /var still leaves ~12 GB against a 106 MB database.
|
||||
# mon_data_avail_crit stays at the 5% default, and CephMonDiskLow in
|
||||
# monitoring-rules watches /var directly for the real risk, which is the
|
||||
# node filling rather than the mon growing.
|
||||
# The Ceph default, stated rather than left out. Removing the line would
|
||||
# not restore it: `ceph config` keeps a value that only disappears from
|
||||
# this file, so the old 20 would sit in the mon config database
|
||||
# indefinitely — the same trap the bluestore settings below describe,
|
||||
# and the one that crash-looped an OSD during the network provider
|
||||
# change when public_network survived being deleted here.
|
||||
#
|
||||
# BARE-METAL MIGRATION: revisit this. Give the mons their own volume and
|
||||
# the default 30% becomes meaningful again, at which point this override
|
||||
# should be dropped rather than carried over. It cannot be done on the
|
||||
# current cluster — every StorageClass is Ceph-backed, so a mon PVC
|
||||
# would be circular, and Rook only applies mon.volumeClaimTemplate to
|
||||
# newly created mons, never migrating existing ones.
|
||||
mon_data_avail_warn: "20"
|
||||
# It was 20 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.
|
||||
#
|
||||
# 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.
|
||||
mon_data_avail_warn: "30"
|
||||
# Stop compacting the whole RocksDB prefix on every paxos trim.
|
||||
#
|
||||
# Upstream default is true — "Compact a certain prefix (including paxos)
|
||||
|
||||
@@ -29,20 +29,22 @@
|
||||
# needed — unlike the v30iot attachments, there is no DHCP-supplied default
|
||||
# route to confine, because there is no DHCP and no default route.
|
||||
#
|
||||
# The pool stays inside 10.99.0.0/29, on-link with the node addresses, and
|
||||
# this is load-bearing during the migration rather than a detail. OSDs move
|
||||
# one at a time, so the cluster runs mixed for a while: an OSD already on
|
||||
# this attachment has to reach an OSD still using the node's host address.
|
||||
# There is no router on this segment, so anything outside the /29 would be
|
||||
# unreachable from the un-migrated side and the two would mark each other
|
||||
# down — during precisely the window that is supposed to be safe.
|
||||
# The pool is the whole usable /29, .1 through .6. It was .4-.6 while the
|
||||
# migration ran, because the nodes still held .1-.3 for as long as Ceph
|
||||
# needed host networking to reach this link — three addresses for three
|
||||
# OSDs, no spare. The nodes have since given those up (the link is only a
|
||||
# macvlan parent now and needs no address of its own), so the reservation
|
||||
# has nothing left to avoid.
|
||||
#
|
||||
# range_start/range_end reserve .4-.6 and leave .1-.3 to the nodes. That is
|
||||
# exactly one address per OSD with nothing spare, which is sufficient
|
||||
# because osdMaxUpdatesInParallel bounds the migration to one OSD at a
|
||||
# time: allocations grow 1, 2, 3 and never overlap. Once the switch is
|
||||
# confirmed and the node addresses come out of the Talos configs, the pool
|
||||
# can take the whole /29 and the spare returns.
|
||||
# Keeping the pool inside the /29 rather than moving it elsewhere on the
|
||||
# segment is not cosmetic. There is no router here, so an address outside
|
||||
# the /29 would be unreachable from anything still using one inside it —
|
||||
# which is exactly what would break a future rolling change, where migrated
|
||||
# and un-migrated OSDs have to keep talking to each other.
|
||||
#
|
||||
# Six addresses for three OSDs is the headroom the migration could not
|
||||
# afford: room for a leaked allocation to sit until the reconciler collects
|
||||
# it, and room to add a second OSD per node without touching this file.
|
||||
#
|
||||
# IPAM
|
||||
#
|
||||
@@ -76,7 +78,7 @@ spec:
|
||||
"ipam": {
|
||||
"type": "whereabouts",
|
||||
"range": "10.99.0.0/29",
|
||||
"range_start": "10.99.0.4",
|
||||
"range_start": "10.99.0.1",
|
||||
"range_end": "10.99.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,17 @@ machine:
|
||||
# Only read at install and upgrade time. Changing it here does nothing to a
|
||||
# running node until that node is upgraded. Bump the tag with the Talos
|
||||
# version — it must match the running version, or the upgrade is a downgrade.
|
||||
#
|
||||
# Upgrades follow the documented path: migration is only tested between
|
||||
# adjacent minor releases, so go to the latest patch of each intermediate
|
||||
# minor rather than skipping. 1.12.11 -> 1.13.9 satisfies that — one minor
|
||||
# step from the last 1.12 patch, no intermediate needed.
|
||||
#
|
||||
# Kubernetes is a separate decision and is not forced by this. Talos 1.13
|
||||
# supports Kubernetes 1.36, 1.35, 1.34, 1.33, 1.32 and 1.31, so the cluster
|
||||
# stays on 1.35.2 until `talosctl upgrade-k8s` moves it deliberately.
|
||||
install:
|
||||
image: factory.talos.dev/metal-installer/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9:v1.12.11
|
||||
image: factory.talos.dev/metal-installer/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9:v1.13.9
|
||||
features:
|
||||
rbac: true
|
||||
stableHostname: true
|
||||
|
||||
@@ -20,6 +20,29 @@ machine:
|
||||
# un-translated SYN-ACK -> client kernel RST, verified live 2026-07-23).
|
||||
# This disables v6 entirely on that link, including link-local.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# Same argument, applied to the physical ports rather than a VLAN child.
|
||||
#
|
||||
# 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
|
||||
# themselves from its router advertisements: an fd00::/64 ULA each, plus
|
||||
# an IPv6 default route each.
|
||||
#
|
||||
# The result was 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, so the cluster had no IPv6 egress at all while IPv4 worked
|
||||
# normally. It surfaced as two NetBird probes reporting the proxies down
|
||||
# for 30 hours; the proxies were fine and answered IPv6 in 13 ms from
|
||||
# outside.
|
||||
#
|
||||
# Verified on k8s-1 before rolling to the others: disabling v6 on a VLAN
|
||||
# parent leaves the VLAN child alone. mgmt0 kept its global address and
|
||||
# its fd40:: address, Ceph did not notice, and every failing probe
|
||||
# recovered on the next scrape.
|
||||
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
|
||||
net.ipv6.conf.enp87s0.disable_ipv6: "1"
|
||||
install:
|
||||
# Three NVMe drives are ordinary block devices to Talos here: the Kingston
|
||||
# OM8PGP41024Q-A0 system disk, the Samsung PM983 that Rook claims as the
|
||||
@@ -94,6 +117,30 @@ vlanID: 40
|
||||
parent: enp2s0f0np0
|
||||
up: true
|
||||
---
|
||||
# Management addressing is DHCP and the lease is NOT reserved anywhere in this
|
||||
# repo — the k8s-{1,2,3}.srv.renner.uno reservations live on the UDM out of band
|
||||
# and opentofu/unifi.tf declares no live resources. The addresses do move under
|
||||
# a running node: k8s-3 went 10.2.40.168 -> .138 mid-flight on 2026-08-26
|
||||
# without its Node object ever re-registering (issue #635).
|
||||
#
|
||||
# kube-apiserver does not follow when that happens. Talos starts it with
|
||||
# `--advertise-address=$(POD_IP)` and no `--endpoint-reconciler-type` override,
|
||||
# so the advertised address is fixed at pod start and the default lease
|
||||
# reconciler goes on renewing the masterlease under the OLD address. Nothing
|
||||
# expires it while the process lives, so `default/kubernetes` keeps handing out
|
||||
# a dead endpoint and the node's live address is never registered at all — 19h
|
||||
# of TargetDown on job="apiserver", a monitoring blind spot on one of three
|
||||
# apiservers, and it cleared only when the static pod restarted.
|
||||
#
|
||||
# So after any node re-IPs, check that
|
||||
# `kubectl get endpoints kubernetes -n default` agrees with
|
||||
# `kubectl get nodes -o wide`, and restart the offending node
|
||||
# (`talosctl -n <node> reboot`) if it does not.
|
||||
#
|
||||
# Pinning mgmt0 with a static AddressConfig alongside this document would remove
|
||||
# the trigger, but it needs an address outside the UDM's DHCP pool — which is
|
||||
# not declared in this repo — and §6 of RUNBOOK-baremetal-conversion.md applies:
|
||||
# prove it with `talosctl apply-config --mode=try` before making it permanent.
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: mgmt0
|
||||
|
||||
@@ -9,6 +9,15 @@ machine:
|
||||
# macvlan parent for the v30iot pod attachments; host addresses on that L2
|
||||
# make node replies bypass the UDM and break every UDM-DNATed flow.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# Same argument for the physical ports: enp2s0f0np0 is only the trunk
|
||||
# parent for mgmt0 and ens19, enp87s0 is unused, and both sit on the
|
||||
# untagged fallback network where nothing is supposed to run. Both were
|
||||
# taking an fd00::/64 and an IPv6 default route from its router
|
||||
# advertisements, leaving four equal-priority default routes per node of
|
||||
# which only mgmt0 has a globally routable source. See k8s-1.yaml for the
|
||||
# full reasoning and how it was verified.
|
||||
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
|
||||
net.ipv6.conf.enp87s0.disable_ipv6: "1"
|
||||
install:
|
||||
# Three NVMe drives are visible on bare metal. Select the installer target
|
||||
# by serial so it cannot land on the OSD drive or on EPHEMERAL, whatever
|
||||
|
||||
@@ -8,6 +8,15 @@ machine:
|
||||
sysctls:
|
||||
# v30iot NIC carries no host addressing — see k8s-1.yaml.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# Same argument for the physical ports: enp2s0f0np0 is only the trunk
|
||||
# parent for mgmt0 and ens19, enp87s0 is unused, and both sit on the
|
||||
# untagged fallback network where nothing is supposed to run. Both were
|
||||
# taking an fd00::/64 and an IPv6 default route from its router
|
||||
# advertisements, leaving four equal-priority default routes per node of
|
||||
# which only mgmt0 has a globally routable source. See k8s-1.yaml for the
|
||||
# full reasoning and how it was verified.
|
||||
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
|
||||
net.ipv6.conf.enp87s0.disable_ipv6: "1"
|
||||
install:
|
||||
# Three NVMe drives are visible on bare metal. Select the installer target
|
||||
# by serial so it cannot land on the OSD drive or on EPHEMERAL, whatever
|
||||
|
||||
Reference in New Issue
Block a user