Compare commits

...
Author SHA1 Message Date
claude-bot 49cc880169 fix(unifi): capture the API response on create only, not on every write
ci/woodpecker/manual/deploy-infrastructure Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Root cause of the 60 permanently-dirty hairpin NAT rules.

The provider was configured write_returns_object = true, which makes it store
the API response into `data` after POST *and* PUT. The UDM assigns `_id`;
unifi_nat_hairpin.tf's data = jsonencode({...}) never sets it. So every write
put a field into state that the config does not have, and all 60 rules planned
as "will be updated in-place" with `- _id` as the entire diff — re-dirtying
themselves on the next write, indefinitely.

No provider-side ignore can suppress this, because `data` is a configured
attribute and Terraform compares config to state in core, before provider
logic runs. Three were tried and disproven against real plans rather than
argued about:

  ignore_changes_to = ["_id"]                       plan 5661 — 60 remained
  ignore_all_server_changes = true                  plan 5662 — 60 remained
  restapi 3.x + ignore_server_additions = true      plan 5667 — 60 remained,
                                                    plus a new api_data diff

create_returns_object keeps the part the provider genuinely needs — the POST
response is how it learns `_id` for id_attribute = "_id" — while no longer
letting updates rewrite `data` from the response.

This does not clear the existing pollution by itself: state still carries `_id`
in `data` for all 60 rules, so the plan will keep showing them until one apply
rewrites `data` to the configured value. What it changes is that the apply then
sticks, instead of the next write undoing it.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 07:55:44 +02:00
claude-bot 08b3a39878 fix(opentofu): ignore operating_system instead of declaring it
ci/woodpecker/manual/deploy-infrastructure Pipeline was successful
Plan 5657 rejected the previous commit:

  Error: Missing required argument
    on lxc_800_rustfs_decommission.tf line 98, in resource
    "proxmox_virtual_environment_container" "rustfs_decommission":
    The argument "template_file_id" is required, but no definition was found.

The plan's own state dump showed only `type = "debian"` inside
operating_system, so mirroring it looked complete. It is not:
template_file_id is create-time-only, PVE does not report the original
template for an existing container, and nothing else in opentofu/ declares
one to copy — these three are the only container resources in the repo.

Guessing a template would be a lie that stays invisible until something
recreates these containers, which is precisely the event this file exists to
prevent. So operating_system is left undeclared and ignored via lifecycle
instead, which stops it planning as a removal without asserting a value that
is not known.

The timeouts remain outside ignore_changes on purpose — they are the one
thing this shim needs to actually apply.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 07:11:39 +02:00
claude-bot a8c1af1502 fix(opentofu): make the rustfs decommission shim plan in-place, not replace
ci/woodpecker/manual/deploy-infrastructure Pipeline failed
The v2 shim's own instructions say the plan MUST show an in-place update and
no replace before applying. It does not. Pipeline 5656 planned all three
containers as `must be replaced`, which would destroy and recreate LXCs
tagged critical / state-backend / terraform-backend instead of nudging a
timeout value into state.

Cause: v2 declared only node_name, vm_id, started, unprivileged and the
timeouts, on the stated assumption that "the other attributes mirror the real
containers so the plan stays in-place". They were not declared at all, so
every absent attribute planned as a removal — and two of those removals force
replacement:

  - mount_point { ... }       # forces replacement (size, volume)
  - operating_system { ... }  # forces replacement

Declaring the full attribute set, mirrored from the values the plan itself
reported out of state: cpu, memory, disk, mount_point, network_interface,
operating_system and initialization. hostname and mac_address differ per
container, so the local map carries them per key rather than being repeated
or guessed.

tags is declared for a separate reason: it was planning as
["critical","rustfs","state-backend","terraform-backend"] -> null, which does
not force replacement but would have stripped the tags off the live
containers on apply.

This does not unblock the WordPress backup on its own. That needs
renner-uno-k8s-mariadb, which the same plan wants to create — but the bucket
cannot be applied while the plan also carries three container replacements,
and a -target apply is not an acceptable way around it: it diverges state
from config and leaves the real problem in place.

