Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5 32d12f93d8 docs(chrony): correct the root cause recorded for the makestep fix
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#465 landed the makestep directive with the wrong root cause in its
comment. It claimed edge-1 "rebooted with an RTC ~16 days in the past".
That is false, and the real cause matters for what to do next.

edge-1's CMOS battery is dead. After a power loss the RTC came up reading
2018-08-20 -- roughly eight years stale, not 16 days:

  PM: RTC time: 09:18:56, date: 2018-08-20
  rtc_cmos 00:01: setting system clock to 2018-08-20T09:18:58 UTC

The clock was then reconstructed entirely in software. systemd floored it
forward to its own build date:

  systemd[1]: System time advanced to built-in epoch: Fri 2026-06-26 11:27:23 UTC

and it settled at ~2026-07-19, about 16 days behind. So the 16 days is
incidental -- it is just where the flooring landed. There is no 16-day
mechanism, and nothing "drifted". The two real faults are a dead RTC
battery and a chrony config that never steps. #465 fixed the second.

Also collapses the duplicated rationale in chrony.conf.j2 to a pointer at
defaults/main.yml, as suggested in review on #465, so the explanation
lives in exactly one place.

No behaviour change: the makestep directive and its defaults are
untouched. Comments only.

The commit message on 7fcaa4b0 still carries the wrong explanation and is
left alone rather than rewriting merged history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:35:51 +02:00
k.renner d41f4870ad Merge pull request 'fix(chrony): step the clock at boot so a bad RTC self-corrects' (#465) from worktree-chrony-makestep into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#465
2026-08-05 15:14:21 +00:00
claude-botandClaude Opus 5 7fcaa4b035 fix(chrony): step the clock at boot so a bad RTC self-corrects
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
chrony.conf.j2 had no makestep directive, so chronyd only ever slewed.
A large offset is therefore never closed in practice.

On 2026-08-04 edge-1 rebooted with its RTC ~16 days in the past. chronyd
was synchronised to ptbtime1 at stratum 2 with a sub-millisecond RMS
offset, yet reported "System time: 1376235.375 seconds slow of NTP time"
and corrected none of it. With host time at 2026-07-20, every outbound
TLS handshake failed x509 NotBefore validation against a cluster
certificate issued 2026-07-23:

  tls: failed to verify certificate: x509: certificate has expired or is
  not yet valid: current time 2026-07-20T16:15:36+02:00 is before
  2026-07-23T06:15:01Z

That took out the local mosquitto -> cluster MQTT bridge (all Vitocal
250-A / Vitocharge VX3 / E380 heating telemetry went unavailable in Home
Assistant for ~18h) and the Alloy -> Loki log shipper simultaneously.
Because the same skew broke log shipping, the outage was invisible to
log-based alerting.

Recovery required a manual `chronyc makestep`. Adding the directive lets
this correct itself at boot instead.

The limit keeps steps confined to the first few updates after chronyd
starts, so a long-running NTP server is never jumped out from under its
workloads mid-flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:35:18 +02:00
k.renner 11bf13883f Merge pull request 'ci: validate the GitOps tree and pipeline configs on every PR' (#460) from ci-k8s-validate into master
ci/woodpecker/cron/update-all Pipeline failed
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#460
2026-08-01 10:20:58 +00:00
security-bot 4b502daa98 chore(image-automation): bump woodpeckerci/woodpecker-server:v3.17.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-07-31 14:33:28 +00:00
security-bot 83a20bfa80 chore(image-automation): bump woodpeckerci/woodpecker-agent:v3.17.0
Updates managed by image-automation-controller.
2026-07-31 14:33:17 +00:00
security-bot c1a1a59ecc chore(image-automation): bump redis:8.10.0-alpine
ci/woodpecker/cron/e2e Pipeline was successful
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-07-30 21:32:22 +00:00
claude-botandClaude Opus 5 39dc23a999 ci: document why the shellcheck discovery floor stays at 1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review flagged that shellcheck's floor (1) is far weaker than kustomize-build's
(50), so it catches total discovery failure but not partial loss.

Correct, and kept as-is with the reasoning written down. The kustomize floor is
loose relative to the tree it guards, which is what lets it catch gross partial
loss without tripping on churn. The shell-script set is small enough that any
floor tight enough to catch partial loss would sit inside normal churn:
deleting one script would fail CI spuriously, and the fix a hurried person
reaches for is lowering the floor — which is how a guard becomes decoration. A
floor that must be edited whenever the tree changes is not a guard, it is a
snapshot with an exit code, and this file just had all its snapshots removed
for the same reason.

So it catches the failure that actually occurred (discovery silently returning
nothing, step green having checked nothing) and not partial loss. Noted as
worth revisiting if kubernetes/ grows a lot more shell, when a loose
proportionate floor becomes possible.

The review's other point — that the image pins and the /bin/sh and
/bin/woodpecker-cli claims are not checkable from a static checkout, and that a
green run is the real confirmation — is now settled by pipeline 3185 on
b1f5d6dc: all four steps green, with 60 / 18 / 6 ok lines from kustomize-build,
woodpecker-lint and shellcheck respectively. Those counts are cited here as
run-time evidence, not written into the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:55:33 +02:00
claude-botandClaude Opus 5 b1f5d6dcbc ci: explain the ci-k8s tag split and track the stale pins (#461)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review noted this file pins ci-k8s at a newer dated tag than e2e.yml,
e2e-test.yml, alert-monitor.yml and issue-fix.yml, and asked for either a
stated reason or a follow-up so it does not read as an oversight.

There is no reason — the other four are stale. Per the latest-stable rule all
five should sit on the newest dated tag.

Not bumped here on purpose. Those four pipelines exercise different tooling in
the image and three are cron- or event-triggered, so none of them run on this
PR. Bumping them in this branch would ship four untested changes under cover of
a green run that never touched them — the same shape as the digest decay in
#458, where breakage sat unnoticed precisely because the affected jobs did not
run for months. Each wants its own verification, so it is tracked as #461
rather than smuggled in here.

Comment added above kustomize-build so the split is explained where a reader
meets it, pointing at the issue rather than restating the whole argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:49:42 +02:00
claude-botandClaude Opus 5 ba052332bf docs(ci): drop snapshot counts from comments — they decay and prove nothing
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
The file count was meaningless and would be wrong tomorrow. Correcting it from
197 to 180 last commit was the wrong fix: a number that has to be re-derived
every time the tree changes does not belong in a comment at all. It cost two
review rounds — one to flag that 197 did not reproduce, one to correct it —
and neither round was about anything that mattered, because the count was
never the argument. Widening update.path is pointless because Flux cannot
patch non-Kubernetes YAML at all; the size of the walk was only ever a
secondary aside.

Swept every snapshot figure I had introduced, not just the one flagged:

  * "180 unrelated ansible/esphome YAMLs (179 + 1)" -> "the whole ansible/ and
    esphome/ trees", in both the pipeline comment and the README.
  * "builds 60 kustomizations" -> "builds every kustomization in the tree".
  * "verified green across all 60 kustomizations" -> "across every
    kustomization", keeping the as-of-writing hedge that made it a historical
    claim rather than a live one.
  * "expected ~60" in the discovery-floor failure message -> "floor is 50",
    which cites the actual guard on the line above instead of a tree size that
    drifts away from it. The message already prints the real $count.
  * "scratch-based (19 MB, 2 layers, no shell)" -> "ships no shell", plus what
    the reader actually needs: a step on the plain tag fails to START rather
    than failing a check, which is the confusing symptom. The byte size would
    have gone stale on the next upstream release.

The floor of 50 itself stays — it is a deliberately loose guard against
discovery breaking, not a description of the tree, and it is asserted in code
where a wrong value fails loudly instead of merely misinforming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:47:11 +02:00
claude-botandClaude Opus 5 1379813459 docs(ci): correct the file count in the update.path argument (197 -> 180)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
Review could not reproduce "197 unrelated ansible/esphome YAML files" and
counted 180. The reviewer is right, and the number was wrong twice over.

Recounted in this checkout: ansible/ has 179, esphome/ has 1 -> 180, which
matches the review exactly. My 197 came from also folding in .woodpecker/,
which is wrong on both counts: those files are neither ansible nor esphome,
and they are precisely the directory you WOULD want in the walk, so counting
them as "unrelated" inverts the argument the sentence is making. The composite
is also 198 rather than 197 today — the original count was taken against
master, where .woodpecker/ still had 17 files because ci-manifests-test.yml
did not exist yet.

The conclusion is unchanged (widening update.path is pointless because Flux
cannot patch non-Kubernetes YAML at all, and it would pull in a large number
of unrelated files), but the supporting figure now reproduces, and both places
that cite it are fixed with the derivation shown so it can be re-checked.

Also recorded the review's rule-of-three note where it will actually be read:
kustomize-build and shellcheck share a discovery shape, two copies is
deliberate because the two traps commented around are different, and a third
instance is the signal to factor it into scripts/ci/ — which would pick up
unit tests via ci-scripts-test.yml on the way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:42:42 +02:00
claude-botandClaude Opus 5 5115378835 ci: address review on #460 — drop an unverifiable claim, dedupe rationale
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review raised two 🟡 points and one nit. Both 🟡 points turned out to be
already answered by this PR's own CI run, but one of them exposed a real
accuracy problem in a comment, which is fixed here.

1. ci-k8s:2026-07-30 might never have been built. Good reasoning — build-k8s
   only mints a dated tag when ci/images/k8s/** or build-ci-images.yml changes
   on a push, and this PR touches neither; secret-rotator:2026-07-30 does not
   transfer, being a separately-gated step. Checked rather than assumed: the
   registry API is closed to this token (no read:package), so the evidence is
   pipeline 3178 on 8a580e34, where kustomize-build and no-digest-pins both ran
   green on that exact tag, with 60 real `ok` lines — a pull failure could not
   produce that. Tag exists. No change needed.

2. v3.16.0-alpine unverified for /bin/sh and the lint invocation. Same run:
   woodpecker-lint emitted 18 `ok` lines, one per .woodpecker/*.yml, which is
   only reachable if the image has a working shell and /bin/woodpecker-cli.

   But checking this found a claim I could NOT verify, which the review had
   independently guessed at: the comment asserted that several deploy-*.yml
   files emit "Additional property timeout is not allowed" warnings. That was
   observed under the v3.9.0 CLI this step used to pin. Whether v3.16.0 still
   emits it is unknown — and a green run cannot settle it, because the loop
   redirects lint output to /tmp/out and prints it only on failure, so warnings
   are invisible exactly when the step passes. Review's hypothesis (schema
   drift in a 7-minor-old CLI, not real debt in those files) is plausible and
   untested. Dropped the claim instead of restating it; the comment now
   documents only the exit-code contract the gate actually rests on.

3. Nit: the depends_on rationale was duplicated verbatim across all four
   steps. Hoisted to one note above `steps:`, with a warning that the key
   itself must stay on every step — it is per-step, so removing it from one
   silently re-serialises that step.

Also updated the PR description, which both reviews flagged as stale: it still
quoted v3.9.0-alpine / v0.10.0 as shipped, so the probing evidence in the body
referred to different tags than the diff. Corrected, with a note recording the
bump rather than quietly rewriting history.

Verified: all four steps still parse with depends_on: [] and unchanged images.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:36:49 +02:00
claude-botandClaude Opus 5 8a580e341d docs(ci): record why woodpecker-cli cannot be enrolled in image-automation
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
I proposed widening ImageUpdateAutomation's `update.path` to `./` and adding
a woodpecker-cli ImagePolicy so automation would own the lint pin the way it
owns the server and agent. Checking that before building it killed the plan,
and corrected two things I had asserted without evidence.

WRONG #1 — my reason for hesitating. I claimed the 7 ansible-vault encrypted
`.yml` files would break the walk and take all 33 automated images down with
them. That was a guess about kyaml I never verified. Objects that are not
Kubernetes manifests are SKIPPED, not fatal.

WRONG #2 — and this is the one that matters: the plan cannot work at all.
Image-automation only patches YAML that is a Kubernetes manifest. Maintainer,
fluxcd/flux2#2826: "Flux can only patch Kubernetes manifests. This means the
YAML manifest must have `apiVersion` and `kind` fields." The docs agree —
markers go "in any Kubernetes custom resource stored in Git". A Woodpecker
pipeline file is plain YAML with no apiVersion/kind, so a marker in it is
silently ignored.

That is a property of the FILE, not of `update.path`. Widening the path buys
exactly nothing and would drag 197 unrelated ansible/esphome YAMLs into the
walk. So no policy, no second automation, no path change — all three would
have been motion that looked like a fix and changed nothing. The failure mode
would have been the quiet kind this repo keeps getting bitten by: a marker
sitting in the file looking maintained, never once updated.

What actually closes the gap, given the pin must stay manual:

  * README gains a "Not enrolled (structurally impossible)" section holding
    the quote and the explicit "do not try widening the path" warning, so the
    next person does not re-derive this.
  * update-audit item 5 now covers `.woodpecker/*.yml` step images and is told
    to flag woodpecker-cli whenever it trails the DEPLOYED SERVER, not merely
    the newest CLI release — the server auto-bumps and the CLI does not, so
    that skew is the thing worth reporting. Also told to compare only the
    vX.Y.Z part (the tag carries `-alpine`; the plain tag is scratch with no
    shell and would break the step) and to ignore the `next-*`/`pull_*` dev
    tags, which are the newest by date on that repo and are not releases.
  * the step comment states the real constraint instead of my earlier wrong
    one, which told the reader that widening the path was the fix.

Verified: v3.16.0-alpine is the newest 3.x alpine release tag, and matches the
deployed server v3.16.0. Config still parses; step image unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:19:02 +02:00
claude-botandClaude Opus 5 fbfaf90923 docs(ci): frame the CLI pin as the latest-stable rule, not a separate one
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
I had written that the CLI "MUST track the deployed woodpecker-server",
which invents a rule that does not exist and misstates the one that does.

The standing rule already covers it: any software uses the latest stable
version. CLI tools are software. If the CLI and the server both follow it,
they match by construction — matching is a CONSEQUENCE, not a separate
requirement. I had read "any software" as meaning installs and images but
not CLIs, and was about to propose adding a redundant rule to the wiki off
the back of that misreading.

The comment now says that, and keeps the reason the skew mattered here: the
CLI compiles against its own schema, so one behind the server can pass a
config the server rejects, and the gate stops predicting server behaviour.

Also recorded a real limitation found while checking this. The server and
agent are bumped automatically by image-automation within the 3.x range,
and their policy comment notes they must stay version-matched. This pin is
manual, because ImageUpdateAutomation is scoped to
`update.path: ./kubernetes` and cannot maintain a tag under .woodpecker/.
So the CLI will lag a server upgrade until someone bumps it. Widening that
path would let a policy own this line the same way it owns the server and
agent — noted rather than done, since it changes automation scope repo-wide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:02:23 +02:00
claude-botandClaude Opus 5 7b5f244b7f ci: use latest stable versions, and match the woodpecker CLI to the server
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Violated the standing rule: "use the latest stable version. Old versions
must not be hardcoded without a specific, documented reason."

woodpecker-cli was pinned at v3.9.0 while the deployed server and agent run
v3.16.0 — seven minor versions behind. That version was never chosen; I
picked it while probing for any tag that had a shell and then never
revisited it.

For this step the skew is not cosmetic. The CLI compiles the config against
its own schema, so one behind the server can pass a config the server later
rejects, or reject one the server accepts. Either way the gate stops
predicting what the server will do, which is the only reason it exists —
and the failure it was added to catch (#443) is precisely a
server-side compile error.

Now v3.16.0-alpine, matched to the running deployment (checked against the
live Deployment, not assumed), with a comment saying it must be bumped
alongside the server. Verified it still behaves correctly: rc=0 on a valid
config, rc=1 on the #443 construct.

shellcheck was pinned at v0.10.0 for the same non-reason — it was simply
the newer of the two tags I happened to probe. v0.11.0 exists, so bumped.
Verified first that the existing scripts still pass at -S error under the
newer version, since a shellcheck bump can add checks and I did not want to
ship a gate that fails on the current tree.

ci-k8s stays on the dated-tag convention for registry-local images.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:56:37 +02:00
claude-botandClaude Opus 5 888aa60fe0 ci: run the four checks in parallel so every failure shows at once
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review points on #460.

Woodpecker runs steps sequentially by default, so a failing kustomize-build
hid woodpecker-lint, shellcheck and no-digest-pins entirely — a contributor
would fix one thing, repush, and only then discover the next. All four are
independent, cheap and offline, with no ordering between them, so
depends_on: [] fans them out and one run reports everything.

Verified the structure rather than the diff: all four steps now render with
depends_on=[].

Also made the discovery-floor failure actionable. The message said only
"Discovery is broken", which is wrong advice if the tree legitimately
shrank below 50 kustomizations — it now tells the reader to lower the floor
in that case, and explicitly not to delete it, since removing the guard
restores the silent-green no-op it exists to prevent.

On the ci-k8s:2026-07-30 tag, raised for the third time: confirmed by
execution, not inspection. It resolved in pipeline 3161, whose
kustomize-build and no-digest-pins steps both ran that image and logged
"discovered 60 kustomizations" with 60 ok lines. An image that failed to
pull could not have produced that output. The review is right that nothing
in THIS PR rebuilds the tag — it was published earlier today by
build-ci-images runs triggered by edits to .woodpecker/build-ci-images.yml,
which is also exactly the drift that left other pipelines pinned to
ci-k8s:2026-05-01.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:25:23 +02:00
claude-botandClaude Opus 5 9d3dc152c1 fix(ci): repair the block scalar I broke in the previous commit
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The previous commit set IFS to a literal newline by writing

        IFS="
"

inside a `- |` block scalar. The closing quote sits at column 0, which
terminates the block — so the config no longer parsed:

  go-yaml load error in scanner (while scanning a simple key) at
  L70.C1-L89.C3: could not find expected ':'

Same class as #443, in the very pipeline written to catch #443, and I
pushed it: `woodpecker lint` printed the error in the same command as the
push, and I read the push output rather than the lint output.

Replaced with `while IFS= read -r` fed from a FILE rather than a pipe.
That handles paths with spaces without needing a literal newline anywhere,
and the file redirection keeps the loop in the current shell so `fail`
still propagates — a pipe would have run it in a subshell and made a
failing kustomize build exit 0, which is the silent-pass shape this step
already shipped once with -printf.

Verified both properties in the actual image rather than reasoning about
them: a directory named "has space" iterates once intact, and a `fail=1`
set inside the loop is still 1 after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:18:39 +02:00
claude-botandClaude Opus 5 1911a20db5 ci: path-filter the manifest gate and harden its loops
Review points on #460.

Path-filter the pipeline to kubernetes/** and .woodpecker/**. It was
unfiltered, matching ci-scripts-test.yml, but that comparison does not
hold: ci-scripts-test is one step on one already-cached image, whereas this
pulls three images and builds 60 kustomizations — real cost to impose on a
docs-only PR. The filter still covers everything the steps read, and
including .woodpecker/** means an edit to this file re-runs its own checks.

Pin IFS to newline in both find-driven loops so a path containing spaces is
passed as one argument rather than split into fragments. Verified in the
actual image: a directory named "has space" now iterates once, intact.

Deliberately NOT rewritten as `printf | while read`, which is the usual
advice: that puts the loop in a subshell, so `fail` would not propagate and
a failing kustomize build would exit 0. That is the same silent-pass shape
this step already shipped once with -printf, and fixing a latent
whitespace bug by reintroducing a live correctness bug would be a poor
trade.

Also corrected the file header, which still claimed the gate runs on every
pull_request.

On the ci-k8s:2026-07-30 tag the review asked about again: confirmed by
execution rather than inspection. Pipeline 3161 ran this pipeline against a
real checkout with all five steps green, which requires all three images to
have pulled in the runner, and its kustomize-build step logged "discovered
60 kustomizations" with 60 ok lines — so the image both resolved and did
the work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:17:27 +02:00
claude-botandClaude Opus 5 ddf5f5007b docs(ci): correct how the digest guard avoids self-matching
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review point on #460. The comment claimed "`image:` anchors the match so
the kustomization comment documenting this rule does not flag itself".
That is wrong about the mechanism, and the reviewer proved it by running
the bare grep — kustomization.yaml DOES match, because the comment
documenting the rule contains the literal string `image:.*@sha256`.

What actually excludes it is the `grep -v kustomization.yaml` filter. The
step's behaviour was always correct; only the explanation was wrong.

Worth fixing rather than shrugging at: this is the third time today the
same self-matching trap has caught me. Twice while writing the guard in
rotation/kustomization.yaml (where the final version documents it
accurately), and now once more in the comment describing it here. A
comment that misidentifies which line is load-bearing invites someone to
delete the filter as redundant tidying, at which point the step fails
permanently on its own documentation.

The comment now says the filter is load-bearing and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:11:26 +02:00
claude-botandClaude Opus 5 790fd1933a fix(ci): kustomize-build validated nothing and reported green
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Blocking review point on #460, and it was exactly right.

`find -printf` is a GNU findutils extension. ci-k8s is alpine-based and
never installs findutils, so find is BusyBox — confirmed by running it in
the image:

  find: unrecognized: -printf
  BusyBox v1.37.0

Because the result fed a command substitution used as a word list, the
failure was SILENT: empty output, zero loop iterations, fail stayed 0, and
the step exited green having run kubectl kustomize on nothing at all.

The proof is in this PR's own pipeline run (3158). The step is recorded as
"success", and its log contains ZERO `ok` lines — sixty were expected. So
the gate I added to catch silent no-ops was itself a silent no-op, and CI
reported it as passing.

Worth stating why neither of my checks caught it: I dry-ran the loop on my
workstation, which has GNU find, and the CI run cannot distinguish a
zero-iteration loop from a working one when every kustomization already
builds — both exit 0. The review's suggested test (paste the per-directory
log lines, not the exit code) is the only one that separates them.

Fixed by replacing -printf with a portable pipe through sed. Verified in
the actual image, with a synthetic tree, that the loop now iterates.

Also added a discovery assertion to both find-based steps: fail if the
count is implausible (kustomize-build expects >=50 of ~60; shellcheck
expects >=1) rather than proceeding with an empty list. That is the general
fix — without it, ANY future breakage of discovery degrades back into a
green no-op instead of a failure, which is the trap this whole commit is
about rather than the -printf detail specifically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:04:48 +02:00
claude-botandClaude Opus 5 cd12bc16c9 ci: validate the GitOps tree and pipeline configs on every PR
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Nothing covered kubernetes/** before this. deploy-infrastructure.yml gates
only opentofu/**, and ci-scripts-test.yml runs only the scripts/ci/ unit
tests — so a broken manifest reached the cluster and was caught by Flux
failing to reconcile, or by someone noticing. Reviewers flagged the gap on
three separate PRs today.

Four static checks, all offline (no cluster, no registry, no secrets):

  kustomize-build   every kustomization under kubernetes/ must build
  woodpecker-lint   every .woodpecker/*.yml must compile
  shellcheck        standalone .sh under kubernetes/, severity=error
  no-digest-pins    rotators must not reintroduce @sha256 image pins

Each was verified GREEN against the tree as it stands before being added,
so this gates new breakage rather than failing on pre-existing debt: all 60
kustomizations build, all pipeline configs lint, all 6 shell scripts are
clean at error severity, and no digest pins remain after #459.

The woodpecker-lint step is the one that matters most: it exits 1 on the
exact defect that took every pipeline in this repo down earlier today
(#443) — an unquoted YAML scalar containing ": ", which `yq` accepts
because it IS valid YAML, just a map rather than a string. Verified both
directions in the image the step actually uses: rc=1 on that construct,
rc=0 on the pre-existing "Additional property timeout is not allowed"
warnings in four deploy-*.yml files, so it fails on real breakage without
blocking on legacy noise.

Image choices, all version-pinned with no floating tags:

  ci-k8s:2026-07-30                       kubectl for kustomize; also the
                                          shell for the grep-based guard
  woodpecker-cli:v3.9.0-alpine            the non-alpine image is distroless
                                          and has no /bin/sh, so Woodpecker
                                          `commands:` cannot run in it; the
                                          binary is /bin/woodpecker-cli, not
                                          on PATH as `woodpecker`
  shellcheck-alpine:v0.10.0               pinned rather than :stable, which
                                          floats like :latest

Deliberately NOT included: kubeconform. It needs CRD schemas for
ExternalSecret, Certificate, HelmRelease and friends, so it either needs a
schema-fetching step (network, and a new failure mode) or
-ignore-missing-schemas, which would skip precisely the CRDs most of this
tree is made of. Worth doing properly as its own change.

Known gap, called out in the file rather than implied away: shellcheck
cannot see shell embedded in manifests (init containers, probe exec
blocks), which is where several real bugs lived this year.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:56:06 +02:00
k.renner 971ea6dc09 Merge pull request 'fix(rotation): repin every rotator to a dated tag, not a digest' (#459) from rotator-repin into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#459
2026-07-30 17:44:22 +00:00
8 changed files with 323 additions and 3 deletions
+254
View File
@@ -0,0 +1,254 @@
# Validate the GitOps tree and the pipeline definitions on PRs that touch them.
#
# Nothing covered kubernetes/** before this. deploy-infrastructure.yml only
# gates opentofu/**, and ci-scripts-test.yml only runs scripts/ci/ unit tests —
# so a broken manifest reached the cluster and was caught by Flux failing to
# reconcile, or by a human noticing. Three separate defects on 2026-07-30 would
# have been caught here instead:
#
# * an unquoted YAML scalar containing ": " in .woodpecker/build-ci-images.yml,
# which made EVERY pipeline in the repo fail to compile (#443). `yq` parsed
# it happily — it is valid YAML, just a map instead of a string — so
# "it parses" was mistaken for "it is valid". Only woodpecker lint catches it.
# * a rotator pinned to an image digest that no longer existed (#458).
# * manifests whose kustomize wiring was only ever checked by hand.
#
# Deliberately CHEAP and offline: no cluster, no registry, no secrets. Everything
# here is a static check that either passes or fails on the checkout alone, so it
# can gate PRs without becoming a bottleneck or a flake source.
when:
# Path-filtered on purpose, unlike ci-scripts-test.yml. That pipeline is one
# step on one already-cached image, so running it everywhere is free; this one
# pulls three images and builds every kustomization in the tree, which is real
# cost to put on a docs-only PR. The filter covers everything the steps actually read:
# kubernetes/** for the manifests and scripts, .woodpecker/** for the lint (and
# so an edit to this file re-runs its own checks).
- event: pull_request
path:
- "kubernetes/**"
- ".woodpecker/**"
# ADDING A THIRD DISCOVERY CHECK: kustomize-build and shellcheck both hand-roll
# the same shape — `find`, a floor guard against empty discovery, then a
# file-fed `while read`. Two copies is deliberate: the two traps being commented
# around (BusyBox find lacking `-printf`, and a piped loop running in a subshell
# where `fail` cannot propagate) are different, and both are worth spelling out
# where they bite. A third instance is the point to stop copying and factor it
# into a shared script under `scripts/ci/` — which is already covered by
# ci-scripts-test.yml, so it would gain unit tests in the process.
#
# Every step carries `depends_on: []` so all four run in PARALLEL and every
# failure shows in one run. Woodpecker's default is sequential, which would stop
# at the first failure and make a contributor fix-and-repush to discover the
# next — and these four are independent, cheap, offline checks with no ordering
# between them. Keep it on every step: `depends_on` is per-step, so dropping it
# from one silently re-serialises that step behind the others.
steps:
# Every kustomization must build. This is the check that catches a resource
# entry pointing at a file that does not exist, a bad patch target, or YAML
# that is fine in isolation but broken once overlaid — the class of error that
# otherwise surfaces as a Flux reconcile failure after merge.
#
# Verified green across every kustomization in the tree when this was added,
# so this gates new breakage rather than pre-existing debt.
#
# ci-k8s TAG SPLIT: this file pins a newer dated tag than e2e.yml, e2e-test.yml,
# alert-monitor.yml and issue-fix.yml, which still sit on an older one. That is
# staleness in those four, not a deliberate split here — everything should be on
# the newest dated tag. They are not bumped here because none of them are
# triggered by this PR, so it would ship untested changes under cover of a green
# run that never touched them. Tracked in #461.
- name: kustomize-build
depends_on: [] # see note above `steps:`
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-07-30
commands:
- |
set -eu
# NOT `find -printf '%h\n'`: that is a GNU findutils extension and this
# image ships BusyBox find (v1.37.0), which rejects it outright with
# "find: unrecognized: -printf". Because the result feeds a command
# substitution used as a word list, the failure is SILENT — empty output,
# zero loop iterations, fail=0, step exits green having validated
# nothing. That is exactly what this pipeline shipped in its first
# revision, and CI reported it as passing.
dirs=$(find kubernetes -name kustomization.yaml | sed 's|/kustomization.yaml$||' | sort)
# Assert the discovery actually found something. Without this, any future
# breakage of the find/sed above degrades back into a green no-op rather
# than a failure — the specific trap that made the first revision of this
# step useless while looking healthy.
count=$(printf '%s\n' "$dirs" | grep -c . || true)
if [ "$count" -lt 50 ]; then
echo "FAIL: found only $count kustomizations; floor is 50."
echo " If the tree legitimately shrank, lower this floor in"
echo " .woodpecker/ci-manifests-test.yml — do not delete it."
echo " Otherwise discovery is broken — refusing to report success."
exit 1
fi
echo "discovered $count kustomizations"
# `while read` fed from a FILE, not a pipe: a pipe would run the loop in
# a subshell where `fail` cannot propagate, so a failing build would exit
# 0 — the same silent-pass shape this step already shipped once. Reading
# a file keeps the loop in the current shell, and read -r handles paths
# containing spaces without IFS games.
printf '%s\n' "$dirs" > /tmp/dirs
fail=0
while IFS= read -r d; do
[ -n "$d" ] || continue
if kubectl kustomize "$d" >/dev/null 2>/tmp/err; then
echo "ok $d"
else
echo "FAIL $d"
sed 's/^/ /' /tmp/err
fail=1
fi
done < /tmp/dirs
exit $fail
# Lint the pipeline definitions themselves. This is the one that would have
# prevented the repo-wide CI outage: `woodpecker lint` exits 1 on a config that
# fails to compile, and exits 0 on mere warnings — so it fails on real breakage
# without blocking on pre-existing noise. Both behaviours were verified against
# this repo (rc=1 reproducing #443, rc=0 on the tree as it stands).
#
# An earlier revision of this comment also asserted that several deploy-*.yml
# files emit "Additional property timeout is not allowed" warnings. That was
# observed under the v3.9.0 CLI this step used to pin, and it is NOT known to
# still hold on v3.16.0 — review raised the likelihood that the warning was
# schema drift in a 7-minor-old CLI rather than real debt in those files, which
# is one of the reasons this pin moved to latest stable. It cannot be confirmed
# from a green run either, because the loop below sends lint output to /tmp/out
# and prints it only on failure, so warnings are invisible when the step passes.
# The claim is dropped rather than restated: what this step guarantees is the
# exit-code contract above, which is what the gate actually rests on.
# Latest stable, per the standing rule that any software uses the latest
# stable version. Matching the deployed woodpecker-server (v3.16.0) is a
# CONSEQUENCE of that, not a separate requirement — the server is bumped by
# image-automation within the 3.x range, so both land on the same release.
#
# It matters here because the CLI compiles the config against its own schema:
# one behind the server can pass a config the server then rejects, or reject
# one it accepts, and the gate stops predicting what the server will do —
# which is its only purpose.
#
# `-alpine` is required, not cosmetic: the plain vX.Y.Z CLI image ships no
# shell, and the loop below needs `for`, `sed` and `echo` around the binary.
# A step using the plain tag fails to start rather than failing a check.
#
# DRIFT RISK — accepted, and NOT fixable here. The server auto-bumps via
# image-automation; this pin cannot, so the two will drift apart.
# Image-automation can only patch YAML that is a Kubernetes manifest —
# fluxcd/flux2#2826: "Flux can only patch Kubernetes manifests. This means the
# YAML manifest must have `apiVersion` and `kind` fields." A Woodpecker
# pipeline file has neither, so an `$imagepolicy` marker here is silently
# ignored. That is a property of the FILE, not of `update.path`: widening the
# automation's path to `./` would buy nothing, and would additionally drag the
# whole ansible/ and esphome/ trees into the walk. Do not try it — it looks
# like the fix and is not.
#
# So this bump is manual, and drift is caught by the update-audit agent
# (ci/agents/update-audit/prompt.md, item 5) which is told to flag this image
# whenever it trails the deployed server. If you bump the server, bump here.
- name: woodpecker-lint
depends_on: [] # see note above `steps:`
image: woodpeckerci/woodpecker-cli:v3.16.0-alpine
commands:
- |
set -eu
fail=0
for f in .woodpecker/*.yml; do
if /bin/woodpecker-cli lint "$f" >/tmp/out 2>&1; then
echo "ok $f"
else
echo "FAIL $f"
sed 's/^/ /' /tmp/out
fail=1
fi
done
exit $fail
# Standalone shell scripts under kubernetes/ (sidecar and bootstrap scripts
# mounted via configMapGenerator). Severity is pinned to `error` on purpose:
# style and info findings would fail the build on existing scripts for no
# safety gain. All scripts in the tree are clean at this level today.
#
# NOTE this does NOT cover shell embedded in manifests (init containers, probe
# exec blocks) — shellcheck cannot see inside YAML. That embedded shell is
# where several real bugs lived this year, so it remains a gap worth closing
# separately rather than one this step quietly implies is covered.
- name: shellcheck
depends_on: [] # see note above `steps:`
image: koalaman/shellcheck-alpine:v0.11.0
commands:
- |
set -eu
files=$(find kubernetes -name '*.sh' | sort)
# Same no-op guard as kustomize-build: if discovery ever returns nothing,
# fail loudly instead of passing having checked no files.
#
# This floor is 1 while kustomize-build's is 50, and the asymmetry is
# deliberate rather than an oversight. That floor is loose relative to the
# tree it guards, so it catches both total failure and gross partial loss
# while tolerating normal churn. The shell-script set is small enough that
# any floor tight enough to catch PARTIAL loss would sit inside normal
# churn — deleting one script would fail CI spuriously, and the fix a
# hurried person reaches for is lowering the floor, which is how a guard
# quietly becomes decoration. A floor that has to be edited whenever the
# tree changes is not a guard, it is a snapshot with an exit code.
#
# So this catches the failure that actually happened here (discovery
# silently returning nothing, step green having checked no files) and not
# partial loss. Worth revisiting only if kubernetes/ grows a lot more
# shell, at which point a loose proportionate floor becomes possible.
count=$(printf '%s\n' "$files" | grep -c . || true)
if [ "$count" -lt 1 ]; then
echo "FAIL: no shell scripts found under kubernetes/ — discovery is broken."
exit 1
fi
echo "discovered $count shell scripts"
# Same file-fed while-read reasoning as kustomize-build above.
printf '%s\n' "$files" > /tmp/files
fail=0
while IFS= read -r f; do
[ -n "$f" ] || continue
if shellcheck -S error "$f"; then
echo "ok $f"
else
echo "FAIL $f"
fail=1
fi
done < /tmp/files
exit $fail
# Guard the convention that cost a day to diagnose: registry-local images are
# pinned by DATED TAG, never by digest. Digests here are garbage-collected once
# :latest and the dated tags move past them — and that happens on edits to
# .woodpecker/build-ci-images.yml, a shared file, so an unrelated image addition
# silently orphans the manifest a rotator was pinned to. 11 rotators were dead
# this way and would have failed on their first scheduled run months later.
#
# Scoped to the rotation directory, which is where the convention is documented
# and where the failure actually bit.
#
# The `grep -v kustomization.yaml` is load-bearing, not tidying: that file
# documents this very rule, so its comment contains the literal string
# `image:.*@sha256` and therefore MATCHES the pattern. Anchoring on `image:`
# does not prevent that — verified by running the bare grep, which returns
# kustomization.yaml. Only the filename filter excludes it. Drop the filter and
# this step fails permanently on its own documentation.
- name: no-digest-pins
depends_on: [] # see note above `steps:`
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-07-30
commands:
- |
set -eu
hits=$(grep -l 'image:.*@sha256' kubernetes/infrastructure/openbao/rotation/*.yaml 2>/dev/null \
| grep -v kustomization.yaml || true)
if [ -n "$hits" ]; then
echo "FAIL: digest-pinned images found; use a dated tag (see rotation/kustomization.yaml):"
echo "$hits" | sed 's/^/ /'
exit 1
fi
echo "ok no digest pins under openbao/rotation/"
@@ -18,6 +18,26 @@ chrony_upstream_pools:
# Local stratum if disconnected from upstream
chrony_local_stratum: 10
# Step (rather than slew) the clock when the offset exceeds the threshold, but
# only for the first N updates after chronyd starts. Without a makestep
# directive chronyd only ever slews, so a large offset is never closed in
# practice.
#
# edge-1, 2026-08-04: the CMOS battery is dead, so after a power loss the RTC
# came up reading 2018-08-20. The kernel set the clock from it, systemd floored
# that forward to its own built-in epoch (2026-06-26), and it ended up at
# ~2026-07-19 -- about 16 days behind. chronyd then synced to ptbtime1, reported
# "System time: 1376235.375 seconds slow of NTP time", and corrected none of it.
# With host time before the cluster certificate's NotBefore, every outbound TLS
# handshake failed x509 validation, taking out the MQTT bridge (heating
# telemetry) and the Loki log shipper at once. The 16 days is incidental -- it
# is just where the software flooring landed; the fault is that nothing steps.
#
# Limiting to the first few updates keeps steps confined to boot, so a
# long-running server is never jumped out from under its workloads.
chrony_makestep_threshold: 1.0
chrony_makestep_limit: 3
# Hardware timestamping. Fine on the PVE nodes' Intel NICs; the Wyse 3040
# (edge-1) has a Realtek NIC that does not support it, and chronyd logs an
# error per interface when asked for it. Overridden to false in
@@ -14,6 +14,11 @@ pool {{ pool }}
# Record the rate at which the system clock gains/losses time
driftfile /var/lib/chrony/chrony.drift
# Step the clock instead of slewing it when the offset is large, limited to the
# first few updates after start so only boot-time corrections jump. Rationale
# and the incident behind it: chrony_makestep_* in the role's defaults/main.yml.
makestep {{ chrony_makestep_threshold }} {{ chrony_makestep_limit }}
# Allow NTP client access from local networks
{% for network in chrony_allowed_networks %}
allow {{ network }}
+16
View File
@@ -59,6 +59,22 @@ For each, find the **current pin in the repo** (Read/Glob/Grep), then look up th
5. **Images deliberately excluded from automation** — listed in the
image-automation `README.md` (e.g. vsftpd, otbr, ical-proxy) and any image with
no `# {"$imagepolicy": ...}` marker. Check whether a newer usable tag exists.
This includes **CI step images in `.woodpecker/*.yml`**, which are all manual:
image-automation cannot patch those files at all (they are not Kubernetes
manifests — no `apiVersion`/`kind` — so markers there are ignored), and no
amount of `update.path` widening changes that. Grep `.woodpecker/` for
`image:` and check each pin.
Give `woodpeckerci/woodpecker-cli` in `.woodpecker/ci-manifests-test.yml`
particular attention: the woodpecker **server** IS auto-bumped (`3.x`), the
CLI is not, so they silently drift apart. The CLI lints pipeline configs
against its own schema, so one behind the server can pass a config the server
then rejects — the gate stops predicting server behaviour. Report the CLI as
needing an update whenever it trails the server version, not only when it
trails the newest CLI release. Its tag carries an `-alpine` suffix (the plain
tag is scratch-based with no shell and would break the step) — compare the
`vX.Y.Z` part, and ignore the `next-*` / `pull_*` development tags, which are
the newest by date on that repo and are not releases.
6. **Ansible roles & OpenTofu providers** — `ansible/requirements.yml` (Galaxy
roles/collections) and `opentofu/providers.tf` + `opentofu/.terraform.lock.hcl`.
Report any with a newer published version.
+1 -1
View File
@@ -25,7 +25,7 @@ spec:
type: RuntimeDefault
containers:
- name: redis
image: redis:8.8.1-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
image: redis:8.10.0-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
args:
- redis-server
- --save
+1 -1
View File
@@ -34,7 +34,7 @@ spec:
# Image automation, range 3.x (see image-automation/woodpecker.yaml).
# MUST stay version-matched with woodpecker-server (gRPC parity) — both
# track the same range/filter so they bump together on each release.
image: woodpeckerci/woodpecker-agent:v3.16.0 # {"$imagepolicy": "flux-system:woodpecker-agent"}
image: woodpeckerci/woodpecker-agent:v3.17.0 # {"$imagepolicy": "flux-system:woodpecker-agent"}
env:
- name: WOODPECKER_SERVER
value: woodpecker.apps.svc.cluster.local:9000 # in-cluster gRPC, replaces LXC :9000
+1 -1
View File
@@ -40,7 +40,7 @@ spec:
# minor + patch auto-update within 3.x; only the 4.0 major is held for
# a reviewed bump. Safe now the DB is postgres (the old sqlite-surprise
# -bump hazard is gone). Stays in lockstep with the agent.
image: woodpeckerci/woodpecker-server:v3.16.0 # {"$imagepolicy": "flux-system:woodpecker-server"}
image: woodpeckerci/woodpecker-server:v3.17.0 # {"$imagepolicy": "flux-system:woodpecker-server"}
env:
- name: WOODPECKER_HOST
value: https://ci.renner.uno
@@ -55,6 +55,31 @@ existing `flux-system` GitRepository whose Secret was created by
| mmwave-configurator | `2.x` | Plain 3-component filter (rejects `addon-`/`-armv7`/`-arm64`/`-amd64` variants) |
| cnpg-barman-plugin | `0.x` | v-prefix filter; marker lives in upstream-imported manifest (see below) |
## Not enrolled (structurally impossible)
- `woodpeckerci/woodpecker-cli` — the lint image in
`.woodpecker/ci-manifests-test.yml`. It publishes a perfectly good
version stream (`v3.16.0-alpine`), so unlike the three below this is
**not** a tag-format problem. It cannot be enrolled because
**image-automation can only patch YAML that is a Kubernetes manifest** —
a maintainer states it plainly in fluxcd/flux2#2826: *"Flux can only
patch Kubernetes manifests. This means the YAML manifest must have
`apiVersion` and `kind` fields."* A Woodpecker pipeline file is plain
YAML with neither, so the marker would be silently ignored.
Note this is **not** fixable by widening `update.path`. The path only
controls which directory is walked; objects without `apiVersion`/`kind`
are skipped regardless. Widening the path to `./` buys nothing here and
additionally pulls the whole `ansible/` and `esphome/` trees into the
walk, so don't.
Consequence: the CLI is a **manual** pin while the server and agent
auto-bump, so it *will* drift. That is a known, accepted gap, covered
by the update-audit agent (item 5) rather than by automation. When
bumping the server major/minor, bump this too — it must stay on the
latest stable like everything else, and a CLI behind the server can
pass a config the server then rejects.
## Not enrolled (deliberate)
These three images publish no usable version stream — there is nothing