Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2279d14ec5 | ||
|
|
0fbf544d5e | ||
|
|
8427b28b0e | ||
|
|
64c72fd394 | ||
|
|
1ea73544c9 | ||
|
|
296d6d3275 | ||
|
|
55b232e2ce | ||
|
|
b93594b7c8 | ||
|
|
0c6949ed98 |
No files matched your search
@@ -131,6 +131,10 @@ jobs:
|
|||||||
# merging. Green here means the token resolved and the live Gitea matched the
|
# merging. Green here means the token resolved and the live Gitea matched the
|
||||||
# declaration in infra/gitea/expected-branch-protection.json.
|
# declaration in infra/gitea/expected-branch-protection.json.
|
||||||
REQUIRE_GITEA_SETTINGS: "true"
|
REQUIRE_GITEA_SETTINGS: "true"
|
||||||
|
# ci/approval-policy.sh needs to know WHICH PR it is looking at, so it can read that
|
||||||
|
# PR's changed files and its reviews. Absent on a push event, where the stage is N/A.
|
||||||
|
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
run: ./ci/gate.sh || [ "$?" -eq 3 ]
|
run: ./ci/gate.sh || [ "$?" -eq 3 ]
|
||||||
|
|
||||||
# NOTE: no actions/upload-artifact step. Gitea Actions rejects upload-artifact@v4
|
# NOTE: no actions/upload-artifact step. Gitea Actions rejects upload-artifact@v4
|
||||||
|
|||||||
@@ -14,7 +14,28 @@ There is no other path. Specifically:
|
|||||||
|
|
||||||
- **Do not `git push origin main`.**
|
- **Do not `git push origin main`.**
|
||||||
- **Do not `git push origin HEAD:main`.** Same thing wearing a hat.
|
- **Do not `git push origin HEAD:main`.** Same thing wearing a hat.
|
||||||
- **Do not merge your own PR.** A human approves and clicks merge.
|
- **Do not merge your own PR.** See the approval model below — and either way, the merge is
|
||||||
|
someone else's action, never yours.
|
||||||
|
|
||||||
|
### Who approves
|
||||||
|
|
||||||
|
**The CI/CD pipeline is the approver.** A green gate is sufficient for an ordinary change; the
|
||||||
|
branch requires no human approval for one. That is a deliberate transfer of authority, on the
|
||||||
|
operator's stated principle: *if the pipeline is not good enough to be trusted for this, it is not
|
||||||
|
good enough to do its job and needs improving* — so a weak gate is a bug to fix, not a reason to
|
||||||
|
add a human.
|
||||||
|
|
||||||
|
**One exception, enforced by `ci/approval-policy.sh`:** a change to the pipeline itself, the
|
||||||
|
admission policies, the Flux wiring, the SOPS recipients, the RBAC, or this file needs an
|
||||||
|
approving review from someone who is not its author. The reason is narrow and structural — **a
|
||||||
|
change to the gate cannot be gated by the gate.** A PR that weakens a check would be approved by
|
||||||
|
the weakened check. Everything else merges on green.
|
||||||
|
|
||||||
|
The path list is data, with a reason per entry: `infra/gitea/approval-policy.json`. It is itself a
|
||||||
|
sensitive path, so the policy cannot be widened or narrowed without a human.
|
||||||
|
|
||||||
|
That stage is **fail-closed** — if it cannot evaluate, it blocks. Unlike `gitea-settings`, where a
|
||||||
|
missing credential is a lock-out, here a skip would be a bypass.
|
||||||
- **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`;
|
- **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`;
|
||||||
anything applied by hand is drift that the next reconcile reverts or, worse, adopts.
|
anything applied by hand is drift that the next reconcile reverts or, worse, adopts.
|
||||||
|
|
||||||
|
|||||||
+134
-2
@@ -133,6 +133,24 @@ OAUTH2_PROXY_IMAGE = "quay.io/oauth2-proxy/oauth2-proxy:v7.8.1"
|
|||||||
OAUTH2_PROXY_PORT = 4180
|
OAUTH2_PROXY_PORT = 4180
|
||||||
KEYCLOAK_ISSUER = f"https://keycloak.{ZONE}/realms/homelab"
|
KEYCLOAK_ISSUER = f"https://keycloak.{ZONE}/realms/homelab"
|
||||||
|
|
||||||
|
# --- Hardware capability: `hardware.gpu: intel-quicksync` ------------------------------
|
||||||
|
# The ServiceDefinition declares the CAPABILITY (schemas/servicedefinition.schema.json
|
||||||
|
# `hardware.gpu`); everything below is the MECHANISM that currently satisfies it and is
|
||||||
|
# deliberately confined to this file. Swapping to the Intel GPU device plugin
|
||||||
|
# (`gpu.intel.com/i915` resource requests instead of a hostPath) is a change here and
|
||||||
|
# nowhere else -- no catalog entry moves.
|
||||||
|
DRI_VOLUME_NAME = "dri"
|
||||||
|
DRI_DEVICE_DIR = "/dev/dri"
|
||||||
|
# GID of the host `render` group, which owns /dev/dri/renderD128 (mode 0660, root:render).
|
||||||
|
# MEASURED 2026-09-30 on ALL THREE prod nodes (HP EliteDesk 800 G5 Mini, i7-9700T, UHD
|
||||||
|
# Graphics 630, i915 bound -- not xe): GID 992, group name `render`, identical on each.
|
||||||
|
# This is a *systemd dynamic* allocation (systemd-udev creates `render` out of the dynamic
|
||||||
|
# system range at first boot), NOT a Debian/LSB static GID, so it is NOT guaranteed stable:
|
||||||
|
# a node rebuild, or a reinstall that creates system groups in a different order, can move
|
||||||
|
# it. If hardware transcode stops working after a node rebuild, re-measure
|
||||||
|
# `getent group render` on every node before assuming anything else.
|
||||||
|
INTEL_RENDER_GID = 992
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Defaults (renderer-contract.md "Default posture") — materialize BEFORE mapping.
|
# Defaults (renderer-contract.md "Default posture") — materialize BEFORE mapping.
|
||||||
@@ -483,6 +501,30 @@ def r_deployment(s: dict) -> dict:
|
|||||||
"persistentVolumeClaim": {"claimName": f"{s['name']}-{vol['name']}"},
|
"persistentVolumeClaim": {"claimName": f"{s['name']}-{vol['name']}"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
# hardware capability (schema-optional): `hardware.gpu: intel-quicksync` means this
|
||||||
|
# workload needs the host iGPU's render node for hardware video transcode. The SD says
|
||||||
|
# only that; the wiring below is the platform's current mechanism for satisfying it.
|
||||||
|
#
|
||||||
|
# `type: DirectoryOrCreate` is DELIBERATE and load-bearing -- do NOT "tighten" it to
|
||||||
|
# `Directory`. A node that comes back without the render device (rebuild, BIOS change,
|
||||||
|
# driver regression, hardware swap) must degrade the workload to SOFTWARE transcoding,
|
||||||
|
# not fail the mount: `apps-wave-0` is `wait: true` and `apps-wave-1` dependsOn it, so a
|
||||||
|
# failed hostPath mount stalls the entire app tier for the sake of a feature that is not
|
||||||
|
# load-bearing. `DirectoryOrCreate` turns that failure mode into "transcodes in
|
||||||
|
# software" instead of "the app tier does not reconcile". /dev is tmpfs, so the empty
|
||||||
|
# directory the kubelet creates on such a node leaves nothing durable behind.
|
||||||
|
#
|
||||||
|
# The mount is deliberately NOT readOnly: VA-API opens the render node read-write.
|
||||||
|
hw_gpu = (s.get("hardware") or {}).get("gpu")
|
||||||
|
if hw_gpu == "intel-quicksync":
|
||||||
|
volume_mounts.append({"name": DRI_VOLUME_NAME, "mountPath": DRI_DEVICE_DIR})
|
||||||
|
volumes.append(
|
||||||
|
{
|
||||||
|
"name": DRI_VOLUME_NAME,
|
||||||
|
"hostPath": {"path": DRI_DEVICE_DIR, "type": "DirectoryOrCreate"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if volume_mounts:
|
if volume_mounts:
|
||||||
container["volumeMounts"] = volume_mounts
|
container["volumeMounts"] = volume_mounts
|
||||||
|
|
||||||
@@ -505,8 +547,19 @@ def r_deployment(s: dict) -> dict:
|
|||||||
# pod spec. Its primary use is `fsGroup`, which makes a mounted Longhorn PVC group-writable by a
|
# pod spec. Its primary use is `fsGroup`, which makes a mounted Longhorn PVC group-writable by a
|
||||||
# non-root image (e.g. semaphore runs as UID 1001 and cannot otherwise write its config PVC).
|
# non-root image (e.g. semaphore runs as UID 1001 and cannot otherwise write its config PVC).
|
||||||
# Absent => the key is never set, so apps that declare none render byte-identical.
|
# Absent => the key is never set, so apps that declare none render byte-identical.
|
||||||
if s.get("podSecurityContext"):
|
#
|
||||||
pod_spec["securityContext"] = s["podSecurityContext"]
|
# `hardware.gpu: intel-quicksync` additionally needs the host `render` GID in
|
||||||
|
# supplementalGroups, because /dev/dri/renderD128 is root:render 0660 and the container
|
||||||
|
# does not run as root. It is MERGED into whatever the SD already declares rather than
|
||||||
|
# replacing it -- an SD that sets its own supplementalGroups keeps every one of them.
|
||||||
|
psc = dict(s.get("podSecurityContext") or {})
|
||||||
|
if hw_gpu == "intel-quicksync":
|
||||||
|
groups = list(psc.get("supplementalGroups") or [])
|
||||||
|
if INTEL_RENDER_GID not in groups:
|
||||||
|
groups.append(INTEL_RENDER_GID)
|
||||||
|
psc["supplementalGroups"] = groups
|
||||||
|
if psc:
|
||||||
|
pod_spec["securityContext"] = psc
|
||||||
|
|
||||||
if volumes:
|
if volumes:
|
||||||
pod_spec["volumes"] = volumes
|
pod_spec["volumes"] = volumes
|
||||||
@@ -2440,6 +2493,85 @@ def _selftest() -> int:
|
|||||||
"an SD without podSecurityContext must not gain a pod securityContext",
|
"an SD without podSecurityContext must not gain a pod securityContext",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 6b) hardware.gpu: intel-quicksync -> /dev/dri hostPath volume + RW mount + the host
|
||||||
|
# `render` GID. The SD declares the CAPABILITY; these assertions pin the MECHANISM
|
||||||
|
# that currently satisfies it, so a change to it is a deliberate, visible edit.
|
||||||
|
hw_docs = render(
|
||||||
|
{
|
||||||
|
"name": "hw",
|
||||||
|
"source": {"image": "a:1"},
|
||||||
|
"hardware": {"gpu": "intel-quicksync"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
hw_pod = next(
|
||||||
|
d[1] for d in hw_docs if d[1]["kind"] == "Deployment"
|
||||||
|
)["spec"]["template"]["spec"]
|
||||||
|
check(
|
||||||
|
{
|
||||||
|
"name": "dri",
|
||||||
|
"hostPath": {"path": "/dev/dri", "type": "DirectoryOrCreate"},
|
||||||
|
}
|
||||||
|
in hw_pod.get("volumes", []),
|
||||||
|
"hardware.gpu=intel-quicksync must render a /dev/dri hostPath volume "
|
||||||
|
"with type DirectoryOrCreate (a GPU-less node must degrade, not fail the mount)",
|
||||||
|
)
|
||||||
|
hw_mounts = hw_pod["containers"][0].get("volumeMounts", [])
|
||||||
|
check(
|
||||||
|
{"name": "dri", "mountPath": "/dev/dri"} in hw_mounts,
|
||||||
|
"hardware.gpu=intel-quicksync must mount /dev/dri READ-WRITE on the app container",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
hw_pod.get("securityContext", {}).get("supplementalGroups")
|
||||||
|
== [INTEL_RENDER_GID],
|
||||||
|
"hardware.gpu=intel-quicksync must add the host render GID to supplementalGroups",
|
||||||
|
)
|
||||||
|
# Nothing privileged, and no node pinning: Kyverno restrict-host-namespaces (Enforce)
|
||||||
|
# rejects `privileged: true` in an app namespace, and placement is a separate concern
|
||||||
|
# with its own justification -- this field is about the DEVICE only.
|
||||||
|
check(
|
||||||
|
"privileged" not in hw_pod.get("securityContext", {})
|
||||||
|
and "securityContext" not in hw_pod["containers"][0]
|
||||||
|
and "nodeSelector" not in hw_pod,
|
||||||
|
"hardware.gpu must emit no privileged securityContext and no nodeSelector",
|
||||||
|
)
|
||||||
|
# The regression that matters: absence renders NOTHING. Mirrors the podSecurityContext
|
||||||
|
# negative above -- an SD that does not opt in must be byte-identical to before.
|
||||||
|
nohw_pod = next(
|
||||||
|
d[1] for d in render({"name": "nohw", "source": {"image": "a:1"}})
|
||||||
|
if d[1]["kind"] == "Deployment"
|
||||||
|
)["spec"]["template"]["spec"]
|
||||||
|
check(
|
||||||
|
"volumes" not in nohw_pod
|
||||||
|
and "volumeMounts" not in nohw_pod["containers"][0]
|
||||||
|
and "securityContext" not in nohw_pod,
|
||||||
|
"an SD without `hardware` must gain no dri volume, no mount and no securityContext",
|
||||||
|
)
|
||||||
|
# Merge, not clobber: an SD that declares its own supplementalGroups keeps them AND
|
||||||
|
# gains the render GID, and its other podSecurityContext keys survive untouched.
|
||||||
|
hwm_pod = next(
|
||||||
|
d[1]
|
||||||
|
for d in render(
|
||||||
|
{
|
||||||
|
"name": "hwm",
|
||||||
|
"source": {"image": "a:1"},
|
||||||
|
"hardware": {"gpu": "intel-quicksync"},
|
||||||
|
"podSecurityContext": {
|
||||||
|
"fsGroup": 1001,
|
||||||
|
"supplementalGroups": [44],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if d[1]["kind"] == "Deployment"
|
||||||
|
)["spec"]["template"]["spec"]
|
||||||
|
check(
|
||||||
|
hwm_pod["securityContext"] == {
|
||||||
|
"fsGroup": 1001,
|
||||||
|
"supplementalGroups": [44, INTEL_RENDER_GID],
|
||||||
|
},
|
||||||
|
"hardware.gpu must MERGE the render GID into the SD's own supplementalGroups "
|
||||||
|
"(both GIDs, fsGroup preserved), never clobber them",
|
||||||
|
)
|
||||||
|
|
||||||
# 6c) forward-auth (Option B): an exposed forward-auth app gets an oauth2-proxy sidecar that
|
# 6c) forward-auth (Option B): an exposed forward-auth app gets an oauth2-proxy sidecar that
|
||||||
# fronts the Gateway path (fail-closed), while oidc/native/none apps are unchanged.
|
# fronts the Gateway path (fail-closed), while oidc/native/none apps are unchanged.
|
||||||
fa_docs = render(
|
fa_docs = render(
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ decisions-log: ## handoff log: DECISIONS-LOG.md frozen, decisions/ entries well-
|
|||||||
l1: ## L1 rendering checks (no cluster; live now)
|
l1: ## L1 rendering checks (no cluster; live now)
|
||||||
@"$(CI_DIR)l1.sh"
|
@"$(CI_DIR)l1.sh"
|
||||||
|
|
||||||
|
approval-policy: ## CI is the approver; sensitive paths still need a human (needs PR_NUMBER + token)
|
||||||
|
@./approval-policy.sh
|
||||||
|
|
||||||
gitea-settings: ## Gitea branch protection + runners match infra/gitea/expected-branch-protection.json (SKIPS w/o GITEA_SETTINGS_TOKEN)
|
gitea-settings: ## Gitea branch protection + runners match infra/gitea/expected-branch-protection.json (SKIPS w/o GITEA_SETTINGS_TOKEN)
|
||||||
@./gitea-settings.sh
|
@./gitea-settings.sh
|
||||||
|
|
||||||
|
|||||||
Executable
+117
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ci/approval-policy.sh — the CI/CD pipeline is the approver, EXCEPT for changes that alter
|
||||||
|
# what the pipeline verifies or the security posture it rests on.
|
||||||
|
#
|
||||||
|
# THE POLICY, and why it is shaped this way.
|
||||||
|
#
|
||||||
|
# Operator intent (2026-09-29): "CI/CD pipeline is the end-all-be-all for approval. If the CI
|
||||||
|
# pipeline is not good enough to be trusted for this, then it is not good enough to do its
|
||||||
|
# intended job and needs to be improved. Manual approvers should only be for architecturally or
|
||||||
|
# security relevant changes -- which is essentially the CI pipeline itself and kyverno policies."
|
||||||
|
#
|
||||||
|
# So `required_approvals` on the branch drops to 0 and the GATE decides. That is a real transfer
|
||||||
|
# of authority, and it has exactly one hole: **a change to the gate cannot be gated by the gate.**
|
||||||
|
# A PR that weakens a check would be approved by the weakened check. The same applies to the
|
||||||
|
# admission policies, the Flux wiring that decides what reconciles, the SOPS recipients, and the
|
||||||
|
# RBAC. Those are the "architecturally or security relevant" set, and this stage is what still
|
||||||
|
# requires a human for them.
|
||||||
|
#
|
||||||
|
# The list is data, not code: infra/gitea/approval-policy.json, with a stated reason per entry so
|
||||||
|
# it stays reviewable. Changing that list is itself a sensitive change (infra/gitea/**), so the
|
||||||
|
# policy cannot be widened without a human.
|
||||||
|
#
|
||||||
|
# FAIL-CLOSED, DELIBERATELY DIFFERENT FROM gitea-settings.sh. That stage skips when its token is
|
||||||
|
# missing, because an unwired credential blocking every PR is a lock-out. Here the reverse is
|
||||||
|
# true: if this cannot evaluate, an unreviewed change to the gate itself could merge. A skip is a
|
||||||
|
# BYPASS, not a gap. So: cannot determine -> BLOCK.
|
||||||
|
#
|
||||||
|
# EXIT CODES
|
||||||
|
# 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and a
|
||||||
|
# non-author APPROVED review is present
|
||||||
|
# 1 sensitive paths touched without such a review, OR the policy could not be evaluated
|
||||||
|
set -euo pipefail
|
||||||
|
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "${CI_DIR}/lib.sh"
|
||||||
|
|
||||||
|
log "Stage: approval policy (CI approves; humans approve changes to CI)"
|
||||||
|
|
||||||
|
DECL="${REPO_ROOT}/infra/gitea/approval-policy.json"
|
||||||
|
[ -f "$DECL" ] || die "approval policy declaration missing: ${DECL}"
|
||||||
|
require_tool jq
|
||||||
|
require_tool python3
|
||||||
|
|
||||||
|
# Not a PR -> nothing to gate. A push to main is already merged, and direct pushes are refused
|
||||||
|
# by branch protection, so this is genuinely not-applicable rather than a skipped check.
|
||||||
|
EVENT="${GITHUB_EVENT_NAME:-${EVENT_NAME:-}}"
|
||||||
|
PR="${PR_NUMBER:-}"
|
||||||
|
if [ "$EVENT" != "pull_request" ] || [ -z "$PR" ] || [ "$PR" = "null" ]; then
|
||||||
|
ok "not a pull_request event (event='${EVENT:-none}') — approval policy N/A"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
GITEA_URL="${GITEA_URL:-https://gitea.home.keithjackson.dev}"
|
||||||
|
GITEA_REPO="${GITEA_REPO:-Home-Network/home-network-k3s-impl}"
|
||||||
|
TOKEN="${SETTINGS_API_TOKEN:-${GITEA_SETTINGS_TOKEN:-}}"
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
err "no SETTINGS_API_TOKEN — cannot read this PR's changed files or its reviews."
|
||||||
|
err " BLOCKING. A skip here would let an unreviewed change to the gate itself merge."
|
||||||
|
summary "- approval-policy: **BLOCKED — cannot evaluate** (no token; a skip here is a bypass)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
api() { curl -sS --max-time 20 -H "Authorization: token ${TOKEN}" "$@"; }
|
||||||
|
|
||||||
|
FILES_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/files?limit=1000" || true)"
|
||||||
|
PR_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}" || true)"
|
||||||
|
REVIEWS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/reviews" || true)"
|
||||||
|
for v in FILES_JSON PR_JSON REVIEWS_JSON; do
|
||||||
|
jq -e . >/dev/null 2>&1 <<<"${!v}" || {
|
||||||
|
err "could not read ${v} from Gitea for PR #${PR} — BLOCKING rather than assuming."
|
||||||
|
summary "- approval-policy: **BLOCKED — Gitea unreadable**"
|
||||||
|
exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
AUTHOR="$(jq -r '.user.login // empty' <<<"$PR_JSON")"
|
||||||
|
[ -n "$AUTHOR" ] || { err "could not determine PR author — BLOCKING"; exit 1; }
|
||||||
|
|
||||||
|
# NOTE: the PR file list goes in via an ENV VAR, not a second stdin redirect. `python3 - <<EOF`
|
||||||
|
# already consumes stdin for the script; adding `<<<"$json"` silently wins and the script body is
|
||||||
|
# never read. Caught before shipping, by running it.
|
||||||
|
SENSITIVE="$(FILES_JSON="$FILES_JSON" DECL="$DECL" python3 -c '
|
||||||
|
import json, os, fnmatch
|
||||||
|
decl = json.load(open(os.environ["DECL"]))
|
||||||
|
files = [f.get("filename", "") for f in json.loads(os.environ["FILES_JSON"])]
|
||||||
|
pats = decl["sensitive_paths"]
|
||||||
|
hits = sorted({f for f in files for p in pats
|
||||||
|
if fnmatch.fnmatch(f, p) or (p.endswith("/**") and f.startswith(p[:-2]))})
|
||||||
|
print("\n".join(hits))
|
||||||
|
')"
|
||||||
|
|
||||||
|
if [ -z "$SENSITIVE" ]; then
|
||||||
|
ok "no architecturally/security-relevant paths touched — the GATE is the approver."
|
||||||
|
summary "- approval-policy: **green** (CI approves; no sensitive paths touched)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " sensitive paths touched:" >&2
|
||||||
|
printf ' %s\n' $SENSITIVE >&2
|
||||||
|
|
||||||
|
APPROVER="$(jq -r --arg a "$AUTHOR" \
|
||||||
|
'[.[] | select(.state == "APPROVED") | select(.user.login != $a) | .user.login] | first // empty' \
|
||||||
|
<<<"$REVIEWS_JSON")"
|
||||||
|
|
||||||
|
if [ -n "$APPROVER" ]; then
|
||||||
|
ok "approved by '${APPROVER}' (author '${AUTHOR}') — sensitive change is human-reviewed."
|
||||||
|
summary "- approval-policy: **green** (sensitive paths approved by \`${APPROVER}\`)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
err "This PR changes the pipeline or the security posture it rests on, and has NO approving"
|
||||||
|
err " review from anyone other than its author ('${AUTHOR}')."
|
||||||
|
err " A change to the gate cannot be gated by the gate — that is the one thing CI cannot"
|
||||||
|
err " honestly approve for itself. Get a human review, or move the change out of:"
|
||||||
|
printf ' %s\n' $SENSITIVE >&2
|
||||||
|
err " The list lives in infra/gitea/approval-policy.json, with a reason per entry."
|
||||||
|
summary "- approval-policy: **BLOCKED — sensitive paths need a non-author approval**"
|
||||||
|
exit 1
|
||||||
+15
@@ -113,6 +113,7 @@ run_stage lint "${CI_DIR}/lint.sh"
|
|||||||
run_stage l0 "${CI_DIR}/l0.sh"
|
run_stage l0 "${CI_DIR}/l0.sh"
|
||||||
run_stage render-golden "${CI_DIR}/render-golden.sh"
|
run_stage render-golden "${CI_DIR}/render-golden.sh"
|
||||||
run_stage l1 "${CI_DIR}/l1.sh"
|
run_stage l1 "${CI_DIR}/l1.sh"
|
||||||
|
run_stage approval-policy "${CI_DIR}/approval-policy.sh"
|
||||||
run_stage gitea-settings "${CI_DIR}/gitea-settings.sh"
|
run_stage gitea-settings "${CI_DIR}/gitea-settings.sh"
|
||||||
run_stage conformance "${CI_DIR}/conformance.sh"
|
run_stage conformance "${CI_DIR}/conformance.sh"
|
||||||
|
|
||||||
@@ -190,6 +191,20 @@ case "${OUTCOME[spec-immutability]}" in
|
|||||||
pass) ok "spec-immutability: green" ;;
|
pass) ok "spec-immutability: green" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# approval-policy: the branch requires NO human approvals (required_approvals: 0) -- the GATE
|
||||||
|
# is the approver, per operator intent. This stage is the single exception: a change to the
|
||||||
|
# pipeline itself, the admission policies, the Flux wiring, the SOPS recipients or the RBAC
|
||||||
|
# cannot honestly be approved BY the thing it changes, so those paths still need a human.
|
||||||
|
#
|
||||||
|
# It never SKIPS -- it exits 0 or 1 only, and blocks when it cannot evaluate. That is the
|
||||||
|
# opposite of gitea-settings' tolerance and it is deliberate: there, a missing credential is a
|
||||||
|
# lock-out; here, a missing credential would let an unreviewed change to the gate merge.
|
||||||
|
case "${OUTCOME[approval-policy]}" in
|
||||||
|
fail) err "approval-policy: a sensitive change lacks a non-author approval -> blocks merge"; BLOCK=1 ;;
|
||||||
|
skip) err "approval-policy: did not run -- it is fail-closed by design, so treat as a block"; BLOCK=1 ;;
|
||||||
|
pass) ok "approval-policy: green" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# gitea-settings: the ONLY check that can see Gitea's own database (branch protection +
|
# gitea-settings: the ONLY check that can see Gitea's own database (branch protection +
|
||||||
# runner registration). A FAIL is real drift and always blocks -- that is the whole point.
|
# runner registration). A FAIL is real drift and always blocks -- that is the whole point.
|
||||||
# A SKIP means no token or unreachable Gitea, which is an UNWIRED CREDENTIAL, not a defect
|
# A SKIP means no token or unreachable Gitea, which is an UNWIRED CREDENTIAL, not a defect
|
||||||
|
|||||||
@@ -237,6 +237,31 @@ SCRAPE_OWNERS: list[dict] = [
|
|||||||
"backed up (VeleroScheduleNeverBackedUp / SchedulePaused / NoVolumeDataBackup); "
|
"backed up (VeleroScheduleNeverBackedUp / SchedulePaused / NoVolumeDataBackup); "
|
||||||
"Velero itself publishes nothing until a backup is attempted",
|
"Velero itself publishes nothing until a backup is attempted",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"owner": "kube-prometheus-stack: kube-state-metrics custom-resource-state for Flux revisions",
|
||||||
|
"prefixes": ("kube_flux_",),
|
||||||
|
"file": KPS,
|
||||||
|
"require": [
|
||||||
|
r"customResourceState:\s*\n\s*enabled:\s*true",
|
||||||
|
r"metricNamePrefix:\s*kube_flux_kustomization",
|
||||||
|
r"metricNamePrefix:\s*kube_flux_gitrepository",
|
||||||
|
# The RBAC is MANDATORY, not cosmetic — the same measured failure mode as velero.io
|
||||||
|
# above: without read access to these two API groups KSM publishes the
|
||||||
|
# custom-resource-state metrics as an EMPTY SET and logs a forbidden error, which would
|
||||||
|
# leave FluxRolloutInProgress/FluxRolloutStuck inert while looking configured.
|
||||||
|
r'apiGroups:\s*\["kustomize\.toolkit\.fluxcd\.io"\]',
|
||||||
|
r'apiGroups:\s*\["source\.toolkit\.fluxcd\.io"\]',
|
||||||
|
# A config-only edit does not restart KSM (the chart sets no checksum annotation over
|
||||||
|
# the ConfigMap), so the pod annotation that forces the roll is part of the claim: drop
|
||||||
|
# it and a future change to the flux collector set ships green and never takes effect.
|
||||||
|
r"homelab\.keithjackson\.dev/crs-config-rollout",
|
||||||
|
],
|
||||||
|
"forbid": [r"customResourceState:\s*\n\s*enabled:\s*false"],
|
||||||
|
"why": "kube_flux_* is the ONLY revision-carrying signal in the TSDB — gotk_* has no "
|
||||||
|
"revision label anywhere (measured 2026-09-29) and the Flux event stream emits "
|
||||||
|
"ReconciliationSucceeded on every interval tick whether or not anything changed, so "
|
||||||
|
"without these metrics nothing can tell a deploy starting from an idle cluster",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"owner": "kube-prometheus-stack: bundled kube-state-metrics",
|
"owner": "kube-prometheus-stack: bundled kube-state-metrics",
|
||||||
"prefixes": ("kube_",),
|
"prefixes": ("kube_",),
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
---
|
||||||
|
date: 2026-09-29
|
||||||
|
branch: feat/flux-deploy-notifications
|
||||||
|
title: Flux deploy notifications — the operator now gets mail when a git change starts rolling out and when it completes
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
The operator was getting **no notification when Flux began applying a git change, and none when it
|
||||||
|
finished**. Root cause, measured rather than inferred: `notification-controller` v1.4.0 is running
|
||||||
|
(`1/1` ready) and the `Provider`/`Alert`/`Receiver` CRDs are installed and serving `v1beta3`, but
|
||||||
|
there are **zero `Provider` and zero `Alert` objects in the entire cluster** (`kubectl get
|
||||||
|
providers,alerts -A` → `No resources found`). Nothing in git ever created any. The five `gotk_*`
|
||||||
|
rules in `platform/monitoring/alerts.yaml` only cover *failures* (`FluxReconciliationFailure`,
|
||||||
|
`FluxResourceSuspended`) — nothing covered the healthy path.
|
||||||
|
|
||||||
|
**The obvious fix does not work, and this is the substance of the change.** Neither Flux event can be
|
||||||
|
filtered into a deploy signal:
|
||||||
|
|
||||||
|
- `ReconciliationSucceeded` is a **heartbeat, not a change signal**. 16 Kustomizations, 15 on
|
||||||
|
`interval: 10m` → **101 such events in one hour**, with the message `Reconciliation finished in
|
||||||
|
324.161192ms, next run in 10m0s` — byte-identical in shape whether the apply changed anything or
|
||||||
|
not. An `Alert` at `eventSeverity: info` would have mailed ~100 times an hour.
|
||||||
|
- `Progressing` is worse. Five Kustomizations re-apply the same object on **every** tick — event
|
||||||
|
counts **2505 / 2504 / 2501 / 2495 / 2490** (`apps-vms` → `NetworkPolicy/unifi/default-deny`,
|
||||||
|
`platform` → `CustomResourceDefinition/cdis.cdi.kubevirt.io`, `apps-wave-0/1/2` →
|
||||||
|
`NetworkPolicy/{gitea,nextcloud,seerr}/default-deny`). Anything keyed on `Progressing` would mail
|
||||||
|
five times every ten minutes, forever.
|
||||||
|
- `gotk_*` carries **no revision label anywhere** — `gotk_reconcile_condition` 120 series,
|
||||||
|
`gotk_suspend_status` 40, not one with a revision.
|
||||||
|
|
||||||
|
The applied **revision** is the only signal that moves when and only when git moves, and it lives in
|
||||||
|
object status alone. So:
|
||||||
|
|
||||||
|
1. **`platform/monitoring/kube-prometheus-stack.yaml`** — two resources added to the *existing*
|
||||||
|
`kube-state-metrics.customResourceState` config (the Velero-Schedule path, already enabled):
|
||||||
|
`kube_flux_kustomization_info{revision}` + `kube_flux_kustomization_suspended` (from
|
||||||
|
`status.lastAppliedRevision` / `spec.suspend`) and `kube_flux_gitrepository_info{revision}` (from
|
||||||
|
`status.artifact.revision`). Plus the two mandatory read-only `rbac.extraRules` entries, and a
|
||||||
|
`podAnnotations` roll-counter — the chart sets **no checksum annotation** over that ConfigMap, so
|
||||||
|
a config-only edit would have left KSM running the old collector set with the new metrics absent
|
||||||
|
and the new alerts silently inert. Named `kube_flux_*`, never `gotk_*`, for the same reason
|
||||||
|
`kube_velero_schedule_*` is not `velero_*`: these are KSM series about Kubernetes objects.
|
||||||
|
Cost: 33 series.
|
||||||
|
2. **`platform/monitoring/alerts.yaml`**, group `platform-health.flux` — `FluxRolloutInProgress`
|
||||||
|
(`severity: info`, **no `for:`**) fires the moment any non-suspended Kustomization's applied
|
||||||
|
revision is not the revision the GitRepository fetched from `main`, and **clears when all have
|
||||||
|
caught up**. Fired mail = "a deploy started"; **resolved mail = "it completed"** — the completion
|
||||||
|
half is delivered by `send_resolved`, not by a second rule. `FluxRolloutStuck` (`severity:
|
||||||
|
warning`, `for: 30m`) is the same expression as the escalation for a rollout that started and
|
||||||
|
never converged.
|
||||||
|
3. **Alertmanager** — a dedicated `severity = "info"` route and `email-deploy` receiver, subject
|
||||||
|
`[FLUX] applying|applied — …`. Without it a routine deploy would have arrived subject-lined
|
||||||
|
`[WARNING]` via the catch-all receiver and become indistinguishable from a real fault.
|
||||||
|
`group_by: [alertname]` collapses all 16 Kustomizations into one mail per rollout;
|
||||||
|
`group_interval: 2m` so completion is not held 4m behind the event.
|
||||||
|
4. **`platform/monitoring/tests/flux-rollout.test.yaml`** — 6 promtool scenarios, both directions.
|
||||||
|
5. **`runbooks/flux-rollout.md`** (+ its `configMapGenerator` entry) and a `kube_flux_` entry in
|
||||||
|
`conformance/tests/l0-static/alert-metrics-have-scrape-targets/check.sh` whose `require` patterns
|
||||||
|
pin `customResourceState`, both `metricNamePrefix`es, both `apiGroups` and the roll-counter — so
|
||||||
|
deleting any of them fails the gate instead of silently re-inerting the alerts.
|
||||||
|
|
||||||
|
Two guards in the expression, each with a test whose only job is to fail if that clause is removed:
|
||||||
|
`and on () (count(kube_flux_gitrepository_info…) > 0)` (without it, a missing exporter makes
|
||||||
|
`unless on (revision) <empty>` remove nothing and **all 16 Kustomizations alert forever** — the
|
||||||
|
permanently-firing mode this repo shipped once already), and `unless on (namespace, name)
|
||||||
|
(kube_flux_kustomization_suspended == 1)` (a suspended Kustomization pins an old revision by design
|
||||||
|
and would hold the rollout open, so the "applied" mail would never arrive).
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Measured against live prod (`--context k3s-prod`) **before** writing anything, and quoted here as
|
||||||
|
measured:
|
||||||
|
|
||||||
|
- `notification-controller` `1/1`, image `ghcr.io/fluxcd/notification-controller:v1.4.0`;
|
||||||
|
`alerts`/`providers`/`receivers.notification.toolkit.fluxcd.io` all serve `v1beta3` (storage);
|
||||||
|
`get providers,alerts -A` → **`No resources found`**. This is the defect.
|
||||||
|
- Event counts above, from `kubectl get events -A -o json` filtered to `toolkit.fluxcd.io`
|
||||||
|
involvedObjects: 101 `Kustomization/ReconciliationSucceeded`, 10 `Progressing`, 15
|
||||||
|
`DependencyNotReady`, 4 `GitRepository/GitOperationSucceeded`, 1 `NewArtifact`.
|
||||||
|
- The `gotk_*` family **is** in the TSDB now (`flux-podmonitor` works): 6 targets up in job
|
||||||
|
`flux-system/flux-system`, `gotk_reconcile_condition` 120 series, `gotk_suspend_status` 40
|
||||||
|
(`{kind="Kustomization"}` = 16) — and none carries a revision.
|
||||||
|
- `customResourceState` already works end to end: `kube_velero_schedule_{created,paused,
|
||||||
|
protects_volume_data,info}` = 2 series each.
|
||||||
|
- Both CRDs serve **`v1` as the storage version**; `GitRepository/flux-system`
|
||||||
|
`.status.artifact.revision` = `main@sha1:b93594b7c85aeec4d28e008b239d61d181e1e6de`, `spec.interval`
|
||||||
|
`1m`; all 16 Kustomizations report that same `lastAppliedRevision`; **none** sets `spec.suspend`
|
||||||
|
(which is why `nilIsZero: true` is mandatory).
|
||||||
|
- Reachability for the follow-up below: `flux-system/allow-egress` is `egress: [{}]` (allow-all) and
|
||||||
|
the `monitoring` namespace has **no NetworkPolicy at all**, so nothing blocks flux-system →
|
||||||
|
`kube-prometheus-stack-alertmanager` (`10.43.60.242:9093`).
|
||||||
|
- `promtool test rules` on the new suite: **SUCCESS**, 53 rules parsed, promtool 3.14.0 from the
|
||||||
|
pinned toolbox. All 6 scenarios pass, including both guard tests and the convergence test that
|
||||||
|
proves the alert empties itself (the completion mail).
|
||||||
|
- Full gate in the pinned toolbox: `lint`, `l0`, `render-golden`, `l1`, `decisions-log`,
|
||||||
|
`spec-immutability` all **green** — `l0` includes `alert-metrics-have-scrape-targets`,
|
||||||
|
`alert-rules-unit-tested` and `alert-runbooks-resolvable`, i.e. the new owner entry, the new tests
|
||||||
|
and the new runbook all cleared their own checks. Coverage 51/51.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **No mail has been sent.** Nothing here has run on the cluster: the metrics do not exist yet, the
|
||||||
|
rules are not loaded, and the `email-deploy` receiver has never delivered. The end-to-end claim
|
||||||
|
("a merge produces an `[FLUX] applying` mail and then an `[FLUX] applied` mail") is **UNVERIFIED**
|
||||||
|
and can only be proven by merging this and watching the *next* commit. Agents cannot apply, and
|
||||||
|
CI has no cluster (`vars.CLUSTER_AVAILABLE` unset → L2/L3 did not run).
|
||||||
|
- **The KSM custom-resource-state paths are asserted, not observed.** I verified the CRD storage
|
||||||
|
versions and that each status field is populated on the live objects, but I have not seen KSM emit
|
||||||
|
`kube_flux_*`. If a path is wrong KSM emits the metric with the label **omitted**, `revision!=""`
|
||||||
|
then drops the series, and the alerts go quiet rather than wrong — the safe direction, but the
|
||||||
|
first thing to check after merge:
|
||||||
|
`count by (__name__) ({__name__=~"kube_flux.*"})` must return **three** names.
|
||||||
|
- **KSM memory was not re-measured.** Limit is 384Mi against a working set of ~111–123Mi; this adds
|
||||||
|
two small informers (16 + 1 objects). 91 OOMKills followed the *last* CRS addition at a 128Mi
|
||||||
|
limit, so re-measure `max_over_time(container_memory_working_set_bytes{container="kube-state-metrics"}[6h])`
|
||||||
|
after this settles rather than assuming the headroom holds.
|
||||||
|
- **Alertmanager's `.Status` subject template is untested.** `[FLUX] applying|applied` relies on
|
||||||
|
`.Status` being `firing`/`resolved` in the notification template data. Wrong, and the mail still
|
||||||
|
arrives with a poorer subject — cosmetic, but unproven.
|
||||||
|
- **The `notification-controller` `Provider`/`Alert` half is designed but NOT shipped.** It was cut
|
||||||
|
deliberately: its unique value over the rules above is the human-readable **commit subject**
|
||||||
|
(`GitRepository`/`NewArtifact`, count 1 per commit, message `stored artifact for commit '<subject>'`,
|
||||||
|
revision in `source.toolkit.fluxcd.io/revision` metadata), and notification-controller has **no
|
||||||
|
SMTP provider**, so it must post into Alertmanager via `type: alertmanager` at
|
||||||
|
`http://kube-prometheus-stack-alertmanager.monitoring.svc:9093/api/v2/alerts`. I could not
|
||||||
|
establish **what label set that provider actually emits** (whether it sets `alertname`, and whether
|
||||||
|
`severity` is `info`) without applying it, and the `email-deploy` route matches on `severity`. So
|
||||||
|
the route design would have rested on a guess. Follow-up: apply it, read the resulting labels off
|
||||||
|
Alertmanager, then wire the route — and put it in its **own `wait: false` Flux Kustomization**
|
||||||
|
(never `platform` or `platform-config`, both `wait: true`), because a notification mechanism that
|
||||||
|
cannot go Ready must never be able to stall the delivery graph, which is the twice-shipped
|
||||||
|
cold-start deadlock this repo already documents.
|
||||||
|
- **The 2500-count drift loop is NOT fixed, and is not mine.** Five Kustomizations have been
|
||||||
|
re-applying the same object every 10 minutes for ~25 days: something outside git is mutating
|
||||||
|
`NetworkPolicy/{unifi,gitea,nextcloud,seerr}/default-deny` and
|
||||||
|
`CustomResourceDefinition/cdis.cdi.kubevirt.io` after Flux applies them. Cause **UNVERIFIED** —
|
||||||
|
I only measured the event counts. It is a real defect (constant write load on the API server and
|
||||||
|
etcd, on a cluster with a known etcd I/O problem on prod-1) and it deserves its own branch. The
|
||||||
|
alerts here are immune to it by construction: they read revisions, not events.
|
||||||
|
- **Branch protection / runner state re-measured? No.** The `gitea-settings` stage **SKIPPED**
|
||||||
|
(`SETTINGS_API_TOKEN` unset), so whether `main` is still protected and the gate still required is
|
||||||
|
UNVERIFIED by this run. The gate's overall verdict is therefore **rc 3, not rc 0** — every stage
|
||||||
|
that ran is green, and one stage did not run.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
date: 2026-09-30
|
||||||
|
branch: fix/flux-deploy-notification-inhibited
|
||||||
|
title: The deploy notification routed correctly and was then inhibited before sending — `severity: info` is a suppressed class in kube-prometheus-stack
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
`FluxRolloutInProgress` (shipped hours earlier in PR #25) fired correctly on the very first real
|
||||||
|
deploy and **its mail was suppressed before Alertmanager sent it**. One label changed:
|
||||||
|
|
||||||
|
- `platform/monitoring/alerts.yaml` — `FluxRolloutInProgress` now carries `notification: deploy` and
|
||||||
|
**no `severity` label at all**, where it previously carried `severity: info`.
|
||||||
|
- `platform/monitoring/kube-prometheus-stack.yaml` — the deploy route matches
|
||||||
|
`notification = "deploy"` instead of `severity = "info"`. Receiver, subject and grouping unchanged.
|
||||||
|
- `platform/monitoring/tests/flux-rollout.test.yaml` — the three `FluxRolloutInProgress` `exp_labels`
|
||||||
|
blocks updated. `FluxRolloutStuck` keeps `severity: warning` and is unaffected.
|
||||||
|
|
||||||
|
**The mechanism, measured rather than reasoned about.** kube-prometheus-stack's `general.rules`
|
||||||
|
ships:
|
||||||
|
|
||||||
|
InfoInhibitor:
|
||||||
|
group by (namespace) (ALERTS{severity="info"} == 1)
|
||||||
|
unless on (namespace) group by (namespace)
|
||||||
|
(ALERTS{alertname!="InfoInhibitor", alertstate="firing", severity=~"warning|critical"} == 1)
|
||||||
|
|
||||||
|
which fires for any namespace holding a firing `severity="info"` alert **and nothing else wrong in
|
||||||
|
it**. The chart's third `inhibit_rule` then drops exactly that alert:
|
||||||
|
|
||||||
|
source_matchers: [alertname="InfoInhibitor"]
|
||||||
|
target_matchers: [severity="info"]
|
||||||
|
equal: [namespace]
|
||||||
|
|
||||||
|
For a deploy notification this is the worst possible interaction, because the suppressed case is the
|
||||||
|
*only* case that matters: on a healthy rollout of a healthy cluster, `flux-system` holds no warning
|
||||||
|
or critical alert, so `InfoInhibitor` fires and the deploy mail is inhibited. It would have been
|
||||||
|
delivered only when something else in `flux-system` was already broken.
|
||||||
|
|
||||||
|
Upstream's intent is explicit and reasonable — `severity: info` means "only worth showing next to a
|
||||||
|
real problem". A deploy notice is not that, so it must not claim that severity. `notification` is a
|
||||||
|
label **no** `inhibit_rule` in the chart targets (all three match on severity values), and dropping
|
||||||
|
`severity` also stops `InfoInhibitor` firing for `flux-system` at all rather than merely working
|
||||||
|
around it. Both files carry the full reasoning inline, and `alerts.yaml`'s design header now names
|
||||||
|
the severity label as a third thing that must not regress — "do not tidy this by adding a severity"
|
||||||
|
— because the next reader's instinct will be to add one back.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Against live prod, after PR #25 merged as `64c72fd` and Flux converged:
|
||||||
|
|
||||||
|
- **The metrics work.** `kube_flux_kustomization_info` 16, `kube_flux_kustomization_suspended` 16,
|
||||||
|
`kube_flux_gitrepository_info` 1 = **33 series, exactly as designed**. The `revision` label IS
|
||||||
|
populated (`main@sha1:296d6d3…` / `…64c72fd…`), so the `status.lastAppliedRevision` /
|
||||||
|
`status.artifact.revision` paths are correct — previously UNVERIFIED.
|
||||||
|
- **The `podAnnotations` roll mechanism works.** The KSM pod carries
|
||||||
|
`homelab.keithjackson.dev/crs-config-rollout: 1` and is a new pod
|
||||||
|
(`…-7b56d98f46-56n8j`, replacing the 2026-09-19 one), so the config-only-change trap was
|
||||||
|
genuinely closed rather than merely documented.
|
||||||
|
- **The rule works, in both directions.** It fired for **12** lagging Kustomizations while 4 had
|
||||||
|
converged (`count by (revision)` → 12 old / 4 new), each alert carrying its own `name` and
|
||||||
|
`revision`; then the rollout converged (`count(count by (revision) (…))` → **1**) and the alert
|
||||||
|
**cleared**. Firing and self-clearing are both confirmed on real data, which is the completion
|
||||||
|
notification's whole mechanism.
|
||||||
|
- **The routing works.** `amtool config routes` shows
|
||||||
|
`{notification="deploy"} → receiver: email-deploy` under the default route, and
|
||||||
|
`amtool config routes test notification=deploy` returns `email-deploy`.
|
||||||
|
- **The inhibition was real.** `InfoInhibitor` went active `2026-09-30T04:58:48Z` — the same moment
|
||||||
|
the alert began firing — and `amtool alert query` showed **no** `FluxRolloutInProgress` in
|
||||||
|
Alertmanager, active or `--inhibited`. `alertmanager_notifications_failed_total{integration=email}`
|
||||||
|
is 0 for every reason, so nothing failed to send; it was never dispatched.
|
||||||
|
- promtool: **SUCCESS**, 53 rules, 6 scenarios, promtool 3.14.0 in the pinned toolbox.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **No deploy mail has been observed in the inbox — this fix is UNVERIFIED end to end.** The
|
||||||
|
suppression was proven (nothing in Alertmanager, zero send failures) and the routing was proven
|
||||||
|
(`amtool config routes test` → `email-deploy`), but the delivered message is not. It can only be
|
||||||
|
proven by merging this and watching the next commit produce `[FLUX] applying` and then
|
||||||
|
`[FLUX] applied`. Same structural gap as PR #25, one layer further along.
|
||||||
|
- **Whether the single email during the window was ours.**
|
||||||
|
`alertmanager_notifications_total{integration="email"}` went 226 → 227 while other alerts
|
||||||
|
(`Watchdog`, `CNPGPrimariesCoLocated`) were also active, and that counter carries no receiver
|
||||||
|
label, so the increment cannot be attributed. I am NOT claiming our mail was or was not sent — the
|
||||||
|
attribution is genuinely unavailable from this metric.
|
||||||
|
- **Whether `InfoInhibitor` interacts with `FluxRolloutStuck`.** Stuck is `severity: warning`;
|
||||||
|
inhibit_rule 1 (`critical` → `warning|info`) requires an equal `alertname`, so it should not
|
||||||
|
apply — reasoned, not measured. Worth confirming the first time a rollout genuinely hangs.
|
||||||
|
- **The `[FLUX] applying|applied` subject template** is still untested; `.Status` is assumed to be
|
||||||
|
`firing`/`resolved` in the notification template data.
|
||||||
|
- **`required_approvals` disagreement, unresolved and not mine to settle.** Live branch protection
|
||||||
|
reads `required_approvals: 1`, while the `ci/gate.sh` comment introduced by PR #17 describes the
|
||||||
|
model as `required_approvals: 0` ("the gate is the approver"). `ci/gitea-settings.sh` may report
|
||||||
|
this as drift against `infra/gitea/expected-branch-protection.json`. Flagged only.
|
||||||
|
- **The drift loop is still unexplained.** Five Kustomizations re-applying the same object every
|
||||||
|
10 minutes (event counts ~2500) — untouched, cause unknown, still deserves its own branch.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
date: 2026-09-30
|
||||||
|
branch: feat/sd-hardware-capability
|
||||||
|
title: ServiceDefinition gains a `hardware` capability field — `gpu: intel-quicksync` renders the /dev/dri wiring, and the enum is what keeps the mechanism swappable
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
A new optional top-level ServiceDefinition property, `hardware`, with one optional member,
|
||||||
|
`gpu`, whose enum today has exactly one value: `intel-quicksync`. **No catalog entry uses it.**
|
||||||
|
This PR delivers the capability; consuming it (Plex) is separate work on top.
|
||||||
|
|
||||||
|
- `schemas/servicedefinition.schema.json` — adds `hardware` (object, `additionalProperties: false`,
|
||||||
|
`minProperties: 1`) with `gpu` (enum `["intel-quicksync"]`). Schema version bumped **1.7.0 →
|
||||||
|
1.8.0**, changelog entry prepended to the schema `description` per that file's own convention.
|
||||||
|
- `catalog/render.py` — three module-level constants (`DRI_VOLUME_NAME`, `DRI_DEVICE_DIR`,
|
||||||
|
`INTEL_RENDER_GID = 992`) and the emission in `r_deployment`.
|
||||||
|
- `schemas/renderer-contract.md` — new `### hardware` field-mapping section, plus cross-field
|
||||||
|
invariant **8** in that file's numbered list.
|
||||||
|
- `schemas/README.md` — its `**Schema version:**` line said `1.0.0`; it had been stale since 1.1.0.
|
||||||
|
Synced to 1.8.0 and annotated that the schema `description` is the changelog of record. Added one
|
||||||
|
bullet to "Cross-field rules" for the new enum. That was the whole of the staleness; nothing
|
||||||
|
ballooned.
|
||||||
|
|
||||||
|
**What the renderer emits** for `hardware.gpu: intel-quicksync`, on the app container / pod spec
|
||||||
|
only (never a sidecar):
|
||||||
|
|
||||||
|
1. a hostPath volume `dri` → `/dev/dri`, `type: DirectoryOrCreate`;
|
||||||
|
2. a read-write `volumeMount` of `dri` at `/dev/dri` on the app container (deliberately not
|
||||||
|
`readOnly` — VA-API opens the render node read-write);
|
||||||
|
3. `INTEL_RENDER_GID` **merged into** `podSecurityContext.supplementalGroups`, never clobbering
|
||||||
|
what the SD already declares.
|
||||||
|
|
||||||
|
It emits **no** `privileged: true` (Kyverno `restrict-host-namespaces` is Enforce on app namespaces
|
||||||
|
and it is not needed), **no** `nodeSelector` (placement is a separate concern with its own
|
||||||
|
justification) and **no** resource requests (there is no app-container resource render path).
|
||||||
|
|
||||||
|
**The design decision worth defending: the field is an enum of intent, not a device path.** The SD
|
||||||
|
says *what class of host device the workload needs*; the hostPath, the mount and the GID exist only
|
||||||
|
in `catalog/render.py`. Migrating to the Intel GPU device plugin (`gpu.intel.com/i915` resource
|
||||||
|
requests, no hostPath at all) is then a renderer change with zero catalog edits. Both the schema
|
||||||
|
`description` and the contract section say this explicitly, because the obvious "simplification" —
|
||||||
|
letting an SD name `/dev/dri` — would push a platform implementation detail into the operator-facing
|
||||||
|
contract and make that migration a catalog-wide edit.
|
||||||
|
|
||||||
|
**`DirectoryOrCreate` is load-bearing.** A node that comes back without the render device (rebuild,
|
||||||
|
BIOS change, driver regression, hardware swap) must degrade the workload to software transcoding,
|
||||||
|
not fail the mount: `apps-wave-0` is `wait: true` and `apps-wave-1` `dependsOn` it, so a failed
|
||||||
|
hostPath mount stalls the entire app tier for the sake of a feature that is not load-bearing.
|
||||||
|
`/dev` is tmpfs, so the empty directory the kubelet creates on such a node leaves nothing durable
|
||||||
|
behind. Do not "tighten" it to `Directory`. The reasoning is recorded at the emission site.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Measured, in this worktree, on this branch:
|
||||||
|
|
||||||
|
- **`python3 catalog/render.py --selftest` → `render.py selftest: PASS`, rc 0.** Three new cases
|
||||||
|
were added in the existing `check(...)` style: the positive (volume + mount + GID, and *no*
|
||||||
|
privileged/nodeSelector), the negative (an SD without `hardware` gains no volume, no volumeMount
|
||||||
|
and no `securityContext`), and the merge case (`supplementalGroups: [44]` + the field →
|
||||||
|
`[44, 992]`, with `fsGroup` preserved).
|
||||||
|
- **The self-tests actually bite.** I mutated the guard (`if hw_gpu == "intel-quicksync":` → `if
|
||||||
|
True:`) in a scratch copy and re-ran: rc 1 with three named failures, including "an SD without
|
||||||
|
`hardware` must gain no dri volume, no mount and no securityContext". A test that cannot fail is
|
||||||
|
not a test; this one can.
|
||||||
|
- **Re-render is a zero diff.**
|
||||||
|
`python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation` then
|
||||||
|
`git diff --stat apps/` → **empty**, and `git status --porcelain apps/` → **empty** (so the two
|
||||||
|
not-onboarded by-products, `apps/wave-1/keycloak.yaml` and `apps/wave-0/vm-demo.yaml`, were
|
||||||
|
rewritten byte-identically and needed no revert). The change is inert until a service opts in.
|
||||||
|
- **Schema validity and catalog conformance.** `Draft202012Validator.check_schema` passes; all
|
||||||
|
**12** `catalog/*.yaml` validate with **0** errors. On the new field specifically: `gpu:
|
||||||
|
intel-quicksync` accepted; `gpu: /dev/dri` rejected (enum); `hardware: {gpu: …, path: …}` rejected
|
||||||
|
(`additionalProperties: false`); `hardware: {}` rejected (`minProperties: 1`).
|
||||||
|
- **Hand-checked the emitted YAML** by rendering a throwaway SD carrying `hardware`, a Longhorn
|
||||||
|
volume and its own `podSecurityContext: {runAsUser: 1000, supplementalGroups: [44]}`. Result:
|
||||||
|
`volumeMounts` = config then `{name: dri, mountPath: /dev/dri}`; `securityContext` =
|
||||||
|
`{runAsUser: 1000, supplementalGroups: [44, 992]}`; `volumes` = the PVC then
|
||||||
|
`{name: dri, hostPath: {path: /dev/dri, type: DirectoryOrCreate}}`. That toy SD was never written
|
||||||
|
into `catalog/`.
|
||||||
|
- **GATE: `rc 3` — UNDETERMINED, NOT green.** Run in the pinned toolbox from this worktree with
|
||||||
|
both CLAUDE.md §3 workarounds (`DEV_KUBECONFIG=/dev/null`, and an `ENGINE` shim bind-mounting the
|
||||||
|
main checkout's `.git` at its own absolute path). Diff base `origin/main @ 0fbf544d5e2f` … `HEAD @
|
||||||
|
9f0db1495cfd`; touched requirements `R-DNS-03 R-GITOPS-02 R-IDP-02`. Per stage:
|
||||||
|
|
||||||
|
| stage | result |
|
||||||
|
|---|---|
|
||||||
|
| spec-immutability | **SKIPPED** — sibling spec repo not at `/work/../home-network-k3s`. Expected from a worktree; tolerated by §3. |
|
||||||
|
| decisions-log | pass |
|
||||||
|
| lint | pass |
|
||||||
|
| l0 | pass |
|
||||||
|
| render-golden | pass — *"all 10 onboarded service golden(s) reproduce from the canonical render"* |
|
||||||
|
| l1 | pass — 3 offline checks, `0 failure(s), 0 skip(s), 0 unwired`; coverage 51/51 requirements scaffolded |
|
||||||
|
| approval-policy | pass (`event='none'`, not a pull_request event — N/A locally; it will evaluate for real on the PR) |
|
||||||
|
| gitea-settings | **SKIPPED** — `SETTINGS_API_TOKEN` unset. Expected; branch protection and runner registration are therefore UNVERIFIED by this run. |
|
||||||
|
| conformance (L2/L3) | **SKIPPED** — `CLUSTER_AVAILABLE!=true`. Phase-0 standing skip. |
|
||||||
|
|
||||||
|
The gate's own verdict: *"Stages that did not run: spec-immutability gitea-settings"*. **No stage
|
||||||
|
failed, and no stage skipped for a reason caused by this change.** rc 3 is reported as rc 3; I am
|
||||||
|
not calling this green.
|
||||||
|
- **Blast radius of the schema change:** `hardware` is absent from every catalog entry, so
|
||||||
|
`apply_defaults` never materializes it and `s.get("hardware")` is `None` on all 12 — which is what
|
||||||
|
the zero diff above demonstrates rather than asserts.
|
||||||
|
- **Touched no sensitive path.** Changes are confined to `schemas/`, `catalog/`, `decisions/`; per
|
||||||
|
`infra/gitea/approval-policy.json` none of those are sensitive. `conformance/**` and `clusters/**`
|
||||||
|
are untouched. I did not run `--emit-waves` (it writes `clusters/base/`, and the set of files
|
||||||
|
under `apps/` did not change).
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **Whether a non-privileged pod can actually `open()` this hostPath char device at all. This is
|
||||||
|
the largest technical risk in the workstream and nobody has tested it.** `supplementalGroups` gets
|
||||||
|
the DAC permission bits right, but containerd installs a **device cgroup** allow-list per
|
||||||
|
container, and whether `/dev/dri/renderD128` reaching the container as a bind-mounted device node
|
||||||
|
is also permitted by that cgroup on this k3s/containerd version is **unmeasured**. If it is not,
|
||||||
|
the mount will succeed, the group will be present, and the `open()` will still fail `EPERM` — and
|
||||||
|
the design would need the device plugin (which sets the cgroup entry) rather than a hostPath. The
|
||||||
|
first real consumer proves or disproves this; nothing in this PR does.
|
||||||
|
- **That a missing device degrades silently — and nothing will tell you.** This is the accepted cost
|
||||||
|
of `DirectoryOrCreate`. The workload will software-transcode and **no signal reports it**: media
|
||||||
|
apps in this catalog declare no `metricsEndpoint`, so no ServiceMonitor exists, and there is no
|
||||||
|
metric to alert on. I deliberately did **not** propose an alert, because an alert that cannot fire
|
||||||
|
is worse than none — see the 2026-09-13 correction in `renderer-contract.md` §`observability`,
|
||||||
|
where twelve monitors discovered zero targets. The only proof of hardware transcode is an operator
|
||||||
|
seeing it in the application's own dashboard.
|
||||||
|
- **Whether GID 992 survives a node rebuild.** It was measured on all three prod nodes on
|
||||||
|
2026-09-30 and was identical on each, but `render` is a *systemd dynamic* group allocation, not a
|
||||||
|
Debian/LSB static GID. A reinstall that creates system groups in a different order can move it,
|
||||||
|
and nothing in this repo would notice. Recorded in the constant's comment; re-measure `getent
|
||||||
|
group render` on every node after any rebuild.
|
||||||
|
- **Whether `supplementalGroups` is the load-bearing mechanism or belt-and-braces.** A concurrent
|
||||||
|
agent was checking whether the pms-docker image's `45-plex-hw-transcode-and-connected-tuner` init
|
||||||
|
hook resolves the render group dynamically at startup. **No finding reached me**, so the constant's
|
||||||
|
comment describes the GID and its provenance without claiming which mechanism does the work.
|
||||||
|
Update that comment if the answer arrives.
|
||||||
|
- **Anything on the live cluster.** I ran no `kubectl`. The node facts above (device present,
|
||||||
|
`root:render` 0660, GID 992, `i915` bound, no device plugin, no `gpu.intel.com/*` advertised) and
|
||||||
|
the Kyverno posture (`restrict-host-namespaces` Enforce; no hostPath or `supplementalGroups`
|
||||||
|
restriction; no PSA labels on app namespaces) are **prior recon handed to me, not re-measured by
|
||||||
|
me**. Per CLAUDE.md §2b, treat them as second-hand until re-queried.
|
||||||
|
- **That CI exercises the self-tests. It does not.** `git grep -- --selftest ci/` returns nothing —
|
||||||
|
no gate stage invokes the renderer's self-test. They are the executable spec and house style, and
|
||||||
|
I ran them by hand, but a future regression in this code path **will not be caught by the gate**.
|
||||||
|
Wiring `--selftest` into a gate stage is a real gap and is not part of this PR (`ci/` is a
|
||||||
|
sensitive path and would drag in a human reviewer for a change unrelated to this one).
|
||||||
|
- **Runtime behaviour of any kind.** The gate proves the L0/L1 offline subset only;
|
||||||
|
`vars.CLUSTER_AVAILABLE` is unset, so L2/L3 conformance does not run in CI.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"_intent": "The CI/CD pipeline is the approver. A human approval is required ONLY for changes that alter what the pipeline itself verifies, or the security posture it rests on -- because those are the changes CI cannot honestly gate: a PR that weakens the gate would be approved by the weakened gate.",
|
||||||
|
"_rule": "If a PR touches any sensitive_paths glob, it needs an APPROVED review from someone who is not its author. Everything else merges on a green gate alone.",
|
||||||
|
"_on_failure_to_evaluate": "BLOCK. Unlike gitea-settings, a skip here is a bypass, not a gap.",
|
||||||
|
"sensitive_paths": [
|
||||||
|
"ci/**",
|
||||||
|
".gitea/**",
|
||||||
|
"conformance/**",
|
||||||
|
"clusters/**",
|
||||||
|
"bootstrap/**",
|
||||||
|
"infra/gitea/**",
|
||||||
|
"platform/kyverno/**",
|
||||||
|
"platform-config/kustomization.yaml",
|
||||||
|
".sops.yaml",
|
||||||
|
"**/*.sops.yaml",
|
||||||
|
"**/rbac.yaml",
|
||||||
|
"CLAUDE.md"
|
||||||
|
],
|
||||||
|
"_why_each": {
|
||||||
|
"ci/**, .gitea/**": "the pipeline itself -- the thing doing the approving",
|
||||||
|
"conformance/**": "the tests and the harness; changing them changes what green MEANS",
|
||||||
|
"clusters/**, bootstrap/**": "Flux wiring -- what reconciles, in what order, with what substitution",
|
||||||
|
"infra/gitea/**, platform-config/kustomization.yaml": "branch protection, the drift detector, and the list that decides which admission policies are actually applied",
|
||||||
|
"platform/kyverno/**": "the admission policy engine",
|
||||||
|
".sops.yaml, **/*.sops.yaml, **/rbac.yaml": "encryption recipients, secrets, and cluster authority",
|
||||||
|
"CLAUDE.md": "the delivery contract agents follow"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -799,6 +799,123 @@ spec:
|
|||||||
summary: "Flux resource suspended: {{ $labels.kind }}/{{ $labels.name }}"
|
summary: "Flux resource suspended: {{ $labels.kind }}/{{ $labels.name }}"
|
||||||
description: "{{ $labels.kind }} {{ $labels.namespace }}/{{ $labels.name }} has been suspended for 1h — reconciliation/drift-correction is off."
|
description: "{{ $labels.kind }} {{ $labels.namespace }}/{{ $labels.name }} has been suspended for 1h — reconciliation/drift-correction is off."
|
||||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-suspended"
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-suspended"
|
||||||
|
# ===================================================================================
|
||||||
|
# DEPLOY NOTIFICATIONS (added 2026-09-29). The only rules in this repo whose PURPOSE is to
|
||||||
|
# send mail on the HEALTHY path: the operator asked to be told when Flux starts applying a
|
||||||
|
# git change and when it finishes. Read the two together — they share one expression and
|
||||||
|
# differ only in `for` and severity.
|
||||||
|
#
|
||||||
|
# WHY A RULE AND NOT A FLUX Alert/Provider. Neither Flux event can be filtered into a
|
||||||
|
# deploy signal. Measured on prod 2026-09-29: a Kustomization emits ReconciliationSucceeded
|
||||||
|
# on EVERY interval tick (101 events in one hour across 16 Kustomizations) with the message
|
||||||
|
# "Reconciliation finished in 324ms, next run in 10m0s" — byte-identical whether the apply
|
||||||
|
# changed anything or not; and `Progressing` is worse, because five Kustomizations re-apply
|
||||||
|
# the same object on every tick (event counts 2505/2504/2501/2495/2490), so anything keyed
|
||||||
|
# on it mails five times every ten minutes forever. The applied REVISION is the only signal
|
||||||
|
# that moves when and only when git moves. See the kube_flux_* block in
|
||||||
|
# kube-prometheus-stack.yaml for how it reaches the TSDB.
|
||||||
|
#
|
||||||
|
# THE EXPRESSION. `unless on (revision)` keeps a Kustomization only while its applied
|
||||||
|
# revision is absent from the set of revisions the GitRepository has fetched — i.e. while it
|
||||||
|
# is behind main. It empties itself as each Kustomization catches up, which is what makes
|
||||||
|
# the RESOLVED notification mean "the rollout completed" rather than "the alert timed out".
|
||||||
|
# Alertmanager's email-deploy receiver carries send_resolved: true for exactly that reason,
|
||||||
|
# and group_by: [alertname] collapses all 16 Kustomizations into one mail per rollout.
|
||||||
|
#
|
||||||
|
# TWO GUARDS, BOTH LOAD-BEARING, BOTH UNIT-TESTED (tests/flux-rollout.test.yaml):
|
||||||
|
# * `and on () (count(...) > 0)` — if kube_flux_gitrepository_info is ABSENT (KSM rolled
|
||||||
|
# without the ConfigMap, the flux RBAC lost, customResourceState reverted) then
|
||||||
|
# `unless on (revision) <empty>` removes NOTHING and all 16 Kustomizations alert
|
||||||
|
# forever, for no reason. That is the permanently-firing failure mode this repo already
|
||||||
|
# shipped once (CNPG, 2026-09-12). The guard makes the missing-exporter case SILENT,
|
||||||
|
# which is the correct direction for a notification — a broken exporter is what
|
||||||
|
# FluxReconciliationFailure and the platform-health.monitoring-self rules are for.
|
||||||
|
# * `unless on (namespace, name) (kube_flux_kustomization_suspended == 1)` — a suspended
|
||||||
|
# Kustomization pins an old revision BY DESIGN, and would otherwise hold a rollout open
|
||||||
|
# for as long as it stays suspended, so the resolved mail (the "it finished" half of the
|
||||||
|
# feature) would never arrive. FluxResourceSuspended above is what reports that state.
|
||||||
|
#
|
||||||
|
# `revision!=""` on both sides: KSM omits a label whose path is absent, so a Kustomization
|
||||||
|
# that has never applied anything — or a GitRepository with no artifact yet — publishes the
|
||||||
|
# series with no `revision` label. Without the matcher those group together under the empty
|
||||||
|
# string and compare EQUAL, i.e. a never-applied Kustomization would read as converged.
|
||||||
|
#
|
||||||
|
# A THIRD THING THAT MUST NOT REGRESS — THE SEVERITY LABEL. These alerts are delivered
|
||||||
|
# because of the labels they do NOT carry as much as the ones they do. Do not "tidy" this
|
||||||
|
# pair by giving FluxRolloutInProgress a `severity`, and never `severity: info`: the chart's
|
||||||
|
# InfoInhibitor rule plus its `severity="info"` inhibit_rule suppress an info alert whenever
|
||||||
|
# it is the only thing firing in its namespace, which is every healthy deploy. Measured on
|
||||||
|
# prod 2026-09-30 — the alert fired correctly for 12 Kustomizations, resolved correctly, and
|
||||||
|
# was inhibited in between. The full reasoning is on the `notification: deploy` label below.
|
||||||
|
# ===================================================================================
|
||||||
|
- alert: FluxRolloutInProgress
|
||||||
|
expr: |
|
||||||
|
(
|
||||||
|
(
|
||||||
|
kube_flux_kustomization_info{revision!=""}
|
||||||
|
unless on (namespace, name) (kube_flux_kustomization_suspended == 1)
|
||||||
|
)
|
||||||
|
unless on (revision) kube_flux_gitrepository_info{name="flux-system", revision!=""}
|
||||||
|
)
|
||||||
|
and on () (count(kube_flux_gitrepository_info{name="flux-system", revision!=""}) > 0)
|
||||||
|
# NO `for:`, ON PURPOSE. Every other rule in this file waits, because every other rule
|
||||||
|
# reports a fault and a fault that clears in 30s is noise. This one reports an EVENT the
|
||||||
|
# operator asked to be told about, so the failure mode to avoid is the opposite: a `for:`
|
||||||
|
# of even 1m would silently swallow any rollout that converged faster than that (a manual
|
||||||
|
# `flux reconcile`, a change touching one Kustomization), and a deploy notification that
|
||||||
|
# only sometimes arrives is worse than no notification at all.
|
||||||
|
labels:
|
||||||
|
# ROUTED BY `notification`, AND DELIBERATELY CARRYING NO `severity` (fixed 2026-09-30).
|
||||||
|
# This alert originally used `severity: info`. That was WRONG, and wrong in the silent
|
||||||
|
# direction — measured on prod within minutes of the first real deploy.
|
||||||
|
# kube-prometheus-stack ships `general.rules` -> InfoInhibitor:
|
||||||
|
# group by (namespace) (ALERTS{severity="info"} == 1)
|
||||||
|
# unless on (namespace) group by (namespace)
|
||||||
|
# (ALERTS{alertname!="InfoInhibitor", alertstate="firing", severity=~"warning|critical"} == 1)
|
||||||
|
# which fires for any namespace that has a firing info alert AND NOTHING ELSE WRONG in
|
||||||
|
# it; paired with the chart's inhibit_rule
|
||||||
|
# source_matchers: [alertname="InfoInhibitor"]
|
||||||
|
# target_matchers: [severity="info"]
|
||||||
|
# equal: [namespace]
|
||||||
|
# the effect is that an info alert is suppressed EXACTLY WHEN it is the only thing
|
||||||
|
# happening in its namespace. For a deploy notification that is the only case that
|
||||||
|
# matters: on a healthy rollout of a healthy cluster, flux-system has no warning or
|
||||||
|
# critical alert, so InfoInhibitor fired and this alert never reached the receiver.
|
||||||
|
# Verified live: InfoInhibitor went active 2026-09-30T04:58:48Z, the same moment this
|
||||||
|
# alert began firing for 12 Kustomizations.
|
||||||
|
# Upstream's intent is explicit — `severity: info` means "only worth showing next to a
|
||||||
|
# real problem". A deploy notice is not that, so it must not claim that severity.
|
||||||
|
# `notification` is a label no inhibit_rule in the chart targets (all three match on
|
||||||
|
# severity values), and omitting `severity` also stops InfoInhibitor firing for
|
||||||
|
# flux-system at all. Route: platform/monitoring/kube-prometheus-stack.yaml matches
|
||||||
|
# `notification = "deploy"` -> the email-deploy receiver, subject "[FLUX] applying|applied".
|
||||||
|
notification: deploy
|
||||||
|
annotations:
|
||||||
|
summary: "Flux is applying a new revision — {{ $labels.name }} is behind main"
|
||||||
|
description: "Kustomization {{ $labels.namespace }}/{{ $labels.name }} has applied {{ $labels.revision }}, which is not the revision the flux-system GitRepository has fetched from main. THIS MAIL MEANS A GIT CHANGE STARTED ROLLING OUT; the RESOLVED mail for this same alert means every Kustomization reached the new revision and the rollout is complete."
|
||||||
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
|
||||||
|
# The escalation: a rollout that STARTED and never finished. Same expression, so it cannot
|
||||||
|
# disagree with the notification above about what "behind main" means; 30m is comfortably
|
||||||
|
# past the slowest legitimate convergence (the longest Kustomization interval is 1h for
|
||||||
|
# cilium, but its dependents are 10m and a wave chain converges well inside 30m).
|
||||||
|
# severity: warning, so this one takes the normal email-warning path and reads as a fault.
|
||||||
|
- alert: FluxRolloutStuck
|
||||||
|
expr: |
|
||||||
|
(
|
||||||
|
(
|
||||||
|
kube_flux_kustomization_info{revision!=""}
|
||||||
|
unless on (namespace, name) (kube_flux_kustomization_suspended == 1)
|
||||||
|
)
|
||||||
|
unless on (revision) kube_flux_gitrepository_info{name="flux-system", revision!=""}
|
||||||
|
)
|
||||||
|
and on () (count(kube_flux_gitrepository_info{name="flux-system", revision!=""}) > 0)
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Flux rollout has not completed: {{ $labels.name }} stuck on an old revision"
|
||||||
|
description: "Kustomization {{ $labels.namespace }}/{{ $labels.name }} has been behind the flux-system GitRepository revision for 30m (applied {{ $labels.revision }}) — a git change started rolling out and has not converged. Check the Kustomization's Ready condition and its dependencies."
|
||||||
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
|
||||||
|
|
||||||
# =====================================================================================
|
# =====================================================================================
|
||||||
# Certificates — cert-manager
|
# Certificates — cert-manager
|
||||||
|
|||||||
@@ -131,6 +131,44 @@ spec:
|
|||||||
# page-worthy — notify fast, remind often
|
# page-worthy — notify fast, remind often
|
||||||
group_wait: 10s
|
group_wait: 10s
|
||||||
repeat_interval: 1h
|
repeat_interval: 1h
|
||||||
|
# DEPLOY NOTICES, not health alerts (added 2026-09-29). FluxRolloutInProgress fires
|
||||||
|
# the moment the applied revision diverges from main and RESOLVES when every
|
||||||
|
# Kustomization has caught up — so the FIRED mail is "a deploy started" and the
|
||||||
|
# RESOLVED mail is "it finished". Both halves are the product, which is why
|
||||||
|
# send_resolved must stay true on the receiver below.
|
||||||
|
#
|
||||||
|
# A DEDICATED ROUTE, because the default receiver is `email-warning`: without it a
|
||||||
|
# routine deploy arrives subject-lined "[WARNING]" and becomes indistinguishable from a
|
||||||
|
# real platform fault, which is how alert mail stops being read at all.
|
||||||
|
#
|
||||||
|
# MATCHES `notification`, NOT `severity` (corrected 2026-09-30). This route first read
|
||||||
|
# `severity = "info"`, which routed correctly and then had the mail SUPPRESSED before it
|
||||||
|
# was sent: the chart's own InfoInhibitor rule fires for any namespace holding a firing
|
||||||
|
# info alert with no warning/critical beside it, and its inhibit_rule
|
||||||
|
# (source alertname="InfoInhibitor" -> target severity="info", equal [namespace]) drops
|
||||||
|
# exactly that alert. A healthy deploy in a healthy flux-system is precisely that case,
|
||||||
|
# so the notification was inhibited on every occasion it was wanted. Measured live on
|
||||||
|
# prod 2026-09-30: InfoInhibitor went active at 04:58:48Z as the alert began firing.
|
||||||
|
# Routing on a non-severity label sidesteps all three of the chart's inhibit_rules,
|
||||||
|
# which match on severity values only. See the label's own comment in alerts.yaml.
|
||||||
|
- matchers: ['notification = "deploy"']
|
||||||
|
receiver: email-deploy
|
||||||
|
# One mail per ROLLOUT, not per Kustomization: the alert fires once per lagging
|
||||||
|
# Kustomization (16 possible), and grouping on alertname alone collapses them into a
|
||||||
|
# single mail that lists all of them. The default group_by would have split them by
|
||||||
|
# namespace as well — harmless today (all 16 are in flux-system) and wrong the moment
|
||||||
|
# a Kustomization lives elsewhere.
|
||||||
|
group_by: ["alertname"]
|
||||||
|
group_wait: 30s
|
||||||
|
# 2m, not the 5m default: group_interval also bounds how long a group's RESOLVED
|
||||||
|
# notification waits, and holding "the deploy finished" four minutes behind the
|
||||||
|
# deploy finishing makes the completion half of this feature feel broken.
|
||||||
|
group_interval: 2m
|
||||||
|
# A deploy notice is a statement about one revision, so re-sending it on the 12h
|
||||||
|
# default would just repeat old news. 24h is long enough never to fire twice for one
|
||||||
|
# rollout, while still re-nagging if a rollout is genuinely stuck for a whole day
|
||||||
|
# (FluxRolloutStuck, severity warning, is the real escalation for that case).
|
||||||
|
repeat_interval: 24h
|
||||||
receivers:
|
receivers:
|
||||||
- name: email-warning
|
- name: email-warning
|
||||||
email_configs:
|
email_configs:
|
||||||
@@ -144,6 +182,19 @@ spec:
|
|||||||
send_resolved: true
|
send_resolved: true
|
||||||
headers:
|
headers:
|
||||||
subject: '[CRITICAL] {{ .CommonLabels.alertname }} ({{ .CommonLabels.namespace }})'
|
subject: '[CRITICAL] {{ .CommonLabels.alertname }} ({{ .CommonLabels.namespace }})'
|
||||||
|
# Flux deploy notices (the `severity = "info"` route above).
|
||||||
|
- name: email-deploy
|
||||||
|
email_configs:
|
||||||
|
- to: "[email protected]" # alert inbox (operator: change if a different inbox is wanted)
|
||||||
|
# BOTH transitions are the deliverable here: firing = "Flux started applying a new
|
||||||
|
# revision", resolved = "every Kustomization reached it". Turning this off would
|
||||||
|
# delete the completion notification, not merely reduce noise.
|
||||||
|
send_resolved: true
|
||||||
|
headers:
|
||||||
|
# `[FLUX] applying` / `[FLUX] applied` — readable in a notification preview
|
||||||
|
# without opening the mail, and never the word WARNING for a healthy deploy.
|
||||||
|
# .Status is "firing" or "resolved" in Alertmanager's notification template data.
|
||||||
|
subject: '[FLUX] {{ if eq .Status "firing" }}applying{{ else }}applied{{ end }} — {{ .CommonLabels.alertname }}'
|
||||||
|
|
||||||
# --- Grafana: dashboards (R-OBS-03), surfaced in the C2 UI (docs/12) behind Keycloak SSO ---
|
# --- Grafana: dashboards (R-OBS-03), surfaced in the C2 UI (docs/12) behind Keycloak SSO ---
|
||||||
grafana:
|
grafana:
|
||||||
@@ -272,11 +323,33 @@ spec:
|
|||||||
# `config` alone does not restart the pod: after editing it, `kubectl -n monitoring rollout
|
# `config` alone does not restart the pod: after editing it, `kubectl -n monitoring rollout
|
||||||
# restart deploy/kube-prometheus-stack-kube-state-metrics`. Enabling it the first time
|
# restart deploy/kube-prometheus-stack-kube-state-metrics`. Enabling it the first time
|
||||||
# changes the pod spec (new arg + volume), so the initial rollout is automatic.
|
# changes the pod spec (new arg + volume), so the initial rollout is automatic.
|
||||||
|
# FORCE A POD ROLL WHEN `customResourceState.config` BELOW CHANGES. The chart renders that
|
||||||
|
# config into a ConfigMap and mounts it, but the KSM Deployment carries NO checksum annotation
|
||||||
|
# over it — documented in the block above, and the reason the Velero metrics needed a manual
|
||||||
|
# `rollout restart` when they were first tuned. So a PR that edits ONLY `config` changes the
|
||||||
|
# ConfigMap, leaves the pod spec byte-identical, and KSM keeps running the OLD collector set:
|
||||||
|
# the new metrics never appear and every alert reading them is inert, while the PR is merged
|
||||||
|
# and green. That is the exact shape of defect this repo keeps getting bitten by, so it is
|
||||||
|
# closed declaratively rather than by remembering an operator step. BUMP THIS VALUE IN THE
|
||||||
|
# SAME COMMIT as any `customResourceState.config` edit and Flux rolls the pod for you.
|
||||||
|
# (Same mechanism as grafana.podAnnotations/oidc-rollout above.)
|
||||||
|
podAnnotations:
|
||||||
|
homelab.keithjackson.dev/crs-config-rollout: "1"
|
||||||
rbac:
|
rbac:
|
||||||
extraRules:
|
extraRules:
|
||||||
- apiGroups: ["velero.io"]
|
- apiGroups: ["velero.io"]
|
||||||
resources: ["schedules"]
|
resources: ["schedules"]
|
||||||
verbs: ["get", "list", "watch"]
|
verbs: ["get", "list", "watch"]
|
||||||
|
# Flux applied-revision metrics (the kube_flux_* block at the end of `config` below).
|
||||||
|
# MANDATORY for the same measured reason as velero.io above: without read access KSM
|
||||||
|
# publishes the custom-resource-state metrics as an EMPTY SET and logs a forbidden
|
||||||
|
# error, which would leave FluxRolloutInProgress/FluxRolloutStuck inert — silently.
|
||||||
|
- apiGroups: ["kustomize.toolkit.fluxcd.io"]
|
||||||
|
resources: ["kustomizations"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["source.toolkit.fluxcd.io"]
|
||||||
|
resources: ["gitrepositories"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
customResourceState:
|
customResourceState:
|
||||||
enabled: true
|
enabled: true
|
||||||
config:
|
config:
|
||||||
@@ -328,6 +401,89 @@ spec:
|
|||||||
labelsFromPath:
|
labelsFromPath:
|
||||||
cron: [spec, schedule]
|
cron: [spec, schedule]
|
||||||
phase: [status, phase]
|
phase: [status, phase]
|
||||||
|
# --- FLUX APPLIED REVISION AS METRICS (added 2026-09-29) --------------------------
|
||||||
|
# WHY. Nothing in the TSDB could answer "is the cluster running the commit that is on
|
||||||
|
# main?", so "Flux started applying a git change" and "Flux is idle" were
|
||||||
|
# indistinguishable and a deploy could start, stall halfway and never finish with
|
||||||
|
# nothing to say so. Measured on prod 2026-09-29, all three candidate signals fail:
|
||||||
|
# * gotk_* (scraped by flux-podmonitor.yaml) carries NO revision label anywhere —
|
||||||
|
# gotk_reconcile_condition 120 series, gotk_suspend_status 40, not one with a
|
||||||
|
# revision. It can say a Kustomization is unhealthy, never which commit it holds.
|
||||||
|
# * the EVENT stream cannot be filtered into a deploy signal: a Kustomization emits
|
||||||
|
# ReconciliationSucceeded on EVERY interval tick — 101 events in one hour across
|
||||||
|
# 16 Kustomizations — with the message "Reconciliation finished in 324ms, next run
|
||||||
|
# in 10m0s", byte-identical whether the apply changed anything or not.
|
||||||
|
# * `Progressing` is worse, not better: five Kustomizations re-apply the same object
|
||||||
|
# on every single tick (2505/2504/2501/2495/2490 on one event object each), so
|
||||||
|
# anything keyed on it fires five times every ten minutes, forever.
|
||||||
|
# The applied REVISION is the one signal that moves only when git moves, and it lives
|
||||||
|
# in these two objects' status and nowhere else:
|
||||||
|
# GitRepository/flux-system .status.artifact.revision = what main is (poll 1m)
|
||||||
|
# Kustomization/* .status.lastAppliedRevision = what is applied
|
||||||
|
# Exporting both makes "applied != desired" a PromQL question, which is what
|
||||||
|
# FluxRolloutInProgress / FluxRolloutStuck ask (platform/monitoring/alerts.yaml).
|
||||||
|
#
|
||||||
|
# NAMING: kube_flux_*, NOT gotk_* — the same rule as kube_velero_schedule_* above.
|
||||||
|
# These are kube-state-metrics series ABOUT Kubernetes objects and must never be
|
||||||
|
# mistaken for the gotk_* family the Flux controllers themselves export.
|
||||||
|
#
|
||||||
|
# VERIFIED ON PROD before writing this (2026-09-29): both CRDs serve `v1` as the
|
||||||
|
# STORAGE version (kustomizations.kustomize.toolkit.fluxcd.io,
|
||||||
|
# gitrepositories.source.toolkit.fluxcd.io) and both status paths below are populated
|
||||||
|
# — artifact.revision=main@sha1:b93594b7…, and all 16 Kustomizations reporting that
|
||||||
|
# same revision as lastAppliedRevision.
|
||||||
|
#
|
||||||
|
# Cost: 2 series per Kustomization (16 today) + 1 for the GitRepository = 33.
|
||||||
|
- groupVersionKind:
|
||||||
|
group: kustomize.toolkit.fluxcd.io
|
||||||
|
version: v1
|
||||||
|
kind: Kustomization
|
||||||
|
metricNamePrefix: kube_flux_kustomization
|
||||||
|
labelsFromPath:
|
||||||
|
name: [metadata, name]
|
||||||
|
namespace: [metadata, namespace]
|
||||||
|
metrics:
|
||||||
|
- name: info
|
||||||
|
help: "Revision a Flux Kustomization has applied (status.lastAppliedRevision)."
|
||||||
|
each:
|
||||||
|
type: Info
|
||||||
|
info:
|
||||||
|
# KSM OMITS a label whose path is absent (the same behaviour noted for the
|
||||||
|
# Velero `phase` label above), so a Kustomization that has never applied
|
||||||
|
# anything publishes this series with NO `revision` label at all rather than
|
||||||
|
# an empty one. Every rule that reads it therefore matches revision!="" —
|
||||||
|
# otherwise those would collapse together under the empty string and a
|
||||||
|
# never-applied Kustomization would compare equal to a converged one.
|
||||||
|
labelsFromPath:
|
||||||
|
revision: [status, lastAppliedRevision]
|
||||||
|
- name: suspended
|
||||||
|
help: "1 if the Flux Kustomization is suspended (spec.suspend)."
|
||||||
|
each:
|
||||||
|
type: Gauge
|
||||||
|
gauge:
|
||||||
|
# nilIsZero is MANDATORY, exactly as for the Velero `paused` gauge above:
|
||||||
|
# measured 2026-09-29, NONE of the 16 Kustomizations sets spec.suspend at
|
||||||
|
# all. Without it this metric would not exist, and the `unless` that stops a
|
||||||
|
# suspended Kustomization holding a rollout notification open forever would
|
||||||
|
# silently match nothing.
|
||||||
|
path: [spec, suspend]
|
||||||
|
nilIsZero: true
|
||||||
|
- groupVersionKind:
|
||||||
|
group: source.toolkit.fluxcd.io
|
||||||
|
version: v1
|
||||||
|
kind: GitRepository
|
||||||
|
metricNamePrefix: kube_flux_gitrepository
|
||||||
|
labelsFromPath:
|
||||||
|
name: [metadata, name]
|
||||||
|
namespace: [metadata, namespace]
|
||||||
|
metrics:
|
||||||
|
- name: info
|
||||||
|
help: "Revision of the artifact a Flux GitRepository has fetched (what main is)."
|
||||||
|
each:
|
||||||
|
type: Info
|
||||||
|
info:
|
||||||
|
labelsFromPath:
|
||||||
|
revision: [status, artifact, revision]
|
||||||
# MEMORY RAISED 2026-09-13 after 91 OOMKills. Measured, not guessed: working set 111 MiB
|
# MEMORY RAISED 2026-09-13 after 91 OOMKills. Measured, not guessed: working set 111 MiB
|
||||||
# with a 6h peak of 123.3 MiB against the old 128Mi limit — KSM was dying on every spike,
|
# with a 6h peak of 123.3 MiB against the old 128Mi limit — KSM was dying on every spike,
|
||||||
# roughly every 11 minutes (exit 137). That matters beyond the restarts: THIRTEEN alert
|
# roughly every 11 minutes (exit 137). That matters beyond the restarts: THIRTEEN alert
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# promtool rule-unit tests for FluxRolloutInProgress / FluxRolloutStuck — the deploy-notification
|
||||||
|
# pair in the platform-health.flux group (platform/monitoring/alerts.yaml).
|
||||||
|
# Run with: platform/monitoring/tests/run.sh (the merge gate runs the same harness via
|
||||||
|
# conformance/tests/l0-static/alert-rules-unit-tested)
|
||||||
|
#
|
||||||
|
# WHY THESE TESTS CARRY MORE WEIGHT THAN USUAL. This pair is a NOTIFICATION, so both failure
|
||||||
|
# directions are silent in production and neither is visible on a healthy cluster:
|
||||||
|
# * fire when it should not -> a permanently-firing "deploy in progress" that mails on every
|
||||||
|
# repeat_interval and trains the operator to ignore Flux mail. Shipped once already in this
|
||||||
|
# repo (CNPG, 2026-09-12).
|
||||||
|
# * fail to fire -> no deploy notification at all, which is the exact complaint this pair was
|
||||||
|
# written to answer. Indistinguishable from "no deploys happened".
|
||||||
|
# So every clause is pinned in both directions here, and the two GUARDS in the expression (the
|
||||||
|
# missing-exporter guard and the suspended-Kustomization exclusion) each get a test whose ONLY
|
||||||
|
# job is to fail if that clause is deleted.
|
||||||
|
#
|
||||||
|
# MODELLING A REVISION CHANGE. `revision` is a LABEL, so a Kustomization moving from revision A to
|
||||||
|
# revision B is modelled as series A ending and series B beginning — not as one series changing
|
||||||
|
# value. Prometheus' 5m lookback then means series A is still resolvable for 5m after its last
|
||||||
|
# sample, so every eval_time below is placed at least 5m clear of a handoff. That is deliberate:
|
||||||
|
# it keeps these tests about the RULE and not about staleness semantics.
|
||||||
|
#
|
||||||
|
# Sample interval 1m; the longest `for` under test is 30m.
|
||||||
|
rule_files:
|
||||||
|
- generated.rules.yaml
|
||||||
|
|
||||||
|
evaluation_interval: 1m
|
||||||
|
|
||||||
|
tests:
|
||||||
|
# =============================================================================================
|
||||||
|
# 1. CONVERGED — the steady state this cluster is in almost all of the time.
|
||||||
|
# Both rules MUST be silent. This is the "not permanently firing" half of the proof, and it
|
||||||
|
# is the test that fails if `unless on (revision)` is mis-spelled: the left-hand side is
|
||||||
|
# non-empty here, and only the revision match suppresses it.
|
||||||
|
# =============================================================================================
|
||||||
|
- interval: 1m
|
||||||
|
name: converged (every Kustomization on the GitRepository revision) - silent
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:aaaaaaa",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="platform",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="platform",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x40'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x40'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 35m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 35m
|
||||||
|
alertname: FluxRolloutStuck
|
||||||
|
exp_alerts: []
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# 2. A COMMIT LANDS — the notification this whole change exists to produce.
|
||||||
|
# GitRepository moves to revision bbbbbbb at t=11m. apps-wave-0 catches up at t=14m;
|
||||||
|
# `platform` never does.
|
||||||
|
# t=25m -> FluxRolloutInProgress fires for `platform` ONLY (apps-wave-0 has converged),
|
||||||
|
# and FluxRolloutStuck is still quiet because its 30m has not elapsed.
|
||||||
|
# t=50m -> both fire. ("started and never finished" is a real, separate condition.)
|
||||||
|
# =============================================================================================
|
||||||
|
- interval: 1m
|
||||||
|
name: new revision on main - InProgress fires for the lagging Kustomization only
|
||||||
|
input_series:
|
||||||
|
# The old artifact revision ends at t=10m, the new one starts at t=11m.
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:aaaaaaa",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '1x10'
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:bbbbbbb",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '_x10 1x70'
|
||||||
|
# `platform` stays on the old revision for the whole test.
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="platform",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x80'
|
||||||
|
# apps-wave-0 catches up at t=14m.
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x8'
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",revision="main@sha1:bbbbbbb",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '_x13 1x67'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="platform",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x80'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x80'
|
||||||
|
alert_rule_test:
|
||||||
|
# The deploy notification, ~14m after the commit was fetched. Exactly ONE alert: a rule that
|
||||||
|
# ignored the revision match would also list apps-wave-0, which has already converged.
|
||||||
|
- eval_time: 25m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
notification: deploy
|
||||||
|
job: kube-state-metrics
|
||||||
|
namespace: flux-system
|
||||||
|
name: platform
|
||||||
|
revision: "main@sha1:aaaaaaa"
|
||||||
|
customresource_group: kustomize.toolkit.fluxcd.io
|
||||||
|
customresource_kind: Kustomization
|
||||||
|
customresource_version: v1
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Flux is applying a new revision — platform is behind main"
|
||||||
|
description: "Kustomization flux-system/platform has applied main@sha1:aaaaaaa, which is not the revision the flux-system GitRepository has fetched from main. THIS MAIL MEANS A GIT CHANGE STARTED ROLLING OUT; the RESOLVED mail for this same alert means every Kustomization reached the new revision and the rollout is complete."
|
||||||
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
|
||||||
|
# `for: 30m` on the escalation is real — not yet firing 14m in.
|
||||||
|
- eval_time: 25m
|
||||||
|
alertname: FluxRolloutStuck
|
||||||
|
exp_alerts: []
|
||||||
|
# ...and it does fire once the rollout has genuinely hung.
|
||||||
|
- eval_time: 50m
|
||||||
|
alertname: FluxRolloutStuck
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: warning
|
||||||
|
job: kube-state-metrics
|
||||||
|
namespace: flux-system
|
||||||
|
name: platform
|
||||||
|
revision: "main@sha1:aaaaaaa"
|
||||||
|
customresource_group: kustomize.toolkit.fluxcd.io
|
||||||
|
customresource_kind: Kustomization
|
||||||
|
customresource_version: v1
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Flux rollout has not completed: platform stuck on an old revision"
|
||||||
|
description: "Kustomization flux-system/platform has been behind the flux-system GitRepository revision for 30m (applied main@sha1:aaaaaaa) — a git change started rolling out and has not converged. Check the Kustomization's Ready condition and its dependencies."
|
||||||
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# 3. THE ROLLOUT COMPLETES -> the alert clears, which is what sends the "applied" mail.
|
||||||
|
# Without this test the completion half of the feature is unproven: an expression that fired
|
||||||
|
# correctly but never emptied itself would pass test 2 and still never deliver a resolved
|
||||||
|
# notification.
|
||||||
|
# =============================================================================================
|
||||||
|
- interval: 1m
|
||||||
|
name: rollout completes - InProgress clears (this is the resolved/completion notification)
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:aaaaaaa",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '1x10'
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:bbbbbbb",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '_x10 1x70'
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="platform",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x20'
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="platform",revision="main@sha1:bbbbbbb",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '_x23 1x57'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="platform",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x80'
|
||||||
|
alert_rule_test:
|
||||||
|
# Mid-rollout: firing.
|
||||||
|
- eval_time: 20m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
notification: deploy
|
||||||
|
job: kube-state-metrics
|
||||||
|
namespace: flux-system
|
||||||
|
name: platform
|
||||||
|
revision: "main@sha1:aaaaaaa"
|
||||||
|
customresource_group: kustomize.toolkit.fluxcd.io
|
||||||
|
customresource_kind: Kustomization
|
||||||
|
customresource_version: v1
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Flux is applying a new revision — platform is behind main"
|
||||||
|
description: "Kustomization flux-system/platform has applied main@sha1:aaaaaaa, which is not the revision the flux-system GitRepository has fetched from main. THIS MAIL MEANS A GIT CHANGE STARTED ROLLING OUT; the RESOLVED mail for this same alert means every Kustomization reached the new revision and the rollout is complete."
|
||||||
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
|
||||||
|
# Converged (5m clear of the old series' last sample at t=20m): silent, and it never had
|
||||||
|
# 30m of continuous firing, so the escalation must not have fired either.
|
||||||
|
- eval_time: 32m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 32m
|
||||||
|
alertname: FluxRolloutStuck
|
||||||
|
exp_alerts: []
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# 4. GUARD 1 — kube_flux_gitrepository_info ABSENT (KSM rolled without the ConfigMap, the flux
|
||||||
|
# RBAC was lost, customResourceState reverted). `unless on (revision) <empty set>` removes
|
||||||
|
# NOTHING, so without the `and on () (count(...) > 0)` guard EVERY Kustomization alerts
|
||||||
|
# forever. This test exists to go red the moment that guard is deleted.
|
||||||
|
# Correct behaviour is SILENCE: a broken exporter is FluxReconciliationFailure's job and the
|
||||||
|
# platform-health.monitoring-self rules', not a deploy notification's.
|
||||||
|
# =============================================================================================
|
||||||
|
- interval: 1m
|
||||||
|
name: GUARD - GitRepository metric absent - silent, NOT 16 alerts forever
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="platform",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x60'
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x60'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="platform",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x60'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x60'
|
||||||
|
promql_expr_test:
|
||||||
|
# Stated explicitly so the reason for the silence is unambiguous in the test output: the
|
||||||
|
# guard's own count is empty, not merely false.
|
||||||
|
- expr: 'count(kube_flux_gitrepository_info{name="flux-system", revision!=""})'
|
||||||
|
eval_time: 45m
|
||||||
|
exp_samples: []
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 45m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 45m
|
||||||
|
alertname: FluxRolloutStuck
|
||||||
|
exp_alerts: []
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# 5. GUARD 2 — a SUSPENDED Kustomization pins an old revision by design. It must not hold the
|
||||||
|
# rollout notification open (or the "applied" mail never arrives), while a non-suspended
|
||||||
|
# Kustomization on the same old revision MUST still alert.
|
||||||
|
# Both halves are asserted in one test on purpose: an `unless` written with the wrong labels
|
||||||
|
# would suppress EVERYTHING and would pass a suppression-only test.
|
||||||
|
# =============================================================================================
|
||||||
|
- interval: 1m
|
||||||
|
name: GUARD - suspended Kustomization excluded, non-suspended one still alerts
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:bbbbbbb",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
# Suspended and behind -> excluded.
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="platform",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="platform",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
# Not suspended and behind -> must alert.
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="conformance",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="conformance",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x40'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 20m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
notification: deploy
|
||||||
|
job: kube-state-metrics
|
||||||
|
namespace: flux-system
|
||||||
|
name: conformance
|
||||||
|
revision: "main@sha1:aaaaaaa"
|
||||||
|
customresource_group: kustomize.toolkit.fluxcd.io
|
||||||
|
customresource_kind: Kustomization
|
||||||
|
customresource_version: v1
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Flux is applying a new revision — conformance is behind main"
|
||||||
|
description: "Kustomization flux-system/conformance has applied main@sha1:aaaaaaa, which is not the revision the flux-system GitRepository has fetched from main. THIS MAIL MEANS A GIT CHANGE STARTED ROLLING OUT; the RESOLVED mail for this same alert means every Kustomization reached the new revision and the rollout is complete."
|
||||||
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# 6. A Kustomization that has NEVER applied anything publishes no `revision` label at all (KSM
|
||||||
|
# omits absent paths). `revision!=""` must drop it rather than let it group under the empty
|
||||||
|
# string and compare equal to the GitRepository revision.
|
||||||
|
# =============================================================================================
|
||||||
|
- interval: 1m
|
||||||
|
name: never-applied Kustomization (no revision label) is not counted as converged or lagging
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:bbbbbbb",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
# No `revision` label at all — this is what KSM publishes for status.lastAppliedRevision="".
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="brand-new",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="brand-new",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x40'
|
||||||
|
alert_rule_test:
|
||||||
|
# Silent: FluxReconciliationFailure (Ready=False for 15m) is what covers a Kustomization
|
||||||
|
# that cannot apply at all. This pair only ever speaks about revisions it can compare.
|
||||||
|
- eval_time: 30m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 30m
|
||||||
|
alertname: FluxRolloutStuck
|
||||||
|
exp_alerts: []
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# flux-rollout — a git change is rolling out, or has stopped halfway
|
||||||
|
|
||||||
|
**Alerts:** `FluxRolloutInProgress` (info, no `for:`) and `FluxRolloutStuck` (warning, 30m)
|
||||||
|
**File:** `platform/monitoring/alerts.yaml`, group `platform-health.flux`
|
||||||
|
**Metrics:** `kube_flux_kustomization_info` / `kube_flux_kustomization_suspended` /
|
||||||
|
`kube_flux_gitrepository_info` — kube-state-metrics custom-resource-state, configured in
|
||||||
|
`platform/monitoring/kube-prometheus-stack.yaml`
|
||||||
|
|
||||||
|
## What this means here
|
||||||
|
|
||||||
|
These two are the **deploy notification**, not a fault report. On this cluster merging a PR into
|
||||||
|
`main` IS the deploy, and until this pair existed nothing told you a deploy had begun: the Flux event
|
||||||
|
stream emits `ReconciliationSucceeded` on every 10-minute tick whether or not anything changed (101
|
||||||
|
such events in one hour, measured 2026-09-29), so "applying a commit" and "idle" looked identical.
|
||||||
|
|
||||||
|
The pair reads the one signal that moves only when git moves — the **applied revision**:
|
||||||
|
|
||||||
|
- `GitRepository/flux-system` `.status.artifact.revision` — what `main` is (polled every 1m)
|
||||||
|
- `Kustomization/*` `.status.lastAppliedRevision` — what each tier has actually applied
|
||||||
|
|
||||||
|
**`FluxRolloutInProgress` fires** the moment any non-suspended Kustomization's applied revision is
|
||||||
|
not the revision fetched from `main`, and **clears when every one of them has caught up**. So:
|
||||||
|
|
||||||
|
| Mail | Subject | Means |
|
||||||
|
|---|---|---|
|
||||||
|
| firing | `[FLUX] applying — FluxRolloutInProgress` | a commit started rolling out |
|
||||||
|
| resolved | `[FLUX] applied — FluxRolloutInProgress` | every Kustomization reached it; rollout complete |
|
||||||
|
|
||||||
|
Both are expected, routine, and not a problem. The `severity: info` label routes them to the
|
||||||
|
dedicated `email-deploy` Alertmanager receiver so they never arrive subject-lined `[WARNING]`.
|
||||||
|
|
||||||
|
**`FluxRolloutStuck` is the one that matters.** Same expression, `for: 30m`, `severity: warning`. It
|
||||||
|
means a deploy started and did not converge — the "applying" mail arrived and the "applied" mail
|
||||||
|
never did. The 14-tier chain is dependency-ordered and revision-gated, so a single stalled tier holds
|
||||||
|
every tier below it on "dependency not ready".
|
||||||
|
|
||||||
|
## First commands
|
||||||
|
|
||||||
|
`kubectl` does not work on the workstation — the toolchain lives only in the pinned container, and
|
||||||
|
the context must always be named explicitly.
|
||||||
|
|
||||||
|
Which tiers are behind, and on what:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c '
|
||||||
|
kubectl --context k3s-prod -n flux-system get gitrepository flux-system \
|
||||||
|
-o jsonpath="WANTED: {.status.artifact.revision}{\"\n\"}"
|
||||||
|
kubectl --context k3s-prod get kustomizations -A \
|
||||||
|
-o custom-columns="NAME:.metadata.name,APPLIED:.status.lastAppliedRevision,READY:.status.conditions[?(@.type==\"Ready\")].status"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Any row whose `APPLIED` differs from `WANTED` is a tier this alert is naming. Then ask **why** that
|
||||||
|
tier is not moving — its Ready condition carries the reason:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c '
|
||||||
|
kubectl --context k3s-prod -n flux-system get kustomization <name> \
|
||||||
|
-o jsonpath="{range .status.conditions[*]}{.type}={.status} {.reason}: {.message}{\"\n\"}{end}"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the alert is reading live data rather than a stale exporter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c '
|
||||||
|
POD=$(kubectl --context k3s-prod -n monitoring get pod -l app.kubernetes.io/name=prometheus -o name | head -1)
|
||||||
|
kubectl --context k3s-prod -n monitoring exec "$POD" -c prometheus -- \
|
||||||
|
/bin/promtool query instant http://localhost:9090 "count by (__name__) ({__name__=~\"kube_flux.*\"})"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Three names must come back (`kube_flux_kustomization_info`, `kube_flux_kustomization_suspended`,
|
||||||
|
`kube_flux_gitrepository_info`). If that query is **empty**, the alerts are inert — go to the last
|
||||||
|
cause below.
|
||||||
|
|
||||||
|
## Likely causes, most likely first
|
||||||
|
|
||||||
|
1. **Nothing is wrong — the rollout is simply in flight.** Most Kustomizations reconcile on a 10m
|
||||||
|
interval and the chain is sequential, so several minutes between the "applying" and "applied"
|
||||||
|
mails is normal. `cilium` is on a 1h interval and can legitimately be the last to converge.
|
||||||
|
2. **A gating tier is unhealthy, so everything below it is parked.** Look for `DependencyNotReady`
|
||||||
|
in the stuck tier's message and walk *up* the chain to the first tier that is not Ready; that one
|
||||||
|
is the real fault. Its own alert (`FluxReconciliationFailure`) and runbook `flux-reconcile` own it.
|
||||||
|
3. **A Kustomization is suspended.** A suspended tier is excluded from this alert by design (so it
|
||||||
|
cannot hold the "applied" mail open forever), but anything that `dependsOn` it still stalls and
|
||||||
|
*will* show here. `FluxResourceSuspended` / runbook `flux-suspended` covers it.
|
||||||
|
4. **A health check is timing out.** A `wait: true` tier only records `lastAppliedRevision` once its
|
||||||
|
objects pass health checks, so a workload that never goes Ready keeps the tier on the old
|
||||||
|
revision even though the manifests applied fine.
|
||||||
|
5. **kube-state-metrics is not publishing the `kube_flux_*` family.** Then the rules are inert and
|
||||||
|
you would get *no* mail at all rather than a stuck one — so this is the cause when the complaint
|
||||||
|
is silence. The metrics come from `customResourceState` plus a read-only RBAC rule for
|
||||||
|
`kustomize.toolkit.fluxcd.io` / `source.toolkit.fluxcd.io`; without that RBAC, KSM emits the set
|
||||||
|
**empty** and logs a forbidden error. Check its logs:
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c '
|
||||||
|
kubectl --context k3s-prod -n monitoring logs deploy/kube-prometheus-stack-kube-state-metrics \
|
||||||
|
--tail=50 | grep -i "forbidden\|custom.resource" || echo "no RBAC complaints"'
|
||||||
|
```
|
||||||
|
Note the chart puts **no checksum annotation** over that ConfigMap, so a change to the config
|
||||||
|
alone does not restart the pod — that is why `podAnnotations` carries a
|
||||||
|
`homelab.keithjackson.dev/crs-config-rollout` counter that must be bumped in the same commit.
|
||||||
|
|
||||||
|
## When to escalate or stop
|
||||||
|
|
||||||
|
- **`FluxRolloutInProgress` alone, cleared within ~15 minutes: do nothing.** That is the feature
|
||||||
|
working. Do not suspend, reconcile or apply anything in response to it.
|
||||||
|
- **`FluxRolloutStuck` firing: fix the upstream fault, not this symptom.** This alert names the
|
||||||
|
tier that is behind; it never names the cause. Walk up to the first non-Ready tier and use
|
||||||
|
`flux-reconcile`.
|
||||||
|
- **Never `kubectl apply`, `edit` or `patch` the change through by hand.** Flux reconciles `main`;
|
||||||
|
a hand-applied object is drift that the next reconcile reverts or, worse, adopts — and it makes
|
||||||
|
the applied-revision signal these alerts read a lie. `flux reconcile` (an operator action, never
|
||||||
|
an agent's) is the only nudge that is safe, and it only *hurries* the same pull.
|
||||||
|
- **Escalate to a revert if a stuck rollout is degrading service.** Reverting the offending commit
|
||||||
|
on `main` through the normal PR flow is the rollback path; see runbook `update-rollback`.
|
||||||
|
- **If both alerts are silent during a deploy you know happened,** the notification itself is broken,
|
||||||
|
not the deploy. That is cause 5 above, and it is worth fixing immediately — silence from these
|
||||||
|
rules is indistinguishable from "no deploys happened", which is the exact defect they were
|
||||||
|
written to close.
|
||||||
@@ -65,6 +65,7 @@ configMapGenerator:
|
|||||||
- crafty-admin-credential=crafty-admin-credential.md
|
- crafty-admin-credential=crafty-admin-credential.md
|
||||||
- denied-flows=denied-flows.md
|
- denied-flows=denied-flows.md
|
||||||
- flux-reconcile=flux-reconcile.md
|
- flux-reconcile=flux-reconcile.md
|
||||||
|
- flux-rollout=flux-rollout.md
|
||||||
- flux-suspended=flux-suspended.md
|
- flux-suspended=flux-suspended.md
|
||||||
- image-automation=image-automation.md
|
- image-automation=image-automation.md
|
||||||
- image-policy-stalled=image-policy-stalled.md
|
- image-policy-stalled=image-policy-stalled.md
|
||||||
|
|||||||
+4
-1
@@ -15,7 +15,7 @@ docs/13 exactly. Do not diverge from it here — escalate spec problems, don't f
|
|||||||
| `servicedefinition.schema.json` | Formal JSON Schema (draft 2020-12) for a ServiceDefinition. Enforces enums, required fields, patterns, and cross-field invariants. |
|
| `servicedefinition.schema.json` | Formal JSON Schema (draft 2020-12) for a ServiceDefinition. Enforces enums, required fields, patterns, and cross-field invariants. |
|
||||||
| `renderer-contract.md` | Authoritative field → rendered-resource mapping, with requirement IDs. The contract other workstreams consume. |
|
| `renderer-contract.md` | Authoritative field → rendered-resource mapping, with requirement IDs. The contract other workstreams consume. |
|
||||||
|
|
||||||
**Schema version:** 1.0.0 (in the schema `description`). Treat any change as an interface change:
|
**Schema version:** 1.8.0 (in the schema `description`, which carries the full per-version changelog — this line had said `1.0.0` since 1.1.0 and is now synced). Treat any change as an interface change:
|
||||||
propose on the task board, update dependent renderers/tests in the same change, never silently break a
|
propose on the task board, update dependent renderers/tests in the same change, never silently break a
|
||||||
consumer (coordination-protocol §2).
|
consumer (coordination-protocol §2).
|
||||||
|
|
||||||
@@ -35,6 +35,9 @@ consumer (coordination-protocol §2).
|
|||||||
- `ingressRoutes[].host` must match `<label>.home.keithjackson.dev`.
|
- `ingressRoutes[].host` must match `<label>.home.keithjackson.dev`.
|
||||||
- `storage[].class ∈ {longhorn, bulk}`; `exposure ∈ {internal, public}`; `egress ∈ {internet, vpn}`;
|
- `storage[].class ∈ {longhorn, bulk}`; `exposure ∈ {internal, public}`; `egress ∈ {internet, vpn}`;
|
||||||
`auth.mode ∈ {oidc, forward-auth, native, none}`.
|
`auth.mode ∈ {oidc, forward-auth, native, none}`.
|
||||||
|
- `hardware.gpu ∈ {intel-quicksync}` — a host **capability class**, never a device path. The enum
|
||||||
|
is the contract: the mechanism that satisfies it lives only in `catalog/render.py` (see
|
||||||
|
`renderer-contract.md` §`hardware`).
|
||||||
- `additionalProperties: false` everywhere — unknown keys are rejected (catches typos in review/CI).
|
- `additionalProperties: false` everywhere — unknown keys are rejected (catches typos in review/CI).
|
||||||
|
|
||||||
## Validate examples against the schema — L1, no cluster (R-TEST-02)
|
## Validate examples against the schema — L1, no cluster (R-TEST-02)
|
||||||
|
|||||||
@@ -160,6 +160,45 @@ platform must render, and cites the requirement each mapping satisfies. Changing
|
|||||||
- **Requirements:** **R-STATE-01** (rebuild survival), **R-STATE-02** (Longhorn replication),
|
- **Requirements:** **R-STATE-01** (rebuild survival), **R-STATE-02** (Longhorn replication),
|
||||||
**R-STATE-04** (bulk tier). (docs/04)
|
**R-STATE-04** (bulk tier). (docs/04)
|
||||||
|
|
||||||
|
### `hardware` (`gpu`) — host device capability
|
||||||
|
- **The field is INTENT, never MECHANISM.** `hardware.gpu` is an **enum of capability classes**
|
||||||
|
(today: `intel-quicksync`), not a device path, not a hostPath, not a resource request. The
|
||||||
|
ServiceDefinition says *what class of host device this workload needs*; how the platform hands it
|
||||||
|
over is `catalog/render.py`'s business and lives nowhere else. **Do not "simplify" this into a raw
|
||||||
|
`/dev/...` path.** The entire payoff is that migrating from the hostPath mechanism below to the
|
||||||
|
Intel GPU device plugin (`resources.limits: gpu.intel.com/i915`) is a renderer change with **zero**
|
||||||
|
catalog edits and zero re-review of per-service device wiring. Extend the enum for a genuinely
|
||||||
|
different capability class (e.g. `nvidia-nvenc`); never to name a second route to the same one.
|
||||||
|
- `gpu: intel-quicksync` → on the **app container / pod spec only** (never a sidecar):
|
||||||
|
1. a **hostPath volume** `dri` → `/dev/dri`, **`type: DirectoryOrCreate`**;
|
||||||
|
2. a **read-write volumeMount** of `dri` at `/dev/dri` on the app container (VA-API opens the
|
||||||
|
render node read-write, so it is deliberately not `readOnly`);
|
||||||
|
3. the host `render` **GID merged into `podSecurityContext.supplementalGroups`** — merged, never
|
||||||
|
clobbered, so an SD that declares its own supplemental groups keeps all of them and gains one.
|
||||||
|
`/dev/dri/renderD128` is `root:render` mode `0660`, so a non-root container needs the group.
|
||||||
|
The GID is the single constant `INTEL_RENDER_GID` in `catalog/render.py` (992, **measured
|
||||||
|
2026-09-30 on all three prod nodes**; it is a *systemd dynamic* allocation, not a static GID,
|
||||||
|
so a node rebuild can move it — re-measure `getent group render` before assuming).
|
||||||
|
- **`DirectoryOrCreate` is load-bearing — do not tighten it to `Directory`.** A node that comes back
|
||||||
|
without the render device (rebuild, BIOS change, driver regression, hardware swap) must degrade the
|
||||||
|
workload to **software transcoding**, not fail the mount. `apps-wave-0` is `wait: true` and
|
||||||
|
`apps-wave-1` `dependsOn` it, so a failed hostPath mount stalls the **whole app tier** for the sake
|
||||||
|
of a feature that is not load-bearing. `/dev` is tmpfs, so the empty directory the kubelet creates
|
||||||
|
on such a node leaves nothing durable behind.
|
||||||
|
- **The cost of that choice, stated plainly:** a missing device is **silent**. The workload
|
||||||
|
software-transcodes and nothing reports it. There is no metric to alert on — media apps here
|
||||||
|
render no `metricsEndpoint`, so no ServiceMonitor exists — and an alert that cannot fire is worse
|
||||||
|
than none (see §`observability`'s 2026-09-13 correction). The only proof of hardware transcode is
|
||||||
|
an operator observing it in the application's own dashboard.
|
||||||
|
- **Deliberately NOT rendered** from this field: no `privileged: true` (Kyverno
|
||||||
|
`restrict-host-namespaces` is Enforce on app namespaces and would reject it, and the render node
|
||||||
|
does not need it); no `nodeSelector` (placement is a separate concern with its own justification —
|
||||||
|
this field is about the device, not about where the pod lands); no resource requests/limits (there
|
||||||
|
is no app-container resource render path, and inventing one here would be scope creep).
|
||||||
|
- Absent ⇒ **nothing at all** is rendered; every service that does not opt in stays byte-identical.
|
||||||
|
Ignored for `kind: vm` — a VM receives devices through KubeVirt's own `domain.devices`.
|
||||||
|
- **Requirements:** **R-PLAT-02** (the PaaS provides the host capability; the app declares need).
|
||||||
|
|
||||||
### `ingressRoutes[]` (`host` / `port`) — HTTPRoute + Certificate + DNS
|
### `ingressRoutes[]` (`host` / `port`) — HTTPRoute + Certificate + DNS
|
||||||
- Each entry renders an **HTTPRoute** attaching `host` → the service `port` on the shared Gateway.
|
- Each entry renders an **HTTPRoute** attaching `host` → the service `port` on the shared Gateway.
|
||||||
- TLS: the Gateway terminates with the cert-manager **wildcard `*.home.keithjackson.dev`** certificate
|
- TLS: the Gateway terminates with the cert-manager **wildcard `*.home.keithjackson.dev`** certificate
|
||||||
@@ -286,6 +325,11 @@ platform must render, and cites the requirement each mapping satisfies. Changing
|
|||||||
`external-dns.alpha.kubernetes.io/controller: none`, and no `public` service's route carries it.
|
`external-dns.alpha.kubernetes.io/controller: none`, and no `public` service's route carries it.
|
||||||
Derived from `exposure` alone; enforced on every gate run by
|
Derived from `exposure` alone; enforced on every gate run by
|
||||||
`conformance/tests/l0-static/internal-services-publish-no-public-dns` (R-DNS-03).
|
`conformance/tests/l0-static/internal-services-publish-no-public-dns` (R-DNS-03).
|
||||||
|
8. **`hardware` is inert until opted into, and its mechanism is renderer-local.** An SD with no
|
||||||
|
`hardware` key renders no volume, no volumeMount and no `securityContext` — proven by
|
||||||
|
`render.py --selftest`. The `/dev/dri` hostPath, the `DirectoryOrCreate` type and
|
||||||
|
`INTEL_RENDER_GID` appear in `catalog/render.py` and nowhere else, so the capability's
|
||||||
|
implementation can be replaced without touching a single ServiceDefinition.
|
||||||
|
|
||||||
## Requirement traceability (contract-level)
|
## Requirement traceability (contract-level)
|
||||||
Primary **R-GITOPS-02** (single reviewable object wires every requirement). Field-level: R-NET-01/02
|
Primary **R-GITOPS-02** (single reviewable object wires every requirement). Field-level: R-NET-01/02
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://home.keithjackson.dev/schemas/servicedefinition.schema.json",
|
"$id": "https://home.keithjackson.dev/schemas/servicedefinition.schema.json",
|
||||||
"title": "ServiceDefinition",
|
"title": "ServiceDefinition",
|
||||||
"description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.7.0 (no field changes; a MAPPING change to `exposure`, which had never had an effect on DNS. `exposure: internal` now renders ExternalDNS's own controller annotation onto every HTTPRoute it emits, set to a value other than `dns-controller`, so external-dns (v0.21.0, source/gateway.go -> annotations.IsControllerMismatch) skips the route before resolving any hostname and the service is INCAPABLE of producing a public record. Both docs/09 R-DNS-03 and schemas/renderer-contract.md already said `internal` renders no public DNS record; nothing enforced it, and six internal services -- jackett, pihole, qbittorrent, radarr, semaphore, sonarr -- were publishing `<name>.home.keithjackson.dev` into the PUBLIC Cloudflare zone answering with the RFC1918 Gateway VIP, which published the internal subnet and a full service inventory and made each name a DNS-rebind primitive. Enforced on every gate run by conformance/tests/l0-static/internal-services-publish-no-public-dns). 1.6.0 (splits `observability.metrics` from WHERE metrics live: adds optional `metricsEndpoint` (port/name/path/interval) and optional `livenessWorkload` (kind/name). `metrics: true` previously rendered a ServiceMonitor hardcoded to a port named `metrics` that no rendered Service ever defined, so all twelve app monitors discovered ZERO targets and every `up{job=...}` SLI was structurally inert on prod; a monitor is now rendered only from a declared `metricsEndpoint`, which also renders the Service port and the monitoring-namespace ingress allow, and the `up` SLI is expressed against kube-state-metrics -- a signal that exists for every workload without a per-app exporter. `livenessWorkload` names the object whose readiness IS the service, for the case where the running workload is not the one this SD renders). 1.5.0 (adds optional `inClusterClients` — additional in-cluster clients, named by NAMESPACE, admitted to a declared port of this service DIRECTLY, bypassing the shared Gateway. `ingressRoutes` admits only the Gateway proxy identity and `dependencies` only links catalog services, so a PLATFORM controller dialling a Service ClusterIP had no authorable form at all; that gap is what deadlocked prod's GitOps when its GitRepository moved to the in-cluster Gitea Service. Renders one least-privilege ingress CiliumNetworkPolicy per entry — the named namespace and the declared port, nothing wider). 1.4.0 (makes the `bulk` tier REAL: a bulk storage entry now carries `export` (the absolute NFS export path on the ${BULK_NFS_SERVER} host) and renders a STATIC, pre-bound NFS PersistentVolume + PVC over data that already exists outside the cluster, plus optional `subPath` and `readOnly` on any entry. Resolves the OQ-2 bulk-backing stub for STATIC pre-existing datasets; dynamic bulk provisioning remains unimplemented and unneeded). 1.3.0 added optional pod-level podSecurityContext for the container kind — k8s PodSecurityContext emitted verbatim onto the pod spec; its fsGroup unblocks non-root images e.g. semaphore/UID-1001 writing a mounted Longhorn PVC). 1.2.0 added optional per-container env/envFrom/lifecycle for the container kind — k8s EnvVar/EnvFromSource/Lifecycle shapes emitted verbatim onto the app container; unblocks headless-install/DB-backed apps e.g. gitea/nextcloud/semaphore/unifi/minecraft. 1.1.0 added the shape invariant exposure:public => auth.mode!=none (docs/13 v1.1 / docs/15 / R-IDP-02).",
|
"description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.8.0 (adds optional `hardware` - a host HARDWARE CAPABILITY declaration for the container kind, currently one member: `hardware.gpu: intel-quicksync`, meaning the workload needs the host iGPU render device for hardware video transcode. It is an ENUM OF INTENT, not a device path: the renderer satisfies it today with a /dev/dri hostPath volume (`DirectoryOrCreate`), a read-write /dev/dri volumeMount on the app container, and the host `render` GID merged into podSecurityContext.supplementalGroups, and a later move to the Intel GPU device plugin is then a render.py change with no catalog edit. Renders NOTHING when absent, so every existing app stays byte-identical. Emits no privileged securityContext (Kyverno restrict-host-namespaces would reject it and it is not needed), no nodeSelector (node placement is a separate concern with its own justification) and no resource requests). 1.7.0 (no field changes; a MAPPING change to `exposure`, which had never had an effect on DNS. `exposure: internal` now renders ExternalDNS's own controller annotation onto every HTTPRoute it emits, set to a value other than `dns-controller`, so external-dns (v0.21.0, source/gateway.go -> annotations.IsControllerMismatch) skips the route before resolving any hostname and the service is INCAPABLE of producing a public record. Both docs/09 R-DNS-03 and schemas/renderer-contract.md already said `internal` renders no public DNS record; nothing enforced it, and six internal services -- jackett, pihole, qbittorrent, radarr, semaphore, sonarr -- were publishing `<name>.home.keithjackson.dev` into the PUBLIC Cloudflare zone answering with the RFC1918 Gateway VIP, which published the internal subnet and a full service inventory and made each name a DNS-rebind primitive. Enforced on every gate run by conformance/tests/l0-static/internal-services-publish-no-public-dns). 1.6.0 (splits `observability.metrics` from WHERE metrics live: adds optional `metricsEndpoint` (port/name/path/interval) and optional `livenessWorkload` (kind/name). `metrics: true` previously rendered a ServiceMonitor hardcoded to a port named `metrics` that no rendered Service ever defined, so all twelve app monitors discovered ZERO targets and every `up{job=...}` SLI was structurally inert on prod; a monitor is now rendered only from a declared `metricsEndpoint`, which also renders the Service port and the monitoring-namespace ingress allow, and the `up` SLI is expressed against kube-state-metrics -- a signal that exists for every workload without a per-app exporter. `livenessWorkload` names the object whose readiness IS the service, for the case where the running workload is not the one this SD renders). 1.5.0 (adds optional `inClusterClients` — additional in-cluster clients, named by NAMESPACE, admitted to a declared port of this service DIRECTLY, bypassing the shared Gateway. `ingressRoutes` admits only the Gateway proxy identity and `dependencies` only links catalog services, so a PLATFORM controller dialling a Service ClusterIP had no authorable form at all; that gap is what deadlocked prod's GitOps when its GitRepository moved to the in-cluster Gitea Service. Renders one least-privilege ingress CiliumNetworkPolicy per entry — the named namespace and the declared port, nothing wider). 1.4.0 (makes the `bulk` tier REAL: a bulk storage entry now carries `export` (the absolute NFS export path on the ${BULK_NFS_SERVER} host) and renders a STATIC, pre-bound NFS PersistentVolume + PVC over data that already exists outside the cluster, plus optional `subPath` and `readOnly` on any entry. Resolves the OQ-2 bulk-backing stub for STATIC pre-existing datasets; dynamic bulk provisioning remains unimplemented and unneeded). 1.3.0 added optional pod-level podSecurityContext for the container kind — k8s PodSecurityContext emitted verbatim onto the pod spec; its fsGroup unblocks non-root images e.g. semaphore/UID-1001 writing a mounted Longhorn PVC). 1.2.0 added optional per-container env/envFrom/lifecycle for the container kind — k8s EnvVar/EnvFromSource/Lifecycle shapes emitted verbatim onto the app container; unblocks headless-install/DB-backed apps e.g. gitea/nextcloud/semaphore/unifi/minecraft. 1.1.0 added the shape invariant exposure:public => auth.mode!=none (docs/13 v1.1 / docs/15 / R-IDP-02).",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["name", "source"],
|
"required": ["name", "source"],
|
||||||
@@ -139,6 +139,19 @@
|
|||||||
"supplementalGroups": { "type": "array", "items": { "type": "integer" }, "description": "Additional GIDs added to the first process in each container." }
|
"supplementalGroups": { "type": "array", "items": { "type": "integer" }, "description": "Additional GIDs added to the first process in each container." }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"hardware": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Optional host HARDWARE CAPABILITY the workload needs, for the container kind (docs/13). This field declares INTENT (`what device class this service needs`), never MECHANISM (`which host path to bind-mount`, `which extended resource to request`). That separation is the whole point of the enum: the platform currently satisfies `intel-quicksync` with a /dev/dri hostPath volume + the host's `render` GID in supplementalGroups, but a later migration to the Intel GPU device plugin (`gpu.intel.com/i915` resource requests, no hostPath) must be a catalog/render.py change and NOTHING else - no ServiceDefinition may need editing, and no reviewer may have to re-derive per-service device wiring. Do NOT 'simplify' this into a raw device path, a hostPath, or a resource request; that would push a platform implementation detail into the operator-facing contract and make the migration a catalog-wide edit. Absent => no device wiring at all (existing apps render byte-identical). Ignored for kind=vm (a VM gets devices through KubeVirt's own domain.devices).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"minProperties": 1,
|
||||||
|
"properties": {
|
||||||
|
"gpu": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "GPU capability class this service needs. `intel-quicksync` = the host's Intel Quick Sync Video render device (the iGPU's /dev/dri/renderD* node) for hardware video transcode/decode. Enum, not a free-form device path, so the satisfying mechanism stays swappable (see the parent description). Extend the enum when a genuinely different capability class appears (e.g. nvidia-nvenc), never to name a different path to the same one.",
|
||||||
|
"enum": ["intel-quicksync"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"exposure": {
|
"exposure": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "internal = LAN-only (internal DNS + Gateway on LAN); public = additionally edge port-forwarded 80/443 (docs/09; R-DNS/R-NET).",
|
"description": "internal = LAN-only (internal DNS + Gateway on LAN); public = additionally edge port-forwarded 80/443 (docs/09; R-DNS/R-NET).",
|
||||||
|
|||||||
Reference in new issue
Block a user