Compare commits

..
Author SHA1 Message Date
claude-botandClaude Fable 5.1 fe77f262e9 feat(wiki-index): roll out every build via Flux image automation
ci/woodpecker/push/build-ci-images Pipeline was successful
Replaces the per-commit tag pin with the house pattern: the build
publishes build.<unix-ts>, an ImageRepository/ImagePolicy pair tracks
it (numerical, newest wins), and the deployment carries the
$imagepolicy marker so the shared ImageUpdateAutomation bumps it. No
more hand-pinned digests, no same-day tag races. The reflector reads
the package anonymously like the other Gitea-hosted images.

The deployment keeps the verified 2026-09-20-4b529cd1 tag as its
starting value; the first build.* tag after merge replaces it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 11:04:04 +02:00
40 changed files with 117 additions and 512 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.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+6 -5
View File
@@ -396,16 +396,17 @@ steps:
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
# Also a per-commit tag: several builds a day (branch pushes and the
# merge) all rewrite the dated tag, and the deployment pin raced them
# on 2026-09-20. The commit tag is immutable and needs no digest lookup.
- SHORT=$(printf '%s' "$CI_COMMIT_SHA" | cut -c1-8)
# `build.<unix-ts>` is the tag Flux image automation tracks
# (infrastructure/image-automation/wiki-index.yaml): unique per build, so
# every push rolls out without a hand-pinned digest. The dated tag is
# rewritten several times a day and is kept for humans only.
- BUILD=build.$(date +%s)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/wiki-index:$TAG \
--destination gitea.renner.uno/gitea_admin/wiki-index:$TAG-$SHORT \
--destination gitea.renner.uno/gitea_admin/wiki-index:$BUILD \
--destination gitea.renner.uno/gitea_admin/wiki-index:latest
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
+21 -17
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 any pin is off the latest version) rewrite every
# consumer pin to ci-claude:<version> and open a PR. A human
# 3. bump-pr — (only when the version actually changed) rewrite the 5
# consumer pins to ci-claude:<version> and open a PR. A human
# reviews and merges it; nothing auto-merges.
#
# TAG SCHEME — deliberately version tags (ci-claude:<claude-version>), NOT the dated
@@ -107,22 +107,31 @@ steps:
[0-9]*.[0-9]*.[0-9]*) : ;;
*) echo "npm returned an implausible version '$$NEW' — aborting."; exit 1 ;;
esac
# 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"
# 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"
printf '%s' "$$NEW" > "$$CI_WORKSPACE/.ci-refresh/version"
printf '%s' "$$CUR" > "$$CI_WORKSPACE/.ci-refresh/current"
# 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.
# 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.
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
@@ -166,9 +175,6 @@ 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
@@ -184,11 +190,9 @@ 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; }
# 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.
# Exact-string swap of the current tag for the new one, consumers only.
for f in $$CONSUMERS; do
sed -i "s#ci-claude:[0-9][0-9A-Za-z._-]*#ci-claude:$$NEW#g" "$$f"
sed -i "s#ci-claude:$$CUR#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.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
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.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
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.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
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.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
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.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
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.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
-140
View File
@@ -1,140 +0,0 @@
# 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
@@ -1,5 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,37 +0,0 @@
{
"//": "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:*)"
]
}
}
+1 -3
View File
@@ -13,9 +13,7 @@ RUN apk add --no-cache \
git \
jq \
openssh-client \
openssl \
python3 \
py3-yaml
openssl
# 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 -1
View File
@@ -2,7 +2,7 @@ apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gitea
namespace: gitea
namespace: apps
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: gitea
namespace: apps
labels:
app.kubernetes.io/name: gitea
spec:
+26 -6
View File
@@ -1,8 +1,28 @@
# Prometheus scrape and Traefik are admitted by the namespace baseline in
# infrastructure/network-policies/gitea-ns.yaml.
#
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
---
# Secret rotation: the flux-git rotator (openbao ns) reaches gitea to mint a new
# flux PAT. It uses the in-cluster Service (gitea.gitea.svc:3000) — the public
# flux PAT. It uses the in-cluster Service (gitea.apps.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.
@@ -10,7 +30,7 @@ apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-allow-flux-rotator
namespace: gitea
namespace: apps
spec:
endpointSelector:
matchLabels:
@@ -34,7 +54,7 @@ apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-allow-review-bot-rotator
namespace: gitea
namespace: apps
spec:
endpointSelector:
matchLabels:
+1 -1
View File
@@ -13,7 +13,7 @@ apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: gitea
namespace: gitea
namespace: apps
labels:
app.kubernetes.io/name: gitea
spec:
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: v1
kind: Service
metadata:
name: gitea
namespace: gitea
namespace: apps
labels:
app.kubernetes.io/name: gitea
spec:
+2 -2
View File
@@ -2,7 +2,7 @@ apiVersion: apps/v1
kind: StatefulSet
metadata:
name: gitea
namespace: gitea
namespace: apps
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 gitea --cascade=orphan`
# one-time `kubectl delete statefulset gitea -n apps --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,11 +91,12 @@ 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. Scrapes the
# ClusterIP Service of the StatefulSet in the gitea namespace.
# `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.
- job_name: gitea
static_configs:
- targets: ["gitea.gitea.svc.cluster.local:3000"]
- targets: ["gitea.apps.svc.cluster.local:3000"]
metrics_path: /metrics
# Grafana
+2 -4
View File
@@ -27,8 +27,6 @@ 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"
@@ -185,7 +183,7 @@ spec:
drop: ["ALL"]
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:3.2.1 # {"$imagepolicy": "flux-system:paperless-ngx"}
image: ghcr.io/paperless-ngx/paperless-ngx:3.2.0 # {"$imagepolicy": "flux-system:paperless-ngx"}
ports:
- name: http
containerPort: 8000
@@ -275,7 +273,7 @@ spec:
cpu: 200m
memory: 512Mi
limits:
memory: 4Gi
memory: 2Gi
securityContext:
runAsNonRoot: false
allowPrivilegeEscalation: true
+1 -1
View File
@@ -25,7 +25,7 @@ spec:
type: RuntimeDefault
containers:
- name: redis
image: redis:8.10.2-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
image: redis:8.10.1-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
args:
- redis-server
- --save
+2 -2
View File
@@ -56,8 +56,8 @@ spec:
type: RuntimeDefault
containers:
- name: wiki-index
# Per-commit tag (build-ci-images.yml): immutable, no same-day overwrite.
image: gitea.renner.uno/gitea_admin/wiki-index:2026-09-20-4b529cd1
# Flux image automation bumps this to the newest build.<unix-ts> tag.
image: gitea.renner.uno/gitea_admin/wiki-index:2026-09-20-4b529cd1 # {"$imagepolicy": "flux-system:wiki-index"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
-1
View File
@@ -1946,7 +1946,6 @@ 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
@@ -1,20 +0,0 @@
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
@@ -24,5 +24,6 @@ resources:
- valkey.yaml
- velero-plugin-for-aws.yaml
- vsftpd.yaml
- wiki-index.yaml
- woodpecker.yaml
- wyoming.yaml
@@ -0,0 +1,26 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: wiki-index
namespace: flux-system
spec:
image: gitea.renner.uno/gitea_admin/wiki-index
interval: 10m
---
# build-ci-images.yml publishes `build.<unix-ts>` alongside the dated tag so
# every build rolls out on its own; the dated tag is rewritten several times a
# day and cannot be tracked.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: wiki-index
namespace: flux-system
spec:
imageRepositoryRef:
name: wiki-index
filterTags:
pattern: '^build\.(?P<ts>\d+)$'
extract: '$ts'
policy:
numerical:
order: asc
@@ -179,14 +179,6 @@ 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
---
# 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.
@@ -169,9 +169,6 @@ spec:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": apps
app.kubernetes.io/name: gitea
- matchLabels:
"k8s:io.kubernetes.pod.namespace": gitea
app.kubernetes.io/name: gitea
toPorts:
- ports:
- port: "8080"
@@ -1,104 +0,0 @@
---
# 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,7 +22,6 @@ resources:
- external-secrets-ns.yaml
- woodpecker-ci-ns.yaml
- woodpecker-ns.yaml
- gitea-ns.yaml
- trmnl-ns.yaml
- wordpress-ns.yaml
- ai-ns.yaml
@@ -144,9 +144,6 @@ spec:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": apps
app.kubernetes.io/name: gitea
- matchLabels:
"k8s:io.kubernetes.pod.namespace": gitea
app.kubernetes.io/name: gitea
toPorts:
- ports:
- port: "3000"
@@ -172,9 +169,6 @@ spec:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": apps
app.kubernetes.io/name: gitea
- matchLabels:
"k8s:io.kubernetes.pod.namespace": gitea
app.kubernetes.io/name: gitea
toPorts:
- ports:
- port: "3000"
@@ -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.gitea.svc:3000 — the public
# gitea is reached at the in-cluster Service gitea.apps.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.gitea.svc.cluster.local:3000
value: http://gitea.apps.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.gitea.svc:3000, woodpecker at
# gitea is reached in-cluster at gitea.apps.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.gitea.svc.cluster.local:3000
value: http://gitea.apps.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,9 +225,15 @@ metadata:
labels:
app.kubernetes.io/part-of: homelab-backup
spec:
# Daily at 07:15 local. Every PVC in the shared `apps` namespace: Gitea's
# data-gitea-0 until it moves to the `gitea` namespace (#878), gatus-data.
# No CNPG cluster runs here, so no labelSelector exclusion is needed.
# 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.
schedule: "15 7 * * *"
useOwnerReferencesInBackup: false
template:
@@ -241,27 +247,6 @@ 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) once
# Gitea runs in the `gitea` namespace (#878).
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
-30
View File
@@ -1,30 +0,0 @@
#!/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,42 +33,6 @@ 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,40 +84,9 @@ 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