Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 aea4be734c feat(bird): put node0 in the pod routing table (#576 step 2)
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
Pod-sourced traffic is routed from table 47. Once the node IP moves to
VLAN 46 (step 3), pods reach node addresses there: the kubernetes
ClusterIP (socket LB rewrites it to the apiserver's advertise address),
hostNetwork pod IPs, and replies to kubelet probes. Without the attached
node0 route in table 47 those flows take the default to the UDM and every
in-cluster API client breaks. Taking it from the interface, like the
other attached networks, keeps the table in one owner.

Refs #576

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 13:28:57 +02:00
k.renner 60361d4c8a Merge pull request 'chore(talos): drop the #832 stage labels' (#917) from chore/cni-drop-stage-labels into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#917
2026-09-23 10:46:35 +00:00
claude-botandClaude Opus 5.5 a8cef5de1b chore(talos): drop the #832 stage labels
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Nothing selects cni.renner.uno/cilium-uninstall or
cni.renner.uno/multus-sole-writer since stage B (#915, #916). Removed
live from all three nodes with a $patch: delete strategic merge patch
(--mode=no-reboot); Talos dropped them from the Kubernetes nodes and
left the other labels in place.

Closes #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:40:10 +02:00
k.renner 505802dad5 Merge pull request 'feat(cilium): write the conflist where only Multus reads it (stage B2 of #832)' (#916) from feat/cni-sole-writer-converge-b2 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#916
2026-09-23 10:38:00 +00:00
claude-botandClaude Opus 5.5 ba66b604d4 feat(cilium): write the conflist where only Multus reads it (stage B2 of #832)
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
cni.confPath: /etc/cni/net.d/multus mounts that host directory as the
agent's /host/etc/cni/net.d, so write-cni-conf-when-ready (unchanged,
.../05-cilium.conflist) lands in /etc/cni/net.d/multus/05-cilium.conflist,
the file the per-node CiliumNodeConfigs already produced on all three
nodes. The agent finds it unchanged and does not rewrite it.

updateStrategy maxUnavailable: 1 (chart default 2) so the roll takes one
node's datapath at a time.

bootstrap.sh renders the chart from these values, so a cold rebuild
lands directly in this layout.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:55:12 +02:00
k.renner 4fbc49fc8c Merge pull request 'feat(cni): converge Multus as sole CNI writer, part 1 (stage B1 of #832)' (#915) from feat/cni-sole-writer-converge-b1 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#915
2026-09-23 09:53:24 +00:00
claude-botandClaude Opus 5.5 f493c9fe52 feat(cni): converge Multus as sole CNI writer, part 1 (stage B1 of #832)
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
All three nodes run the sole-writer layout. Converge the Multus side and
drop the per-node Cilium overrides:
- kube-multus-ds-subdir becomes the only Multus DaemonSet, no node
  affinity; kube-multus-ds (0 pods) and its ConfigMap are pruned.
- /etc/cni/net.d/multus is mounted DirectoryOrCreate so Multus waits for
  the Cilium conflist instead of exiting on a missing directory.
- CiliumNodeConfigs removed. They only act when a cilium pod is created;
  stage B2 moves the same path into the HelmRelease (cni.confPath) and
  rolls the agents.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:43:48 +02:00
k.renner 8a3c66eb2e Merge pull request 'chore(talos): record #832 stage labels on k8s-1' (#914) from chore/cni-stage-k8s-1 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#914
2026-09-23 09:31:07 +00:00
claude-botandClaude Opus 5.5 9928999da4 chore(talos): record #832 stage labels on k8s-1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
k8s-1 carries cni.renner.uno/cilium-uninstall and
cni.renner.uno/multus-sole-writer, applied live with talosctl patch.
Verified on the node: /etc/cni/net.d holds only 00-multus.conf at the
top level, and after a reboot with immediate uncordon the OSD came up
with net1 (10.99.0.1) and no restarts. All three nodes are converted.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:15:45 +02:00
k.renner b3def28f7c Merge pull request 'fix(whereabouts): let the reconciler respect cordons' (#913) from fix/whereabouts-reconciler-cordon into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#913
2026-09-23 09:04:29 +00:00
claude-botandClaude Opus 5.5 93dbcdb2fb fix(whereabouts): let the reconciler respect cordons
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 Deployment tolerated every NoSchedule taint (operator: Exists, no
key), including node.kubernetes.io/unschedulable. During the #832 node
reboots its ReplicaSet kept placing replacement pods on the cordoned,
shutting-down node; the kubelet rejected each with NodeShutdown, about
200 failed pods per reboot. Tolerate only the control-plane taint the
comment names.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 10:56:50 +02:00
k.renner 47bf16cfac Merge pull request 'chore(talos): record #832 stage labels on k8s-2' (#912) from chore/cni-stage-k8s-2 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#912
2026-09-23 08:45:30 +00:00
claude-botandClaude Opus 5.5 4664de8f97 chore(talos): record #832 stage labels on k8s-2
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
k8s-2 carries cni.renner.uno/cilium-uninstall and
cni.renner.uno/multus-sole-writer, applied live with talosctl patch.
Verified on the node: /etc/cni/net.d holds only 00-multus.conf at the
top level, and after a reboot with immediate uncordon the OSD came up
with net1 (10.99.0.2) and no restarts.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 10:44:17 +02:00
k.renner fb52e4be88 Merge pull request 'chore(talos): record #832 stage labels on k8s-3' (#911) from chore/cni-stage-k8s-3 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#911
2026-09-23 08:28:40 +00:00
claude-botandClaude Opus 5.5 f11a71c33f chore(talos): record #832 stage labels on k8s-3
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
k8s-3 carries cni.renner.uno/cilium-uninstall and
cni.renner.uno/multus-sole-writer, applied live with talosctl patch.
Verified on the node: /etc/cni/net.d holds only 00-multus.conf at the
top level, Cilium writes multus/05-cilium.conflist, and after a reboot
with immediate uncordon the OSD came up with net1 (10.99.0.3) and no
restarts.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 10:25:53 +02:00
k.renner 29326eead3 Merge pull request 'feat(cni): per-node switch for Multus as sole CNI writer (stage A of #832)' (#910) from feat/cni-staged-sole-writer into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#910
2026-09-23 07:52:22 +00:00
claude-botandClaude Opus 5.5 eb09b1b2a3 feat(cni): per-node switch for Multus as sole CNI writer (stage A of #832)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Nothing changes until a node is labelled:
- post-crds/cilium-nodeconfig.yaml: CiliumNodeConfig cni-uninstall
  (label cni.renner.uno/cilium-uninstall) arms the agent's preStop
  conflist cleanup; multus-sole-writer (label
  cni.renner.uno/multus-sole-writer) moves write-cni-conf-when-ready to
  /host/etc/cni/net.d/multus/05-cilium.conflist, a subdirectory libcni
  does not read.
- multus/multus-subdir.yaml: kube-multus-ds-subdir with
  multusAutoconfigDir /host/etc/cni/net.d/multus, scheduled only on
  labelled nodes; kube-multus-ds gets the inverse affinity.

The kube-multus-ds template change rolls Multus node by node once on
merge.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 09:47:16 +02:00
k.renner dd3a1f1e90 Merge pull request 'feat(netbird-service-sync): run the controller in its own namespace (stage 2 of #876)' (#909) from feat/netbird-service-sync-phase2 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#909
2026-09-23 06:55:25 +00:00
claude-botandClaude Opus 5.5 98a0462137 feat(netbird-service-sync): run the controller in its own namespace (stage 2 of #876)
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
- kubernetes/infrastructure/netbird-proxy-services -> netbird-service-sync:
  SA, ClusterRole(Binding) and Deployment renamed, in namespace
  netbird-service-sync, image gitea_admin/netbird-service-sync pinned to
  the master build digest, BAO_ROLE=netbird-service-sync.
- Old policy doc netbird-proxy-services.hcl removed; Traefik comment
  follows the name.

The infrastructure KS owns both old and new objects, so prune and apply
happen in one pass. OpenBao policy and role netbird-service-sync are
already written.

Closes #876

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 08:41:20 +02:00
k.renner 9aef2e0669 Merge pull request 'feat(netbird-service-sync): prepare rename and own namespace (stage 1 of #876)' (#908) from feat/netbird-service-sync-phase1 into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#908
2026-09-23 06:35:14 +00:00
claude-botandClaude Opus 5.5 fd7597ddc2 chore(netbird-service-sync): drop stray __pycache__
ci/woodpecker/push/build-ci-images 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
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 08:30:28 +02:00
claude-botandClaude Opus 5.5 10b0c1b853 feat(netbird-service-sync): prepare rename and own namespace (stage 1 of #876)
ci/woodpecker/push/build-ci-images Pipeline was canceled
Additive; the running controller is untouched:
- ci/images/netbird-proxy-services -> ci/images/netbird-service-sync,
  build step and image name renamed, so the merge builds
  gitea_admin/netbird-service-sync.
- Namespace netbird-service-sync with a baseline policy: probes in;
  out only to kube-dns, kube-apiserver, OpenBao :8200 and world :443
  (NetBird management API).
- openbao-ns admits netbird-service-sync on :8200, like wiki-index.
- policies/netbird-service-sync.hcl documents the new policy and role
  (applied by hand, as before).

Refs #876

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 08:30:15 +02:00
k.renner 674169cb08 Merge pull request 'chore(gitea): drop the apps-namespace grants for Gitea (stage 3 of #878)' (#907) from chore/gitea-namespace-phase3 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#907
2026-09-23 06:20:17 +00:00
claude-botandClaude Opus 5.5 0c6174b7a6 chore(gitea): drop the apps-namespace grants for Gitea (stage 3 of #878)
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
Gitea runs in the gitea namespace since #906. Remove what only served it
in apps:
- openbao rotator egress (flux-git, review-bot): apps/gitea target.
- ai-ns wiki-index ingress: apps/gitea source.
- apps default-deny egress to wiki-index :8080 (gitea's webhook; now in
  gitea-ns.yaml).
- Velero schedule comments.

Refs #878

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 08:09:30 +02:00
k.renner 76888c3771 Merge pull request 'feat(gitea): move Gitea into the gitea namespace (stage 2 of #878)' (#906) from feat/gitea-namespace-phase2 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#906
2026-09-23 06:05:33 +00:00
claude-botandClaude Opus 5.5 eb23d3ac6e feat(gitea): move Gitea into the gitea namespace (stage 2 of #878)
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
- kubernetes/apps/gitea: every object in namespace gitea; applied by a
  new gitea Flux Kustomization (clusters/homelab/gitea.yaml) instead of
  apps. gitea-allow-metrics-scrape dropped: the gitea-ns baseline
  already admits monitoring.
- Rotators (flux-git, review-bot) and the Prometheus gitea job use
  gitea.gitea.svc.
- bootstrap.sh waits for the gitea KS.

Data: data-gitea-0 is not copied. The PV (reclaimPolicy Retain) is
rebound to a PVC gitea/data-gitea-0 pre-created with volumeName, which
the new StatefulSet adopts. gitea-tls is pre-issued in the gitea
namespace so HTTPS has no certificate gap.

Refs #878

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 07:44:26 +02:00
k.renner c1e3f3fb98 Merge pull request 'feat(gitea): prepare the gitea namespace (stage 1 of #878)' (#905) from feat/gitea-namespace-phase1 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#905
2026-09-23 05:35:18 +00:00
claude-botandClaude Opus 5.5 036399095b feat(gitea): prepare the gitea namespace (stage 1 of #878)
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
Additive only, nothing moves yet:
- Namespace gitea, declared in infrastructure like woodpecker.
- network-policies/gitea-ns.yaml: baseline (same-ns, monitoring, host,
  ICMP; egress to traefik, kube-dns, wiki-index, world) and
  gitea-allow-traefik for :3000 and :2222.
- openbao rotator egress (flux-git, review-bot) and wiki-index ingress
  also admit gitea in the gitea namespace; the apps entries stay until
  stage 3.
- Velero schedule gitea-daily (07:45) so data-gitea-0 stays backed up
  after the move; apps-daily comment updated (Woodpecker left apps).

Refs #878

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 07:30:36 +02:00
k.renner 0bac0051b2 Merge pull request 'chore(ci): bump ci-claude image to 2.1.280' (#904) from ci-image/claude-2.1.280 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#904
2026-09-23 05:16:03 +00:00
claude-bot 41a65b65a3 chore(ci): bump ci-claude image to 2.1.280
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
2026-09-23 05:13:16 +00:00
k.renner a530f7c8e6 Merge pull request 'fix(ci-claude-refresh): bump divergent pins instead of aborting' (#903) from fix/ci-claude-refresh-mixed-pins into master
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#903
2026-09-23 05:12:40 +00:00
claude-botandClaude Opus 5.5 8fff4b4012 fix(ci-claude-refresh): bump divergent pins instead of aborting
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
decide required every consumer to pin the same ci-claude tag and aborted
otherwise (pipeline 10497: [2.1.250 2.1.278]), which needed a hand fix
before the latest version could roll out. Record all distinct tags, bump
whenever any is off latest, and rewrite every version-pinned tag to the
new one with a validated x.y.z version.

Merging this is itself a push touching the pipeline file, so the refresh
runs, builds the latest image, and opens the bump PR.

Refs #889

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 07:06:56 +02:00
k.renner 0ada3df260 Merge pull request 'fix(ci-claude-refresh): strip CR/LF from the bump token before use' (#901) from fix/ci-claude-bump-token-newline into master
ci/woodpecker/push/ci-claude-refresh Pipeline failed
Reviewed-on: k.renner/homelab#901
2026-09-23 05:03:19 +00:00
claude-botandClaude Opus 5.5 2144554cfc fix(ci-claude-refresh): strip CR/LF from the bump token before use
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
Pipeline 10186 failed in bump-pr with 'url contains a newline in its
username component': ci_claude_bump_bot_token carries a trailing newline,
so the push URL (and the PR API auth header) broke. Strip CR/LF once at
the top of the step and fail fast if the token ends up empty. This makes
the step independent of how the secret value was written.

Closes #889

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 06:59:15 +02:00
k.renner dba810b87b Merge pull request 'fix(paperless): raise the memory limit to 4Gi' (#900) from worktree-fix-paperless-memory-limit into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/wiki-maintenance Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#900
2026-09-21 19:29:08 +00:00
claude-botandClaude Fable 5.1 f8aa65362b fix(paperless): cap OCR at 5 threads per worker
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
Unset, paperless derives cpu_count / TASK_WORKERS, i.e. 20 parallel
ocrmypdf workers on k8s-1. Memory during OCR scales with that count, so
the limit alone is not enough headroom for large scans.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 21:26:35 +02:00
claude-botandClaude Fable 5.1 0ce0391bb1 fix(paperless): raise the memory limit to 4Gi
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
An 18 MB scan from the ADS-1700W has kept the paperless container in an
OOM crash loop since 2026-09-21 10:21: ocrmypdf runs with 20 workers
(paperless default, cpu_count / TASK_WORKERS on a 20-core node) and blows
the 2Gi limit ~40s into OCR. The consume task is re-queued on every
restart, so the same file kills every attempt. 117 restarts so far, web
UI down as a side effect since worker and webserver share the container.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 21:24:36 +02:00
security-bot 4611ea3578 chore(image-automation): bump redis:8.10.2-alpine
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-21 18:52:58 +00:00
k.renner 63701fbebb Merge pull request 'ci(wiki-maintenance): verify the PR merged instead of trusting the merge status' (#895) from fix/wiki-maintenance-merge-verify into master
ci/woodpecker/manual/wiki-maintenance Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#895
2026-09-21 08:49:10 +00:00
k.renner e8a047eb14 Merge pull request 'ci(pick-model): newest among comparable candidates, policy in code' (#894) from feat/pick-model-newest-among-comparable into master
Reviewed-on: k.renner/homelab#894
2026-09-21 08:48:58 +00:00
claude-botandClaude Fable 5.1 0279210595 ci(wiki-maintenance): verify the PR merged instead of trusting the merge status
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
Run 10254: POST .../merge with delete_branch_after_merge returned
HTTP 500 although PR #2 on k.renner/notes was merged, so the step
failed and left the branch behind. The merge call no longer asks Gitea
to delete the branch; the step reads the PR back and requires
merged=true, then deletes the branch as a separate best-effort call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 10:06:23 +02:00
claude-botandClaude Fable 5.1 25f09e9b52 ci(pick-model): newest among comparable candidates, policy in code
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
First live pick after #893 chose claude-sonnet-4-5 at 0.23 over
claude-sonnet-4-6: same-family models split the probability for a
mid-weight task, and "prefer newer" is a policy, not a judgment. New
scripts/ci/choose-model.sh applies it to the distribution: among
candidates within CHOOSE_MODEL_MARGIN (0.7) of the best probability the
most recently released wins; a clear best stays; NONE yields nothing.
The picker logs the top probabilities. Four policy tests added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 09:59:40 +02:00
k.renner 82f2825d3f Merge pull request 'ci(pick-model): choose the model with one TypeSafe Choice, claude as fallback' (#893) from feat/pick-model-typesafe into master
ci/woodpecker/manual/wiki-maintenance Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#893
2026-09-21 07:56:11 +00:00
claude-botandClaude Fable 5.1 0fec080b4d ci(pick-model): choose the model with one TypeSafe Choice, claude as fallback
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The picker ran a full claude -p in plan mode per selection and parsed
JSON out of prose. With TYPESAFE_API_KEY set it now sends the task and
the catalog rows as state and asks one Choice over the model ids, each
with its catalog row as criterion plus NONE; the id is validated against
the pristine catalog as before. No key or a failed request falls through
to the claude picker unchanged.

Measured on the two-model fixture (Opus 5 / Haiku 4.5): issue-fix and
security-fix -> Opus 0.99, alert triage -> Opus 0.62, wiki audit ->
Haiku 0.94, update audit -> Haiku 0.46, distill -> Haiku 0.63; about a
second per pick. Dry-run test added to test-model-selection.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 09:42:39 +02:00
k.renner 7424c6aefd Merge pull request 'ci(wiki-maintenance): rebase onto main before pushing, retry three times' (#892) from fix/wiki-maintenance-rebase-push into master
ci/woodpecker/manual/wiki-maintenance Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#892
2026-09-21 06:41:40 +00:00
claude-botandClaude Fable 5.1 4e3fb88309 ci(wiki-maintenance): push a branch and merge it through Gitea
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
Instead of rebasing onto a moving main: the run's changes go to
wiki-maintenance/<date>-<run>, a PR is opened and merged via the API
(Do: merge, branch deleted afterwards). A merge conflict leaves the PR
open for a human and fails the run. Fields checked against the
instance's swagger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 08:40:47 +02:00
claude-botandClaude Fable 5.1 c8c5ce9015 ci(wiki-maintenance): rebase onto main before pushing, retry three times
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
Run 10241: distill and audit succeeded (5 sources, 8 new + 6 updated
notes), the push was rejected non-fast-forward because the workstation
pushed the vault while the agents ran. The push step now fetches,
rebases and retries; a real conflict still fails the run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 08:36:58 +02:00
k.renner 293f51b914 Merge pull request 'ci(wiki-maintenance): strip CR/LF from secrets before use' (#891) from fix/wiki-maintenance-strip-secrets into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/manual/wiki-maintenance Pipeline failed
Reviewed-on: k.renner/homelab#891
2026-09-21 06:22:44 +00:00
claude-botandClaude Fable 5.1 70287db150 ci(wiki-maintenance): allow hand-started runs via the manual event
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
A deployment-event run (10239) errored before its first step: the
typesafe_api_key secret is scoped to cron and manual only. Manual
pipelines (woodpecker pipeline create) are the way to start this by
hand; the deployment trigger is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 08:20:36 +02:00
claude-botandClaude Fable 5.1 efedba1ff0 ci(wiki-maintenance): strip CR/LF from secrets before use
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 canceled
First cron run (10220) failed in checkout-notes with "could not read
Username": the wiki_bot_token secret had been set from a file with a
trailing newline, which git carried into the extraheader. The secret
is rewritten without it; the pipeline now strips CR/LF from the token
and the index credential regardless of how they were entered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 08:19:12 +02:00
security-bot f35a2d5985 chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.2.1
ci/woodpecker/cron/wiki-maintenance Pipeline failed
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-20 21:51:31 +00:00
k.renner 19b641a2b6 Merge pull request 'ci(wiki-maintenance): run on ci-claude 2.1.278' (#890) from fix/wiki-maintenance-image-2.1.278 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#890
2026-09-20 20:46:19 +00:00
claude-botandClaude Fable 5.1 e422e6a13d ci(wiki-maintenance): run on ci-claude 2.1.278
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 refresh triggered by #888 built 2.1.278 (the first ci-claude with
python3) rather than rebuilding 2.1.250, and its bump-pr step failed on
a newline in the push token secret (#889), so the consumer bump never
came. This pipeline needs python for wiki_ask.py, so it moves ahead of
the general bump.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 22:45:02 +02:00
k.renner f4d590c40d Merge pull request 'feat(ci): nightly wiki maintenance for the Notes vault as a Woodpecker cron' (#888) from feat/wiki-maintenance-ci into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/ci-claude-refresh Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#888
2026-09-20 20:41:58 +00:00
claude-botandClaude Fable 5.1 791cb6e524 feat(ci): nightly wiki maintenance for the Notes vault as a Woodpecker cron
ci/woodpecker/push/build-ci-images Pipeline was successful
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
Replaces the workstation's wiki-maintenance.timer. The pipeline clones
k.renner/notes, runs wiki-distill over raw/ and wiki-audit over wiki/
as two separate `claude --agent` runs (no background tasks in print
mode, which killed the workstation job at the 600 s ceiling), then
commits and pushes as Claude Bot; the push webhook refreshes wiki-index.

The agent definitions now live in the vault (.claude/agents/), so CI
and workstation share them. The agents and their retrieval scripts
address the vault as ~/Notes; each agent step links $HOME/Notes to the
checkout. ci-claude gains python3 and py3-yaml for wiki_ask.py.

Secrets on the repo: claude_code_oauth_token (existing), typesafe_api_key,
wiki_bot_token (claude-bot, write:repository), wiki_index_basic_auth.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 22:37:59 +02:00
k.renner 3749df798e Merge pull request 'ci(wiki-index): per-commit image tags, deployment pinned to one' (#886) from fix/wiki-index-commit-tags into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#886
2026-09-20 09:00:25 +00:00
51 changed files with 666 additions and 176 deletions
+1 -1
View File
@@ -101,7 +101,7 @@ steps:
bash scripts/ci/make-diag-kubeconfig.sh
- name: alert-triage-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+7 -7
View File
@@ -350,17 +350,17 @@ steps:
--destination gitea.renner.uno/gitea_admin/heating-can:$TAG \
--destination gitea.renner.uno/gitea_admin/heating-can:latest
# netbird-proxy-services: the controller that reconciles NetBird reverse-proxy
# netbird-service-sync: the controller that reconciles NetBird reverse-proxy
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
# Runtime artifact like heating-can; same registry so the audit tracks it.
- name: build-netbird-proxy-services
- name: build-netbird-service-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/netbird-proxy-services
directory: ci/images/netbird-service-sync
depends_on: []
when:
- event: deployment
- event: push
path: ["ci/images/netbird-proxy-services/**", ".woodpecker/build-ci-images.yml"]
path: ["ci/images/netbird-service-sync/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
@@ -374,11 +374,11 @@ steps:
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:$TAG \
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:latest
--destination gitea.renner.uno/gitea_admin/netbird-service-sync:$TAG \
--destination gitea.renner.uno/gitea_admin/netbird-service-sync:latest
# wiki-index: embedding index over the Notes repo's wiki/, fed from Gitea and
# queried by ~/Notes/bin/wiki_rank.py. Runtime artifact like netbird-proxy-services.
# queried by ~/Notes/bin/wiki_rank.py. Runtime artifact like netbird-service-sync.
- name: build-wiki-index
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/wiki-index
+17 -21
View File
@@ -14,8 +14,8 @@
# and ci-claude:latest. The Containerfile makes this layer
# byte-reproducible, so rebuilding an unchanged version dedups
# in the registry instead of orphaning a fresh ~260 MB copy.
# 3. bump-pr — (only when the version actually changed) rewrite the 5
# consumer pins to ci-claude:<version> and open a PR. A human
# 3. bump-pr — (only when any pin is off the latest version) rewrite every
# consumer pin to ci-claude:<version> and open a PR. A human
# reviews and merges it; nothing auto-merges.
#
# TAG SCHEME — deliberately version tags (ci-claude:<claude-version>), NOT the dated
@@ -107,31 +107,22 @@ steps:
[0-9]*.[0-9]*.[0-9]*) : ;;
*) echo "npm returned an implausible version '$$NEW' — aborting."; exit 1 ;;
esac
# The tag every consumer currently references. They are kept identical,
# so this must resolve to EXACTLY ONE non-empty tag; anything else
# (no match, or a partial/disagreeing set) aborts rather than risk a
# corrupting sed later. Counting with `grep -c .` (non-empty lines)
# instead of `wc -l` is deliberate: an empty result yields 0 here but
# 1 under wc -l, which would slip past the guard.
CUR=$$(grep -hoE 'ci-claude:[0-9A-Za-z._-]+' $$CONSUMERS | sed 's/^ci-claude://' | sort -u)
NTAGS=$$(printf '%s' "$$CUR" | grep -c . || true)
if [ -z "$$CUR" ] || [ "$$NTAGS" -ne 1 ]; then
echo "Expected exactly one ci-claude tag across the consumers, got [$$CUR]."
echo "Refusing to guess — fix the pins by hand."; exit 1
fi
echo "latest=$$NEW current=$$CUR event=$$CI_PIPELINE_EVENT"
# Distinct tags the consumers currently reference. Divergent pins are
# normal (a pipeline bumped on its own); the bump rewrites all of them.
CUR=$$(grep -hoE 'ci-claude:[0-9][0-9A-Za-z._-]*' $$CONSUMERS | sed 's/^ci-claude://' | sort -u | tr '\n' ' ' | sed 's/ $$//')
[ -n "$$CUR" ] || { echo "No ci-claude pin found in the consumers — aborting."; exit 1; }
echo "latest=$$NEW current=[$$CUR] event=$$CI_PIPELINE_EVENT"
printf '%s' "$$NEW" > "$$CI_WORKSPACE/.ci-refresh/version"
printf '%s' "$$CUR" > "$$CI_WORKSPACE/.ci-refresh/current"
# Build when the version changed, or on a push (Containerfile edit must
# ship even if the version is unchanged). Bump the pins only when the
# version the consumers reference is not the latest.
# Build when any pin is off latest, or on a push (Containerfile edit must
# ship even if the version is unchanged). Bump when any pin is off latest.
if [ "$$CUR" != "$$NEW" ] || [ "$$CI_PIPELINE_EVENT" = "push" ]; then
touch "$$CI_WORKSPACE/.ci-refresh/do-build"; echo "will build $$NEW"
else
echo "already on $$NEW — nothing to build"
fi
if [ "$$CUR" != "$$NEW" ]; then
touch "$$CI_WORKSPACE/.ci-refresh/do-bump"; echo "will open bump PR $$CUR -> $$NEW"
touch "$$CI_WORKSPACE/.ci-refresh/do-bump"; echo "will open bump PR [$$CUR] -> $$NEW"
else
echo "consumers already pin $$NEW — no PR"
fi
@@ -175,6 +166,9 @@ steps:
- |
[ -f "$$CI_WORKSPACE/.ci-refresh/do-bump" ] || { echo "no version change — no PR"; exit 0; }
set -eu
# Secret values can carry a trailing newline; it breaks the push URL and the auth header.
PUSH_TOKEN=$$(printf '%s' "$$PUSH_TOKEN" | tr -d '\r\n')
[ -n "$$PUSH_TOKEN" ] || { echo "PUSH_TOKEN is empty — aborting"; exit 1; }
NEW=$$(cat "$$CI_WORKSPACE/.ci-refresh/version")
CUR=$$(cat "$$CI_WORKSPACE/.ci-refresh/current")
# Both were validated non-empty by the decide step; re-assert before a
@@ -190,9 +184,11 @@ steps:
# is not a maintained list.
CONSUMERS=$$(grep -rlE 'ci-claude:[0-9]' .woodpecker)
[ -n "$$CONSUMERS" ] || { echo "No ci-claude consumers found — refusing to continue."; exit 1; }
# Exact-string swap of the current tag for the new one, consumers only.
# NEW goes into a sed replacement: allow only a plain x.y.z version.
printf '%s' "$$NEW" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$$' || { echo "refusing non-x.y.z version '$$NEW'"; exit 1; }
# Every version-pinned tag becomes NEW, whatever it was; consumers only.
for f in $$CONSUMERS; do
sed -i "s#ci-claude:$$CUR#ci-claude:$$NEW#g" "$$f"
sed -i "s#ci-claude:[0-9][0-9A-Za-z._-]*#ci-claude:$$NEW#g" "$$f"
done
# Author is claude-bot (the PAT's account); the commit subject makes
# the automated-bump nature clear.
+1 -1
View File
@@ -17,6 +17,6 @@ when:
steps:
- name: model-selection-tests
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -62,7 +62,7 @@ steps:
- bash scripts/ci/make-diag-kubeconfig.sh
- name: issue-fix-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -65,7 +65,7 @@ when:
steps:
- name: pr-review-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: master
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+140
View File
@@ -0,0 +1,140 @@
# Nightly wiki maintenance for the Notes vault (k.renner/notes), replacing the
# workstation's wiki-maintenance.timer: distill the raw/ backlog into wiki/,
# then audit wiki/. Each agent is its own `claude` run, not a subagent of an
# orchestrator, so nothing depends on background tasks in print mode (the
# workstation job died on their 600 s ceiling). The pipeline commits and pushes
# as Claude Bot; the push webhook then refreshes the wiki-index service.
#
# The agents and their retrieval scripts address the vault as ~/Notes, so each
# agent step links $HOME/Notes to the checkout instead of rewriting them.
when:
- event: cron
cron: wiki-maintenance
# Hand-started runs: `woodpecker pipeline create k.renner/homelab --branch master`.
# The typesafe_api_key secret is scoped to cron and manual, not deployment.
- event: manual
steps:
- name: checkout-notes
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
pull: true
environment:
WIKI_BOT_TOKEN:
from_secret: wiki_bot_token
commands:
- |
set -eu
# Token only on the wire, never in .git/config. Secrets set from files carry
# a trailing newline (run 10220: "could not read Username"); strip CR/LF.
TOKEN=$$(printf '%s' "$$WIKI_BOT_TOKEN" | tr -d '\r\n')
git -c http.extraheader="AUTHORIZATION: token $$TOKEN" \
clone --depth 50 --branch main https://gitea.renner.uno/k.renner/notes.git notes
echo "raw backlog: $$(ls notes/raw/*.md | grep -v _index.md | wc -l) file(s)"
- name: distill
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
pull: true
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
TYPESAFE_API_KEY:
from_secret: typesafe_api_key
WIKI_INDEX_BASIC_AUTH:
from_secret: wiki_index_basic_auth
commands:
- |
set -eu
ln -sfn "$$PWD/notes" "$$HOME/Notes"
mkdir -p "$$HOME/.config/wiki-index"
printf '%s' "$$WIKI_INDEX_BASIC_AUTH" | tr -d '\r\n' > "$$HOME/.config/wiki-index/basic-auth"
chmod 600 "$$HOME/.config/wiki-index/basic-auth"
"$$HOME/Notes/bin/wiki_rank.py" --probe-index
- |
set -eu
MODEL=$$(sh scripts/ci/pick-model.sh "distill raw source notes into atomic wiki notes: read sources, check the wiki for existing coverage, write or merge notes; careful writing, moderate reasoning")
if [ -n "$$MODEL" ]; then echo "distill: selected model $$MODEL"; else echo "distill: using the CLI default model"; fi
cd notes
cat ../ci/agents/wiki-maintenance/distill.md | claude $${MODEL:+--model $$MODEL} \
--agent wiki-distill \
--permission-mode acceptEdits \
--allowedTools "Read,Edit,Write,Glob,Grep,Bash(/root/Notes/bin/wiki_rank.py:*),Bash(/root/Notes/bin/wiki_ask.py:*),Bash(mv /root/Notes/raw/:*),Bash(mkdir -p /root/Notes/:*)" \
--settings ../ci/agents/wiki-maintenance/settings.json \
-p
- name: audit
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
pull: true
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
TYPESAFE_API_KEY:
from_secret: typesafe_api_key
WIKI_INDEX_BASIC_AUTH:
from_secret: wiki_index_basic_auth
commands:
- |
set -eu
ln -sfn "$$PWD/notes" "$$HOME/Notes"
mkdir -p "$$HOME/.config/wiki-index"
printf '%s' "$$WIKI_INDEX_BASIC_AUTH" | tr -d '\r\n' > "$$HOME/.config/wiki-index/basic-auth"
chmod 600 "$$HOME/.config/wiki-index/basic-auth"
- |
set -eu
MODEL=$$(sh scripts/ci/pick-model.sh "structural audit of ~1700 markdown notes: frontmatter completeness, broken links, orphans; mechanical checks, little reasoning")
if [ -n "$$MODEL" ]; then echo "audit: selected model $$MODEL"; else echo "audit: using the CLI default model"; fi
cd notes
cat ../ci/agents/wiki-maintenance/audit.md | claude $${MODEL:+--model $$MODEL} \
--agent wiki-audit \
--permission-mode acceptEdits \
--allowedTools "Read,Write,Glob,Grep,Bash(/root/Notes/bin/wiki_rank.py:*),Bash(/root/Notes/bin/wiki_ask.py:*)" \
--settings ../ci/agents/wiki-maintenance/settings.json \
-p
- name: push-notes
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
pull: true
environment:
WIKI_BOT_TOKEN:
from_secret: wiki_bot_token
commands:
- |
set -eu
cd notes
git config user.email "claude-bot@renner.uno"
git config user.name "Claude Bot"
if git diff --quiet && git diff --cached --quiet && [ -z "$$(git ls-files --others --exclude-standard)" ]; then
echo "wiki-maintenance: no changes"
exit 0
fi
# The workstation pushes main on its own (notes-push.path), so main moves
# while the agents run (run 10241: non-fast-forward). The run's work goes
# to a branch and a PR that this step merges through Gitea; a conflict
# leaves the PR open for a human and fails the run.
BRANCH="wiki-maintenance/$$(date -u +%F)-$$CI_PIPELINE_NUMBER"
git checkout -q -b "$$BRANCH"
git add -A
git commit -q -m "wiki maintenance $$(date -u +%F): distill + audit (CI run $$CI_PIPELINE_NUMBER)"
TOKEN=$$(printf '%s' "$$WIKI_BOT_TOKEN" | tr -d '\r\n')
git -c http.extraheader="AUTHORIZATION: token $$TOKEN" push origin "HEAD:$$BRANCH"
API="https://gitea.renner.uno/api/v1/repos/k.renner/notes"
jq -n --arg title "wiki maintenance $$(date -u +%F) (CI run $$CI_PIPELINE_NUMBER)" \
--arg body "Nightly distill + audit. Merged by the pipeline; open only if the merge failed." \
--arg head "$$BRANCH" '{title: $$title, body: $$body, head: $$head, base: "main"}' > /tmp/pr.json
code=$$(curl -s -o /tmp/pr-resp.json -w '%{http_code}' -X POST \
-H "Authorization: token $$TOKEN" -H "Content-Type: application/json" --data @/tmp/pr.json "$$API/pulls")
echo "POST pull -> HTTP $$code"
[ "$$code" -ge 200 ] && [ "$$code" -lt 300 ] || { cat /tmp/pr-resp.json; exit 1; }
NUM=$$(jq -r '.number' /tmp/pr-resp.json)
# Merge without delete_branch_after_merge: run 10254 got HTTP 500 from that
# call although the merge itself had landed. Verify the PR state instead of
# trusting the status code, then drop the branch as a separate, best-effort step.
code=$$(curl -s -o /tmp/merge-resp.json -w '%{http_code}' -X POST \
-H "Authorization: token $$TOKEN" -H "Content-Type: application/json" \
--data '{"Do":"merge"}' "$$API/pulls/$$NUM/merge")
echo "POST merge #$$NUM -> HTTP $$code"
MERGED=$$(curl -s -H "Authorization: token $$TOKEN" "$$API/pulls/$$NUM" | jq -r '.merged')
echo "PR #$$NUM merged: $$MERGED"
[ "$$MERGED" = "true" ] || { cat /tmp/merge-resp.json; echo "PR #$$NUM left open"; exit 1; }
code=$$(curl -s -o /tmp/del-resp.json -w '%{http_code}' -X DELETE -H "Authorization: token $$TOKEN" \
"$$API/branches/$$(printf '%s' "$$BRANCH" | sed 's|/|%2F|g')")
echo "DELETE branch $$BRANCH -> HTTP $$code (best effort)"
+5
View File
@@ -0,0 +1,5 @@
You are running as the scheduled wiki-maintenance job in CI. The Notes vault is checked out at ~/Notes (the repository k.renner/notes).
Run the standard audit pass over ~/Notes/wiki/ and write the dated report to ~/Notes/output/. Do not modify any wiki note. Do not commit or push; the pipeline does that.
End with two lines: notes scanned, and issues found by category.
+7
View File
@@ -0,0 +1,7 @@
You are running as the scheduled wiki-maintenance job in CI. The Notes vault is checked out at ~/Notes (the repository k.renner/notes). Do exactly this, nothing else:
Glob ~/Notes/raw/*.md (top level only, never ~/Notes/raw/_processed/). Distill every file you find into ~/Notes/wiki/ following your process, including the ranker check for every concept. Move each fully processed source to ~/Notes/raw/_processed/. If raw/ has no top-level files besides _index.md, say so and stop.
Do not commit or push; the pipeline does that after you finish. Do not touch anything outside ~/Notes/wiki/ and ~/Notes/raw/.
End with one line per file: which wiki notes it produced or merged into, or "nothing distillable".
+37
View File
@@ -0,0 +1,37 @@
{
"//": "Permission scope for the wiki-maintenance agents (wiki-distill, wiki-audit) in CI.",
"//2": "The step's environment holds a Gitea push token, a TypeSafe key and the wiki-index",
"//3": "client credential, so the Read tool is kept away from /proc (env dumps), /root outside",
"//4": "the vault symlink target, /etc and the checkout's .git/. The vault itself is the work.",
"//5": "Mirrors ci/agents/issue-fix/settings.json; absolute globs because the vault is reached",
"//6": "through the ~/Notes symlink the pipeline creates.",
"permissions": {
"allow": [
"Read(//root/Notes/**)",
"Edit(//root/Notes/wiki/**)",
"Edit(//root/Notes/output/**)",
"Edit(//root/Notes/raw/**)",
"Bash(/root/Notes/bin/wiki_rank.py:*)",
"Bash(/root/Notes/bin/wiki_ask.py:*)",
"Bash(mv /root/Notes/raw/:*)",
"Bash(mkdir -p /root/Notes/:*)",
"Bash(ls:*)",
"Bash(date:*)",
"Bash(wc:*)",
"Bash(head:*)",
"Bash(tail:*)"
],
"deny": [
"Read(//proc/**)",
"Read(//etc/**)",
"Read(//root/.config/**)",
"Read(//root/Notes/.git/**)",
"Read(//root/Notes/_archive/**)",
"Edit(//root/Notes/_archive/**)",
"Bash(rm:*)",
"Bash(git:*)",
"Bash(curl:*)",
"Bash(wget:*)"
]
}
}
+3 -1
View File
@@ -13,7 +13,9 @@ RUN apk add --no-cache \
git \
jq \
openssh-client \
openssl
openssl \
python3 \
py3-yaml
# Install Claude Code, then make the resulting layer byte-stable so that a
# rebuild of the SAME version DEDUPS in the Gitea registry instead of orphaning
@@ -1,4 +1,4 @@
# netbird-proxy-services: reconciles NetBird reverse-proxy services (public
# netbird-service-sync: reconciles NetBird reverse-proxy services (public
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
# public label. Standard library only, so the image is the interpreter plus one
# file; runs unprivileged as its own user.
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""netbird-proxy-services: reconcile NetBird reverse-proxy services from Ingresses.
"""netbird-service-sync: reconcile NetBird reverse-proxy services from Ingresses.
Source of truth: every Ingress carrying the public label (dns.renner.uno/public).
For each host on such an Ingress the NetBird management gets one reverse-proxy
@@ -30,11 +30,11 @@ import urllib.error
import urllib.parse
import urllib.request
LOG = logging.getLogger("netbird-proxy-services")
LOG = logging.getLogger("netbird-service-sync")
NB = os.environ.get("NB_MANAGEMENT_URL", "https://netbird.renner.uno").rstrip("/")
BAO_ADDR = os.environ.get("BAO_ADDR", "http://openbao.openbao.svc.cluster.local:8200").rstrip("/")
BAO_ROLE = os.environ.get("BAO_ROLE", "netbird-proxy-services")
BAO_ROLE = os.environ.get("BAO_ROLE", "netbird-service-sync")
BAO_PAT_PATH = os.environ.get("BAO_PAT_PATH", "homelab/data/infrastructure/secret-rotators-bootstrap/netbird")
BAO_PAT_KEY = os.environ.get("BAO_PAT_KEY", "pat")
SERVICE_CIDR = os.environ.get("SERVICE_CIDR", "10.96.0.0/12")
@@ -156,7 +156,7 @@ def ensure_network(nb):
if n["name"] == NETWORK_NAME:
return n["id"]
n = nb.post("networks", {"name": NETWORK_NAME,
"description": "managed by netbird-proxy-services; public ingress via TLS passthrough"})
"description": "managed by netbird-service-sync; public ingress via TLS passthrough"})
LOG.info("created network %s (%s)", NETWORK_NAME, n["id"])
return n["id"]
@@ -166,7 +166,7 @@ def ensure_resource(nb, net):
if r.get("address") == SERVICE_CIDR:
return r["id"]
r = nb.post(f"networks/{net}/resources", {"name": "cluster service CIDR", "address": SERVICE_CIDR,
"description": "managed by netbird-proxy-services",
"description": "managed by netbird-service-sync",
"enabled": True, "groups": []})
LOG.info("created subnet resource %s (%s)", SERVICE_CIDR, r["id"])
return r["id"]
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gitea
namespace: apps
namespace: gitea
labels:
app.kubernetes.io/name: gitea
annotations:
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
name: gitea-ssh
namespace: apps
namespace: gitea
labels:
app.kubernetes.io/name: gitea
spec:
+6 -26
View File
@@ -1,28 +1,8 @@
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-allow-metrics-scrape
namespace: apps
spec:
# Prometheus (monitoring ns) scrapes gitea's /metrics on :3000. The apps
# default-deny-ingress only allows same-ns / home-assistant / host, so the
# scrape is dropped (context deadline exceeded). This became necessary only
# after the LXC->in-cluster cutover — the old static target was external, so
# no in-cluster policy applied. Scoped to the gitea pod + :3000.
endpointSelector:
matchLabels:
app.kubernetes.io/name: gitea
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
toPorts:
- ports:
- port: "3000"
protocol: TCP
---
# Prometheus scrape and Traefik are admitted by the namespace baseline in
# infrastructure/network-policies/gitea-ns.yaml.
#
# Secret rotation: the flux-git rotator (openbao ns) reaches gitea to mint a new
# flux PAT. It uses the in-cluster Service (gitea.apps.svc:3000) — the public
# flux PAT. It uses the in-cluster Service (gitea.gitea.svc:3000) — the public
# gitea.renner.uno name hairpins off the ingress LB from a pod (same failure as
# pve.renner.uno). Scoped to the gitea pod :3000 and the rotator label; the
# egress half is rotator-gitea-flux-egress in network-policies/openbao-ns.yaml.
@@ -30,7 +10,7 @@ apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-allow-flux-rotator
namespace: apps
namespace: gitea
spec:
endpointSelector:
matchLabels:
@@ -54,7 +34,7 @@ apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-allow-review-bot-rotator
namespace: apps
namespace: gitea
spec:
endpointSelector:
matchLabels:
+1 -1
View File
@@ -13,7 +13,7 @@ apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: gitea
namespace: apps
namespace: gitea
labels:
app.kubernetes.io/name: gitea
spec:
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: v1
kind: Service
metadata:
name: gitea
namespace: apps
namespace: gitea
labels:
app.kubernetes.io/name: gitea
spec:
+2 -2
View File
@@ -2,7 +2,7 @@ apiVersion: apps/v1
kind: StatefulSet
metadata:
name: gitea
namespace: apps
namespace: gitea
labels:
app.kubernetes.io/name: gitea
spec:
@@ -152,7 +152,7 @@ spec:
# NOTE: volumeClaimTemplates is immutable on an existing StatefulSet,
# so bumping this value cannot be `kubectl apply`-ed in place — Flux
# will reject the whole object. Landing this size change requires a
# one-time `kubectl delete statefulset gitea -n apps --cascade=orphan`
# one-time `kubectl delete statefulset gitea -n gitea --cascade=orphan`
# (pod + PVC keep running) so Flux re-creates the STS from this
# template and re-adopts them. After the cleanup_packages GC reclaims
# the ~10G of orphaned blobs, real usage is ~9G, so 40Gi is headroom.
+1 -1
View File
@@ -3,8 +3,8 @@ kind: Kustomization
resources:
- gatus/
- external-dns/
- gitea/
- ical-proxy/
# gitea/ has its own `gitea` Kustomization (clusters/homelab/gitea.yaml, #878).
# woodpecker/ moved to its own dedicated `woodpecker` Kustomization
# (clusters/homelab/woodpecker.yaml) so its wp-pg CNPG cluster's barman
# dependency + Cluster health-gating don't contaminate this shared apps KS.
@@ -91,12 +91,11 @@ spec:
# Additional scrape targets not covered by ServiceMonitors.
additionalScrapeConfigs:
# Gitea exposes /metrics anonymously on its main HTTP port. The
# `gitea_*` metrics power the gitea-overview dashboard. Gitea now runs
# in-cluster (StatefulSet in apps ns; LXC 114 retired), so scrape the
# ClusterIP Service directly instead of the dead LXC hostname.
# `gitea_*` metrics power the gitea-overview dashboard. Scrapes the
# ClusterIP Service of the StatefulSet in the gitea namespace.
- job_name: gitea
static_configs:
- targets: ["gitea.apps.svc.cluster.local:3000"]
- targets: ["gitea.gitea.svc.cluster.local:3000"]
metrics_path: /metrics
# Grafana
+4 -2
View File
@@ -27,6 +27,8 @@ data:
PAPERLESS_ALLOWED_HOSTS: "*"
PAPERLESS_TIME_ZONE: "Europe/Berlin"
PAPERLESS_OCR_LANGUAGE: "deu+eng"
# Default is cpu_count / TASK_WORKERS, 20 on k8s-1; OCR memory scales with it.
PAPERLESS_THREADS_PER_WORKER: "5"
PAPERLESS_CONSUMER_RECURSIVE: "true"
PAPERLESS_CONSUMER_DELETE_DUPLICATES: "true"
PAPERLESS_CONSUMER_ENABLE_BARCODES: "true"
@@ -183,7 +185,7 @@ spec:
drop: ["ALL"]
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:3.2.0 # {"$imagepolicy": "flux-system:paperless-ngx"}
image: ghcr.io/paperless-ngx/paperless-ngx:3.2.1 # {"$imagepolicy": "flux-system:paperless-ngx"}
ports:
- name: http
containerPort: 8000
@@ -273,7 +275,7 @@ spec:
cpu: 200m
memory: 512Mi
limits:
memory: 2Gi
memory: 4Gi
securityContext:
runAsNonRoot: false
allowPrivilegeEscalation: true
+1 -1
View File
@@ -25,7 +25,7 @@ spec:
type: RuntimeDefault
containers:
- name: redis
image: redis:8.10.1-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
image: redis:8.10.2-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
args:
- redis-server
- --save
+1
View File
@@ -1946,6 +1946,7 @@ if phase_runs wait-apps; then
# apps waits on infrastructure-ceph-cluster (Ceph mon/mgr/OSD bring-up
# is genuinely 5–10 min on a fresh OSD-wiped cluster).
wait_kustomization apps "apps" 90
wait_kustomization gitea "Gitea (incl. data-gitea-0 PVC)" 90
wait_kustomization influxdb "InfluxDB (HR install + token Jobs)" 600
wait_kustomization woodpecker "Woodpecker CI (incl. wp-pg CNPG backend)" 600
wait_kustomization authentik "Authentik (incl. ak-pg CNPG backend)" 600
+20
View File
@@ -0,0 +1,20 @@
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: gitea
namespace: flux-system
spec:
# Gitea in its own `gitea` namespace (#878), formerly under the shared `apps` KS.
interval: 10m
retryInterval: 2m
timeout: 15m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/gitea
prune: true
wait: true
dependsOn:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
+1 -1
View File
@@ -23,7 +23,7 @@ protocol device { scan time 10; }
# down. The usual advice is not to export these to the kernel because it
# maintains them itself — true of main, not of a table the kernel never fills.
protocol direct attached {
interface "mgmt0", "ingress0", "pod0";
interface "mgmt0", "ingress0", "pod0", "node0";
ipv4 { table master4; };
ipv6 { table master6; };
}
@@ -118,14 +118,21 @@ spec:
# traffic out to the VIP and back (which fails with L2 announcements).
lbExternalClusterIP: true
cni:
# Multus (kubernetes/infrastructure/multus) runs alongside Cilium and
# writes 00-multus.conf to /etc/cni/net.d so kubelet picks multus
# first; multus then delegates to Cilium's 05-cilium.conflist via
# clusterNetwork. The default `exclusive: true` makes cilium-cni's
# install step rename anything alphabetically before its own config
# to *.cilium_bak on every reconcile, which kept nuking multus.
# Setting it false lets multus's config coexist.
# Multus is the only writer of /etc/cni/net.d (#832): Cilium's conflist
# goes to /etc/cni/net.d/multus/, which the container runtime does not
# read, and Multus delegates to it via clusterNetwork. Without this a
# pod created after a reboot, before Multus rewrote 00-multus.conf, came
# up with Cilium alone. See kubernetes/infrastructure/multus.
confPath: /etc/cni/net.d/multus
# The default `exclusive: true` renames other configs in the conf dir to
# *.cilium_bak on every reconcile, which kept nuking multus.
exclusive: false
# One agent at a time: the chart default of 2 restarts two of three
# nodes' datapath at once.
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
# Talos-specific settings
securityContext:
capabilities:
+1 -1
View File
@@ -19,7 +19,7 @@ resources:
- cloudnative-pg/
- mariadb-operator/
- netbird-ingress/
- netbird-proxy-services/
- netbird-service-sync/
- coredns-config/
- k8s-gateway/
- external-dns-unifi/
@@ -111,10 +111,18 @@ metadata:
name: multus
namespace: kube-system
---
# Multus is the only writer of /etc/cni/net.d (#832). Cilium writes its conflist
# to /etc/cni/net.d/multus/, a subdirectory the container runtime does not read
# (libcni skips directories); Multus picks it up there via multusAutoconfigDir
# and writes 00-multus.conf. Until that file exists the runtime has no network
# config at all, so no pod can be created with Cilium alone after a reboot.
#
# The "-subdir" names date from the node-by-node rollout; renaming would roll
# Multus again for nothing.
kind: ConfigMap
apiVersion: v1
metadata:
name: multus-daemon-config
name: multus-daemon-config-subdir
namespace: kube-system
labels:
tier: node
@@ -127,7 +135,7 @@ data:
"logLevel": "verbose",
"logToStderr": true,
"cniConfigDir": "/host/etc/cni/net.d",
"multusAutoconfigDir": "/host/etc/cni/net.d",
"multusAutoconfigDir": "/host/etc/cni/net.d/multus",
"multusConfigFile": "auto",
"socketDir": "/host/run/multus/"
}
@@ -135,16 +143,16 @@ data:
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: kube-multus-ds
name: kube-multus-ds-subdir
namespace: kube-system
labels:
tier: node
app: multus
name: multus
name: multus-subdir
spec:
selector:
matchLabels:
name: multus
name: multus-subdir
updateStrategy:
type: RollingUpdate
template:
@@ -152,7 +160,7 @@ spec:
labels:
tier: node
app: multus
name: multus
name: multus-subdir
spec:
# Upstream ships this DaemonSet with no priority class, so it runs at 0
# and can preempt nothing. Any event that makes the pod need a fresh
@@ -224,6 +232,10 @@ spec:
volumeMounts:
- name: cni
mountPath: /host/etc/cni/net.d
# Exists before Multus starts, so it waits for Cilium's conflist
# instead of exiting on a missing directory.
- name: cni-cilium-conf
mountPath: /host/etc/cni/net.d/multus
# multus-daemon expects that cnibin path must be identical between pod and container host.
# e.g. if the cni bin is in '/opt/cni/bin' on the container host side, then it should be mount to '/opt/cni/bin' in multus-daemon,
# not to any other directory, like '/opt/bin' or '/usr/bin'.
@@ -279,6 +291,10 @@ spec:
- name: cni
hostPath:
path: /etc/cni/net.d
- name: cni-cilium-conf
hostPath:
path: /etc/cni/net.d/multus
type: DirectoryOrCreate
- name: cnibin
hostPath:
path: /opt/cni/bin
@@ -287,7 +303,7 @@ spec:
path: /
- name: multus-daemon-config
configMap:
name: multus-daemon-config
name: multus-daemon-config-subdir
items:
- key: daemon-config.json
path: daemon-config.json
@@ -39,9 +39,11 @@ spec:
name: whereabouts-reconciler
spec:
serviceAccountName: whereabouts
# Every node here is a control plane node.
# Every node here is a control plane node. Keyed: a keyless Exists would
# also tolerate node.kubernetes.io/unschedulable and ignore cordons.
tolerations:
- operator: Exists
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: whereabouts-reconciler
@@ -179,6 +179,22 @@ kind: Namespace
metadata:
name: woodpecker
---
# Gitea, moving out of the shared `apps` namespace (issue #878). Declared here
# like `woodpecker` above, so it exists before infrastructure-crds applies its
# CiliumNetworkPolicies. No pod-security labels: the cluster default applies.
apiVersion: v1
kind: Namespace
metadata:
name: gitea
---
# netbird-service-sync, moving out of `openbao` (issue #876). Declared here like
# `woodpecker` above, so it exists before infrastructure-crds applies its
# CiliumNetworkPolicies. No pod-security labels: the cluster default applies.
apiVersion: v1
kind: Namespace
metadata:
name: netbird-service-sync
---
# Local LLM inference (kubernetes/apps/llama-server). Declared here, like
# `woodpecker` above, so the `infrastructure` KS creates it before
# `infrastructure-crds` applies the namespace's CiliumNetworkPolicies.
@@ -1,28 +1,28 @@
# Reconciles NetBird reverse-proxy services from Ingresses that carry
# dns.renner.uno/public: one TLS-passthrough service per host on :443,
# targeting Traefik's ClusterIP through a subnet resource over the service
# CIDR (ci/images/netbird-proxy-services/controller.py).
# CIDR (ci/images/netbird-service-sync/controller.py).
#
# openbao namespace, like the rotators: its default-deny already allows egress
# to OpenBao, DNS, the apiserver and `world` (netbird.renner.uno), and the
# NetBird PAT is read from OpenBao on every pass through the
# netbird-proxy-services role — policies/netbird-proxy-services.hcl, applied
# once by hand like the rotator roles. Until that role exists the pod logs an
# auth error each minute and does nothing.
# Own namespace; its egress (DNS, apiserver, OpenBao, NetBird API) is in
# network-policies/netbird-service-sync-ns.yaml. The NetBird PAT is read from
# OpenBao on every pass through the netbird-service-sync role —
# policies/netbird-service-sync.hcl, applied once by hand like the rotator
# roles. Until that role exists the pod logs an auth error each minute and does
# nothing.
apiVersion: v1
kind: ServiceAccount
metadata:
name: netbird-proxy-services
namespace: openbao
name: netbird-service-sync
namespace: netbird-service-sync
labels:
app.kubernetes.io/name: netbird-proxy-services
app.kubernetes.io/name: netbird-service-sync
---
# Read-only, cluster-wide: public Ingresses live in many namespaces, the
# Traefik Service in ingress/.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: netbird-proxy-services-read
name: netbird-service-sync-read
rules:
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
@@ -34,23 +34,23 @@ rules:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: netbird-proxy-services-read
name: netbird-service-sync-read
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: netbird-proxy-services-read
name: netbird-service-sync-read
subjects:
- kind: ServiceAccount
name: netbird-proxy-services
namespace: openbao
name: netbird-service-sync
namespace: netbird-service-sync
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: netbird-proxy-services
namespace: openbao
name: netbird-service-sync
namespace: netbird-service-sync
labels:
app.kubernetes.io/name: netbird-proxy-services
app.kubernetes.io/name: netbird-service-sync
spec:
replicas: 1
# One writer against the management API at a time.
@@ -58,13 +58,13 @@ spec:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: netbird-proxy-services
app.kubernetes.io/name: netbird-service-sync
template:
metadata:
labels:
app.kubernetes.io/name: netbird-proxy-services
app.kubernetes.io/name: netbird-service-sync
spec:
serviceAccountName: netbird-proxy-services
serviceAccountName: netbird-service-sync
securityContext:
runAsNonRoot: true
# Numeric, or the kubelet refuses the image's named user with
@@ -80,7 +80,7 @@ spec:
# image directory (branch and master alike) and the registry keeps
# only the newest manifest per tag, so the digest must come from
# the MASTER build of the code change.
image: gitea.renner.uno/gitea_admin/netbird-proxy-services:2026-09-07@sha256:caffa308d802c6fcc6aea919ac94657c08b993651baa6b8d18de395bcc40523e
image: gitea.renner.uno/gitea_admin/netbird-service-sync:2026-09-23@sha256:64f6057c508640e1dbc50457dc26e9b2de058a111fd46ca096021d7444b5bbff
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -92,7 +92,7 @@ spec:
- name: BAO_ADDR
value: http://openbao.openbao.svc.cluster.local:8200
- name: BAO_ROLE
value: netbird-proxy-services
value: netbird-service-sync
# Must match cluster.network.serviceSubnets in talos/patches/cluster.yaml.
- name: SERVICE_CIDR
value: "10.96.0.0/12"
@@ -167,7 +167,7 @@ spec:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
- matchLabels:
"k8s:io.kubernetes.pod.namespace": apps
"k8s:io.kubernetes.pod.namespace": gitea
app.kubernetes.io/name: gitea
toPorts:
- ports:
@@ -45,16 +45,6 @@ spec:
- ports:
- port: "1883"
protocol: TCP
# gitea delivers the notes repo's push webhook to wiki-index (ai ns) on :8080.
# wiki-index's own ingress policy scopes this to the gitea pod.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
app.kubernetes.io/name: wiki-index
toPorts:
- ports:
- port: "8080"
protocol: TCP
- toEntities:
- host
- remote-node
@@ -0,0 +1,104 @@
---
# Baseline policy for the `gitea` namespace (issue #878).
#
# endpointSelector: {} — every rule applies to every pod in the namespace.
# Workload-scoped grants go in their own policy with their own selector.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny
namespace: gitea
spec:
endpointSelector: {}
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": gitea
# Prometheus scrape of /metrics.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
# Kubelet probes and hostNetwork siblings.
- fromEntities:
- host
- remote-node
# ICMP from any cluster pod; required once an L4 policy selects the pod.
# See woodpecker-ns.yaml for the rationale. INGRESS ONLY.
- 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 }
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": gitea
# Webhooks to hosts served by Traefik (ci.renner.uno).
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
# Push webhook of the notes repo to wiki-index.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
app.kubernetes.io/name: wiki-index
toPorts:
- ports:
- port: "8080"
protocol: TCP
# Push mirror to GitHub and other external targets.
- toEntities:
- host
- remote-node
- world
---
# Traefik serves git.renner.uno / gitea.renner.uno (:3000) and the gitea-ssh
# entrypoint (:2222). Lives beside the baseline so both land in one reconcile.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-allow-traefik
namespace: gitea
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: gitea
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
toPorts:
- ports:
- port: "3000"
protocol: TCP
- port: "2222"
protocol: TCP
@@ -22,6 +22,8 @@ resources:
- external-secrets-ns.yaml
- woodpecker-ci-ns.yaml
- woodpecker-ns.yaml
- gitea-ns.yaml
- netbird-service-sync-ns.yaml
- trmnl-ns.yaml
- wordpress-ns.yaml
- ai-ns.yaml
@@ -0,0 +1,43 @@
---
# Baseline policy for the `netbird-service-sync` namespace (issue #876).
# The controller serves nothing; it only calls out: the Kubernetes API
# (Ingress/Service reads), OpenBao (NetBird PAT) and the NetBird management API.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny
namespace: netbird-service-sync
spec:
endpointSelector: {}
ingress:
- fromEntities:
- host
- remote-node
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
- toEntities:
- kube-apiserver
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": openbao
app.kubernetes.io/name: openbao
toPorts:
- ports:
- port: "8200"
protocol: TCP
# NetBird management API (netbird.renner.uno).
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
@@ -41,6 +41,16 @@ spec:
- ports:
- port: "8200"
protocol: TCP
# netbird-service-sync logs in via auth/kubernetes/role/netbird-service-sync
# and reads the NetBird PAT on every pass.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": netbird-service-sync
app.kubernetes.io/name: netbird-service-sync
toPorts:
- ports:
- port: "8200"
protocol: TCP
# (Secret-rotation CronJobs run in THIS namespace since 2026-07-18, so their
# OpenBao access is covered by the intra-namespace ingress rule above — no
# per-app rule needed.)
@@ -127,7 +137,7 @@ spec:
# Egress complement to gitea-allow-flux-rotator (apps/gitea/networkpolicy.yaml).
# The flux-git rotator mints a PAT against the in-cluster gitea Service; the
# openbao default-deny only permits TCP egress to openbao/kube-dns/apiserver/
# world, and gitea (apps ns) is in-cluster, so it needs an explicit allow.
# world, and gitea (gitea ns) is in-cluster, so it needs an explicit allow.
# Scoped to the flux-git rotator label and gitea :3000.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
@@ -142,7 +152,7 @@ spec:
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": apps
"k8s:io.kubernetes.pod.namespace": gitea
app.kubernetes.io/name: gitea
toPorts:
- ports:
@@ -150,10 +160,10 @@ spec:
protocol: TCP
---
# 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
# mints review-bot's PAT against gitea (gitea :3000), the main step upserts the
# 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 (apps) + woodpecker-allow-rotators (woodpecker).
# gitea-allow-review-bot-rotator (gitea) + woodpecker-allow-rotators (woodpecker).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
@@ -167,7 +177,7 @@ spec:
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": apps
"k8s:io.kubernetes.pod.namespace": gitea
app.kubernetes.io/name: gitea
toPorts:
- ports:
@@ -1,23 +0,0 @@
# OpenBao policy: netbird-proxy-services
#
# The reverse-proxy-services controller (Deployment netbird-proxy-services in
# the openbao namespace) reads the NetBird PAT on every reconcile pass and
# calls the management API with it. Read-only on that one path; it shares the
# bootstrap credential with rotator-netbird but not that role's PATCH rights.
#
# NOT applied by Flux. Apply ONCE with an admin token:
#
# bao policy write netbird-proxy-services kubernetes/infrastructure/openbao/policies/netbird-proxy-services.hcl
# bao write auth/kubernetes/role/netbird-proxy-services \
# bound_service_account_names=netbird-proxy-services \
# bound_service_account_namespaces=openbao \
# policies=netbird-proxy-services ttl=15m
#
# The PAT itself is the one rotator-netbird already uses
# (homelab/infrastructure/secret-rotators-bootstrap/netbird, key `pat`).
#
# Do NOT set token_no_default_policy=true (kv-v2 preflight needs the default policy).
path "homelab/data/infrastructure/secret-rotators-bootstrap/netbird" {
capabilities = ["read"]
}
@@ -0,0 +1,24 @@
# OpenBao policy: netbird-service-sync
#
# The controller that syncs public Ingresses into NetBird reverse-proxy services
# (Deployment netbird-service-sync in the netbird-service-sync namespace) reads
# the NetBird PAT on every reconcile pass and calls the management API with it.
# Read-only on that one path; it shares the bootstrap credential with
# rotator-netbird but not that role's PATCH rights.
#
# NOT applied by Flux. Apply ONCE with an admin token:
#
# bao policy write netbird-service-sync kubernetes/infrastructure/openbao/policies/netbird-service-sync.hcl
# bao write auth/kubernetes/role/netbird-service-sync \
# bound_service_account_names=netbird-service-sync \
# bound_service_account_namespaces=netbird-service-sync \
# policies=netbird-service-sync ttl=15m
#
# The PAT itself is the one rotator-netbird already uses
# (homelab/infrastructure/secret-rotators-bootstrap/netbird, key `pat`).
#
# Do NOT set token_no_default_policy=true (kv-v2 preflight needs the default policy).
path "homelab/data/infrastructure/secret-rotators-bootstrap/netbird" {
capabilities = ["read"]
}
@@ -6,7 +6,7 @@
# newest. A token that fails verification is deleted and the run aborts without
# storing, so Flux is never stranded.
#
# gitea is reached at the in-cluster Service gitea.apps.svc:3000 — the public
# gitea is reached at the in-cluster Service gitea.gitea.svc:3000 — the public
# name hairpins off the ingress LB from a pod. Needs BOTH netpol halves
# (gitea-allow-flux-rotator + rotator-gitea-flux-egress).
#
@@ -76,7 +76,7 @@ spec:
- name: BAO_PATH
value: homelab/infrastructure/flux-git
- name: GITEA_URL
value: http://gitea.apps.svc.cluster.local:3000
value: http://gitea.gitea.svc.cluster.local:3000
- name: GITEA_VERIFY_REPO
value: k.renner/homelab
- name: GITEA_SCOPES
@@ -24,7 +24,7 @@
# synced. Quarterly cadence + backoffLimit:1 makes a two-cycle failure unlikely,
# 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
# gitea is reached in-cluster at gitea.gitea.svc:3000, woodpecker 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: gitea-allow-review-bot-rotator + woodpecker-allow-rotators
@@ -99,7 +99,7 @@ spec:
- name: BAO_PATH
value: homelab/infrastructure/review-bot
- name: GITEA_URL
value: http://gitea.apps.svc.cluster.local:3000
value: http://gitea.gitea.svc.cluster.local:3000
# Private repo review-bot collaborates on: the pull check is a
# real access verification (git-upload-pack on a PRIVATE repo
# needs a token with access), not the "any valid token" weakness
@@ -225,15 +225,8 @@ metadata:
labels:
app.kubernetes.io/part-of: homelab-backup
spec:
# Daily at 07:15 local (next free stagger slot after home-assistant-daily
# at 06:45). Covers every stateful workload in the shared `apps`
# namespace — currently Gitea (data-gitea-0, the Flux GitOps source of
# truth) and Woodpecker (data-woodpecker-server-0), plus gatus-data.
# Added 2026-07-14 (issue #186): the apps namespace had no Velero
# schedule at all, so the in-cluster Gitea/Woodpecker PVCs migrated from
# the legacy LXCs (see MIGRATION-gitea-woodpecker.md) were unprotected.
# No CNPG cluster runs in this namespace (both Gitea and Woodpecker use
# SQLite), so no labelSelector exclusion is needed.
# Daily at 07:15 local. Every PVC in the shared `apps` namespace (gatus-data).
# No CNPG cluster runs here, so no labelSelector exclusion is needed.
schedule: "15 7 * * *"
useOwnerReferencesInBackup: false
template:
@@ -247,6 +240,26 @@ spec:
---
apiVersion: velero.io/v1
kind: Schedule
metadata:
name: gitea-daily
namespace: velero
labels:
app.kubernetes.io/part-of: homelab-backup
spec:
# Daily at 07:45 local. data-gitea-0 (SQLite, repos, LFS, packages).
schedule: "45 7 * * *"
useOwnerReferencesInBackup: false
template:
includedNamespaces:
- gitea
includedResources:
- persistentvolumeclaims
storageLocation: default
ttl: 720h0m0s
snapshotMoveData: true
---
apiVersion: velero.io/v1
kind: Schedule
metadata:
name: trmnl-daily
namespace: velero
@@ -96,7 +96,7 @@ spec:
# that traffic is always a pod IP — collapsing all external clients
# to one source. The NetBird reverse-proxy services are TLS
# passthrough and carry the WAN client address in a PROXY protocol
# v2 header (options.proxy_protocol, set by netbird-proxy-services);
# v2 header (options.proxy_protocol, set by netbird-service-sync);
# X-Forwarded-For stays trusted for HTTP-mode services. Both are
# accepted from the pod CIDRs only; connections without a header
# (direct LAN/mesh) keep their RemoteAddr.
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# Turn the TypeSafe picker's probability distribution into one model id.
#
# The judgment (how well each catalog model fits the task) comes from the
# model; the policy lives here, in code: among the candidates whose probability
# is within MARGIN of the best one, take the most recently released. Several
# same-family models (Sonnet 4.5 / 4.6 / 5) split the probability for a
# mid-weight task, and the newest of a comparable set is the one to run.
#
# Usage: choose-model.sh <catalog-table-file> < <systemone-response.json>
# The table is fetch-models.sh --table output: id \t name \t released=YYYY-MM-DD ...
# Prints the chosen id, or nothing when the answer is NONE / unusable.
# Env: CHOOSE_MODEL_MARGIN (default 0.7): candidate if p >= MARGIN * p_best.
set -eu
TABLE_FILE="${1:?usage: choose-model.sh <catalog-table-file> < response.json}"
MARGIN="${CHOOSE_MODEL_MARGIN:-0.7}"
jq -r --rawfile table "$TABLE_FILE" --argjson margin "$MARGIN" '
($table | split("\n") | map(select(length > 0) | split("\t")
| {key: .[0], value: ((.[2:] | map(select(startswith("released="))) | .[0] // "released=") | ltrimstr("released="))})
| from_entries) as $released
| (.answers.model.probabilities // {}) | to_entries
| map(select(.key != "NONE")) | sort_by(-.value)
| if length == 0 then empty
else (.[0].value) as $best
| map(select(.value >= $margin * $best))
| sort_by([($released[.key] // ""), .value])
| .[-1].key
end' 2>&1
+36
View File
@@ -33,6 +33,42 @@ none() { log "$1"; exit 0; } # emit nothing -> caller uses CLI defaul
TABLE="$(sh "$HERE/fetch-models.sh" --table || true)"
[ -n "$TABLE" ] || none "no catalog available — caller will use the CLI default model"
# First choice: one TypeSafe (System One) Choice over the catalog. The task and
# the catalog rows are state, every model id is an option with its catalog row
# as the criterion, and the answer is typed — no prompt, no JSON parsing, and a
# request that takes a fraction of a second instead of a claude run. The id is
# still validated against the pristine catalog. Falls through to the claude
# picker when the key is absent or the request fails.
if [ -n "${TYPESAFE_API_KEY:-}" ]; then
REQ="$(printf '%s\n' "$TABLE" | jq -R -s --arg task "$GUIDANCE" '
split("\n") | map(select(length > 0) | split("\t") | {id: .[0], name: .[1], info: (.[2:] | join(", "))})
| {model: "jev-latest",
state: {task: $task, models: .},
questions: {model: {
type: "choice",
instructions: "Which model in `models` should run `task`? Match the model to the task: not the largest or newest flagship for trivial or mechanical work, not a minimal model for hard, multi-step or security-sensitive work; prefer a newer model when otherwise comparable. Consider every listed model, including unfamiliar ones. Pick NONE only if no listed model fits.",
criteria: ((map({key: .id, value: "\(.name); \(.info)"}) | from_entries) + {NONE: "No listed model fits the task"})}}}')"
if [ -n "${PICK_MODEL_TYPESAFE_DRYRUN:-}" ]; then printf '%s\n' "$REQ"; exit 0; fi
RESP="$(printf '%s' "$REQ" | curl -sS --max-time 30 -X POST https://api.typesafe.ai/v1/systemone \
-H "Authorization: Bearer $TYPESAFE_API_KEY" -H 'Content-Type: application/json' --data-binary @- || true)"
# Policy in code (choose-model.sh): among candidates within a margin of the
# best probability, the most recently released wins. Same-family models split
# the probability for mid-weight tasks (live catalog: Sonnet 4.5 chosen at
# 0.23 over Sonnet 4.6).
TABLE_FILE="$(mktemp)"
printf '%s\n' "$TABLE" > "$TABLE_FILE"
CHOICE="$(printf '%s' "$RESP" | sh "$HERE/choose-model.sh" "$TABLE_FILE" | head -1)"
rm -f "$TABLE_FILE"
TOP="$(printf '%s' "$RESP" | jq -r '(.answers.model.probabilities // {}) | to_entries | sort_by(-.value) | .[0:4] | map("\(.key)=\(.value)") | join(" ")' 2>&1 | head -1)"
VALID="$(printf '%s\n' "$TABLE" | sh "$HERE/valid-model.sh" "$CHOICE")"
if [ -n "$VALID" ]; then
log "typesafe chose '$VALID' (top: ${TOP:-?}) for: $GUIDANCE"
printf '%s\n' "$VALID"
exit 0
fi
log "typesafe picker returned '${CHOICE:-<empty>}' (rejected, NONE, or request failed) — falling back to the claude picker"
fi
PROMPT="$(cat <<EOF
You are a model-selection agent for an automated CI job. From the models
currently available (listed below with their capabilities), choose the single
+31
View File
@@ -84,9 +84,40 @@ else
printf 'FAIL recorded fixture: opus=[%s] haiku=[%s]\n' "$opusline" "$haikuline"; fails=$((fails + 1))
fi
# TypeSafe path of pick-model.sh: the request offers exactly the catalog ids plus
# NONE as options, carries the task as state, and never sends anything else.
printf '%s' '{"data":[{"type":"model","id":"claude-opus-5","display_name":"Claude Opus 5","created_at":"2026-07-24T00:00:00Z","max_input_tokens":1000000,"max_tokens":128000},{"type":"model","id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5","created_at":"2025-10-15T00:00:00Z","max_input_tokens":200000,"max_tokens":64000}],"has_more":false}' > "$FX"
req="$(FETCH_MODELS_BODY_FILE="$FX" TYPESAFE_API_KEY=dry PICK_MODEL_TYPESAFE_DRYRUN=1 sh "$HERE/pick-model.sh" "a task; with \"quotes\" and $ signs" 2>/dev/null || true)"
keys="$(printf '%s' "$req" | jq -r '.questions.model.criteria | keys | join(",")' 2>/dev/null || true)"
task="$(printf '%s' "$req" | jq -r '.state.task' 2>/dev/null || true)"
if [ "$keys" = "NONE,claude-haiku-4-5-20251001,claude-opus-5" ] && [ "$task" = 'a task; with "quotes" and $ signs' ]; then
printf 'ok typesafe request offers catalog ids + NONE and carries the task verbatim\n'
else
printf 'FAIL typesafe request: keys [%s] task [%s]\n' "$keys" "$task"; fails=$((fails + 1))
fi
# choose-model.sh: newest among comparable candidates, best otherwise, NONE -> nothing.
TB="$(mktemp)"
printf '%s\n' 'claude-sonnet-4-5 Claude Sonnet 4.5 released=2025-09-29 context=200000' \
'claude-sonnet-4-6 Claude Sonnet 4.6 released=2026-02-17 context=200000' \
'claude-opus-5 Claude Opus 5 released=2026-07-24 context=1000000' \
'claude-haiku-4-5 Claude Haiku 4.5 released=2025-10-15 context=200000' > "$TB"
choose() { printf '%s' "$1" | sh "$HERE/choose-model.sh" "$TB" || true; }
got="$(choose '{"answers":{"model":{"probabilities":{"claude-sonnet-4-5":0.30,"claude-sonnet-4-6":0.25,"claude-opus-5":0.10,"NONE":0.05}}}}')"
if [ "$got" = "claude-sonnet-4-6" ]; then printf 'ok newer sibling within margin wins\n'; else printf 'FAIL comparable: got [%s]\n' "$got"; fails=$((fails + 1)); fi
got="$(choose '{"answers":{"model":{"probabilities":{"claude-opus-5":0.90,"claude-haiku-4-5":0.05,"claude-sonnet-4-6":0.04,"NONE":0.01}}}}')"
if [ "$got" = "claude-opus-5" ]; then printf 'ok clear best is kept\n'; else printf 'FAIL clear best: got [%s]\n' "$got"; fails=$((fails + 1)); fi
got="$(choose '{"answers":{"model":{"probabilities":{"claude-haiku-4-5":0.60,"claude-sonnet-4-5":0.35,"NONE":0.05}}}}')"
if [ "$got" = "claude-haiku-4-5" ]; then printf 'ok older best outside margin is kept\n'; else printf 'FAIL margin: got [%s]\n' "$got"; fails=$((fails + 1)); fi
got="$(choose '{"answers":{"model":{"probabilities":{"NONE":1.0}}}}')"
if [ -z "$got" ]; then printf 'ok NONE yields nothing\n'; else printf 'FAIL NONE: got [%s]\n' "$got"; fails=$((fails + 1)); fi
rm -f "$TB"
if [ "$fails" -eq 0 ]; then
echo "All model-selection tests passed."
else
echo "$fails test(s) FAILED."
exit 1
fi