Verification is the file's own gate — a plan showing in-place only.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 07:08:47 +02:00
k.renner 38eb6b6503 Merge pull request 'fix(woodpecker): revive the rotation alert rules after the namespace move' (#512) from fix-rotator-woodpecker-url into master
ci/woodpecker/manual/deploy-infrastructure Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#512
2026-08-19 04:53:19 +00:00
claude-bot 5d542af431 fix(woodpecker): revive the rotation alert rules after the namespace move
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
Rebased onto master now that #511 landed the WOODPECKER_URL fix for both
rotators — that part is dropped from here rather than duplicated. What remains
is the half nothing else covered.

All four rules in prometheusrule-woodpecker-rotation.yaml were selecting
series that do not exist, so the whole group was silent:

- WoodpeckerServerNotReady queried kube_statefulset_status_replicas_ready.
  The server became a Deployment when the sqlite->CNPG migration dropped its
  PVC, so that metric has no series at all — a `critical` "all CI is down"
  alert that could never fire, and had been dead well before the namespace
  move. Now kube_deployment_status_replicas_ready in the woodpecker namespace.

- WoodpeckerAgentNotReady selected namespace="apps". Broken by the namespace
  migration; mine.

- The two woodpecker-secret-rotate rules selected namespace="apps", but that
  rotation runs from the openbao namespace. Broken since that move, which the
  woodpecker kustomization documents.

Verified against live Prometheus instead of reasoned about: server ready=1,
agent available=1, cronjob 1 series. The originals returned nothing, which is
exactly why they never fired rather than firing wrongly.

Two stale claims in the same file corrected while here: it pointed at
kubernetes/apps/woodpecker/rotate-agent-secret.yaml, which no longer exists,
and asserted the CronJob/Job series "stay empty until the rotation feature is
merged" — that rotation has since landed in openbao.

Also finishes the netpol comments #511 left mid-update, which still credited
woodpecker-allow-rotators to `apps/*`. gitea.apps.svc:3000 in review-bot.yaml
is deliberately untouched: gitea really is still in apps.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 06:50:42 +02:00
k.renner 05945545e8 Merge pull request 'fix: [alert] KubeJobFailed (openbao) (#510)' (#511) from alert-fix/issue-510 into master
Reviewed-on: k.renner/homelab#511
2026-08-19 04:46:25 +00:00
issue-fix-bot d9f1d6cfc8 fix: [alert] KubeJobFailed (openbao) (#510)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
2026-08-19 03:44:13 +00:00
k.renner 4b1179e37b Merge pull request 'chore(ci): bump the woodpecker lint CLI to v3.17.0-alpine' (#509) from bump-woodpecker-cli into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#509
2026-08-18 21:46:13 +00:00
claude-bot 3d4b7a02f3 chore(ci): bump the woodpecker lint CLI to v3.17.0-alpine
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
From the update audit (#426), which flags this as the one item warranting
prompt action. Confirmed live: woodpecker-server runs v3.17.0 while the lint
step still pinned the v3.16.0 CLI.

That gap is the whole point of the step. The CLI compiles pipeline configs
against its own schema, so one behind the server can pass a config the server
then rejects — the gate stops predicting what the server will do, which is the
only thing it exists to do.

Verified the bump does not trade one problem for another: pulled
v3.17.0-alpine and ran the step's own lint loop over all 18 files in
.woodpecker/. Every one passes, so v3.17's schema does not reject anything
currently committed.

The drift is structural, not an oversight, and the comment now says so instead
of implying the two versions track each other. The server is a Kubernetes
manifest and image-automation bumps it; this pipeline file is plain YAML with
no apiVersion/kind, which Flux cannot patch (fluxcd/flux2#2826, recorded in
infrastructure/image-automation/README.md). So the server always moves first
and this always trails until someone bumps it by hand. Caught here by the
audit rather than by CI, which is the arrangement working as designed.

Also drops two stale "v3.16.0" references in the surrounding commentary that
would have aged into the same confusion.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 23:44:44 +02:00
k.renner 2e93edfda9 Merge pull request 'feat(woodpecker): stage 3 — retire the apps cluster, promote the survivor' (#508) from migrate-woodpecker-stage3 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#508
2026-08-18 21:38:52 +00:00
k.renner 550f7c223e Merge pull request 'fix(woodpecker): scope traefik ingress to the server, and record the cutover data gap' (#507) from migrate-woodpecker-stage2 into master
Reviewed-on: k.renner/homelab#507
2026-08-18 21:34:37 +00:00
claude-bot cca475ffe6 feat(woodpecker): stage 3 — retire the apps cluster, promote the survivor
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Closes #433.

Deletes the four apps-namespace manifests, promotes the migration-* set onto
their names, and gives the surviving Cluster the prune: disabled data guard it
was deliberately denied while a rollback still existed. After this the
kustomization renders nothing into `apps` at all.

Merging this deletes nothing. The old resources carry prune: disabled, so
dropping them from git only stops Flux managing them — they keep running and
keep their PVC. The teardown is a separate, deliberate act, and the runbook
carries the commands plus the precondition to check first.

That precondition is already satisfied: the new cluster took its own base
backup to the wp-pg-woodpecker prefix (wp-pg-daily-20260818205831, completed)
and reports Initialized, ConsistentSystemID, Ready, ContinuousArchiving and
LastBackupSucceeded all True. Recovery no longer depends on the retired
cluster's data.

Two things kept rather than cleaned up, both deliberate:

- bootstrap.recovery and its externalCluster stay on the Cluster. CNPG honours
  bootstrap only when PGDATA is absent, so it is inert on a live cluster — and
  keeping it means a cold rebuild restores from backup instead of coming up
  empty, which is the gap RECOVERY.md exists to close for the other clusters.
- wp-pg-store-source stays, pointing read-only at the old prefix. Nothing
  writes to it, and it is a second independent restore point predating the
  move — worth having until the new prefix builds real backup history. It is
  also the actual safety net for the teardown, rather than the running cluster.

The hetzner-creds comment now records the openbao ClusterSecretStore allowlist
requirement that cost stage 1 a failed restore (#505), so the next namespace
move does not rediscover it.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 23:33:19 +02:00
claude-bot c4be814524 docs(woodpecker): record the cutover's data gap instead of claiming there is none
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 stage-1 verification section said row counts "will not match exactly if
builds ran after the backup the restore used — that is expected, and stage 2
quiesces the server before the final cutover".

Stage 2 does not quiesce. Flux prunes the apps workloads and creates the
woodpecker ones; nothing stops writes to the old database in between. The
sentence described an intent that was never implemented, which is worse than
saying nothing.

Measured on the live cutover rather than estimated: the restore captured
through pipeline 9488; 9491, 9492, 9494 and 9496 landed in apps/wp-pg before
the swap; the new cluster resumed at 9521. Four pipeline records and their log
entries exist only in the old cluster. No ID collision and no functional
breakage — cluster.yaml itself calls this database small and non-critical with
largely reconstructable state — but it is real history loss, and it should be a
decision rather than something discovered later from a row count.

Documents how to avoid it next time (suspend the KS, scale the old server and
agent to zero, re-run the restore) and notes the rows stay recoverable while
the old cluster keeps its prune guard, which is until stage 3.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 23:28:29 +02:00
claude-bot d697f0f2c2 fix(woodpecker): scope traefik ingress to the server, not the namespace
Follow-up to #506, which merged before this landed. Addresses the review's
stale-comment suggestion, and a real over-grant found while checking it.

The comment above woodpecker-allow-rotators still explained the rule in terms
of the `apps` baseline and allow-ingress.yaml. Chasing that down showed
allow-ingress.yaml is hard-scoped to `namespace: apps` and matches traefik by
label — so it does not apply in the new namespace, and the traefik admit I put
in the woodpecker baseline instead was broader than the thing it replaced:
endpointSelector:{} plus a whole-namespace source, where the `ingress`
namespace also holds netbird-ingress. That let those pods reach wp-pg and the
agent, not just the server.

Replaced with woodpecker-allow-traefik, scoped to traefik and to the server
pod, matching how the rotator and CI-step rules are already written.

It lives in network-policies/woodpecker-ns.yaml rather than beside the other
workload policies, deliberately: the baseline admit is withdrawn in the same
file, so both halves ship in the infrastructure-crds Kustomization and swap in
one reconcile. Split across Kustomizations they would not — infrastructure-crds
reconciles before the `woodpecker` KS, so the admit would be withdrawn minutes
before its replacement arrived and ci.renner.uno would 503 in the gap.

Confirmed safe against certificate renewal: letsencrypt-cluster solves DNS-01
through Cloudflare, so there is no in-cluster ACME solver pod needing ingress.
With an HTTP-01 issuer this scoping would have broken renewals silently, 90
days later.

Runbook: records that the stage-2 outage outlasts pods becoming Ready, because
certificates are namespace-scoped and cert-manager must reissue woodpecker-tls.
Observed during the live cutover as a TLS SNI failure with no HTTP status at
all, which reads like a routing fault and is not one.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 23:27:00 +02:00
k.renner 900dfb66e5 Merge pull request 'feat(woodpecker): stage 2 — cut the server and agent over to the new namespace' (#506) from migrate-woodpecker-stage2 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#506
2026-08-18 21:23:13 +00:00
claude-bot 143eb8c8a8 feat(woodpecker): stage 2 — cut the server and agent over to the new namespace
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
Addresses #433. This is the downtime window: Flux prunes the apps copies and
creates the woodpecker ones, so ci.renner.uno is unavailable in between and
in-flight pipelines die with their agent.

Moved into `woodpecker`: server, agent, service, ingress, pdb, the agent
ServiceAccount, the woodpecker-secret ExternalSecret, and the rotator ingress
policy. The server's DSN now points at wp-pg-rw.woodpecker.svc and the agent's
gRPC at woodpecker.woodpecker.svc:9000.

Stage 1's restore is verified before doing this, not assumed: the new cluster
reports Ready=True and ConsistentSystemID=True (both False on the apps copy for
three weeks), its row counts match the live database exactly — 6 repos, 6125
pipelines, 2 users, 659 MB — and cnpg_collector_up now has a woodpecker series
for the first time.

Cross-namespace edges that had to move with it:

- rbac.yaml's RoleBinding subject. The Role and RoleBinding stay in
  woodpecker-ci, because that is where they grant pod create/delete; only the
  subject namespace changes.
- The openbao rotators' egress, which targeted `namespace: apps` + name
  woodpecker on :8000.

New: woodpecker-allow-ci-steps grants woodpecker-ci → server :8000, scoped to
the SERVER pod. Stage 1 left this out on purpose after review found the same
rule on the namespace baseline would have handed arbitrary build steps a route
to wp-pg, since the CNPG instance manager serves its admin API on :8000 too.
Post-stage-1 the datapath confirms step-pod identities reach wp-pg on ICMP types
only; this rule must not change that.

Deliberately NOT touched: cluster.yaml, objectstore.yaml, scheduled-backup.yaml
and hetzner-creds-externalsecret.yaml still describe the original apps cluster.
They are the rollback, and they keep their prune guards. Stage 3 retires them
and renames the migration-* set onto their names.

Also corrects the runbook. It told the next person to verify policy semantics
with `cilium-dbg policy trace`, which does not exist in Cilium 1.20 — the
subcommand was removed, leaving only get/selectors/subject-selectors/wait.
Replaced with reading the realized BPF policy map via `bpf policy get
<endpoint-id>`, which is better evidence anyway: it is the datapath as
programmed rather than a simulation, and it carries live byte/packet counters.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 23:18:41 +02:00
k.renner 597aa957ea Merge pull request 'fix(eso): allow the woodpecker namespace to use the openbao store' (#505) from fix-eso-woodpecker-namespace into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#505
2026-08-18 21:04:51 +00:00
claude-bot 8156d919e1 fix(eso): allow the woodpecker namespace to use the openbao store
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Stage 1 of #433 is failing in the cluster. The wp-pg restore job errors
with:

  can't get credentials for cluster wp-pg: while getting secret
  hetzner-s3-creds: secrets "hetzner-s3-creds" not found

and the ExternalSecret behind it reports SecretSyncedError:

  using cluster store "openbao" is not allowed from namespace
  "woodpecker": denied by spec.condition

The ClusterSecretStore carries an explicit namespace allowlist, and the new
namespace was not on it. Its own header says so — "adding an ExternalSecret
in a NEW namespace requires adding that namespace here first, or ESO
reports 'not allowed to reference ClusterSecretStore' and the secret never
syncs" — so the requirement was documented and simply missed.

The reference sweep for #504 looked for everything that referenced
woodpecker or resolved *.apps.svc. It never asked the other question a
namespace move raises: what allowlists must the NEW namespace join? This
allowlist is exactly that shape, and it is the kind of thing kustomize
build and dry-run cannot surface, because the manifests are all valid — the
denial happens at ESO's admission of a cross-namespace reference.

Added in sorted position, keeping the list's stated ordering.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 23:00:27 +02:00
k.renner e102e3c259 Merge pull request 'feat(woodpecker): stage 1 — restore wp-pg into a dedicated namespace' (#504) from migrate-woodpecker-namespace into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#504
2026-08-18 20:56:42 +00:00
claude-bot 4996a6f738 fix(woodpecker): allow ICMP to the new namespace's endpoints
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
From the fourth review on #504 — no blocking issues, but this one is a real
gap rather than a style point.

The woodpecker baseline was the only one of the repo's namespace policies
without an ICMP allow. That matters here specifically: wp-pg-allow-cnpg-operator
attaches an L4 rule (toPorts :8000) to the wp-pg pods, and Cilium blocks ICMP
to an endpoint once any layer 4 policy selects it unless the traffic is
CT-related. So PMTUD Destination Unreachable and Packet Too Big toward wp-pg
would have been dropped silently, surfacing as stalled transfers rather than
as a policy denial.

Uses the 16-type set the other baselines now carry after #495 trimmed them
from 39, rather than reintroducing the old list.

Granting this namespace-wide does not contradict the caution comment above it.
The :8000 case had to be per-workload because that port is shared between the
woodpecker server and the CNPG instance manager's admin API, so a blanket rule
handed CI step pods a route to the database. ICMP echo and path-MTU messages
expose no API surface, so a namespace-wide grant concedes nothing a scoped one
would withhold — which is why every other baseline does it this way too.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 22:52:31 +02:00
claude-bot e56f1e7cf6 fix(woodpecker): add the missing PodMonitor and base backup for wp-pg
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
Addresses the third blocking review finding on #504.

The PR claimed the namespace move fixes wp-pg's absent metrics. It does not,
on its own. Prometheus requires the `release: kube-prometheus-stack` label
that CNPG's own enablePodMonitor omits, so every scraped cluster has a
hand-written PodMonitor in apps/monitoring-rules/cnpg-podmonitors.yaml —
ak-pg, im-pg, gf-pg, pprl-pg. There has never been one for wp-pg.

So wp-pg was missing both halves: no scrape target configured, and no
monitoring ingress allow in the apps baseline. Fixing the policy half was
necessary but not sufficient, and the runbook's cnpg_collector_up check
would have returned empty either way — unable to confirm the thing it
existed to confirm. Adds the PodMonitor in the woodpecker namespace.

Also from the review:

- Added a ScheduledBackup for the restored cluster. Every other CNPG
  cluster here pairs isWALArchiver with one. Without it the new
  wp-pg-woodpecker prefix takes continuous WAL but never a base backup, so
  it has no independent recovery point and wp-pg-store's 30d retention has
  nothing to prune against — the WAL chain grows unbounded for as long as
  stage 1 sits before cutover. Scheduled 02:40, clear of the apps cluster's
  02:35 and the other clusters' 02:30.

- Documented that the `woodpecker` KS goes not-Ready while the restore runs.
  Its healthCheckExprs matches Cluster by GVK with no name qualifier, so
  Flux evaluates it against both the live apps/wp-pg and the restoring
  woodpecker/wp-pg, and wait: true makes the KS that owns production
  Woodpecker report unhealthy. Expected, does not cascade since nothing
  dependsOn it, but it would read as an outage without a note.

- Dropped the app.kubernetes.io/name label from the new Namespace; no other
  namespace in that file carries one.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 22:44:36 +02:00
claude-bot 1b5ad600d5 fix(woodpecker): set database/owner on the wp-pg recovery bootstrap
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
Addresses the second blocking review finding on #504.

bootstrap.recovery set only `source`. The live CRD documents the defaults:
`database` defaults to `app`, and `owner` defaults to whatever `database`
is. Neither name exists in the physically restored data, which carries
woodpecker/woodpecker from the backup.

So CNPG would have restored the real data, then provisioned a separate
empty `app` database and pointed the generated wp-pg-app Secret and the
-app Service at that instead — a cluster reporting healthy, holding the
right bytes, with the credentials aimed at the wrong database. The runbook's
own verification (psql -U woodpecker -d woodpecker using the wp-pg-app
password) would not have authenticated, so the failure would have surfaced
as a confusing auth error rather than as what it was.

kubernetes/RECOVERY.md sets both fields on all three recovery examples it
documents (ak-pg, im-pg, pprl-pg) for exactly this reason; this one did not
follow the pattern.

Added a runbook step to read dbname/username back out of the wp-pg-app
Secret before the row-count checks, so the mismatch is caught directly
rather than inferred from a psql failure.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 22:33:50 +02:00
claude-bot b0ddbe0dfd fix(woodpecker): drop the woodpecker-ci rule off the blanket ingress policy
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
Addresses the blocking review finding on #504.

The "step pods call the server back on :8000" rule was attached to the
namespace baseline, which uses endpointSelector: {}. A toPorts block
narrows the port, not the destination workload — that is governed solely by
the enclosing policy's endpointSelector. So the rule granted woodpecker-ci
access to :8000 on every pod in the namespace, and the CNPG instance
manager serves its status API on :8000 (server.yaml:94 uses the same port).
Arbitrary repo-supplied build steps would have had a route to wp-pg's
instance manager.

That is the same endpointSelector:{}-means-blanket-grant failure this PR
exists to escape, reproduced inside the fix.

Removed rather than rescoped: there is no woodpecker server in this
namespace until stage 2, so the rule was premature as well as wrong. It
lands there as its own policy selecting the server pod, the way
woodpecker-allow-rotators already scopes its :8000 rule.

The baseline now carries no port-scoped rules at all, so nothing in it can
be mistaken for workload scoping, and a comment warns why that must stay
true.

Also from the review:

- Corrected the KS attribution. The network policies ship with
  infrastructure-crds (infrastructure/post-crds includes
  ../network-policies/), not the top-level infrastructure KS. The ordering
  conclusion is unchanged — infrastructure-crds dependsOn infrastructure,
  and woodpecker dependsOn both — but the comments named the wrong one.
- Documented why the baseline has no egress section, instead of leaving it
  to read as an oversight. Writing that ruleset in stage 2, with the server
  and agent present, beats guessing it now and breaking the WAL archiving
  this stage exists to verify.
- Dropped the monitoring -> :9187 rule from wp-pg-allow-cnpg-operator. The
  baseline already allows the monitoring namespace unrestricted, so it added
  no coverage while implying scoping it did not provide.
- Added a cilium-dbg policy trace step to the runbook. dry-run validates
  manifest shape, not Cilium semantics, and would not have caught this bug.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 22:25:32 +02:00
claude-bot 85268c1e79 feat(woodpecker): stage 1 — restore wp-pg into a dedicated namespace
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
Addresses #433. No cutover, no CI impact: this stands up the `woodpecker`
namespace and restores wp-pg into it from barman, running in parallel with
the live apps/wp-pg. Nothing points at it yet.

Why a namespace move rather than a policy fix. wp-pg has been Ready=False
with "Instance Status Extraction Error" for three weeks because the CNPG
operator in `database` cannot reach https://<pod-ip>:8000/pg/status. The
apps default-deny-ingress uses endpointSelector:{}, so the only
namespace-level remedy is to allow the whole database namespace in — which
would expose gitea, gatus, external-dns and ical-proxy along with wp-pg, to
fix one pod on one port. The same gap explains why cnpg_collector_up has
series for authentik, immich, monitoring and paperless but not wp-pg: apps
never allowed the monitoring namespace either.

woodpecker-ci is deliberately not the target. It enforces pod-security:
privileged and exists for ephemeral step pods running repo-supplied code;
the database has no business sharing it.

Two ObjectStores, deliberately. wp-pg-store-source reads the existing
s3://renner-uno-k8s-cnpg/wp-pg prefix; wp-pg-store archives to a new
wp-pg-woodpecker prefix. Two clusters writing one destinationPath would
interleave their WAL streams and corrupt both, and splitting them keeps the
pre-migration backup intact as rollback.

The Namespace is declared in infrastructure/namespaces rather than beside
these workloads: its network policies ship with the `infrastructure` KS,
which the `woodpecker` KS dependsOn, so creating it in the workload KS would
invert that order and deadlock a cold rebuild.

The restored cluster deliberately carries no prune: disabled guard. The
original has one because it holds the only copy of the data; this one is
reconstructable, and a prune guard on a migration artifact is how orphans
outlive an abandoned migration. It gains the guard in stage 3.

Barman creds still resolve from seed/paperless/hetzner-creds. #397 wants a
woodpecker-owned path and this was the natural moment, but creating it needs
OpenBao write access that is not available here (bao kv list seed -> 403).
Pointing at a path that may not exist would leave the restored cluster
unable to archive WAL, which is what this stage exists to prove.

Runbook, including how to verify the restore holds real data rather than
merely coming up healthy: MIGRATION-namespace.md.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 22:14:26 +02:00
k.renner 283f7b1fee Merge pull request 'fix(ceph): set mon_data_avail_warn to 20%, matching the shared /var' (#502) from ceph-mon-data-avail-warn into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#502
2026-08-18 19:45:24 +00:00
k.renner 96d60e3da7 Merge pull request 'revert(kubelet): restore image GC thresholds to 70/60' (#501) from revert-image-gc-thresholds into master
Reviewed-on: k.renner/homelab#501
2026-08-18 19:44:59 +00:00
claude-bot 57acf5d8f2 fix(ceph): set mon_data_avail_warn to 20%, matching the shared /var
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
Addresses #434.

Ceph's 30% default assumes the mon owns its filesystem, where 30% free is
a real reserve. Our mons sit on dataDirHostPath /var/lib/rook — the node's
/var, shared with ~29 GB of containerd storage — and the mon DB is ~106 MB.
The default therefore measures container churn rather than Ceph state, and
k8s-2 trips MON_DISK_LOW whenever its image cache fills.

Tuning image GC does not help: measured on k8s-2, dropping the GC high
threshold to 60 deleted 28 images and moved free space by 0.02 percentage
points, because most of /var/lib/containerd is overlayfs snapshots held by
running containers, which image GC cannot reclaim.

20% of a 60 GB /var still leaves ~12 GB behind a 106 MB database.
mon_data_avail_crit is untouched at 5%, and CephMonDiskLow watches /var
directly for the real risk — the node filling, not the mon growing.

Set through cephConfig so it is reconciled from git rather than applied as
a live `ceph config set`.

The comment records that this override should be revisited, and dropped,
when mons get their own volume at bare-metal migration. That is not
possible on the current cluster: every StorageClass is Ceph-backed, so a
mon PVC would be circular, and Rook applies mon.volumeClaimTemplate only
to newly created mons.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 21:41:02 +02:00
claude-bot b7f599b2c1 revert(kubelet): restore image GC thresholds to 70/60
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 60/50 change in #500 was based on a wrong premise. Reverting the
values and correcting the reasoning recorded around them.

Applied to k8s-2 alone and measured. GC did fire: 28 images deleted,
content store 9.93 -> 8.62 GB. Free space moved 31.49% -> 31.47% — no
effect.

The premise was that ~19 GB of overlayfs snapshots would be reclaimed
along with their images, at roughly 2.9x the compressed size. That is
wrong. /var/lib/containerd is 20.5 GB of snapshots against 8.6 GB of
content, and those snapshots are unpacked layers shared with running
containers. Image GC cannot touch them, so deleting unused image
references frees only their unshared blobs. Remaining reclaimable
content is now ~0.7 GB; further sweeps free nothing.

70/60 stands on its own merits as a bound on the image cache — it is
simply not a lever on MON_DISK_LOW, and the comment no longer claims it
is. The mon's own data is ~106 MB, so a 30%-free threshold on a 60 GB
filesystem it merely shares says nothing about Ceph.

The CephMonDiskLow runbook text carried the same wrong claim and is
corrected to point at finding the real consumer instead.

k8s-2's live kubelet is already back to 70/60 via talosctl patch; node
Ready, Ceph HEALTH_OK with mon quorum a,b,c intact. k8s-1 and k8s-3 were
never changed.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 21:35:29 +02:00
k.renner 2337aa16e7 Merge pull request 'feat(ceph): add the toolbox, move image GC off Ceph's warn line, alert on health' (#500) from fix-ceph-toolbox-and-gc-thresholds into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#500
2026-08-18 19:27:30 +00:00
claude-bot 16360aacf9 feat(ceph): add the toolbox, move image GC off Ceph's warn line, alert
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
Addresses #434.

Gap 2 — no admin path. There is still no rook-ceph-tools deployment, so
ceph status, ceph health detail, ceph osd df tree, ceph osd perf and ceph
device ls are all unreachable during an incident: mons, OSDs and mgr
mount no admin keyring and the operator's /etc/ceph is empty. Adds the
upstream toolbox from rook v1.20.4 (matching the running operator image)
with the image pinned to the cluster's own quay.io/ceph/ceph:v19.2.5 and
carrying the same imagepolicy marker as cephcluster.yaml, so the client
cannot drift ahead of the mons. Prerequisites verified present: the
rook-ceph-default ServiceAccount, the rook-ceph-mon-endpoints ConfigMap,
and ceph-secret/ceph-username in the rook-ceph-mon secret.

Image GC. The thresholds were already lowered once from the kubelet
defaults, to 70/60, explicitly to stay under Ceph's mon_data_avail_warn.
But that line is <=30% free and a high of 70 fires at exactly 30% free —
on the threshold, not below it. So the cache fills to precisely the point
where the mon warns, and MON_DISK_LOW trips before kubelet reclaims
anything. Observed on k8s-2: 29.81% free and HEALTH_WARN MON_DISK_LOW,
clearing to 31.64% once GC ran. 60/50 fires at 40% free and reclaims to
50%, leaving 10 points of margin.

There is plenty to reclaim, so the lower threshold will bite: containerd
holds ~29 GB of k8s-2's 60 GB /var and caches 179 images against 60 in
use, some dating to May. (The ~309 GB talosctl attributes to
/var/lib/kubelet is mounted Ceph PVCs counted through mount points, not
local disk.)

Alerting, the follow-up #434 parked until ceph_* series existed — they do
now, via #432. CephHealthWarn deliberately waits 6h: Ceph warns for
self-clearing conditions and paging on those teaches people to ignore it,
so what is worth waking for is a warning that does not clear.
CephHealthError fires at 5m. CephMonDiskLow watches /var free directly
rather than going through Ceph, because this fires for a full image cache
rather than for Ceph growth, and naming that keeps the next person from
debugging the wrong subsystem.

NOTE: cluster.yaml is a bootstrap patch. Merging changes nothing on
running nodes — the live kubelet value needs talosctl patch machineconfig.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-18 21:06:43 +02:00
k.renner bb136e30b3 Merge pull request 'chore(dns): drop the stale UDM records pointing at the retired Hetzner VM' (#497) from drop-stale-hetzner-dns into master
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/cron/update-audit Pipeline was successful
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
Reviewed-on: k.renner/homelab#497
2026-08-16 21:15:06 +00:00
k.renner 9a1f6cce0c Merge pull request 'fix(cilium): stop the endpoint policy map filling — trim ICMP set, limit identity labels, size + alert' (#495) from fix-cilium-policy-map-exhaustion into master
Reviewed-on: k.renner/homelab#495
2026-08-16 21:14:08 +00:00
claude-bot 25c8c806b4 fix(cilium): cut policy map demand at the source, not just the ceiling
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 earlier commits in this branch raised policyMapMax and added an alert.
Neither addresses why the map fills. This does.

Measured on k8s-1 with cilium-dbg: 8 endpoint policy maps sit at exactly
16384 entries — full — and the rest cluster at ~8,500. The split tracks
policy direction, so one direction costs ~8,500 entries. Dumping velero's
endpoint 2328 (8,499 entries, ingress only) shows 217 identities x 39
port/proto values, of which 38 are ICMP types. The map is ~99% ICMP, and
3,744 of its entries (44%) belong to woodpecker-ci-step identities.

Two multiplying causes, both fixed here.

The ICMP rule in every default-deny-ingress allowed 39 enumerated types
from `namespace: Exists` — every identity in the cluster. Cilium expands
that to one entry per (identity, type), per endpoint, per direction, in
19 namespaces. The rule's own comment records it was widened "from the
error class to the full standard type set"; that widening is the cost.
Trimmed to 16: the error types that motivated the rule (v4 3/11/12, v6
1/2/3/4 — PMTUD is the documented need), echo, and the IPv6 ND/RA/MLDv2
types observed in flight via Hubble (133, 135, 143). Dropped types are
deprecated (v4 4 Source Quench, RFC 6633), router-only (v4 5, v6 137),
obsolete (v4 13-18, 30), superseded (v6 130-132 by 143), or never
deployed (v6 138, 141/142, 148/149, 151-153). openbao-ns.yaml already
runs an error-only set of 7 in production, so 16 is the conservative side
of existing practice.

Cilium had no `labels` filter, so identity came from all pod labels.
Woodpecker stamps each step pod with woodpecker-ci.org/task-uuid and
step=<name>, making every build step its own security identity — 96 of
them minted in the last hour, against zero pods in the namespace. The GC
is healthy and simply outpaced. Excluding those two label patterns
collapses 78 current identities to one. No CiliumNetworkPolicy selects on
them, so they bought churn and nothing else.

Together these take a direction from ~8,500 entries to roughly 140
identities x 17 values ~= 2,400 — about 4,800 for both directions, well
inside even the old 16384 ceiling. policyMapMax stays raised as headroom,
not as the fix.

Per Cilium's docs, existing identities are not recomputed by a `labels`
change; new ones use the filter and the old ones age out through normal
GC, so the map drains over roughly an hour rather than instantly.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-16 23:08:26 +02:00
claude-botandClaude Opus 5 0211269b91 chore(dns): drop the stale UDM records pointing at the retired Hetzner VM
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
renner.uno and www.renner.uno were pinned to 159.69.42.237, the VM that hosted
WordPress before it moved into the cluster. Neither record is live on the UDM
any more (verified: a LAN query for www.renner.uno returns the public answer,
proxy.renner.uno), so the file was drift waiting to be re-applied — a tofu
apply would have pointed the house at a host that is being decommissioned,
while the rest of the world already reaches the cluster.

Their absence is the correct end state, not an omission: www.renner.uno
carries dns.renner.uno/public, so external-dns-unifi writes no UDM record by
design and LAN clients take the public path and hairpin back in. The apex
stays with Cloudflare, which now redirects renner.uno/* to www.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014giKN9N6e5J2DF8QZmVh98
2026-08-16 22:43:42 +02:00
claude-bot 9371f188b6 docs(cilium): state the alert's map scope and complete the lockstep refs
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Two optional points from the second review on #495.

The PrometheusRule alerts on cilium_bpf_map_pressure without filtering on
map_name, so it covers conntrack, NAT and LB maps as well as the policy
map. That is intended — running out of any of them degrades the datapath
— but the PR is framed around the policy map, so a reader could take the
narrow reading. Say so in the file.

The cross-references between the three copies of bpf-policy-map-max were
one-directional: helmrelease.yaml and patches/cilium-cni.yaml each named
only one other site, while talos/cilium.yaml named both. Each now names
the other two.

Not taken: the suggestion to quote policyMapMax as "65536". The chart
declares it `type: [null, integer]` in its values schema
(helm show values cilium --version 1.20.0), so an integer is correct and
a quoted string would be the schema violation.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-16 22:41:42 +02:00
claude-bot 621e4e25bf fix(cilium): carry policyMapMax into the bootstrap inline manifest
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
Addresses the blocking review finding on #495.

Raising bpf.policyMapMax only in the HelmRelease left the bootstrap path
on the old value. kubernetes/bootstrap.sh:126 passes
kubernetes/talos/patches/cilium-cni.yaml to talosctl apply-config for
every node, and that manifest hardcoded bpf-policy-map-max: "16384" — so
a cold rebuild would install Cilium with the map size this PR exists to
fix, and stay there until Flux reconciled the HelmRelease.

The sibling bpf-lb-map-max is "65536" in both files, matching the live
agent config, so keeping these in sync is the repo's existing practice.

kubernetes/talos/cilium.yaml carries the same key and is updated too.
Nothing in the repo references that file — only patches/cilium-cni.yaml
is used by bootstrap.sh — so it looks like an orphaned rendered snapshot.
Syncing it rather than deleting it: whether it should exist at all is a
separate question from this fix.

Each of the three sites now names the others, so the next change to this
value has the lockstep written down where someone will hit it.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-16 22:36:35 +02:00
k.renner fe75b912f5 Merge pull request 'chore(ci): bump all ci-k8s pipeline pins to 2026-08-10' (#496) from bump-ci-k8s-pins into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#496
2026-08-16 20:34:16 +00:00
claude-bot 8f45e2901b fix(cilium): size the endpoint policy map and alert before it fills
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
Closes #451. Closes #454.

Both issues are the same fault: a pod's dedicated cilium_policy_v3_<id>
BPF map ran out of space, endpoint regeneration failed with "no space
left on device", the CNI ADD call never returned, and the pod sat in
sandbox creation. For paperless — replicas: 1 with strategy Recreate —
that was a 41-minute outage on 2026-07-30.

It is not over. Prometheus (metrics landed via #452) puts
cilium_bpf_map_pressure{map_name="cilium_policy_v3_*"} at 0.867 on all
three nodes right now, and max_over_time(...[30d]) at 1.03 — the map has
been driven past capacity. Loki has the overflow recurring as recently
as 2026-08-12 17:46Z, hitting authentik and esphome endpoints.

Three changes:

- bpf.policyMapMax 16384 (chart default) -> 65536, matching the cluster's
  existing bpf-lb-map-max. Takes steady-state pressure from ~87% to ~22%.
  The map is sized at creation, so this only takes effect as agents
  restart.
- The chart range ">=1.19.0" is what let Flux take 1.19.6 -> 1.20.0
  unattended 45 minutes before the first outage. Narrowed to
  ">=1.20.0 <1.21.0" so a CNI minor is a reviewed diff, not a surprise.
- A PrometheusRule on cilium_bpf_map_pressure: warning over 80% for 15m,
  critical at 100%. The metric was already scraped but nothing alerted on
  it, so the first signal was a downstream replica mismatch.

Not addressed: why the busiest endpoint needs ~14k entries. Only 178
CiliumIdentities exist and no CiliumNetworkPolicy declares more than 9
ports, so the entry count is not obviously explained by policy shape.
Establishing that needs `cilium-dbg bpf policy get` on an agent.

MERGING THIS ROLLS THE CNI. The DaemonSet uses
updateStrategy.rollingUpdate.maxUnavailable: 2, so two of three agents
restart at once.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-16 22:28:20 +02:00
k.renner d9b4c47cf2 Merge pull request 'fix(wordpress): route the outpost OAuth path so the gate is a gate, not a dead end' (#494) from fix-outpost-callback-path into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#494
2026-08-16 20:24:09 +00:00
claude-bot 632e092363 chore(ci): bump all ci-k8s pipeline pins to 2026-08-10
ci/woodpecker/manual/e2e-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Closes #461.

Five pipeline steps pinned ci-k8s at two stale tags: e2e-test.yml,
e2e.yml, alert-monitor.yml and issue-fix.yml at 2026-05-01, and
ci-manifests-test.yml at 2026-07-30. 2026-08-10 is the newest dated tag
in gitea_admin/ci-k8s and shares a digest with :latest
(sha256:0019f264...), so all five now run the same image the build
pipeline last produced.

A stale pin eventually points at a garbage-collected tag, and the split
had no deliberate reason behind it.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-16 22:21:57 +02:00
59 changed files with 1288 additions and 710 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ when:
steps:
- name: fetch-alerts
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-05-01
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
+13 -7
View File
@@ -60,7 +60,7 @@ steps:
# 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
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
commands:
- |
set -eu
@@ -115,7 +115,7 @@ steps:
# 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
# still hold on current CLIs — 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
@@ -123,9 +123,15 @@ steps:
# 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.
# stable version. Matching the deployed woodpecker-server is a CONSEQUENCE of
# that, not a separate requirement — the server is bumped by image-automation
# within the 3.x range, so both should land on the same release.
#
# They do not land together, though: the server is a Kubernetes manifest and
# is bumped automatically, this pipeline file is not and cannot be (see
# infrastructure/image-automation/README.md). So the server moves first and
# this trails until someone bumps it by hand — which is what happened at
# v3.16.0 -> v3.17.0, caught by the update audit (#426) rather than by CI.
#
# 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
@@ -152,7 +158,7 @@ steps:
# 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
image: woodpeckerci/woodpecker-cli:v3.17.0-alpine
commands:
- |
set -eu
@@ -240,7 +246,7 @@ steps:
# 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
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
commands:
- |
set -eu
+1 -1
View File
@@ -4,7 +4,7 @@ when:
steps:
- name: e2e
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-05-01
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
+1 -1
View File
@@ -30,7 +30,7 @@ steps:
- sh scripts/ci/test-model-selection.sh
- name: e2e-test
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-05-01
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
+1 -1
View File
@@ -49,7 +49,7 @@ steps:
# (the issue-pick logic needs jq, which only the ci-claude image has);
# minting a 1h read-only token per run is cheap. Degrades to
# snapshot-only if the ci-diagnostics ServiceAccount isn't reconciled.
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-05-01
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
@@ -73,3 +73,29 @@ spec:
interval: 30s
podTargetLabels:
- role
---
# wp-pg, restored into the `woodpecker` namespace (issue #433). It has never
# been scraped: `apps` had no PodMonitor for it AND its baseline network policy
# never allowed the monitoring namespace, so `cnpg_collector_up` has series for
# the four clusters above but not this one. The namespace move fixes the policy
# half; without this PodMonitor there is still no scrape target at all.
#
# Deliberately not adding one for the cluster still in `apps`: it is retired in
# stage 3, and a PodMonitor there would only be reconciled to be deleted again.
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: cnpg-wp-pg
namespace: woodpecker
labels:
app.kubernetes.io/name: cloudnative-pg
release: kube-prometheus-stack
spec:
selector:
matchLabels:
cnpg.io/cluster: wp-pg
podMetricsEndpoints:
- port: metrics
interval: 30s
podTargetLabels:
- role
@@ -4,6 +4,8 @@ kind: Kustomization
# come from the kube-prometheus-stack chart. Flux applies this Kustomization
# only after `monitoring` is Ready (chart installed → CRDs present).
resources:
- prometheusrule-ceph.yaml
- prometheusrule-cilium.yaml
- prometheusrule-hetzner-backup.yaml
- prometheusrule-loki.yaml
- prometheusrule-netbird-availability.yaml
@@ -0,0 +1,56 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: ceph
namespace: monitoring
labels:
# kube-prometheus-stack's Prometheus resource selects on this label;
# without it the rule is created but never loaded into Prometheus.
release: kube-prometheus-stack
spec:
groups:
- name: ceph.rules
rules:
- alert: CephHealthWarn
# ceph_health_status: 0 = HEALTH_OK, 1 = HEALTH_WARN, 2 = HEALTH_ERR.
# 6h is deliberately long. Ceph warns for self-clearing conditions —
# a slow BlueStore op during the Sunday restore sweep, a mon briefly
# below its disk threshold — and paging on those trains people to
# ignore the alert. What matters is a warning that does not clear.
expr: ceph_health_status == 1
for: 6h
labels:
severity: warning
annotations:
summary: "Ceph has been HEALTH_WARN for 6h"
description: "The Ceph cluster has held HEALTH_WARN for six hours, so it is not a self-clearing blip. Get the detail with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph health detail`."
- alert: CephHealthError
# HEALTH_ERR means data availability or redundancy is at risk.
expr: ceph_health_status == 2
for: 5m
labels:
severity: critical
annotations:
summary: "Ceph is HEALTH_ERR"
description: "The Ceph cluster is in HEALTH_ERR — availability or redundancy is at risk. Inspect immediately with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph health detail` and `ceph osd df tree`."
- alert: CephMonDiskLow
# Named explicitly rather than left to CephHealthWarn, because the
# mons sit on the node's /var alongside container storage: this
# fires for a full node, not for Ceph growth. The mon's own data is
# ~106 MB, so 30% of a 60 GB shared filesystem says nothing about
# Ceph.
# Image GC does not clear this — measured on k8s-2, the reclaimable
# content store is a small fraction of /var/lib/containerd next to
# ~20 GB of overlayfs snapshots held by running containers. Look at
# what actually grew before reaching for a threshold.
expr: |
100 * node_filesystem_avail_bytes{mountpoint="/var"}
/ node_filesystem_size_bytes{mountpoint="/var"} < 30
for: 30m
labels:
severity: warning
annotations:
summary: "{{ $labels.instance }} /var below 30% free — Ceph mon will warn"
description: "/var on {{ $labels.instance }} is under 30% free, which is Ceph's mon_data_avail_warn threshold, so the mon on this node will report MON_DISK_LOW. Find the actual consumer with `talosctl -n <node> usage /var/lib --depth 1` and then `/var/lib/containerd --depth 1` — note that overlayfs snapshots belong to running containers and are not reclaimable by image GC."
@@ -0,0 +1,44 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: cilium
namespace: monitoring
labels:
# kube-prometheus-stack's Prometheus resource selects on this label;
# without it the rule is created but never loaded into Prometheus.
release: kube-prometheus-stack
spec:
groups:
- name: cilium.rules
# Both alerts are map-agnostic on purpose. cilium_bpf_map_pressure is
# emitted per map type — policy, conntrack, NAT, LB — distinguished by
# the map_name label, and running out of any of them degrades the
# datapath. The policy map is merely the one that has bitten us.
# Cilium only exports the metric for maps above
# policyMapPressureMetricsThreshold (chart default 0.1), so quiet maps
# produce no series rather than a flood of near-zero ones.
rules:
- alert: CiliumBPFMapPressureHigh
# cilium_bpf_map_pressure is used/capacity for the fullest map of
# that name on the agent. A full map is not a slow path — it is a
# hard failure: the agent cannot program the datapath, so endpoint
# regeneration fails and every new pod on the node hangs in sandbox
# creation until something frees space.
expr: cilium_bpf_map_pressure > 0.8
for: 15m
labels:
severity: warning
annotations:
summary: "Cilium BPF map {{ $labels.map_name }} is {{ $value | humanizePercentage }} full on {{ $labels.node }}"
description: "The BPF map {{ $labels.map_name }} on node {{ $labels.node }} has been over 80% full for 15 minutes. At 100% the Cilium agent can no longer program the datapath and new pods on this node fail sandbox creation. Raise the corresponding size in kubernetes/infrastructure/cilium/helmrelease.yaml (values.bpf) — the map is sized at creation, so the change needs an agent restart."
- alert: CiliumBPFMapFull
# Pressure reaches (and can exceed) 1.0 when the agent wants more
# entries than the map holds. Pods are already failing to start.
expr: cilium_bpf_map_pressure >= 1
for: 0m
labels:
severity: critical
annotations:
summary: "Cilium BPF map {{ $labels.map_name }} is full on {{ $labels.node }}"
description: "The BPF map {{ $labels.map_name }} on node {{ $labels.node }} is at or over capacity. Endpoint regeneration is failing with 'no space left on device' and pods scheduled to this node will hang in ContainerCreating. Check `{namespace=\"kube-system\",pod=~\"cilium-.+\"} |= \"no space left on device\"` in Loki for the affected endpoints."
@@ -12,37 +12,37 @@ spec:
groups:
- name: woodpecker-rotation.rules
# Convergence safety net for the automated WOODPECKER_AGENT_SECRET rotation
# (kubernetes/apps/woodpecker/rotate-agent-secret.yaml). That rotation Job is
# (kubernetes/infrastructure/openbao/rotation/woodpecker.yaml). That rotation Job is
# fire-and-forget: it writes OpenBao and exits WITHOUT verifying ESO re-synced
# or that both consumers restarted healthy. These alerts close that gap. ESO
# metrics are not scraped in this cluster, so everything here is built on
# kube-state-metrics. The CronJob/Job series stay empty until the rotation
# feature (branch feat/woodpecker-secret-auto-rotation) is merged — harmless.
# kube-state-metrics. The rotation has since landed: the CronJob lives in the
# openbao namespace, not apps, which is where the last two rules look.
rules:
- alert: WoodpeckerServerNotReady
# 0 ready replicas well past the 10m ESO refresh + Reloader restart cycle.
expr: kube_statefulset_status_replicas_ready{namespace="apps",statefulset="woodpecker-server"} == 0
expr: kube_deployment_status_replicas_ready{namespace="woodpecker",deployment="woodpecker-server"} == 0
for: 20m
labels:
severity: critical
annotations:
summary: "Woodpecker CI server has no ready replica"
description: >-
StatefulSet woodpecker-server (ns apps) has had 0 ready replicas for 20m;
all CI is down (agents cannot reach woodpecker.apps.svc:9000). If this
followed a secret rotation, the server pod failed to return after its
Reloader-triggered restart — check `kubectl -n apps describe pod
woodpecker-server-0` (PVC attach / crashloop) and the woodpecker-secret
ExternalSecret sync status.
Deployment woodpecker-server (ns woodpecker) has had 0 ready replicas
for 20m; all CI is down (agents cannot reach
woodpecker.woodpecker.svc:9000). If this followed a secret rotation, the
server pod failed to return after its Reloader-triggered restart — check
`kubectl -n woodpecker describe deploy woodpecker-server` and the
woodpecker-secret ExternalSecret sync status.
- alert: WoodpeckerAgentNotReady
expr: kube_deployment_status_replicas_available{namespace="apps",deployment="woodpecker-agent"} == 0
expr: kube_deployment_status_replicas_available{namespace="woodpecker",deployment="woodpecker-agent"} == 0
for: 20m
labels:
severity: warning
annotations:
summary: "Woodpecker CI agent has no available replica"
description: >-
Deployment woodpecker-agent (ns apps) has had 0 available replicas for
Deployment woodpecker-agent (ns woodpecker) has had 0 available replicas for
20m; no pipelines will run. If this followed a secret rotation, the agent
did not return after its Reloader restart, or its WOODPECKER_AGENT_SECRET
no longer matches the server's — check the agent pod logs for a gRPC auth
@@ -50,29 +50,29 @@ spec:
- alert: WoodpeckerSecretRotationJobFailed
# Named, clearer signal than the generic KubeJobFailed for the OpenBao-write
# step. Fires while a failed rotation Job is retained (ttl 3d).
expr: kube_job_status_failed{namespace="apps",job_name=~"woodpecker-secret-rotate.*"} > 0
expr: kube_job_status_failed{namespace="openbao",job_name=~"woodpecker-secret-rotate.*"} > 0
for: 15m
labels:
severity: warning
annotations:
summary: "Woodpecker agent-secret rotation Job failed"
description: >-
A woodpecker-secret-rotate Job (ns apps) failed at the OpenBao-write step;
A woodpecker-secret-rotate Job (ns openbao) failed at the OpenBao-write step;
the agent secret was NOT rotated. Most common cause: the one-time OpenBao
`rotator` role/policy bootstrap was never applied (see
kubernetes/infrastructure/openbao/policies/rotator.hcl). Check
`kubectl -n apps logs job/<name>`.
`kubectl -n openbao logs job/<name>`.
- alert: WoodpeckerSecretRotationStale
# Quarterly cadence (~91d) + grace. The series is ABSENT until the first
# successful run, so this cannot false-fire before rotation has ever worked.
expr: (time() - kube_cronjob_status_last_successful_time{namespace="apps",cronjob="woodpecker-secret-rotate"}) > 100 * 24 * 3600
expr: (time() - kube_cronjob_status_last_successful_time{namespace="openbao",cronjob="woodpecker-secret-rotate"}) > 100 * 24 * 3600
for: 1h
labels:
severity: warning
annotations:
summary: "Woodpecker agent secret has not rotated in >100 days"
description: >-
The quarterly woodpecker-secret-rotate CronJob (ns apps) has not completed
The quarterly woodpecker-secret-rotate CronJob (ns openbao) has not completed
successfully in over 100 days — rotation has silently stopped (CronJob
suspended/deleted, or every run failing). Investigate the CronJob and its
recent Jobs.
@@ -0,0 +1,388 @@
# Woodpecker: `apps` → `woodpecker` namespace migration
Moves the Woodpecker service — server, agent and the `wp-pg` CNPG backend — out
of the shared `apps` namespace into one of its own. Distinct from
`MIGRATION.md`, which covers the earlier SQLite → CNPG move.
## Why
`apps` holds gitea, gatus, external-dns, ical-proxy and woodpecker together,
and its `default-deny-ingress` uses `endpointSelector: {}`. Every allow rule it
carries therefore applies to *every* workload in the namespace.
That is not academic. Issue #433 — `wp-pg` stuck at `Ready=False` with
`Instance Status Extraction Error` for three weeks — is caused by the CNPG
operator in `database` being unable to reach `https://<pod-ip>:8000/pg/status`.
The only namespace-level fix is `fromEndpoints: namespace=database`, which
would expose gitea, gatus, external-dns and ical-proxy to the whole `database`
namespace to solve a problem affecting one pod on one port.
`cnpg_collector_up` has series for authentik, immich, monitoring and paperless
but not for `wp-pg`, for the same reason: `apps` never allowed the monitoring
namespace either.
A namespace per service keeps each grant on the workload that needs it.
**`woodpecker-ci` is not the target.** It enforces `pod-security: privileged`
and exists solely for ephemeral pipeline step pods running repo-supplied code.
Putting the database there would let any build step reach it through the
intra-namespace allow.
## Staging
Rollback is trivial through stage 2. The `apps` cluster is untouched until
stage 3, so reverting means pointing the DSN back and scaling up.
| Stage | Contents | CI impact |
|---|---|---|
| **1** (this PR) | `woodpecker` namespace, its network policies, ExternalSecret, ObjectStores, and `wp-pg` restored from barman | none — parallel, nothing points at it |
| **runbook** | verify the restore holds real data | none |
| **2** | move server + agent, flip DSN, RBAC subject, rotator policies, docs | **downtime** |
| **3** | delete the `apps` originals, add the prune guard to the survivor | none |
## Stage 1 design notes
**Two ObjectStores, deliberately.** `wp-pg-store-source` points at
`s3://renner-uno-k8s-cnpg/wp-pg` — the prefix the live cluster archives to —
and is read-only. `wp-pg-store` points at a **new** prefix,
`s3://renner-uno-k8s-cnpg/wp-pg-woodpecker`, for ongoing archiving.
Both clusters writing one `destinationPath` would interleave their WAL streams
and corrupt both. Splitting them also leaves the pre-migration backup intact
and independently restorable.
**`serverName: wp-pg`** on the `externalCluster` names the cluster that
*produced* the backup, not the one being created.
**The namespace is declared in `infrastructure/namespaces/namespaces.yaml`,**
not alongside these workloads. Its CiliumNetworkPolicies ship with the
`infrastructure-crds` KS (`infrastructure/post-crds` includes
`../network-policies/`), which `dependsOn` `infrastructure` — so the namespace
exists before any policy targets it. Creating it in the `woodpecker` workload
KS would invert that order, since that KS runs after both.
**Ingress from `woodpecker-ci` is deliberately absent in stage 1.** Step pods do
call the server back on `:8000`, but the CNPG instance manager serves its status
API on `:8000` too, and the baseline policy uses `endpointSelector: {}`. A
`toPorts` block narrows the port, not the destination workload — so putting that
rule on the baseline would hand arbitrary repo-supplied build steps a route to
wp-pg's instance manager. It lands in stage 2 as its own policy selecting the
server pod.
**No `prune: disabled` guard on the restored cluster.** The original carries one
because it holds the only copy of the data. This one is reconstructable by
re-running the restore, and a prune guard on a migration artifact is how orphans
survive an abandoned migration. It gains the guard in stage 3.
**Barman credentials still come from `seed/paperless/hetzner-creds`.** Issue
#397 wants woodpecker to own `seed/woodpecker/hetzner-creds`, and this would
have been the natural moment — but creating that path needs OpenBao write
access that was not available (`bao kv list seed` → 403). Repointing at a path
that may not exist would leave the restored cluster unable to archive WAL,
which is the thing this stage exists to prove. #397 stays open; the swap is one
line once the path exists.
## Expect the `woodpecker` Kustomization to go not-Ready during the restore
`kubernetes/clusters/homelab/woodpecker.yaml` sets `wait: true` and a
`healthCheckExprs` entry matching `postgresql.cnpg.io/v1 Cluster` **with no
name or namespace qualifier**. Flux evaluates it against every Cluster in that
Kustomization's inventory — which, after this stage, is both the live
`apps/wp-pg` and the restoring `woodpecker/wp-pg`.
So while the restore runs, the whole `woodpecker` KS — the one that owns
production Woodpecker — reports not-Ready, and a reconcile timeout in that
window will trip the `flux-failures` Alert. **That is expected and is not
Woodpecker breaking.** Nothing `dependsOn: woodpecker`, so it does not cascade.
If the restore is going to sit for a long time, or the noise is unwelcome, the
options are to suspend the KS while it completes, or to split the migration
resources into their own `wait: false` Kustomization. For a ~660 MB database the
window should be minutes.
## Verifying stage 1
Restore is asynchronous. Watch it reach a healthy state:
```sh
kubectl -n woodpecker get cluster wp-pg -w
kubectl -n woodpecker get pods
```
Expect `Cluster in healthy state`. If it sticks in bootstrap, the recovery job's
logs carry the reason:
```sh
kubectl -n woodpecker logs -l cnpg.io/jobRole=full-recovery --tail=100
```
**First check the generated app credentials target the right database.** CNPG
defaults `bootstrap.recovery.database` to `app`; the restored data uses
`woodpecker`. If this reads anything but `woodpecker`, the cluster has
provisioned an empty `app` database and the real data is unreachable through
the `-app` Secret and Service:
```sh
kubectl -n woodpecker get secret wp-pg-app -o jsonpath='{.data.dbname}' | base64 -d; echo
kubectl -n woodpecker get secret wp-pg-app -o jsonpath='{.data.username}' | base64 -d; echo
```
**Then confirm the restore actually holds Woodpecker's data** — a cluster that
comes up healthy but empty is the failure mode worth catching:
```sh
PGPASS=$(kubectl -n woodpecker get secret wp-pg-app -o jsonpath='{.data.password}' | base64 -d)
kubectl -n woodpecker exec -it wp-pg-1 -- env PGPASSWORD="$PGPASS" \
psql -U woodpecker -d woodpecker -c '\dt'
kubectl -n woodpecker exec -it wp-pg-1 -- env PGPASSWORD="$PGPASS" \
psql -U woodpecker -d woodpecker -c 'select count(*) from repos;'
```
Row counts should match the live cluster:
```sh
PGPASS_OLD=$(kubectl -n apps get secret wp-pg-app -o jsonpath='{.data.password}' | base64 -d)
kubectl -n apps exec -it wp-pg-1 -- env PGPASSWORD="$PGPASS_OLD" \
psql -U woodpecker -d woodpecker -c 'select count(*) from repos;'
```
They will not match exactly if builds ran after the backup the restore used.
**And stage 2 does not close that gap.** An earlier draft of this document
claimed it quiesced the server first; it does not — Flux simply prunes the
`apps` workloads and creates the `woodpecker` ones, so anything written to the
old database between the restore and the cutover stays only in the old database.
Measured on the live cutover: the restore captured through pipeline `9488`,
four more (`9491`, `9492`, `9494`, `9496`) landed in `apps/wp-pg` before the
swap, and the new cluster resumed at `9521`. Four pipeline records and their
log entries exist only in the old cluster. No ID collision, no functional
breakage — Woodpecker's own manifest calls this database "small and
non-critical (state is largely reconstructable)" — but it is real history loss
and should be a decision, not a surprise.
If that gap matters for a future migration, quiesce before cutting over:
```sh
flux suspend kustomization woodpecker
kubectl -n <old-ns> scale deploy/woodpecker-server deploy/woodpecker-agent --replicas=0
# re-run the restore so it captures the final state, then resume
```
The old cluster keeps its `prune: disabled` guard until stage 3, so the missing
rows remain recoverable until then.
Confirm the two fixes this move exists for:
```sh
# #433: operator can now reach the instance
kubectl -n woodpecker get cluster wp-pg -o jsonpath='{.status.conditions}'
# metrics now scraped — needs BOTH the PodMonitor (cnpg-podmonitors.yaml) and
# the monitoring ingress allow; wp-pg had neither in `apps`, which is why it is
# absent from cnpg_collector_up while ak-pg/im-pg/gf-pg/pprl-pg are present
kubectl get --raw '/api/v1/namespaces/monitoring/services/kube-prometheus-stack-prometheus:9090/proxy/api/v1/query?query=cnpg_collector_up{namespace="woodpecker"}'
```
Confirm the scrape target is actually registered, not just that the metric is
missing for some other reason:
```sh
kubectl -n woodpecker get podmonitor cnpg-wp-pg
```
**Check the policy semantics by hand.** `kubectl --dry-run=server` validates
manifest shape, not Cilium behaviour, so it cannot catch a rule that is
syntactically fine but scoped wider than intended.
`cilium-dbg policy trace` does **not** exist in Cilium 1.20 — it was removed,
and `cilium-dbg policy` now offers only `get`, `selectors`, `subject-selectors`
and `wait`. Read the realized BPF policy map instead, which is better evidence
anyway: it is the datapath as programmed, not a simulation of it, and it
carries live byte/packet counters.
Find the agent on wp-pg's node and the endpoint id:
```sh
kubectl -n woodpecker get pod wp-pg-1 -o jsonpath='{.spec.nodeName}'
kubectl -n kube-system get pod -l k8s-app=cilium \
--field-selector spec.nodeName=<node> --no-headers -o custom-columns=N:.metadata.name
kubectl -n kube-system exec <cilium-pod> -c cilium-agent -- \
cilium-dbg endpoint list -o json \
| jq -r '.[] | select((.status.labels."security-relevant"[]?|tostring)
| test("cnpg.io/cluster=wp-pg")) | "\(.id) \(.status.networking.addressing[0].ipv4)"'
```
Then, on that endpoint id — who may reach `:8000`:
```sh
kubectl -n kube-system exec <cilium-pod> -c cilium-agent -- \
cilium-dbg bpf policy get <endpoint-id> | awk '$4 ~ /8000/ || NR<=1'
```
Expect exactly one row, the CNPG operator, with non-zero counters once it has
polled:
```
Allow Ingress k8s:app.kubernetes.io/instance=cloudnative-pg 8000/TCP 11756 58
```
And confirm CI step pods reach **ICMP only** — never a TCP port:
```sh
kubectl -n kube-system exec <cilium-pod> -c cilium-agent -- \
cilium-dbg bpf policy get <endpoint-id> | grep woodpecker-ci | awk '{print $4}' | sort -u
```
Every line must be an ICMP/ICMPv6 type. A bare port number here means the
namespace baseline has grown a rule it should not have.
Note the operator's allow is also confirmed indirectly and more strongly by
`Ready=True` on the Cluster: that condition only goes true once the operator has
successfully polled this exact endpoint and port.
And that archiving goes to the new prefix, leaving the old one alone:
```sh
kubectl -n woodpecker get cluster wp-pg -o jsonpath='{.status.conditions[?(@.type=="ContinuousArchiving")]}'
```
## Stage 2 — the cutover
Moves server, agent, service, ingress, pdb, the agent ServiceAccount, the
`woodpecker-secret` ExternalSecret and the rotator policy into `woodpecker`,
points the server's DSN at `wp-pg-rw.woodpecker.svc`, repoints the agent's gRPC
at `woodpecker.woodpecker.svc:9000`, moves the openbao rotators' egress, and
adds the `woodpecker-ci` → server `:8000` policy that stage 1 deliberately
deferred — scoped to the server pod, not the namespace baseline.
**This is the downtime window.** Flux prunes the `apps` copies and creates the
`woodpecker` ones; `ci.renner.uno` is down in between, and in-flight pipelines
die with their agent.
**The outage outlasts the pods becoming Ready, because of TLS.** Certificates
are namespace-scoped, so moving the Ingress does not move `woodpecker-tls` —
cert-manager issues a fresh one in the new namespace. Until it completes,
Traefik serves a default certificate and clients fail before they get an HTTP
status at all:
```
curl: (60) SSL: no alternative certificate subject name matches target hostname 'ci.renner.uno'
```
That is expected, not a routing or policy fault. Watch it clear with:
```sh
kubectl -n woodpecker get certificate woodpecker-tls -w
```
`letsencrypt-cluster` solves **DNS-01** via Cloudflare, so there is no in-cluster
ACME solver pod and no HTTP challenge to admit through the network policies —
worth knowing before scoping any ingress rule, since an HTTP-01 issuer would
need the solver reachable too.
The four CNPG files (`cluster.yaml`, `objectstore.yaml`, `scheduled-backup.yaml`,
`hetzner-creds-externalsecret.yaml`) still describe the **original** apps-namespace
cluster and are deliberately untouched — they are the rollback. Stage 3 retires
them.
### Verifying stage 2
```sh
kubectl -n woodpecker get pods
kubectl -n apps get pods -l app.kubernetes.io/name=woodpecker # expect none
```
The server must reach the restored database, not the old one:
```sh
kubectl -n woodpecker logs deploy/woodpecker-server --tail=40 | grep -i "database\|migrat\|error"
kubectl -n woodpecker exec deploy/woodpecker-server -- env | grep DATASOURCE
```
Then confirm the UI and a real pipeline, which is the only check that exercises
server → agent → step pod → server end to end:
```sh
curl -sS -o /dev/null -w '%{http_code}\n' https://ci.renner.uno/
woodpecker pipeline create --branch master --var PIPELINE=e2e-test k.renner/homelab
```
Step pods must still reach the server on `:8000` while still being unable to
reach wp-pg on any TCP port — re-run the `bpf policy get` checks above against
both the server's and wp-pg's endpoint ids.
Check the rotators can still reach the server, since their egress moved:
```sh
kubectl -n openbao get cronjob
kubectl -n openbao logs -l app.kubernetes.io/component=secret-rotator --tail=20
```
### Rolling back stage 2
Revert the PR. Flux recreates the `apps` workloads and the DSN points back at
`wp-pg-rw.apps.svc`, which never stopped running. Data written to the new
cluster during the window is lost, so roll back promptly or not at all — if
pipelines have run against `woodpecker/wp-pg`, prefer fixing forward.
## Stage 3 — retiring the old cluster
Deletes the four `apps` manifests, promotes the `migration-*` set onto their
names, and gives the surviving Cluster the `prune: disabled` data guard it was
deliberately denied while a rollback still existed.
**Merging stage 3 does not delete anything.** The old resources carry
`kustomize.toolkit.fluxcd.io/prune: disabled`, so dropping them from git only
stops Flux managing them — they keep running, and keep occupying their PVC.
That is the point: the PR is reversible, the teardown is a separate, deliberate
act.
Before deleting anything, confirm the new cluster is genuinely standing on its
own — that it has taken at least one base backup to its own prefix, so recovery
no longer depends on the retired cluster's data:
```sh
kubectl -n woodpecker get backup.postgresql.cnpg.io
kubectl -n woodpecker get cluster wp-pg -o jsonpath='{.status.conditions}'
```
`LastBackupSucceeded` must be `True`, not merely `BackupStarted`.
Then retire the old set:
```sh
kubectl -n apps delete scheduledbackup.postgresql.cnpg.io wp-pg-daily
kubectl -n apps delete cluster.postgresql.cnpg.io wp-pg # deletes its PVCs
kubectl -n apps delete objectstore.barmancloud.cnpg.io wp-pg-store
kubectl -n apps delete externalsecret hetzner-s3-creds
```
Delete the ScheduledBackup first: leaving it while the Cluster goes away
produces failing backup jobs against a cluster that no longer exists.
**What this destroys and what it does not.** It destroys the old cluster's PVCs
— including the four pipeline records that only ever existed there (see the
stage-1 verification section). It does **not** touch
`s3://renner-uno-k8s-cnpg/wp-pg`: that prefix keeps its backups until their own
retention expires, and `wp-pg-store-source` is retained precisely so it stays
restorable. That is the real safety net, not the running cluster.
### After the teardown
The `apps` namespace should have no woodpecker-related objects left:
```sh
kubectl -n apps get cluster,objectstore,scheduledbackup,externalsecret 2>/dev/null | grep -i "wp-pg\|hetzner" || echo "clean"
```
## Rolling back stage 1
Delete the four resources and the namespace. Nothing references them, and the
`apps` cluster is untouched:
```sh
kubectl delete ns woodpecker
```
Then revert this PR so Flux stops recreating them.
+3 -3
View File
@@ -2,7 +2,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: woodpecker-agent
namespace: apps
namespace: woodpecker
labels:
app.kubernetes.io/name: woodpecker
app.kubernetes.io/component: agent
@@ -12,7 +12,7 @@ metadata:
secret.reloader.stakater.com/reload: woodpecker-secret
spec:
# 1: the in-cluster server owns ci.renner.uno now, so this agent registers with
# it (woodpecker.apps.svc:9000) and runs pipelines on the kubernetes backend,
# it (woodpecker.woodpecker.svc:9000) and runs pipelines on the kubernetes backend,
# replacing the retired docker agents (LXC 910/911).
replicas: 1
selector:
@@ -37,7 +37,7 @@ spec:
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
value: woodpecker.woodpecker.svc.cluster.local:9000 # in-cluster gRPC, replaces LXC :9000
- name: WOODPECKER_MAX_WORKFLOWS
value: "2"
# --- kubernetes backend: pipeline steps run as native pods (retires
+34 -10
View File
@@ -1,19 +1,27 @@
---
# Woodpecker's Postgres backend. Serves the server at wp-pg-rw.woodpecker.svc.
#
# Originally created in `apps` with bootstrap.initdb; moved here by restoring
# from barman into the new namespace rather than by editing a namespace field,
# because PVCs are namespace-scoped and a Cluster cannot be relocated in place
# (issue #433, MIGRATION-namespace.md).
#
# The bootstrap.recovery stanza and its externalCluster are retained rather than
# reverted to initdb. CNPG honours `bootstrap` only when PGDATA is absent, so on
# this live cluster it is inert — and keeping it means a cold rebuild of an
# empty cluster restores from backup instead of coming up empty, which is the
# gap kubernetes/RECOVERY.md exists to close for the other CNPG clusters.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: wp-pg
namespace: apps
namespace: woodpecker
annotations:
# CRITICAL data-guard: prune-disabled so the seed-woodpecker -> dedicated
# woodpecker KS ownership handoff can never prune this Cluster (which would
# delete its data PVCs). Landed here first, via seed-woodpecker, so it's
# live before the move. Kept permanently as a standing guard.
# Data guard, earned at stage 3 now that this is the only copy: prevents a
# Flux prune from deleting the Cluster and with it the data PVCs. The
# migration deliberately withheld this while a rollback still existed.
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
# Single instance: the Woodpecker CI DB is small and non-critical (state is
# largely reconstructable), so HA isn't warranted — but WAL archiving to
# object storage still gives point-in-time backups. Mirrors grafana-db's
# single-instance shape, with barman enabled like immich/paperless.
instances: 1
imageName: ghcr.io/cloudnative-pg/postgresql:16
storage:
@@ -25,9 +33,25 @@ spec:
parameters:
barmanObjectName: wp-pg-store
bootstrap:
initdb:
recovery:
source: wp-pg-apps
# Must mirror the initdb values of the cluster this restores from
# (apps/wp-pg: database woodpecker, owner woodpecker). CNPG defaults
# `database` to `app` and `owner` to whatever `database` is, and those
# names do not exist in the physically restored data — it would provision
# an empty `app` database, point the generated wp-pg-app Secret and the
# -app Service at it, and leave the real data unreachable through them.
# Same pattern as every recovery example in kubernetes/RECOVERY.md.
database: woodpecker
owner: woodpecker
externalClusters:
- name: wp-pg-apps
plugin:
name: barman-cloud.cloudnative-pg.io
parameters:
barmanObjectName: wp-pg-store-source
# The cluster that produced the backup, not this one.
serverName: wp-pg
postgresql:
parameters:
shared_buffers: "128MB"
@@ -2,7 +2,7 @@ apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: woodpecker-secret
namespace: apps
namespace: woodpecker
spec:
# 10m (was 1h) so an automated rotation (rotate-agent-secret.yaml writes a new
# value to OpenBao) propagates into the k8s Secret within ~10m without a manual
@@ -1,11 +1,20 @@
---
# Barman S3 credentials for wp-pg.
#
# Sourced from seed/paperless/hetzner-creds, which is not where they belong.
# Issue #397 wants woodpecker to own seed/woodpecker/hetzner-creds; creating
# that path needs OpenBao write access (`bao kv list seed` → 403 for the
# identities available here), so it stays open. The values are identical, so it
# is a one-line swap of the `key:` below once the path exists.
#
# The namespace allowlist on the openbao ClusterSecretStore
# (infrastructure/post-crds/openbao-clustersecretstore.yaml) must include this
# namespace, or ESO silently never syncs — see #505.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hetzner-s3-creds
namespace: apps
annotations:
# Prune-disabled — a standing guard kept from the seed->dedicated-KS handoff (barman S3 creds).
kustomize.toolkit.fluxcd.io/prune: disabled
namespace: woodpecker
spec:
refreshInterval: 1h
secretStoreRef:
@@ -14,10 +23,5 @@ spec:
target:
name: hetzner-s3-creds
dataFrom:
# Reuses the paperless copy of the shared Hetzner S3 credentials (identical
# ACCESS_KEY_ID / ACCESS_SECRET_KEY / REGION) to avoid seeding a new OpenBao
# path for this migration. FOLLOW-UP: give woodpecker its own
# seed/woodpecker/hetzner-creds path once OpenBao can be written, and repoint
# this here (the value is the same, so it's a drop-in swap).
- extract:
key: seed/paperless/hetzner-creds
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: woodpecker
namespace: apps
namespace: woodpecker
labels:
app.kubernetes.io/name: woodpecker
annotations:
+16 -7
View File
@@ -21,11 +21,20 @@ resources:
# (kubernetes/infrastructure/openbao/rotation/woodpecker.yaml) — intra-ns
# OpenBao access, no NetworkPolicy needed. server.yaml/agent.yaml keep their
# Reloader annotations; externalsecret.yaml keeps the 10m refreshInterval.
# wp-pg CNPG Postgres backend (moved here from the retired seed-woodpecker KS;
# each object carries prune: disabled so ownership transferred without a
# teardown). The dedicated `woodpecker` KS depends on infrastructure-cnpg-barman
# for the barman plugin behind the ObjectStore/ScheduledBackup.
- cluster.yaml
- objectstore.yaml
- scheduled-backup.yaml
# --- namespace migration, stages 1-2 (issue #433) ---
# Stage 2 has moved the workloads above (server, agent, service, ingress, pdb,
# rbac, secrets, policies) into the `woodpecker` namespace and pointed the
# server's DSN at wp-pg-rw.woodpecker.svc. Stage 3 retired the `apps`
# originals; these four now describe the surviving cluster. History and the
# reasoning behind the three stages: MIGRATION-namespace.md.
#
# The `woodpecker` Namespace itself is declared in infrastructure/namespaces,
# not here: it must exist before the `infrastructure-crds` KS applies the
# namespace's CiliumNetworkPolicies, and this KS runs after both of those.
#
# This KS depends on infrastructure-cnpg-barman for the barman plugin behind
# the ObjectStore and ScheduledBackup.
- hetzner-creds-externalsecret.yaml
- objectstore.yaml
- cluster.yaml
- scheduled-backup.yaml
+31 -6
View File
@@ -1,20 +1,23 @@
# Secret rotation: the review-bot and claude-oauth rotators (openbao ns) reach
# the Woodpecker SERVER to upsert org-level CI secrets (review_bot_token,
# claude_code_oauth_token). They use the in-cluster Service
# (woodpecker.apps.svc:8000) — the public ci.renner.uno name hairpins off the
# ingress LB from a pod. The apps default-deny-ingress only allows same-ns /
# host / a few named sources, so without this the rotator's connect to :8000 is
# (woodpecker.woodpecker.svc:8000) — the public ci.renner.uno name hairpins off the
# ingress LB from a pod. This namespace's baseline
# (network-policies/woodpecker-ns.yaml) admits only same-namespace, monitoring,
# host/remote-node and ICMP, so without this the rotator's connect to :8000 is
# dropped. Scoped to the woodpecker SERVER pod :8000 and the two rotator labels;
# the egress halves are rotator-review-bot-egress + rotator-claude-oauth-egress
# in network-policies/openbao-ns.yaml.
#
# This is additive to the apps-ns baseline allows (allow-ingress.yaml selects all
# apps pods), so the server's existing ingress — Traefik, agents — is unaffected.
# Cilium unions allow rules, so this composes with the baseline and with the
# sibling policies below (traefik, CI steps) rather than replacing them. Note
# allow-ingress.yaml does NOT apply here — it is hard-scoped to `namespace:
# apps`, which is why traefik needs its own rule in this file now.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: woodpecker-allow-rotators
namespace: apps
namespace: woodpecker
spec:
endpointSelector:
matchLabels:
@@ -34,3 +37,25 @@ spec:
- ports:
- port: "8000"
protocol: TCP
---
# Pipeline step pods call the server back on :8000 to stream logs and report
# status. Deferred from stage 1 on purpose, and scoped to the SERVER pod rather
# than granted on the namespace baseline.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: woodpecker-allow-ci-steps
namespace: woodpecker
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: woodpecker
app.kubernetes.io/component: server
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": woodpecker-ci
toPorts:
- ports:
- port: "8000"
protocol: TCP
+43 -9
View File
@@ -1,18 +1,20 @@
---
# RECOVERY SOURCE — read-only. The prefix the retired `apps/wp-pg` archived to.
#
# Kept after the migration on purpose. Nothing writes to it, and the backups
# under it age out on their own retention, but while they last this is a second,
# independent restore point that predates the move — worth having until the new
# prefix has a meaningful backup history of its own.
#
# serverName on the consuming externalCluster must be `wp-pg`, the name of the
# cluster that produced these backups.
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: wp-pg-store
namespace: apps
annotations:
# Prune-disabled — a standing guard kept from the seed->dedicated-KS handoff (backup target).
kustomize.toolkit.fluxcd.io/prune: disabled
name: wp-pg-store-source
namespace: woodpecker
spec:
configuration:
# Same offsite Hetzner Object Storage bucket as the other CNPG clusters,
# under a per-cluster prefix. See apps/paperless/objectstore.yaml for the
# SSE / addressing rationale (Hetzner is SSE-C only; transit is TLS,
# bucket private).
destinationPath: s3://renner-uno-k8s-cnpg/wp-pg
endpointURL: https://fsn1.your-objectstorage.com
s3Credentials:
@@ -29,4 +31,36 @@ spec:
compression: gzip
data:
compression: gzip
# No retentionPolicy: this store is a read source during the migration, and
# the old cluster in `apps` owns the lifecycle of the data underneath it.
---
# ARCHIVING TARGET — a DIFFERENT prefix from the source, deliberately.
#
# Two clusters archiving WAL to one destinationPath interleave their streams and
# corrupt both. That mattered during the parallel-run window; it still matters
# now, because the source prefix above is retained and must stay frozen for the
# independent restore point it provides.
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: wp-pg-store
namespace: woodpecker
spec:
configuration:
destinationPath: s3://renner-uno-k8s-cnpg/wp-pg-woodpecker
endpointURL: https://fsn1.your-objectstorage.com
s3Credentials:
accessKeyId:
name: hetzner-s3-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: hetzner-s3-creds
key: ACCESS_SECRET_KEY
region:
name: hetzner-s3-creds
key: REGION
wal:
compression: gzip
data:
compression: gzip
retentionPolicy: "30d"
+1 -1
View File
@@ -15,7 +15,7 @@ apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: woodpecker-server
namespace: apps
namespace: woodpecker
labels:
app.kubernetes.io/name: woodpecker
app.kubernetes.io/component: server
+5 -2
View File
@@ -17,7 +17,7 @@ apiVersion: v1
kind: ServiceAccount
metadata:
name: woodpecker-agent
namespace: apps
namespace: woodpecker
labels:
app.kubernetes.io/name: woodpecker
app.kubernetes.io/component: agent
@@ -60,4 +60,7 @@ roleRef:
subjects:
- kind: ServiceAccount
name: woodpecker-agent
namespace: apps
# The agent's SA lives in `woodpecker`; this Role/RoleBinding stays in
# `woodpecker-ci`, because that is the namespace it grants pod
# create/delete in. Cross-namespace subject, deliberately.
namespace: woodpecker
@@ -1,18 +1,19 @@
---
# Daily base backup at 02:35 UTC (offset from the other clusters' 02:30 to
# avoid a thundering herd against the Hetzner bucket). WAL archiving runs
# continuously (see cluster.yaml spec.plugins[].isWALArchiver). immediate:true
# takes the first base backup as soon as this CR applies.
# Daily base backups onto the wp-pg-woodpecker prefix. Continuous WAL archiving
# runs separately (cluster.yaml spec.plugins[].isWALArchiver); without periodic
# base backups the prefix would have no recovery point of its own and
# wp-pg-store's retentionPolicy: 30d would have nothing to prune against, so the
# WAL chain would grow without bound.
#
# 02:40 UTC, offset from the other CNPG clusters' 02:30 so they do not all hit
# the Hetzner bucket together.
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: wp-pg-daily
namespace: apps
annotations:
# Prune-disabled — a standing guard kept from the seed->dedicated-KS handoff (backup schedule).
kustomize.toolkit.fluxcd.io/prune: disabled
namespace: woodpecker
spec:
schedule: "0 35 2 * * *"
schedule: "0 40 2 * * *"
immediate: true
backupOwnerReference: self
cluster:
@@ -14,7 +14,7 @@ apiVersion: v1
kind: Secret
metadata:
name: woodpecker-secret
namespace: apps
namespace: woodpecker
labels:
app.kubernetes.io/name: woodpecker
type: Opaque
+2 -2
View File
@@ -2,7 +2,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: woodpecker-server
namespace: apps
namespace: woodpecker
labels:
app.kubernetes.io/name: woodpecker
app.kubernetes.io/component: server
@@ -84,7 +84,7 @@ spec:
- name: WOODPECKER_DATABASE_DRIVER
value: postgres
- name: WOODPECKER_DATABASE_DATASOURCE
value: "host=wp-pg-rw.apps.svc.cluster.local port=5432 user=$(WP_DB_USER) password=$(WP_DB_PASS) dbname=woodpecker sslmode=require"
value: "host=wp-pg-rw.woodpecker.svc.cluster.local port=5432 user=$(WP_DB_USER) password=$(WP_DB_PASS) dbname=woodpecker sslmode=require"
# AGENT_SECRET + GITEA_CLIENT + GITEA_SECRET come from the SOPS secret.
envFrom:
- secretRef:
+2 -2
View File
@@ -2,13 +2,13 @@ apiVersion: v1
kind: Service
metadata:
name: woodpecker
namespace: apps
namespace: woodpecker
labels:
app.kubernetes.io/name: woodpecker
spec:
# Flipped ExternalName -> ClusterIP: ci.renner.uno (via the Ingress, by name)
# and the in-cluster agent both target this Service now. http:8000 serves the
# UI/API; grpc:9000 is how the agent (woodpecker.apps.svc:9000) reaches the
# UI/API; grpc:9000 is how the agent (woodpecker.woodpecker.svc:9000) reaches the
# server. Selects the server pod only (not the agent).
type: ClusterIP
selector:
@@ -17,7 +17,11 @@ spec:
chart:
spec:
chart: cilium
version: ">=1.19.0"
# Patch-only range: Flux reconciles every 30m and would otherwise take a
# CNI minor or major unattended. Widening this is a deliberate, reviewed
# act — a minor bump changes datapath behaviour and rolls 2 of 3 agents
# at once.
version: ">=1.20.0 <1.21.0"
sourceRef:
kind: HelmRepository
name: cilium
@@ -29,6 +33,16 @@ spec:
remediation:
retries: 3
values:
# Which pod labels feed security-identity computation. Patterns are
# start-anchored regexes; a leading "!" excludes while keeping the default
# set. Woodpecker stamps every step pod with woodpecker-ci.org/task-uuid
# and step=<name>, so without this each build step is its own identity —
# and every identity costs one entry per allowed (direction, proto, port)
# in every endpoint's policy map, cluster-wide. Nothing selects on these
# labels in policy, so they buy identity churn and nothing else.
# Keep in lockstep with kubernetes/talos/patches/cilium-cni.yaml and
# kubernetes/talos/cilium.yaml.
labels: '!woodpecker-ci\.org/.* !step$'
ipv6:
enabled: true
ipam:
@@ -46,6 +60,19 @@ spec:
serviceMonitor:
enabled: true
bpf:
# Size of each endpoint's own cilium_policy_v3_<id> map, holding one
# entry per (identity, direction, protocol, port) the endpoint is
# allowed. When it fills, endpoint regeneration fails with "no space
# left on device", the CNI ADD call never returns, and the pod is stuck
# in sandbox creation — so this must stay comfortably above the busiest
# endpoint's entry count, tracked by cilium_bpf_map_pressure.
# Takes effect on agent restart; the map is sized at creation.
# Bootstrap installs Cilium from the inline manifest in
# kubernetes/talos/patches/cilium-cni.yaml, so bpf-policy-map-max there
# must move with this value or a cold rebuild starts with the old size.
# kubernetes/talos/cilium.yaml carries a third copy of the same key.
# Nothing enforces any of this; grep bpf-policy-map-max before changing it.
policyMapMax: 65536
# Let pods reach the cluster's own LoadBalancer external IPs; Cilium
# redirects the socket directly to the backend pod instead of sending
# traffic out to the VIP and back (which fails with L2 announcements).
@@ -160,3 +160,26 @@ apiVersion: v1
kind: Namespace
metadata:
name: wordpress
---
# The Woodpecker service itself — server, agent and its wp-pg Postgres backend,
# moving out of the shared `apps` namespace (issue #433). Distinct from
# `woodpecker-ci`, which enforces pod-security: privileged and holds only
# ephemeral pipeline step pods.
#
# `apps` uses an endpointSelector:{} default-deny, so every allow it carries
# applies to gitea, gatus, external-dns and ical-proxy alike — granting the
# CNPG operator access to wp-pg there would have opened all of `apps` to all of
# `database`. A namespace per service keeps that grant on the one workload.
#
# Declared here rather than alongside the workloads so it is created by the
# `infrastructure` KS. The namespace's CiliumNetworkPolicies ship with the
# `infrastructure-crds` KS (infrastructure/post-crds includes
# ../network-policies/), which itself dependsOn `infrastructure` — so the
# namespace exists before any policy targets it. Creating it in the `woodpecker`
# workload KS would invert that order, since that KS runs after both.
#
# No pod-security labels: the cluster default applies.
apiVersion: v1
kind: Namespace
metadata:
name: woodpecker
@@ -31,43 +31,20 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
egress:
- toEndpoints:
- matchLabels:
@@ -103,40 +80,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -38,43 +38,20 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
egress:
- toEndpoints:
- matchLabels:
@@ -103,40 +80,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -36,40 +36,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -32,40 +32,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -27,43 +27,20 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
egress:
- toEndpoints:
- matchLabels:
@@ -102,40 +79,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -28,43 +28,20 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
egress:
- toEndpoints:
- matchLabels:
@@ -103,40 +80,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -157,44 +157,21 @@ spec:
icmps:
- fields:
- { type: 0, family: IPv4 } # Echo Reply
- { type: 3, family: IPv4 } # Destination Unreachable
- { type: 4, family: IPv4 } # Source Quench
- { type: 5, family: IPv4 } # Redirect
- { type: 3, family: IPv4 } # Destination Unreachable — PMTUD
- { type: 8, family: IPv4 } # Echo Request
- { type: 9, family: IPv4 } # Router Advertisement
- { type: 10, family: IPv4 } # Router Solicitation
- { type: 11, family: IPv4 } # Time Exceeded
- { type: 12, family: IPv4 } # Parameter Problem
- { type: 13, family: IPv4 } # Timestamp
- { type: 14, family: IPv4 } # Timestamp Reply
- { type: 15, family: IPv4 } # Information Request
- { type: 16, family: IPv4 } # Information Reply
- { type: 17, family: IPv4 } # Address Mask Request
- { type: 18, family: IPv4 } # Address Mask Reply
- { type: 30, family: IPv4 } # Traceroute
- { type: 1, family: IPv6 } # Destination Unreachable
- { type: 2, family: IPv6 } # Packet Too Big
- { type: 2, family: IPv6 } # Packet Too Big — PMTUD, mandatory
- { type: 3, family: IPv6 } # Time Exceeded
- { type: 4, family: IPv6 } # Parameter Problem
- { type: 128, family: IPv6 } # Echo Request
- { type: 129, family: IPv6 } # Echo Reply
- { type: 130, family: IPv6 } # Multicast Listener Query
- { type: 131, family: IPv6 } # Multicast Listener Report
- { type: 132, family: IPv6 } # Multicast Listener Done
- { type: 133, family: IPv6 } # Router Solicitation
- { type: 134, family: IPv6 } # Router Advertisement
- { type: 135, family: IPv6 } # Neighbor Solicitation
- { type: 136, family: IPv6 } # Neighbor Advertisement
- { type: 137, family: IPv6 } # Redirect
- { type: 138, family: IPv6 } # Router Renumbering
- { type: 141, family: IPv6 } # Inverse ND Solicitation
- { type: 142, family: IPv6 } # Inverse ND Advertisement
- { type: 143, family: IPv6 } # MLDv2 Listener Report
- { type: 148, family: IPv6 } # Certification Path Solicitation
- { type: 149, family: IPv6 } # Certification Path Advertisement
- { type: 151, family: IPv6 } # Multicast Router Advertisement
- { type: 152, family: IPv6 } # Multicast Router Solicitation
- { type: 153, family: IPv6 } # Multicast Router Termination
egress:
# Cluster-internal callers HA initiates connections to: mosquitto,
# whisper, matter-server, etc.
@@ -253,43 +230,20 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
---
# Scoped world ingress — replaces the former namespace-wide `fromEntities:
# world` allow (see the note in the default-deny-ingress policy above). IoT-VLAN
@@ -42,40 +42,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -42,40 +42,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -21,6 +21,7 @@ resources:
- openbao-ns.yaml
- external-secrets-ns.yaml
- woodpecker-ci-ns.yaml
- woodpecker-ns.yaml
- trmnl-ns.yaml
- wordpress-ns.yaml
- allow-cilium-health.yaml
@@ -67,40 +67,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -141,9 +141,9 @@ spec:
---
# Egress for the review-bot rotator. Its CronJob has two steps: the init step
# mints review-bot's PAT against gitea (apps :3000), the main step upserts the
# Woodpecker org secret against the woodpecker server (apps :8000). Both run
# Woodpecker org secret against the woodpecker server (woodpecker :8000). Both run
# under the same pod labels, so one egress policy covers both hops. Complements
# gitea-allow-review-bot-rotator + woodpecker-allow-rotators (apps side).
# gitea-allow-review-bot-rotator (apps) + woodpecker-allow-rotators (woodpecker).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
@@ -165,16 +165,16 @@ spec:
protocol: TCP
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": apps
"k8s:io.kubernetes.pod.namespace": woodpecker
app.kubernetes.io/name: woodpecker
toPorts:
- ports:
- port: "8000"
protocol: TCP
---
# Egress for the claude-oauth sync rotator: woodpecker server only (apps :8000).
# Egress for the claude-oauth sync rotator: woodpecker server only (woodpecker :8000).
# It never touches gitea (the OAuth value is seeded into OpenBao by hand, not
# minted). Complements woodpecker-allow-rotators (apps side).
# minted). Complements woodpecker-allow-rotators (woodpecker side).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
@@ -188,7 +188,7 @@ spec:
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": apps
"k8s:io.kubernetes.pod.namespace": woodpecker
app.kubernetes.io/name: woodpecker
toPorts:
- ports:
@@ -37,43 +37,20 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
egress:
- toEndpoints:
- matchLabels:
@@ -109,43 +86,20 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
---
# Allow FTP access (from scanners on the LAN) to the ftp-scanner pod.
apiVersion: cilium.io/v2
@@ -41,40 +41,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -26,43 +26,20 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
egress:
# Peer P2P
- toEndpoints:
@@ -92,40 +69,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -42,43 +42,20 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
---
# The TRMNL panel polls /api/display over cleartext HTTP on the
# terminus-device LoadBalancer. The firmware cannot be handed the internal
@@ -28,40 +28,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -45,40 +45,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -0,0 +1,156 @@
---
# Baseline ingress policy for the `woodpecker` namespace (wp-pg now; server and
# agent arrive in stage 2).
#
# Unlike the `apps` baseline this replaces, every allow here lands on a
# namespace holding one service, so a namespace-scoped grant is not a blanket
# grant across unrelated workloads. That is the point of the move: in `apps`,
# letting the CNPG operator reach wp-pg would also have opened gitea, gatus,
# external-dns and ical-proxy to the whole database namespace (issue #433).
#
# CAUTION when adding rules here: this policy has `endpointSelector: {}`, so
# every rule below applies to EVERY pod in the namespace. A `toPorts` block
# narrows the port, NOT the destination workload — that is governed solely by
# the enclosing policy's endpointSelector. Anything meant for one workload
# belongs in its own policy with its own selector, as
# `wp-pg-allow-cnpg-operator` below does.
#
# No egress section, so egress is unrestricted. Deliberate for now and revisited
# in stage 2: wp-pg needs DNS plus `world` for WAL archiving to Hetzner S3, and
# the server/agent add gitea, the Kubernetes API and outbound plugin pulls.
# Writing that ruleset once, with the full workload set present, beats guessing
# it here and silently breaking the WAL archiving this stage exists to verify.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny-ingress
namespace: woodpecker
spec:
endpointSelector: {}
ingress:
# Intra-namespace: server <-> agent, server -> wp-pg.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": woodpecker
# NOT here: ingress from the `ingress` namespace. Traefik terminates
# ci.renner.uno and forwards to the server, but that namespace also holds
# netbird-ingress, and this policy's endpointSelector:{} would extend the
# grant to wp-pg and the agent as well. Scoped to traefik -> server in the
# companion policy at the bottom of this file.
# Prometheus scrape. Namespace-wide and port-unrestricted, matching the
# other *-ns baselines; this is what covers wp-pg's :9187 metrics.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
# Kubelet probes and hostNetwork siblings.
- fromEntities:
- host
- remote-node
# ICMP — from any cluster pod. See home-assistant-ns.yaml for the rationale.
# INGRESS ONLY: do NOT add an egress section to this CNP.
#
# Needed here specifically because wp-pg-allow-cnpg-operator below attaches
# an L4 rule (toPorts :8000) to the wp-pg pods, and Cilium blocks ICMP to an
# endpoint once any layer 4 policy selects it unless the traffic is
# CT-related. Without this, PMTUD Destination Unreachable and Packet Too Big
# toward wp-pg are dropped silently — the failure shows up as stalled
# transfers, not as a policy denial.
#
# Namespace-wide is right for this one, unlike the :8000 case noted below:
# these are diagnostic and path-MTU messages that expose no API surface, so
# granting them across the namespace concedes nothing a per-workload rule
# would withhold.
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
operator: Exists
icmps:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
#
# NOT here: ingress from `woodpecker-ci`. Pipeline step pods do call the
# server back on :8000 to stream logs, but the CNPG instance manager serves
# its status API on :8000 too — so granting that on this endpointSelector:{}
# policy would hand arbitrary repo-supplied build steps a route to wp-pg's
# instance manager. It lands in stage 2 as its own policy selecting the
# server pod, the way woodpecker-allow-rotators already scopes its :8000
# rule in apps/woodpecker/networkpolicy.yaml.
---
# The CNPG operator polls each instance's status endpoint over HTTPS
# (https://<pod-ip>:8000/pg/status) to populate Cluster status. Without it the
# Cluster reports "Instance Status Extraction Error: HTTP communication issue"
# while Postgres serves normally, and the operator loses failover and
# switchover decisions — issue #433, where the operator's dial timed out
# because the apps baseline never allowed the database namespace.
#
# Scoped to the wp-pg endpoints and that one port: the operator has no reason to
# reach the woodpecker server or agent, and the barman and mariadb operators
# sharing its namespace have no reason to reach anything here at all.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: wp-pg-allow-cnpg-operator
namespace: woodpecker
spec:
endpointSelector:
matchLabels:
cnpg.io/cluster: wp-pg
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": database
app.kubernetes.io/name: cloudnative-pg
toPorts:
- ports:
- port: "8000"
protocol: TCP
---
# Traefik terminates ci.renner.uno and forwards to the server on :8000.
#
# Lives here, beside the baseline, rather than with the workload policies in
# apps/woodpecker/networkpolicy.yaml — deliberately. The baseline's `ingress`
# admit was removed in the same commit, and these two ship in the same
# Kustomization (infrastructure-crds), so the swap lands in one reconcile. Split
# across Kustomizations it would not: infrastructure-crds reconciles before the
# `woodpecker` KS, so the admit would be withdrawn minutes before its
# replacement arrived, and ci.renner.uno would 503 in the gap.
#
# Scoped to traefik and to the server pod. The `ingress` namespace also holds
# netbird-ingress, and the baseline's endpointSelector:{} would have extended a
# namespace-level admit to wp-pg and the agent. The `apps` policy this replaces
# (allow-ingress.yaml) matches traefik by label for the same reason, and does
# NOT apply here — it is hard-scoped to `namespace: apps`.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: woodpecker-allow-traefik
namespace: woodpecker
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: woodpecker
app.kubernetes.io/component: server
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
toPorts:
- ports:
- port: "8000"
protocol: TCP
@@ -53,43 +53,20 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
egress:
- toEndpoints:
- matchLabels:
@@ -128,40 +105,17 @@ spec:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -14,8 +14,10 @@
# immediately:
# kubectl -n openbao create job --from=cronjob/claude-oauth-secret-sync claude-oauth-sync-now
#
# woodpecker is reached in-cluster at woodpecker.apps.svc:8000. Needs the netpol
# halves woodpecker-allow-rotators (apps/*) + rotator-claude-oauth-egress
# woodpecker is reached in-cluster at woodpecker.woodpecker.svc:8000 (it moved
# out of `apps` in the namespace migration — see
# kubernetes/apps/woodpecker/MIGRATION-namespace.md). Needs the netpol
# halves woodpecker-allow-rotators (woodpecker ns) + rotator-claude-oauth-egress
# (openbao-ns.yaml). OpenBao policy/role rotator-claude-oauth + the bootstraps
# (woodpecker-bot token, the seeded OAuth value) are provisioned by hand — see
# policies/rotator-claude-oauth.hcl.
@@ -89,7 +91,7 @@ spec:
- name: BAO_FIELD
value: setup-token
- name: WOODPECKER_URL
value: http://woodpecker.apps.svc.cluster.local:8000
value: http://woodpecker.woodpecker.svc.cluster.local:8000
- name: WOODPECKER_ORG
value: k.renner
- name: WOODPECKER_SECRET_NAME
@@ -25,9 +25,10 @@
# but a failed sync should be fixed before the next run rather than left.
#
# gitea is reached in-cluster at gitea.apps.svc:3000, woodpecker at
# woodpecker.apps.svc:8000 (both hairpin off the ingress LB from a pod). Needs
# woodpecker.woodpecker.svc:8000 (it moved out of `apps` in the namespace
# migration — see kubernetes/apps/woodpecker/MIGRATION-namespace.md). Needs
# the netpol halves: gitea-allow-review-bot-rotator + woodpecker-allow-rotators
# (apps/*) and rotator-review-bot-egress (openbao-ns.yaml).
# (woodpecker ns) and rotator-review-bot-egress (openbao-ns.yaml).
#
# OpenBao policy/role rotator-review-bot + the bootstraps (review-bot password,
# woodpecker-bot token, seed review-bot#token) are provisioned by hand — see
@@ -147,7 +148,7 @@ spec:
- name: BAO_FIELD
value: token
- name: WOODPECKER_URL
value: http://woodpecker.apps.svc.cluster.local:8000
value: http://woodpecker.woodpecker.svc.cluster.local:8000
- name: WOODPECKER_ORG
value: k.renner
- name: WOODPECKER_SECRET_NAME
@@ -30,6 +30,7 @@ spec:
- paperless
- trmnl
- velero
- woodpecker # wp-pg barman creds; woodpecker moving out of `apps` (#433)
- wordpress
provider:
vault: # ESO's provider name; speaks OpenBao fine
@@ -24,6 +24,27 @@ spec:
# gives headroom for parallel restores during cluster rebuild too
# (paperless + audiobookshelf + immich + ... all clone at once).
cephConfig:
mon:
# Ceph's 30% default assumes the mon has a filesystem to itself, where
# 30% free is a meaningful reserve. Here the mons live on
# dataDirHostPath /var/lib/rook — the node's /var, shared with ~29 GB
# of containerd image and snapshot storage — and the mon DB itself is
# ~106 MB. So the default measures container churn, not Ceph: k8s-2
# trips MON_DISK_LOW whenever the image cache fills, and image GC does
# not clear it (most of /var/lib/containerd is overlayfs snapshots held
# by running containers, which GC cannot reclaim — see #434).
# 20% of a 60 GB /var still leaves ~12 GB against a 106 MB database.
# mon_data_avail_crit stays at the 5% default, and CephMonDiskLow in
# monitoring-rules watches /var directly for the real risk, which is the
# node filling rather than the mon growing.
#
# BARE-METAL MIGRATION: revisit this. Give the mons their own volume and
# the default 30% becomes meaningful again, at which point this override
# should be dropped rather than carried over. It cannot be done on the
# current cluster — every StorageClass is Ceph-backed, so a mon PVC
# would be circular, and Rook only applies mon.volumeClaimTemplate to
# newly created mons, never migrating existing ones.
mon_data_avail_warn: "20"
mgr:
mgr/volumes/max_concurrent_clones: "16"
# QLC tune for the Crucial P2 NVMe per host. Baseline 2026-05-05:
@@ -2,6 +2,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- cephcluster.yaml
- toolbox.yaml
- storageclass.yaml
- volumesnapshotclass.yaml
- objectstore.yaml
@@ -0,0 +1,172 @@
# Upstream Rook toolbox, taken verbatim from
# https://raw.githubusercontent.com/rook/rook/v1.20.4/deploy/examples/toolbox.yaml
# (matching the operator image docker.io/rook/ceph:v1.20.4), with three
# deliberate changes:
# - the image is pinned to the cluster's own Ceph version and carries the
# same imagepolicy marker as cephcluster.yaml, so the client never drifts
# ahead of the mons it talks to
# - resource requests/limits, since this is a long-lived idle pod
# - no other divergence; keep it that way so upstream diffs stay readable
#
# This is the only admin path to Ceph internals. Without it `ceph status`,
# `ceph health detail`, `ceph osd df tree`, `ceph osd perf` and `ceph device
# ls` are unreachable during an incident: the mons, OSDs and mgr mount no
# admin keyring, and the operator's /etc/ceph is empty.
#
# It mounts the client.admin keyring from the rook-ceph-mon secret, so it is
# a full-privilege credential holder, not a read-only one — Rook offers no
# lesser-privileged toolbox. Ingress to it is gated by the namespace
# default-deny-ingress CNP (network-policies/rook-ceph-ns.yaml).
apiVersion: apps/v1
kind: Deployment
metadata:
name: rook-ceph-tools
namespace: rook-ceph
labels:
app: rook-ceph-tools
spec:
replicas: 1
selector:
matchLabels:
app: rook-ceph-tools
template:
metadata:
labels:
app: rook-ceph-tools
spec:
dnsPolicy: ClusterFirstWithHostNet
serviceAccountName: rook-ceph-default
containers:
- name: rook-ceph-tools
image: quay.io/ceph/ceph:v19.2.5 # {"$imagepolicy": "flux-system:ceph"}
command:
- /bin/bash
- -c
- |
# Replicate the script from toolbox.sh inline so the ceph image
# can be run directly, instead of requiring the rook toolbox
CEPH_CONFIG="/etc/ceph/ceph.conf"
MON_CONFIG="/etc/rook/mon-endpoints"
KEYRING_FILE="/etc/ceph/keyring"
CONFIG_OVERRIDE="/etc/rook-config-override/config"
# create a ceph config file in its default location so ceph/rados tools can be used
# without specifying any arguments
write_endpoints() {
endpoints=$(cat ${MON_CONFIG})
# filter out the mon names
# external cluster can have numbers or hyphens in mon names, handling them in regex
# shellcheck disable=SC2001
mon_endpoints=$(echo "${endpoints}"| sed 's/[a-z0-9_-]\+=//g')
DATE=$(date)
echo "$DATE writing mon endpoints to ${CEPH_CONFIG}: ${endpoints}"
cat <<EOF > ${CEPH_CONFIG}
[global]
mon_host = ${mon_endpoints}
[client.admin]
keyring = ${KEYRING_FILE}
EOF
# Merge the config override if it exists and is not empty
if [ -f "${CONFIG_OVERRIDE}" ] && [ -s "${CONFIG_OVERRIDE}" ]; then
echo "$DATE merging config override from ${CONFIG_OVERRIDE}"
echo "" >> ${CEPH_CONFIG}
cat ${CONFIG_OVERRIDE} >> ${CEPH_CONFIG}
echo "" >> ${CEPH_CONFIG}
fi
}
# watch the endpoints config file and update if the mon endpoints ever change
watch_endpoints() {
# get the timestamp for the target of the soft link
real_path=$(realpath ${MON_CONFIG})
initial_time=$(stat -c %Z "${real_path}")
while true; do
real_path=$(realpath ${MON_CONFIG})
latest_time=$(stat -c %Z "${real_path}")
if [[ "${latest_time}" != "${initial_time}" ]]; then
write_endpoints
initial_time=${latest_time}
fi
sleep 10
done
}
# read the secret from an env var (for backward compatibility), or from the secret file
ceph_secret=${ROOK_CEPH_SECRET}
if [[ "$ceph_secret" == "" ]]; then
ceph_secret=$(cat /var/lib/rook-ceph-mon/secret.keyring)
fi
# create the keyring file
cat <<EOF > ${KEYRING_FILE}
[${ROOK_CEPH_USERNAME}]
key = ${ceph_secret}
EOF
# write the initial config file
write_endpoints
# continuously update the mon endpoints if they fail over
watch_endpoints
imagePullPolicy: IfNotPresent
tty: true
securityContext:
runAsNonRoot: true
runAsUser: 2016
runAsGroup: 2016
capabilities:
drop: ["ALL"]
env:
- name: ROOK_CEPH_USERNAME
valueFrom:
secretKeyRef:
name: rook-ceph-mon
key: ceph-username
resources:
requests:
cpu: 10m
memory: 128Mi
limits:
memory: 512Mi
volumeMounts:
- mountPath: /etc/ceph
name: ceph-config
- name: mon-endpoint-volume
mountPath: /etc/rook
- name: ceph-admin-secret
mountPath: /var/lib/rook-ceph-mon
readOnly: true
- name: rook-config-override
mountPath: /etc/rook-config-override
readOnly: true
volumes:
- name: ceph-admin-secret
secret:
secretName: rook-ceph-mon
optional: false
items:
- key: ceph-secret
path: secret.keyring
- name: mon-endpoint-volume
configMap:
name: rook-ceph-mon-endpoints
items:
- key: data
path: mon-endpoints
- name: rook-config-override
configMap:
name: rook-config-override
optional: true
- name: ceph-config
emptyDir: {}
tolerations:
- key: "node.kubernetes.io/unreachable"
operator: "Exists"
effect: "NoExecute"
tolerationSeconds: 5
+8 -2
View File
@@ -249,6 +249,11 @@ data:
identity-heartbeat-timeout: "30m0s"
identity-gc-interval: "15m0s"
# Exclude Woodpecker's per-build labels from identity computation, so CI
# steps share one identity instead of minting one each. Keep in lockstep
# with patches/cilium-cni.yaml and values.labels in
# kubernetes/infrastructure/cilium/helmrelease.yaml.
labels: '!woodpecker-ci\.org/.* !step$'
cilium-endpoint-gc-interval: "5m0s"
nodes-gc-interval: "5m0s"
@@ -302,8 +307,9 @@ data:
# sizing of the TCP CT, non-TCP CT, NAT and policy BPF maps.
bpf-map-dynamic-size-ratio: "0.0025"
# bpf-policy-map-max specifies the maximum number of entries in endpoint
# policy map (per endpoint)
bpf-policy-map-max: "16384"
# policy map (per endpoint). Keep in lockstep with patches/cilium-cni.yaml and
# values.bpf.policyMapMax in kubernetes/infrastructure/cilium/helmrelease.yaml.
bpf-policy-map-max: "65536"
# bpf-policy-stats-map-max specifies the maximum number of entries in global
# policy stats map
bpf-policy-stats-map-max: "65536"
+11 -2
View File
@@ -267,6 +267,11 @@ cluster:
identity-heartbeat-timeout: "30m0s"
identity-gc-interval: "15m0s"
# Exclude Woodpecker's per-build labels from identity computation, so
# CI steps share one identity instead of minting one each. Keep in
# lockstep with values.labels in
# kubernetes/infrastructure/cilium/helmrelease.yaml.
labels: '!woodpecker-ci\.org/.* !step$'
cilium-endpoint-gc-interval: "5m0s"
nodes-gc-interval: "5m0s"
@@ -320,8 +325,12 @@ cluster:
# sizing of the TCP CT, non-TCP CT, NAT and policy BPF maps.
bpf-map-dynamic-size-ratio: "0.0025"
# bpf-policy-map-max specifies the maximum number of entries in endpoint
# policy map (per endpoint)
bpf-policy-map-max: "16384"
# policy map (per endpoint). Keep in lockstep with values.bpf.policyMapMax
# in kubernetes/infrastructure/cilium/helmrelease.yaml, and with the third
# copy in kubernetes/talos/cilium.yaml: bootstrap installs Cilium from this
# inline manifest, so a lower value here means a cold rebuild comes up with
# a map that overflows until Flux reconciles.
bpf-policy-map-max: "65536"
# bpf-policy-stats-map-max specifies the maximum number of entries in global
# policy stats map
bpf-policy-stats-map-max: "65536"
+14 -10
View File
@@ -26,16 +26,20 @@ machine:
nodeIP:
validSubnets:
- 10.2.40.0/24
# Lower the image GC thresholds from the kubelet defaults (85/80)
# so containerd's image cache is pruned before /var crosses Ceph's
# mon_data_avail_warn line (mon DB warns when its hostpath has
# <30% free). k8s-2 and k8s-3 sit at 77-79% used because of pod
# density (75 pods on k8s-2 vs 42 on k8s-1) — unused image layers
# accumulate untouched until the 85% high watermark, which the
# node never hits, so GC never fires. 70/60 keeps /var safely
# under 30%-free without raising the VM disk again. Spegel P2P
# pull-through softens the re-pull cost when GC'd images are
# needed back.
# Lower the image GC thresholds from the kubelet defaults (85/80) to
# bound containerd's image cache, which is the largest single consumer
# of /var. Spegel P2P pull-through softens the re-pull cost when GC'd
# images are needed back.
#
# These thresholds do NOT control whether Ceph's mon reports
# MON_DISK_LOW, and must not be tuned for that. Measured on k8s-2:
# image GC frees only the content store (~8.6 GB), while the bulk of
# /var/lib/containerd is ~20 GB of overlayfs snapshots — unpacked
# layers shared with running containers, which GC cannot reclaim.
# Dropping high to 60 deleted 28 images and moved free space by 0.02
# percentage points. The mon's own data is ~106 MB; its 30%-free
# warning is measuring a filesystem it merely shares, so the lever for
# it is elsewhere (mon volume, /var size, or mon_data_avail_warn).
extraConfig:
imageGCHighThresholdPercent: 70
imageGCLowThresholdPercent: 60
+15 -3
View File
@@ -1,8 +1,20 @@
locals {
renner_uno_records = {
# A Records
"@" = { type = "A", value = "159.69.42.237" }
"www" = { type = "A", value = "159.69.42.237" }
# No "@" / "www" entries. Both used to pin renner.uno and www.renner.uno
# to 159.69.42.237, the Hetzner VM that hosted the WordPress site until it
# moved into the cluster (kubernetes/apps/wordpress). Neither record is
# actually live on the UDM any more — verified 2026-08-16: a LAN query for
# www.renner.uno returns the public answer (proxy.renner.uno) — so this
# block was drift waiting to be re-applied.
#
# The correct end state is exactly this absence: www.renner.uno is labelled
# dns.renner.uno/public, so external-dns-unifi deliberately writes no UDM
# record and LAN clients follow the public path (wildcard -> proxy) and
# hairpin back in. Re-adding an A record here would point the house at a
# server that is being decommissioned.
#
# The apex stays with Cloudflare, where a redirect rule sends
# renner.uno/* to https://www.renner.uno/*.
# CNAME Records (External Targets)
"nut" = { type = "CNAME", value = "nut.srv.renner.uno" }
+87 -8
View File
@@ -1,4 +1,4 @@
# TEMPORARY shims v2 — rustfs LXCs 800/801/802, decommission in progress.
# TEMPORARY shims v3 — rustfs LXCs 800/801/802, decommission in progress.
#
# Stage 2 (#270) failed: both destroy attempts died instantly with
# "context deadline exceeded" on the pre-destroy status GET, while the PVE
@@ -10,34 +10,113 @@
#
# Fix: declare the timeouts explicitly, apply ONCE so they land in state
# (in-place no-API update), then delete this file again and the next apply
# destroys the containers. No lifecycle ignore_changes this time — the
# timeout update must actually apply; the other attributes mirror the real
# containers so the plan stays in-place.
# destroys the containers.
#
# v3: v2 declared ONLY the ids, `started`, `unprivileged` and the timeouts,
# claiming "the other attributes mirror the real containers so the plan stays
# in-place". They did not. Everything absent from config planned as a removal,
# and two of those removals — `mount_point` and `operating_system` — force
# replacement, so the plan came back `must be replaced` for all three. Applying
# that would have destroyed and recreated containers tagged `critical`,
# `state-backend`, `terraform-backend` instead of nudging a timeout into state.
#
# So the blocks below are not decoration: each one exists to keep an attribute
# out of the diff. They mirror the live containers, read from the plan's own
# state values. `tags` is declared for the same reason — without it the apply
# strips the tags off the real containers.
#
# 1. merge; run PIPELINE=deploy-infrastructure (plan MUST show in-place
# update only, no replace), then TOFU_APPLY=true
# 2. follow-up PR deletes this file; plan shows 3 destroys; apply.
locals {
# Per-container values mirrored from live state. hostname and mac_address
# differ per container; declaring them wrong would itself force a diff.
rustfs_decommission = {
"800" = "saturn"
"801" = "jupiter"
"802" = "neptune"
"800" = { node = "saturn", hostname = "rustfs-1.srv.renner.uno", mac = "BC:24:11:0B:E5:7B" }
"801" = { node = "jupiter", hostname = "rustfs-2.srv.renner.uno", mac = "BC:24:11:EA:1D:38" }
"802" = { node = "neptune", hostname = "rustfs-3.srv.renner.uno", mac = "BC:24:11:8F:F4:67" }
}
}
resource "proxmox_virtual_environment_container" "rustfs_decommission" {
for_each = local.rustfs_decommission
node_name = each.value
node_name = each.value.node
vm_id = tonumber(each.key)
started = false
unprivileged = true
start_on_boot = true
tags = [
"critical",
"rustfs",
"state-backend",
"terraform-backend",
]
timeout_create = 1800
timeout_clone = 1800
timeout_update = 1800
timeout_delete = 1800
timeout_start = 300
cpu {
architecture = "amd64"
cores = 1
units = 1024
}
memory {
dedicated = 1024
swap = 0
}
disk {
datastore_id = "local"
size = 10
}
# forces replacement if absent — this is the block that broke v2.
mount_point {
path = "/mnt/data"
size = "50G"
volume = "local:${each.key}/vm-${each.key}-disk-1.raw"
backup = true
replicate = true
}
network_interface {
name = "eth0"
bridge = "v40srv"
enabled = true
firewall = false
mac_address = each.value.mac
}
initialization {
hostname = each.value.hostname
ip_config {
ipv4 {
address = "dhcp"
}
}
}
# operating_system is NOT declared, and must not be. The provider requires
# template_file_id inside it, which is a create-time-only value: PVE does not
# report the original template for an existing container, so it is absent
# from state and cannot be mirrored. Declaring the block without it fails
# validation ("Missing required argument"); declaring it with a guessed
# template would be a lie that only surfaces if something ever recreates
# these.
#
# Left undeclared it would plan as a removal, and removing operating_system
# forces replacement — the exact failure this file exists to avoid. So it is
# ignored instead. The timeouts above are deliberately NOT ignored: they are
# the one thing this shim needs to actually apply.
lifecycle {
ignore_changes = [operating_system]
}
}
+20 -4
View File
@@ -61,11 +61,27 @@ provider "unifi" {
# as the unifi provider above; the v2 endpoints are simply not covered by it.
# UniFi PUT quirk: updates may require _id in the body, which restapi does
# not send — if an in-place update 400s, taint the object so CI recreates it.
#
# create_returns_object, NOT write_returns_object. Both make the provider store
# the API response into `data`; write_returns_object does it for PUT as well as
# POST. Since `data` in unifi_nat_hairpin.tf never sets the UDM-assigned `_id`,
# every write put a field into state that config does not have, so all 60 rules
# planned as "will be updated in-place" with `- _id` as the whole diff — and
# re-dirtied themselves on the next write, forever.
#
# No provider-side ignore can suppress that: `data` is a configured attribute,
# compared by Terraform core before provider logic runs. Tried and disproven —
# ignore_changes_to = ["_id"] (plan 5661), ignore_all_server_changes (5662),
# and ignore_server_additions on restapi 3.x (5667). All three left the 60.
#
# create_returns_object keeps what the provider actually needs — the POST
# response is how it learns `_id` for id_attribute below — without letting
# updates rewrite `data`.
provider "restapi" {
uri = "${var.unifi_api_url}/proxy/network/v2/api/site/default"
insecure = var.unifi_insecure
write_returns_object = true
id_attribute = "_id"
uri = "${var.unifi_api_url}/proxy/network/v2/api/site/default"
insecure = var.unifi_insecure
create_returns_object = true
id_attribute = "_id"
headers = {
"X-API-KEY" = var.unifi_api_key
"Content-Type" = "application/json"