Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc32e99438 | ||
|
|
82ffca3aaa | ||
|
|
21c4c8c96b | ||
|
|
ae5de9bc84 | ||
|
|
3b0c42473a | ||
|
|
ad152d2d16 | ||
|
|
0cfc4dc486 | ||
|
|
50c62da2f4 | ||
|
|
abb4096c67 | ||
|
|
12e1a6e6f0 | ||
|
|
b3dc3f6b4c | ||
|
|
b4de51b412 | ||
|
|
2bb2704b88 | ||
|
|
ee5f3acbaa | ||
|
|
80a0ee5b19 | ||
|
|
476c48c0af | ||
|
|
8200ce1898 | ||
|
|
17bf12cc4f | ||
|
|
1fc8073218 | ||
|
|
161c04daff | ||
|
|
999e3fbac9 | ||
|
|
70208c42da | ||
|
|
94b35c3f64 | ||
|
|
5cbdb6e67f | ||
|
|
ee08edba3e | ||
|
|
96d15f19c8 | ||
|
|
0581fd461f | ||
|
|
f1024fa4b3 | ||
|
|
bd4553cc63 | ||
|
|
904475b89a | ||
|
|
c395a9fc67 | ||
|
|
bf3d58628e | ||
|
|
6edde49b48 | ||
|
|
94b6d77bac | ||
|
|
c7f6514e20 | ||
|
|
d865043eaa | ||
|
|
47fbc917fd | ||
|
|
de1530789e | ||
|
|
c92c1f4ab5 | ||
|
|
6bb8ef4776 | ||
|
|
33a0079a20 | ||
|
|
80816d7433 | ||
|
|
32a995a6bf | ||
|
|
8a0ed135f8 | ||
|
|
be643533f6 | ||
|
|
87083c46e7 | ||
|
|
311b4639a6 | ||
|
|
a95efdef33 | ||
|
|
aea4be734c | ||
|
|
07dd6097ec | ||
|
|
60361d4c8a | ||
|
|
a8cef5de1b | ||
|
|
505802dad5 | ||
|
|
ba66b604d4 | ||
|
|
4fbc49fc8c | ||
|
|
f493c9fe52 | ||
|
|
8a3c66eb2e | ||
|
|
9928999da4 | ||
|
|
b3def28f7c | ||
|
|
93dbcdb2fb | ||
|
|
47bf16cfac | ||
|
|
4664de8f97 | ||
|
|
fb52e4be88 | ||
|
|
f11a71c33f | ||
|
|
29326eead3 | ||
|
|
eb09b1b2a3 | ||
|
|
dd3a1f1e90 | ||
|
|
98a0462137 | ||
|
|
9aef2e0669 | ||
|
|
fd7597ddc2 | ||
|
|
10b0c1b853 | ||
|
|
674169cb08 | ||
|
|
0c6174b7a6 | ||
|
|
76888c3771 | ||
|
|
eb23d3ac6e | ||
|
|
c1e3f3fb98 | ||
|
|
036399095b | ||
|
|
0bac0051b2 | ||
|
|
41a65b65a3 | ||
|
|
a530f7c8e6 | ||
|
|
8fff4b4012 | ||
|
|
0ada3df260 | ||
|
|
2144554cfc | ||
|
|
dba810b87b | ||
|
|
f8aa65362b | ||
|
|
0ce0391bb1 | ||
|
|
4611ea3578 | ||
|
|
63701fbebb | ||
|
|
e8a047eb14 | ||
|
|
0279210595 | ||
|
|
25f09e9b52 | ||
|
|
82f2825d3f | ||
|
|
0fec080b4d | ||
|
|
7424c6aefd | ||
|
|
4e3fb88309 | ||
|
|
c8c5ce9015 | ||
|
|
293f51b914 | ||
|
|
70287db150 | ||
|
|
efedba1ff0 | ||
|
|
f35a2d5985 | ||
|
|
19b641a2b6 | ||
|
|
e422e6a13d | ||
|
|
f4d590c40d | ||
|
|
791cb6e524 | ||
|
|
3749df798e | ||
|
|
50b4cbeb2b | ||
|
|
4b529cd15c | ||
|
|
53a9348254 | ||
|
|
ce06d4e7da | ||
|
|
e716632c5c | ||
|
|
9736656fed | ||
|
|
d4e5029ebd | ||
|
|
0fb5ff3fb6 | ||
|
|
58e4c25729 | ||
|
|
a3ca6c6065 | ||
|
|
816f161591 | ||
|
|
5f86bdde64 | ||
|
|
814c5b88fa | ||
|
|
0420be0a21 | ||
|
|
7da72b34cf | ||
|
|
c36c290b13 | ||
|
|
8fb7430fb7 | ||
|
|
d82b96a751 | ||
|
|
463cfa7bde |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -396,11 +396,16 @@ 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)
|
||||
- |
|
||||
/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:latest
|
||||
|
||||
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# Nightly wiki maintenance for the Notes vault (k.renner/notes), replacing the
|
||||
# workstation's wiki-maintenance and wiki-consolidate timers: distill the raw/
|
||||
# backlog into wiki/ (wiki-distill agent), audit wiki/ with the vault's own
|
||||
# script (structural checks, no model), then consolidate (wiki-consolidate
|
||||
# agent: verifies a cohort of notes against sources, repos and the live
|
||||
# cluster and edits them). The pipeline commits and pushes as Claude Bot; the
|
||||
# push webhook then refreshes the wiki-index service.
|
||||
#
|
||||
# The agents address the vault as ~/Notes and the repos as ~/source/<repo>, so
|
||||
# each agent step links those into the workspace 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)"
|
||||
# Full history for the consolidate agent's `git log` checks; the
|
||||
# workspace clone is shallow and tree-filtered.
|
||||
mkdir -p sources
|
||||
git clone --quiet https://gitea.renner.uno/k.renner/homelab.git sources/homelab
|
||||
git -c http.extraheader="AUTHORIZATION: token $$TOKEN" \
|
||||
clone --quiet https://gitea.renner.uno/k.renner/homeassistant.git sources/homeassistant
|
||||
|
||||
- name: fetch-diag
|
||||
# Read-only live-cluster access for the consolidate agent, same mechanism
|
||||
# as issue-fix.yml: ./.ci-diag/kubectl plus a 1h ci-diagnostics token.
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
environment:
|
||||
SOPS_AGE_KEY:
|
||||
from_secret: sops_age_key
|
||||
S3_ACCESS_KEY_ID:
|
||||
from_secret: hetzner_s3_access_key
|
||||
S3_SECRET_ACCESS_KEY:
|
||||
from_secret: hetzner_s3_secret_key
|
||||
commands:
|
||||
- bash scripts/ci/fetch-kubeconfig.sh
|
||||
- bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- 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
|
||||
# An empty queue must not start the agent: its startup context alone
|
||||
# costs more than the whole audit.
|
||||
BACKLOG=$$(ls notes/raw/*.md | grep -v _index.md | wc -l)
|
||||
if [ "$$BACKLOG" -eq 0 ]; then
|
||||
echo "distill: raw/ is empty, skipping the agent"
|
||||
exit 0
|
||||
fi
|
||||
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
|
||||
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/:*),Bash(~/Notes/bin/wiki_rank.py:*),Bash(~/Notes/bin/wiki_ask.py:*),Bash(mv ~/Notes/raw/:*),Bash(mkdir -p ~/Notes/:*)" \
|
||||
--settings ../ci/agents/wiki-maintenance/settings.json \
|
||||
-p
|
||||
|
||||
- name: audit
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
|
||||
pull: true
|
||||
commands:
|
||||
# Findings are report lines in output/, not a pipeline failure.
|
||||
- python3 -B notes/bin/wiki_audit.py --vault "$$PWD/notes"
|
||||
|
||||
- name: consolidate
|
||||
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"
|
||||
ln -sfn "$$PWD/sources" "$$HOME/source"
|
||||
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"
|
||||
if [ -f .ci-diag/kubeconfig ]; then
|
||||
ln -sf "$$PWD/.ci-diag/kubectl" /usr/local/bin/kubectl
|
||||
export KUBECONFIG="$$PWD/.ci-diag/kubeconfig"
|
||||
else
|
||||
echo "consolidate: no ci-diag kubeconfig, live cluster checks unavailable"
|
||||
fi
|
||||
cd notes
|
||||
# JSON output: the agent's report and the run's cost come out of one
|
||||
# record at the end instead of a 45-minute silent log.
|
||||
# One source for the Bash allowlist: the settings file (run 10608: the
|
||||
# agent writes ~/Notes, the rules carried only /root/Notes).
|
||||
BASH_RULES=$$(jq -r '[.permissions.allow[] | select(startswith("Bash("))] | join(",")' ../ci/agents/wiki-maintenance/consolidate-settings.json)
|
||||
rc=0
|
||||
cat ../ci/agents/wiki-maintenance/consolidate.md | claude \
|
||||
--agent wiki-consolidate \
|
||||
--permission-mode acceptEdits \
|
||||
--allowedTools "Read,Edit,Write,Glob,Grep,Agent,$$BASH_RULES" \
|
||||
--settings ../ci/agents/wiki-maintenance/consolidate-settings.json \
|
||||
--output-format json \
|
||||
-p > /tmp/consolidate.json || rc=$$?
|
||||
# A killed or crashed claude leaves no valid JSON; report rc, not a jq error.
|
||||
if [ -s /tmp/consolidate.json ] && jq -e . /tmp/consolidate.json > /dev/null; then
|
||||
jq -r '.result // empty' /tmp/consolidate.json
|
||||
jq -c '{duration_ms, num_turns, total_cost_usd, is_error}' /tmp/consolidate.json
|
||||
else
|
||||
echo "consolidate: no result record (exit $$rc)"; head -c 2000 /tmp/consolidate.json || true
|
||||
fi
|
||||
echo "consolidate: git log --oneline of the run:"
|
||||
git log --oneline origin/main..HEAD
|
||||
exit $$rc
|
||||
|
||||
- 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"
|
||||
# Only the vault content is committed; anything an agent leaves elsewhere
|
||||
# in the checkout (run 10362: scratch scripts in the root) stays behind.
|
||||
# The consolidate agent commits its own snapshots, so HEAD may already be
|
||||
# ahead of origin/main with a clean tree.
|
||||
git add -A -- wiki raw output .wiki-consolidation-state.json
|
||||
git diff --cached --quiet || git commit -q -m "wiki maintenance $$(date -u +%F): distill + audit + consolidate (CI run $$CI_PIPELINE_NUMBER)"
|
||||
if [ "$$(git rev-list --count origin/main..HEAD)" -eq 0 ]; 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"
|
||||
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 + consolidate. 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)"
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"//": "Permission scope for the wiki-consolidate agent in CI: read the vault and the",
|
||||
"//2": "source repos cloned under /root/source, read the live cluster through the",
|
||||
"//3": "ci-diagnostics kubeconfig, edit wiki/, output/ and the rotation state only.",
|
||||
"//4": "Deny rules win; git and kubectl are read-only subcommands, no network tools.",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(//root/Notes/**)",
|
||||
"Read(//root/source/**)",
|
||||
"Edit(//root/Notes/wiki/**)",
|
||||
"Edit(//root/Notes/output/**)",
|
||||
"Edit(//root/Notes/.wiki-consolidation-state.json)",
|
||||
"Bash(/root/Notes/bin/wiki-snapshot.sh:*)",
|
||||
"Bash(/root/Notes/bin/wiki_rank.py:*)",
|
||||
"Bash(/root/Notes/bin/wiki_ask.py:*)",
|
||||
"Bash(~/Notes/bin/wiki-snapshot.sh:*)",
|
||||
"Bash(~/Notes/bin/wiki_rank.py:*)",
|
||||
"Bash(~/Notes/bin/wiki_ask.py:*)",
|
||||
"Bash(~/Notes/bin/wiki-delete.sh:*)",
|
||||
"Bash(git -C /root/Notes log:*)",
|
||||
"Bash(git -C /root/Notes show:*)",
|
||||
"Bash(git -C /root/Notes diff:*)",
|
||||
"Bash(git -C /root/Notes status:*)",
|
||||
"Bash(git -C /root/Notes rev-parse:*)",
|
||||
"Bash(git -C /root/Notes ls-files:*)",
|
||||
"Bash(git -C ~/Notes log:*)",
|
||||
"Bash(git -C ~/Notes show:*)",
|
||||
"Bash(git -C ~/Notes diff:*)",
|
||||
"Bash(git -C ~/Notes status:*)",
|
||||
"Bash(git -C ~/Notes rev-parse:*)",
|
||||
"Bash(git -C ~/Notes ls-files:*)",
|
||||
"Bash(kubectl get:*)",
|
||||
"Bash(kubectl describe:*)",
|
||||
"Bash(kubectl version:*)",
|
||||
"Bash(kubectl api-resources:*)",
|
||||
"Bash(kubectl explain:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git rev-parse:*)",
|
||||
"Bash(git ls-files:*)",
|
||||
"Bash(git -C /root/source/homelab log:*)",
|
||||
"Bash(git -C /root/source/homelab show:*)",
|
||||
"Bash(git -C /root/source/homelab diff:*)",
|
||||
"Bash(git -C /root/source/homelab status:*)",
|
||||
"Bash(git -C /root/source/homelab rev-parse:*)",
|
||||
"Bash(git -C /root/source/homelab ls-files:*)",
|
||||
"Bash(git -C /root/source/homeassistant log:*)",
|
||||
"Bash(git -C /root/source/homeassistant show:*)",
|
||||
"Bash(git -C /root/source/homeassistant diff:*)",
|
||||
"Bash(git -C /root/source/homeassistant status:*)",
|
||||
"Bash(git -C /root/source/homeassistant rev-parse:*)",
|
||||
"Bash(git -C /root/source/homeassistant ls-files:*)",
|
||||
"Bash(git -C ~/source/homelab log:*)",
|
||||
"Bash(git -C ~/source/homelab show:*)",
|
||||
"Bash(git -C ~/source/homelab diff:*)",
|
||||
"Bash(git -C ~/source/homelab status:*)",
|
||||
"Bash(git -C ~/source/homelab rev-parse:*)",
|
||||
"Bash(git -C ~/source/homelab ls-files:*)",
|
||||
"Bash(git -C ~/source/homeassistant log:*)",
|
||||
"Bash(git -C ~/source/homeassistant show:*)",
|
||||
"Bash(git -C ~/source/homeassistant diff:*)",
|
||||
"Bash(git -C ~/source/homeassistant status:*)",
|
||||
"Bash(git -C ~/source/homeassistant rev-parse:*)",
|
||||
"Bash(git -C ~/source/homeassistant ls-files:*)",
|
||||
"Bash(/root/Notes/bin/wiki-delete.sh:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(date:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(stat:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(tail:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Read(//proc/**)",
|
||||
"Read(//etc/**)",
|
||||
"Read(//root/.config/**)",
|
||||
"Read(//root/.kube/**)",
|
||||
"Read(//woodpecker/**/.ci-diag/**)",
|
||||
"Read(//root/Notes/.git/**)",
|
||||
"Read(//root/source/*/.git/**)",
|
||||
"Read(//root/Notes/_archive/**)",
|
||||
"Edit(//root/Notes/raw/**)",
|
||||
"Edit(//root/Notes/_archive/**)",
|
||||
"Edit(//root/source/**)",
|
||||
"Bash(kubectl apply:*)",
|
||||
"Bash(kubectl delete:*)",
|
||||
"Bash(kubectl patch:*)",
|
||||
"Bash(kubectl edit:*)",
|
||||
"Bash(kubectl scale:*)",
|
||||
"Bash(kubectl rollout:*)",
|
||||
"Bash(kubectl exec:*)",
|
||||
"Bash(kubectl cp:*)",
|
||||
"Bash(kubectl drain:*)",
|
||||
"Bash(kubectl cordon:*)",
|
||||
"Bash(kubectl label:*)",
|
||||
"Bash(kubectl annotate:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git reset:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git clean:*)",
|
||||
"Bash(git rebase:*)",
|
||||
"Bash(git config:*)",
|
||||
"Bash(git -C /root/Notes config:*)",
|
||||
"Bash(git -C ~/Notes config:*)",
|
||||
"Bash(git -C /root/source/homelab config:*)",
|
||||
"Bash(git -C /root/source/homeassistant config:*)",
|
||||
"Bash(git -C ~/source/homelab config:*)",
|
||||
"Bash(git -C ~/source/homeassistant config:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(rm -rf:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(wget:*)",
|
||||
"Bash(ssh:*)",
|
||||
"Bash(tea:*)",
|
||||
"Bash(woodpecker:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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); the homelab and homeassistant repositories are checked out under ~/source/. `kubectl` reads the live cluster with a read-only identity.
|
||||
|
||||
Run the wiki-consolidate agent now for today's scheduled consolidation pass. Follow its instructions exactly: snapshot the vault to git first, abort if that fails, select today's rotating cohort of 20 from the state file, verify claims against both their sources and the live systems, act on the verdicts, write the dated report to ~/Notes/output/, update the state file, and snapshot again at the end. Do not push; the pipeline pushes the run's commits.
|
||||
|
||||
Report the counts and the coverage progress when you are done.
|
||||
@@ -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".
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"//": "Permission scope for the wiki-maintenance agent (wiki-distill) 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(~/Notes/bin/wiki_rank.py:*)",
|
||||
"Bash(~/Notes/bin/wiki_ask.py:*)",
|
||||
"Bash(mv ~/Notes/raw/:*)",
|
||||
"Bash(mkdir -p ~/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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
|
||||
ARG TALOS_VERSION=v1.13.9
|
||||
ARG TALOS_VERSION=v1.13.10
|
||||
ARG VELERO_VERSION=v1.16.0
|
||||
ARG KUBECTL_VERSION=v1.36.3
|
||||
ARG FLUX_VERSION=2.9.5
|
||||
@@ -18,7 +18,7 @@ RUN apk add --no-cache \
|
||||
|
||||
RUN curl -sfL -o /usr/local/bin/talosctl \
|
||||
"https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" \
|
||||
&& echo "7e1d4b7d5846964bdcf63a794e3c8161bb6ef2983d5ace58ea5322f3bf32a27e /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& echo "28e9ccd9aebd690fa03d8cf2582221d87ea38b2a1704f411d75a03589c58f2aa /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& chmod +x /usr/local/bin/talosctl
|
||||
|
||||
RUN curl -sfL -o /usr/local/bin/kubectl \
|
||||
|
||||
+1
-1
@@ -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.
|
||||
+5
-5
@@ -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"]
|
||||
@@ -14,7 +14,7 @@ FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d650
|
||||
# kubernetes/talos/patches/cluster.yaml. The phase reads the version from that
|
||||
# file at run time and passes it to the provisioner; this pin only decides which
|
||||
# client talks to it.
|
||||
ARG TALOS_VERSION=v1.13.9
|
||||
ARG TALOS_VERSION=v1.13.10
|
||||
ARG KUBECTL_VERSION=v1.36.2
|
||||
ARG FLUX_VERSION=2.9.2
|
||||
ARG KOPIA_VERSION=0.23.1
|
||||
@@ -49,7 +49,7 @@ RUN apk add --no-cache \
|
||||
|
||||
RUN curl -sfL -o /usr/local/bin/talosctl \
|
||||
"https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" \
|
||||
&& echo "7e1d4b7d5846964bdcf63a794e3c8161bb6ef2983d5ace58ea5322f3bf32a27e /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& echo "28e9ccd9aebd690fa03d8cf2582221d87ea38b2a1704f411d75a03589c58f2aa /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& chmod +x /usr/local/bin/talosctl
|
||||
|
||||
RUN curl -sfL -o /usr/local/bin/kubectl \
|
||||
|
||||
@@ -36,6 +36,46 @@ class ParseTests(unittest.TestCase):
|
||||
self.assertTrue(text.endswith("The UDM answers every UDP/53 query itself."))
|
||||
self.assertLessEqual(len(text), wi.EMBED_CHARS)
|
||||
|
||||
def test_credentials_are_stripped(self):
|
||||
original = wi.http
|
||||
wi.http = lambda method, url, body=None, headers=None, timeout=60.0: (
|
||||
{"auth": {"client_token": "t"}} if url.endswith("/login")
|
||||
else {"data": {"data": {"gitea_token": "abc\n", "webhook_secret": " s3cret\n"}}})
|
||||
original_sa = wi.SA_TOKEN
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
wi.SA_TOKEN = Path(d) / "token"
|
||||
wi.SA_TOKEN.write_text("jwt\n")
|
||||
self.assertEqual(wi.Credentials().fetch(), {"gitea_token": "abc", "webhook_secret": "s3cret"})
|
||||
finally:
|
||||
wi.http, wi.SA_TOKEN = original, original_sa
|
||||
|
||||
def test_tree_paging_stops_on_short_last_page(self):
|
||||
# Gitea: truncated=true on every page including the last, "tree": null past it.
|
||||
pages = {
|
||||
1: {"tree": [{"path": f"wiki/n{i}.md", "type": "blob", "sha": f"s{i}"} for i in range(1000)], "truncated": True, "total_count": 1192},
|
||||
2: {"tree": [{"path": f"wiki/n{i}.md", "type": "blob", "sha": f"s{i}"} for i in range(1000, 1192)] + [{"path": "raw/x.md", "type": "blob", "sha": "r"}, {"path": "wiki/sub/y.md", "type": "blob", "sha": "y"}], "truncated": True, "total_count": 1192},
|
||||
3: {"tree": None, "truncated": False, "total_count": 1192},
|
||||
}
|
||||
calls = []
|
||||
original = wi.http
|
||||
|
||||
def fake_http(method, url, body=None, headers=None, timeout=60.0):
|
||||
page = int(url.rsplit("page=", 1)[1])
|
||||
calls.append(page)
|
||||
return pages[page]
|
||||
|
||||
wi.http = fake_http
|
||||
try:
|
||||
tree = wi.Gitea("t").tree("abc")
|
||||
finally:
|
||||
wi.http = original
|
||||
self.assertEqual(calls, [1, 2])
|
||||
self.assertEqual(len(tree), 1192)
|
||||
self.assertNotIn("raw/x.md", tree)
|
||||
self.assertNotIn("wiki/sub/y.md", tree)
|
||||
self.assertEqual(tree["wiki/n1191.md"], "s1191")
|
||||
|
||||
def test_signature(self):
|
||||
body = b'{"ref":"refs/heads/master"}'
|
||||
sig = hmac.new(b"s3cret", body, hashlib.sha256).hexdigest()
|
||||
@@ -115,11 +155,11 @@ class IndexTests(unittest.TestCase):
|
||||
|
||||
def test_status_reports_age_and_errors(self):
|
||||
idx = wi.Index(self.path)
|
||||
self.assertFalse(idx.status()["ok"])
|
||||
self.assertFalse(idx.status()["ready"])
|
||||
g = FakeGitea("c1", {"wiki/a.md": "x"}, {"wiki/a.md": "---\ntitle: A\n---\n# A\n\nt\n"})
|
||||
idx.sync(g)
|
||||
st = idx.status()
|
||||
self.assertTrue(st["ok"])
|
||||
self.assertTrue(st["ready"])
|
||||
self.assertEqual(st["notes"], 1)
|
||||
self.assertEqual(st["commit"], "c1")
|
||||
|
||||
@@ -131,6 +171,7 @@ class IndexTests(unittest.TestCase):
|
||||
idx.sync(Broken("c1", {}, {}))
|
||||
self.assertIn("gitea down", idx.status()["last_error"])
|
||||
self.assertEqual(idx.status()["notes"], 1) # last good index kept
|
||||
self.assertTrue(idx.status()["ready"]) # and still served
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -9,7 +9,8 @@ runs at start, on every push webhook from Gitea, and every RESYNC_SECONDS.
|
||||
|
||||
HTTP:
|
||||
POST /rank {"query": str, "k": int} -> {"rows": [{"slug","title","score"}], "index": {...}}
|
||||
GET /health -> {"ok", "notes", "commit", "synced_at", "age_s", "syncing"}
|
||||
GET /health -> 200 always (liveness) with {"ready", "notes", "commit", "synced_at", "age_s", "syncing", "last_error"}
|
||||
GET /ready -> 200 when an index exists, else 503 (readiness)
|
||||
POST /webhook Gitea push event, HMAC-SHA256 in X-Gitea-Signature -> {"queued": true}
|
||||
|
||||
Credentials (Gitea token, webhook secret) are read from OpenBao through the
|
||||
@@ -95,7 +96,7 @@ class Credentials:
|
||||
login = http("POST", f"{BAO_ADDR}/v1/auth/kubernetes/login", {"role": BAO_ROLE, "jwt": jwt})
|
||||
token = login["auth"]["client_token"]
|
||||
kv = http("GET", f"{BAO_ADDR}/v1/{BAO_SECRET_PATH}", headers={"X-Vault-Token": token})
|
||||
data = kv["data"]["data"]
|
||||
data = {k: str(v).strip() for k, v in kv["data"]["data"].items()} # `bao kv put key=@file` keeps the file's newline
|
||||
for key in ("gitea_token", "webhook_secret"):
|
||||
if not data.get(key):
|
||||
raise ApiError(f"OpenBao {BAO_SECRET_PATH} has no key {key}")
|
||||
@@ -120,14 +121,17 @@ class Gitea:
|
||||
def tree(self, commit: str) -> dict[str, str]:
|
||||
"""path -> blob sha for every wiki note at that commit."""
|
||||
out: dict[str, str] = {}
|
||||
page = 1
|
||||
page, per_page, seen = 1, 1000, 0
|
||||
while True:
|
||||
t = self.api(f"/git/trees/{commit}", recursive="true", per_page=1000, page=page)
|
||||
for e in t.get("tree", []):
|
||||
t = self.api(f"/git/trees/{commit}", recursive="true", per_page=per_page, page=page)
|
||||
entries = t.get("tree") or [] # Gitea answers "tree": null past the last page
|
||||
for e in entries:
|
||||
p = e.get("path", "")
|
||||
if e.get("type") == "blob" and p.startswith(WIKI_DIR + "/") and p.endswith(".md") and "/" not in p[len(WIKI_DIR) + 1:]:
|
||||
out[p] = e["sha"]
|
||||
if not t.get("truncated") and len(t.get("tree", [])) < 1000:
|
||||
seen += len(entries)
|
||||
total = int(t.get("total_count") or 0)
|
||||
if not entries or len(entries) < per_page or (total and seen >= total):
|
||||
break
|
||||
page += 1
|
||||
if page > 50:
|
||||
@@ -282,8 +286,9 @@ class Index:
|
||||
return [{"slug": s, "title": t, "score": round(sc, 4)} for s, t, sc in rows[:k]]
|
||||
|
||||
def status(self) -> dict:
|
||||
"""`ready` = an index exists and can answer /rank; a failed sync keeps the last good index."""
|
||||
with self.lock:
|
||||
return {"ok": bool(self.notes) and not self.last_error, "notes": len(self.notes), "commit": self.commit,
|
||||
return {"ready": bool(self.notes), "notes": len(self.notes), "commit": self.commit,
|
||||
"synced_at": self.synced_at, "age_s": round(time.time() - self.synced_at) if self.synced_at else None,
|
||||
"syncing": self.syncing, "last_error": self.last_error or None}
|
||||
|
||||
@@ -302,15 +307,19 @@ class Syncer(threading.Thread):
|
||||
def trigger(self) -> None:
|
||||
self.wake.set()
|
||||
|
||||
RETRY_SECONDS = 60
|
||||
|
||||
def run(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
data = self.creds.fetch()
|
||||
summary = self.index.sync(Gitea(data["gitea_token"]))
|
||||
LOG.info("sync done: %s", summary)
|
||||
except Exception as e: # keep serving the last good index
|
||||
wait = self.interval
|
||||
except Exception as e: # keep serving the last good index; retry soon, not next interval
|
||||
LOG.error("sync failed: %s", e)
|
||||
self.wake.wait(self.interval)
|
||||
wait = self.RETRY_SECONDS
|
||||
self.wake.wait(wait)
|
||||
self.wake.clear()
|
||||
|
||||
|
||||
@@ -340,9 +349,11 @@ def make_handler(index: Index, syncer: Syncer, creds: Credentials):
|
||||
return self.rfile.read(n) if n else b""
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
if self.path == "/health": # liveness: the process serves
|
||||
return self.send_json(200, index.status())
|
||||
if self.path == "/ready": # readiness: an index exists
|
||||
st = index.status()
|
||||
return self.send_json(200 if st["ok"] else 503, st)
|
||||
return self.send_json(200 if st["ready"] else 503, st)
|
||||
self.send_json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
|
||||
@@ -1,117 +1,99 @@
|
||||
# Traffic classes on the k8s nodes
|
||||
|
||||
Issue #576. Three of the four classes are separated; management is not.
|
||||
Issue #576. Five classes, each on its own VLAN.
|
||||
|
||||
## Where each class runs
|
||||
|
||||
| class | link | mechanism |
|
||||
|---|---|---|
|
||||
| Ceph replication | `ceph-san`, VLAN 49 | the whole link is handed to the OSD pod (Multus `host-device`) |
|
||||
| world→pod | `ingress0`, VLAN 47 | BGP session sourced there, so the advertised next hop is that address |
|
||||
| pod→world | `ingress0`, VLAN 47 | routing rule on the pod source prefix → table 47 |
|
||||
| pod↔pod | `pod0`, VLAN 48 | routing rule on pod source **and** destination → table 48 |
|
||||
| management | `mgmt0`, VLAN 40 | not started |
|
||||
| pod↔pod | `pod0`, VLAN 48 | pod-sourced traffic is routed from table 47; bird puts the peers' pod CIDRs there via `pod0` |
|
||||
| world↔pod | `ingress0`, VLAN 47 | BGP session sourced there (next hop of every LoadBalancer VIP); pod egress takes table 47's default |
|
||||
| node↔node | `node0`, VLAN 46 | the node InternalIP and etcd live there; so do apiserver↔kubelet, cilium-health and host→remote-pod traffic |
|
||||
| world↔node | `mgmt0`, VLAN 40 | API VIP for kubectl, talosctl from outside, node egress (DNS, NTP, image pulls) |
|
||||
|
||||
Hard constraint, set by the owner: inter-node and pod traffic never traverses
|
||||
the UDM. Everything above stays on the 10G switch.
|
||||
the UDM. Everything but world↔node stays on the 10G switch. VLAN 46 and 48 are
|
||||
statically addressed, without a gateway; VLAN 46 is isolated in UniFi.
|
||||
|
||||
## Why policy routing rather than moving the node IP
|
||||
## Pod traffic: one table
|
||||
|
||||
Cilium reaches a peer's pods at that peer's Kubernetes InternalIP, in native
|
||||
and tunnel mode alike. `--ipv4-node` is overwritten from the Node object, and
|
||||
`nodePort.directRoutingDevice` selects the BPF NodePort device, not a next hop.
|
||||
Both were established by test (#837, #838), not by reading.
|
||||
Two rules, one per family, send every packet with a pod source to table 47
|
||||
(`talos/patches/site-homelab.yaml`, priorities 1000/1001, below main). The
|
||||
table decides by longest prefix; no rule carves out exceptions.
|
||||
|
||||
So the InternalIP decides where pod traffic goes — unless the lookup happens in
|
||||
a different table. The rules do that, and nothing about the node's identity
|
||||
moves. The routing decision runs before Cilium's masquerade hook, so a rule
|
||||
matching the pod source sees the pod address, and the masquerade then rewrites
|
||||
it to the egress link's.
|
||||
bird (`kubernetes/infrastructure/bird/`) owns table 47 and fills it with:
|
||||
|
||||
## Rule order
|
||||
- the node's attached networks — `mgmt0`, `ingress0`, `pod0`, `node0` —
|
||||
taken from the interfaces;
|
||||
- the peers' pod CIDRs, learned over an iBGP mesh on `pod0`, next hop the
|
||||
peer's `pod0` address;
|
||||
- the node's own pod CIDR via `cilium_host`, originated rather than read back
|
||||
from the kernel (reading it back made BIRD depend on the shape of Cilium's
|
||||
route, and KRT rejects the IPv4 one);
|
||||
- the egress default via the UDM on `ingress0`, from the node annotations
|
||||
`net.renner.uno/egress-gw-v4/v6`.
|
||||
|
||||
Lower priority runs first.
|
||||
`node0` has to be in the table: a pod reaching a node address — the
|
||||
`kubernetes` ClusterIP, which socket LB rewrites to the apiserver's advertise
|
||||
address, or a hostNetwork pod — would otherwise take the default to the UDM.
|
||||
|
||||
| prio | match | table |
|
||||
|---|---|---|
|
||||
| 980 | src pod, dst pod | 48 |
|
||||
| 981 | src pod, dst pod (v4) | 48 |
|
||||
| 990 | dst `fd00::/8` | main |
|
||||
| 991 | dst `10.0.0.0/8` | main |
|
||||
| 1000 | src pod | 47 |
|
||||
| 1001 | src pod (v4) | 47 |
|
||||
The routing decision runs before Cilium's masquerade hook, so the rule sees the
|
||||
pod address and the masquerade then rewrites it to the egress link's.
|
||||
|
||||
980/981 match source **and** destination. Source alone would also catch the
|
||||
node's own traffic to a pod, which would then leave with a `pod0` source — an
|
||||
address `CiliumNode.spec.addresses` does not list, so Cilium classifies it as
|
||||
`world` rather than `host`.
|
||||
|
||||
990/991 are Cilium's `ipv4/ipv6NativeRoutingCIDR`, and they are what keeps
|
||||
cluster-internal traffic out of table 47. Without them a pod's packet to a
|
||||
service matches the source rule, finds only a default route, and leaves for the
|
||||
UDM — measured, in-cluster DNS stopped resolving.
|
||||
|
||||
Traffic to a pod on the same node falls through on its own: table 48 holds only
|
||||
the peers' CIDRs, so the lookup misses and 990/991 send it to main.
|
||||
|
||||
## Where each table comes from
|
||||
|
||||
- **47** — two default routes declared per node in `talos/patches/k8s-*.yaml`.
|
||||
The IPv6 gateway is the UDM's configured address on the segment, not the
|
||||
RA-learned link-local; `accept_ra_defrtr` stays `0` on `ingress0` so the RA
|
||||
default does not land in main.
|
||||
- **48** — filled by the `bird` DaemonSet: an iBGP mesh over `pod0`, one route
|
||||
per peer pod CIDR with the peer's `pod0` address as next hop.
|
||||
|
||||
## The bird mesh
|
||||
|
||||
`kubernetes/infrastructure/bird/`. Each node advertises only its own pod CIDR
|
||||
and installs what it learns into table 48. Cilium's routes in main are
|
||||
untouched, which is why the two coexist.
|
||||
Node-originated traffic does not match the rules and uses main, where Cilium
|
||||
routes each peer's pod CIDR via the peer's InternalIP — on `node0`.
|
||||
|
||||
No address is written into this repo twice. Each node publishes its `pod0`
|
||||
addresses as `nodeAnnotations`, beside the `VLANConfig` that assigns them; the
|
||||
pod CIDRs come from the Node objects. An init container renders both from the
|
||||
API with `kubectl -o go-template-file`, excludes the node itself with a field
|
||||
selector, and ends with `bird -p` so BIRD's own parser gates startup.
|
||||
addresses as `nodeAnnotations` beside the `VLANConfig` that assigns them; the
|
||||
pod CIDRs come from the Node objects. bird's init container renders both from
|
||||
the API and ends with `bird -p`, so BIRD's own parser gates startup.
|
||||
|
||||
The node's own CIDR is **originated**, not read back from the kernel. Reading
|
||||
it back made BIRD depend on the shape Cilium installs, and the IPv4 route is
|
||||
rejected outright: its next hop sits inside the prefix it describes, which KRT
|
||||
refuses with `strange next-hop`. The IPv6 route is a device route and passed,
|
||||
so the failure was silent on one family only.
|
||||
## Node↔node
|
||||
|
||||
`kubelet.nodeIP.validSubnets` and `cluster.etcd.advertisedSubnets` point at
|
||||
VLAN 46. Talos picks one InternalIP per family, IPv4 first — the service CIDR
|
||||
is IPv4, and the apiserver's advertise address must match it.
|
||||
|
||||
The API VIP stays on `mgmt0` for kubectl. No in-cluster client uses it:
|
||||
kubelet, scheduler, controller-manager and Cilium go through KubePrism
|
||||
(`localhost:7445`), which balances over every address of every control-plane
|
||||
member; pods use the ClusterIP.
|
||||
|
||||
Talos never updates an existing etcd member's peer URLs
|
||||
(siderolabs/talos#12720). Changing `advertisedSubnets` means, per member:
|
||||
stage the config, `talosctl etcd forfeit-leadership`, `talosctl etcd leave`,
|
||||
reboot. `leave` removes only `/var/lib/etcd`; the node rejoins as a learner
|
||||
and is promoted. EPHEMERAL does not need a wipe.
|
||||
|
||||
## Moving node IPs: the mixed state
|
||||
|
||||
While some nodes have their InternalIP on the new network and some on the old,
|
||||
host→remote-pod traffic leaves with a node address that is not an InternalIP:
|
||||
the kernel picks the source from the route to the peer's InternalIP. Cilium
|
||||
classifies that as `world`, and every ingress policy that admits only
|
||||
`host`/`remote-node` (rook-ceph, monitoring, …) drops it. Kernel Ceph clients
|
||||
lose the OSDs on the other side; RBD mounts hang.
|
||||
|
||||
Bridge it for the duration with a temporary `CiliumClusterwideNetworkPolicy`
|
||||
admitting the node subnets of both networks, both families, with
|
||||
`enableDefaultDeny: false` so it restricts nothing else, and delete it once
|
||||
every node has moved. Keep all OSD nodes schedulable while one is drained:
|
||||
draining two takes Ceph below its data availability.
|
||||
|
||||
## Failure behaviour
|
||||
|
||||
Benign at every layer. An empty or absent table 48 makes 980/981 fall through
|
||||
to main, and pod traffic returns to VLAN 40 — unseparated, not broken. The same
|
||||
holds if the DaemonSet cannot start, which is the case during a cold rebuild
|
||||
until Gitea serves its image.
|
||||
|
||||
## Management, the remaining class
|
||||
|
||||
Not started. The levers differ per service and none of them is the node IP:
|
||||
|
||||
| service | can leave the node IP? | lever |
|
||||
|---|---|---|
|
||||
| etcd | yes | `cluster.etcd.advertisedSubnets`; needs a reboot per node |
|
||||
| kube-apiserver endpoint | yes | it is a VIP — `Layer2VIPConfig.link` |
|
||||
| Talos API | n/a | `apid` answers on every node address; a firewall question |
|
||||
| kubelet | no | it *is* the node IP |
|
||||
|
||||
Two approaches were rejected by the owner and are not to be proposed again:
|
||||
moving the node IP and etcd to `pod0` (#838), and relabelling VLAN 40 as the
|
||||
cluster VLAN while moving etcd and the API VIP to a new management VLAN.
|
||||
Replacing the CNI (#839, Antrea) was shelved.
|
||||
|
||||
Note that the reasoning which produced that list also concluded pod↔pod was
|
||||
blocked, which policy routing disproved. Treat it as a record of decisions, not
|
||||
as an exhaustive analysis.
|
||||
If bird stops, table 47 empties, the rules find nothing and fall through to
|
||||
main: pod traffic returns to the node networks, unseparated rather than
|
||||
misrouted. The same holds during a cold rebuild until Gitea serves bird's
|
||||
image. The default route lives in table 47, not in the Talos config, so a
|
||||
default cannot outlive the specific routes and send pod traffic to the UDM.
|
||||
|
||||
## Sources
|
||||
|
||||
Cilium v1.20.1 `pkg/datapath/linux/node.go`, `pkg/node/types/node.go`,
|
||||
`pkg/node/sync/local_node_sync.go`; BIRD 2.17 user manual (kernel protocol,
|
||||
`learn all`, `strict bind`, `direct`); Talos v1.13 `RoutingRuleConfig` and
|
||||
`machine.nodeAnnotations`. PRs #831, #833, #835, #836, #837, #842, #852, #854,
|
||||
#855; closed unmerged: #830, #838, #839.
|
||||
Cilium v1.20 `pkg/datapath/linux/node.go`; BIRD 2.17 user manual (kernel
|
||||
protocol, `strict bind`, `direct`); Talos v1.13 `RoutingRuleConfig`,
|
||||
`machine.nodeAnnotations`, configuration patching (lists are appended,
|
||||
`$patch: delete`), etcd `LeaveCluster`; KubePrism docs;
|
||||
siderolabs/talos#12720. PRs #831, #833, #835, #836, #837, #842, #852, #854,
|
||||
#855, #857, #858, #918, #919, #922; closed unmerged: #830, #838, #839.
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
# Wiki: local web interface and agent API, as a modular .NET solution
|
||||
|
||||
## Goal
|
||||
|
||||
A web interface for the Notes wiki that runs entirely in the cluster, and an
|
||||
HTTP API through which agents ask the wiki typed questions and deposit facts.
|
||||
Built as one C# solution with a MudBlazor front end, and built so that every
|
||||
external dependency is replaceable: where the notes live, which model embeds
|
||||
them, what judges a typed question, what runs an agent, how the vector index
|
||||
is stored. The first adapters are what the homelab uses today. The point of
|
||||
the design is that the next one is a project and a config line, not a
|
||||
rewrite.
|
||||
|
||||
## Where we are
|
||||
|
||||
- `wiki-index` (Python, ns `ai`) syncs `wiki/` from `k.renner/notes` by blob
|
||||
SHA, embeds through `wiki-embed` (bge-m3 on llama-server), answers
|
||||
`POST /rank`. It keeps slug, title and vector, not the text. LAN-only behind
|
||||
Traefik BasicAuth; the Gitea webhook hits the ClusterIP.
|
||||
- `~/Notes/bin/wiki_rank.py` and `wiki_ask.py` rank through the index, build a
|
||||
document context from a local vault checkout, send it with typed questions
|
||||
to TypeSafe, gate on `covered`. The wiki-synth agent composes answers from
|
||||
those judgements.
|
||||
- New knowledge enters as a file in `raw/` (facts with provenance, never
|
||||
hypotheses), distilled nightly in CI.
|
||||
- The house C# project, shelly-manager, sets the conventions: net10.0, an
|
||||
`.slnx` with `src/` and `test/`, xUnit, MudBlazor 9, Blazor Web App with
|
||||
interactive server rendering, minimal APIs, an Authentik proxy-header
|
||||
authentication handler for humans and an API-key handler for machines, a
|
||||
multi-stage Dockerfile, `dotnet test` on every push and a kaniko release
|
||||
pipeline to the Gitea registry. Human-facing apps sit behind an Authentik
|
||||
outpost (`<app>-gate`) with a CNP that admits only that outpost.
|
||||
|
||||
## Architecture: ports in the core, adapters around it
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Wiki.Host (ASP.NET Core + Blazor/Mud) │ composition root,
|
||||
│ /api/v1, /rank, /webhook, UI pages │ config, auth
|
||||
└───────────────┬──────────────────────────┘
|
||||
│ uses
|
||||
┌───────────────▼──────────────────────────┐
|
||||
│ Wiki.Application (use cases) │ Sync, Rank, Ask,
|
||||
│ │ DepositFact, Queue-
|
||||
└───────────────┬──────────────────────────┘ Question, Evaluate
|
||||
│ depends only on
|
||||
┌───────────────▼──────────────────────────┐
|
||||
│ Wiki.Core (domain + ports) │ Note, RawSource,
|
||||
│ no package references │ Report, LinkGraph,
|
||||
└──────────────────────────────────────────┘ the interfaces
|
||||
implemented by ▲ ▲ ▲ ▲ ▲
|
||||
Adapters.Gitea Adapters.LlamaCpp Adapters.TypeSafe Adapters.FileIndex Adapters.Anthropic
|
||||
(later: Database, Ollama/Onnx, local Jev, pgvector/Qdrant, OpenAI-compatible answer models)
|
||||
```
|
||||
|
||||
Rules that keep it modular, enforced by project references and a test:
|
||||
|
||||
- `Wiki.Core` references nothing. It holds the domain types and the port
|
||||
interfaces. Adapters reference Core only; they never reference each other
|
||||
or the host. `Wiki.Host` is the only project that knows which adapters
|
||||
exist.
|
||||
- Every port has a **contract test suite** in `test/Wiki.Contracts` that
|
||||
runs against every adapter of that port. A new adapter is done when the
|
||||
suite is green. That is what makes "swap the storage" a safe operation.
|
||||
- Adapters are selected by configuration and registered by name (keyed
|
||||
services), so several can be active at once. The UI's lab page and the
|
||||
evaluator use that to compare, the API uses the one marked active.
|
||||
- Nothing in Core or Application knows about HTTP, Gitea, TypeSafe, files or
|
||||
Kubernetes.
|
||||
|
||||
### Ports
|
||||
|
||||
| port | what it abstracts | first adapter | examples of the next ones |
|
||||
|---|---|---|---|
|
||||
| `INoteStore` | read notes, raw sources and reports at a head; create a new document (a fact becomes a raw note in the application layer, the store only moves text) | Gitea repository API (`k.renner/notes`, blob SHA) | git checkout on a volume; a database (CNPG Postgres) with the vault imported; an Obsidian sync folder |
|
||||
| `IEmbeddingModel` | text → vector, with a model id and dimension | llama-server `/v1/embeddings` (bge-m3 via wiki-embed, the model that won the German-document comparison on accuracy per cost) | the qwen3 embedding variants from that comparison; ONNX in-process; any OpenAI-compatible endpoint |
|
||||
| `IVectorIndex` | store vectors per (model id, document), query top-k | in-memory cosine with a file snapshot per model, as today | pgvector in the database; Qdrant |
|
||||
| `IChunker` | split a document into addressable units for typed questions | paragraph splitter (headings, fences), as in `wiki_ask.py` | sentence windows; semantic chunks |
|
||||
| `IJudge` | typed questions over a state: Choice, Noul, Score, with probabilities | TypeSafe System One (`jev-latest`, cloud; the wiki records no locally hosted Jev today) | a locally hosted Jev with the same request shape, should TypeSafe offer one; a constrained-output local LLM adapter, flagged as uncalibrated |
|
||||
| `IAnswerModel` | prose from a question plus evidence: the selected paragraphs with their notes and the judgements; must cite by slug; streaming | Anthropic Messages API (Claude, model from config, Sonnet by default) | any OpenAI-compatible endpoint, including the local llama-server; a different Claude model per question |
|
||||
| `IEvaluator` | measure a retrieval or judge configuration against the labelled set (questions and cited sources in `output/`) | recall@k, hit rate, MRR, as `wiki_rank_eval.py` | judge accuracy against hand-labelled paragraphs |
|
||||
| `IIdentity` | who is calling, for attribution of facts and saved answers | Authentik proxy headers (humans), API key (machines), as in shelly-manager | OIDC bearer tokens |
|
||||
|
||||
Two ports deserve a sentence of scope each. The judge returns judgements
|
||||
with probabilities, never prose, and the UI never presents its output as an
|
||||
answer, only as ranked, quoted evidence. The answer model turns that
|
||||
evidence into words and is allowed nothing else: it sees the selected
|
||||
paragraphs and the judgements, not the whole context, and its output is
|
||||
shown next to the evidence it was given. Both are single model calls inside
|
||||
the request. The service runs no agent: no tool loop, no vault edits. The
|
||||
wiki-synth agent in a Claude session remains the path for artifacts and for
|
||||
multi-question synthesis, and it can call `/questions` itself.
|
||||
|
||||
### Solution layout
|
||||
|
||||
```
|
||||
Wiki.slnx
|
||||
src/
|
||||
Wiki.Core/ domain types, ports, frontmatter and link parsing
|
||||
Wiki.Application/ use cases; depends on Core only
|
||||
Wiki.Adapters.Gitea/ INoteStore (read at a head, create)
|
||||
Wiki.Adapters.LlamaCpp/ IEmbeddingModel (OpenAI-compatible embeddings)
|
||||
Wiki.Adapters.TypeSafe/ IJudge (System One client)
|
||||
Wiki.Adapters.FileIndex/ IVectorIndex (memory + snapshot per model)
|
||||
Wiki.Adapters.Anthropic/ IAnswerModel (Messages API, streaming)
|
||||
Wiki.Host/ ASP.NET Core: API, webhook, MudBlazor UI, auth,
|
||||
composition root, appsettings
|
||||
test/
|
||||
Wiki.Core.Tests/ parsing, link graph, chunker
|
||||
Wiki.Contracts/ one suite per port, parameterised over adapters
|
||||
Wiki.Application.Tests/ use cases with fake ports
|
||||
Wiki.Host.Tests/ API surface, auth handlers
|
||||
```
|
||||
|
||||
Own repository `k.renner/wiki`, same shape as shelly-manager: `Dockerfile`,
|
||||
`.woodpecker/test.yml` on pull requests, `.woodpecker/release.yml` building
|
||||
the image with kaniko into the Gitea registry on push to `main`, tagged
|
||||
`build.<unix-ts>` plus the commit SHA so Flux image automation can follow
|
||||
it. The homelab repo holds only the manifests. `claude-bot` needs write
|
||||
access to the new repository.
|
||||
|
||||
Two things the shelly-manager history settles in advance: the TypeSafe
|
||||
client is written from the documented field contract (endpoint, bearer key,
|
||||
`jev-latest`, Noul/Choice/Score, 255-option cap, 32k-token state budget),
|
||||
there is no prior C# client to copy; and the UI needs no login page, because
|
||||
identity comes from the outpost's headers, which avoids the Blazor-circuit
|
||||
sign-in constraint shelly-manager ran into.
|
||||
|
||||
### Configuration
|
||||
|
||||
Adapters are declared in `appsettings` and selected by name; secrets come
|
||||
through environment variables, filled by ExternalSecret, never from the file.
|
||||
|
||||
```json
|
||||
"Wiki": {
|
||||
"NoteStore": { "Active": "gitea", "gitea": { "Repository": "k.renner/notes", "Branch": "main" } },
|
||||
"Embeddings": {
|
||||
"Active": "bge-m3",
|
||||
"Models": {
|
||||
"bge-m3": { "Adapter": "LlamaCpp", "Url": "http://wiki-embed.ai.svc:8080", "Model": "bge-m3" },
|
||||
"e5-large": { "Adapter": "Ollama", "Url": "http://ollama.ai.svc:11434", "Model": "multilingual-e5-large" }
|
||||
}
|
||||
},
|
||||
"Judges": {
|
||||
"Active": "typesafe",
|
||||
"Models": {
|
||||
"typesafe": { "Adapter": "TypeSafe", "Url": "https://api.typesafe.ai/v1/systemone", "Model": "jev-latest" },
|
||||
"jev-local": { "Adapter": "TypeSafe", "Url": "http://jev.ai.svc:8080/v1/systemone", "Model": "jev" } // illustration: same adapter, other endpoint
|
||||
}
|
||||
},
|
||||
"Index": { "Active": "file", "file": { "Path": "/data/index" } },
|
||||
"AnswerModels": {
|
||||
"Active": "claude-sonnet",
|
||||
"Models": {
|
||||
"claude-sonnet": { "Adapter": "Anthropic", "Model": "claude-sonnet-5" },
|
||||
"qwen-local": { "Adapter": "OpenAI", "Url": "http://llama-server.ai.svc:8080/v1", "Model": "qwen3.6-35b-a3b" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every embedding model gets its own index namespace. Switching the active
|
||||
model is a config change after the new index has been built in the
|
||||
background; until then both exist and can be compared. Nothing is
|
||||
re-embedded in place.
|
||||
|
||||
## The web interface (MudBlazor)
|
||||
|
||||
Blazor Web App, interactive server rendering, hosted in `Wiki.Host`. Static
|
||||
assets vendored into the image, no CDN. Pages:
|
||||
|
||||
1. **Search**: a query box, results ranked by the active index with score and
|
||||
tags; for a phrased question, optionally the paragraph-level hits from the
|
||||
judge with their probabilities, shown as quoted evidence.
|
||||
2. **Note**: frontmatter, rendered body with `[[wikilinks]]` resolved,
|
||||
backlinks, sources linking to their raw file.
|
||||
3. **Browse**: by tag, by `updated`, recently changed, orphans.
|
||||
4. **Answers**: a question box. The answer streams in within seconds: rank,
|
||||
judge, compose, all in the request. Below it the evidence: the paragraphs
|
||||
the judge selected with their notes and probabilities, and the `covered`
|
||||
gate; a low gate is shown as "the wiki does not cover this" instead of an
|
||||
answer. A save button writes the answer to `output/` with the wiki-synth
|
||||
frontmatter (`question`, `sources`, `confidence`), so it joins the
|
||||
labelled set. Underneath, the saved answers, newest first, including the
|
||||
ones the wiki-synth agent wrote from a session.
|
||||
5. **Maintenance**: the raw queue with ages, the last consolidation and audit
|
||||
reports, index and sync status.
|
||||
6. **Lab**: pick any registered embedding model and judge, run the same query
|
||||
through them side by side, and run the evaluator on the labelled set to
|
||||
get recall@k per configuration. This is the page for "play around with
|
||||
different models".
|
||||
7. **Settings**: read-only view of the active adapters and their health.
|
||||
|
||||
No editing of notes in the browser. The vault stays a git repository edited
|
||||
by the consolidate agent and by hand.
|
||||
|
||||
## API v1
|
||||
|
||||
Reads are authenticated by the hostname's gate (humans) or API key
|
||||
(machines); writes always need an API key so a fact carries its author.
|
||||
|
||||
| method and path | purpose |
|
||||
|---|---|
|
||||
| `GET /api/v1/status` | index commit and age, counts, active adapters |
|
||||
| `GET /api/v1/notes?q=&tag=&limit=` | list; `q` ranks through the active index |
|
||||
| `GET /api/v1/notes/{slug}` | frontmatter, body, links, backlinks, sources |
|
||||
| `GET /api/v1/tags` | tags with counts |
|
||||
| `POST /api/v1/rank` | `{question, k, model?}` → ranked slugs; also served at `/rank` for the existing scripts |
|
||||
| `POST /api/v1/ask` | `{queries, questions, k, judge?, model?}` → context, typed judgements with the answering paragraphs verbatim, `covered`. For agents and code. |
|
||||
| `POST /api/v1/questions` | `{question, k?, judge?, model?, answerModel?, save?}` → the answer text with `[[slug]]` citations, the evidence (paragraphs, probabilities), `covered`, model and token usage; streamed as server-sent events when requested; with `save`, the `output/` path and commit |
|
||||
| `POST /api/v1/facts` | deposit a raw source: title, source, `kind: fact`, observed_at, content, tags → path and commit |
|
||||
| `GET /api/v1/raw` | the distill queue with ages |
|
||||
| `GET /api/v1/reports` | `output/` documents by type, newest first |
|
||||
| `POST /api/v1/lab/eval` | run the evaluator for a (model, judge) pair |
|
||||
| `POST /webhook` | Gitea push, HMAC as today |
|
||||
|
||||
`POST /api/v1/facts` is rejected without `source`, with a `kind` other than
|
||||
`fact`, or with content under a minimum length. The raw rule is enforced by
|
||||
shape: there is no field for a hypothesis. The file gets the vault's
|
||||
provenance frontmatter with `captured_by` set from the API key's identity.
|
||||
|
||||
`POST /api/v1/ask` is `wiki_ask.py` moved server-side, so the scripts become
|
||||
thin clients and agents no longer need a vault checkout. Its result is
|
||||
judgements and quotations, never prose.
|
||||
|
||||
`POST /api/v1/questions` is `/ask` plus one answer-model call, in process:
|
||||
rank the question, build the context, run the default typed questions
|
||||
(answered, where, covered), hand the selected paragraphs and judgements to
|
||||
the answer model, stream the prose back. Below the `covered` threshold it
|
||||
returns the gap instead of an answer. Time is the sum of the parts, about
|
||||
0.2 s rank, 1 to 4 s judge, a few seconds of generation. Cost is one model
|
||||
call over a few thousand tokens, a few cents on Sonnet, against a quarter
|
||||
dollar for a full agent run.
|
||||
|
||||
## Auth and network
|
||||
|
||||
- Humans: `wiki.renner.uno`, LAN-only (no `dns.renner.uno/public` label),
|
||||
Ingress backend `ak-outpost-wiki-gate:9000`, a `wiki-gate-egress` CNP in
|
||||
`authentik`, the proxy-header handler trusts `X-Authentik-*` only from that
|
||||
outpost.
|
||||
- Machines: `wiki-index.renner.uno` with the API-key handler; the workstation
|
||||
key stays where it is. CI reaches the ClusterIP.
|
||||
- Secrets (Gitea token, webhook secret, API keys, TypeSafe key, Anthropic
|
||||
key) reach the
|
||||
pod as environment variables from Kubernetes Secrets filled by
|
||||
ExternalSecret from the `openbao` ClusterSecretStore. The pod itself has
|
||||
no OpenBao access. The Python service fetched its secrets from OpenBao
|
||||
directly through its own Kubernetes-auth role; that role, its policy and
|
||||
the `ai` → `openbao` CNP entry are retired with it.
|
||||
- Egress from the pod: Gitea, wiki-embed, `api.typesafe.ai:443`,
|
||||
`api.anthropic.com:443`. Every
|
||||
further adapter that talks to a new endpoint gets its own CNP line, which
|
||||
is the one place adding an adapter touches the cluster.
|
||||
|
||||
## Health endpoints
|
||||
|
||||
Three endpoints with three different meanings, wired to the .NET health
|
||||
checks with tags so that the probes and the humans see the right thing:
|
||||
|
||||
| endpoint | meaning | probe |
|
||||
|---|---|---|
|
||||
| `GET /health` | the process serves HTTP. Always 200 once Kestrel listens; no dependency checks. | liveness |
|
||||
| `GET /ready` | the active note store has synced at least once and the active index is loaded; 503 while the first sync or index build runs. A failed later sync keeps the last good state and stays ready. | readiness, plus a startup probe with enough budget for the first sync (the Python service was killed mid-build when its `/health` returned 503 during the initial sync) |
|
||||
| `GET /api/v1/status` | the full picture for humans and the Settings page: index commit and age, counts, queue lengths, and one entry per registered adapter with its last check result and latency. | none |
|
||||
|
||||
Adapter checks (Gitea reachable, embeddings endpoint answering, judge
|
||||
endpoint answering, answer model answering) are reported in `/status` and
|
||||
on the Settings page and are never part of `/ready`: a judge outage must not
|
||||
take the reader down. Their failures show up as red entries, not as
|
||||
restarts.
|
||||
|
||||
## Phases
|
||||
|
||||
One PR each, verified before the next. The Python `wiki-index` keeps running
|
||||
until phase 3 proves parity.
|
||||
|
||||
0. **Skeleton.** Repository, solution, `Wiki.Core` with the ports and domain
|
||||
types, `Wiki.Contracts` with the suites, `Wiki.Host` serving `/health`,
|
||||
`/ready` and `/api/v1/status`. CI: test and release pipelines. Verify:
|
||||
image in the registry, pipeline green, the Deployment's three probes
|
||||
wired and the pod Ready.
|
||||
1. **Parity with the Python service.** Gitea, LlamaCpp and FileIndex
|
||||
adapters, the sync use case, `/rank` and `/webhook`. Verify: the 40-query
|
||||
recall eval gives the same numbers as the Python service on the same
|
||||
commit; `wiki_rank.py --probe-index` against the new service passes.
|
||||
2. **Read API and UI.** Notes, tags, raw, reports; Search, Note, Browse,
|
||||
Maintenance pages; `wiki-gate`. Verify: a note renders with resolved
|
||||
links; an unauthenticated request is redirected to Authentik.
|
||||
3. **Ask.** Chunker, TypeSafe adapter, `/api/v1/ask`, scripts as clients.
|
||||
Verify: `test_wiki_ask.py` passes against the API; a wiki-synth run with
|
||||
the definition unchanged. Then the Python service is retired and the
|
||||
`wiki-index` hostname points at the new one.
|
||||
4. **Facts.** API keys as an ExternalSecret, the deposit-fact use case, `/api/v1/facts`. Verify:
|
||||
a fact posted from the workstation shows as a `claude-bot` commit and in
|
||||
the next distill.
|
||||
5. **Questions.** Anthropic adapter, `/api/v1/questions` with streaming,
|
||||
the Answers page with save. Verify: a question answered in under ten
|
||||
seconds with citations that resolve to the shown evidence; a question the
|
||||
wiki does not cover returns the gap; a saved answer appears in `output/`
|
||||
with the wiki-synth frontmatter and is picked up by the evaluator.
|
||||
6. **Lab.** Evaluator, a second embedding model and a second answer model
|
||||
registered, the Lab page. Verify: two models compared on the labelled set
|
||||
from the UI.
|
||||
|
||||
## Not in scope
|
||||
|
||||
- Editing notes in the browser.
|
||||
- Replacing Obsidian; the vault stays a git repository and `notes-push.path`
|
||||
keeps pushing.
|
||||
- Public exposure; LAN and VPN only.
|
||||
- Running or triggering agents. The service makes single model calls inside
|
||||
a request; it never runs a tool loop or edits the vault. Distill,
|
||||
consolidate and multi-question synthesis stay Claude processes in CI or in
|
||||
a session; the service shows their inputs and outputs and serves their API
|
||||
calls.
|
||||
@@ -107,7 +107,7 @@ config, and the nodes are addressed by name.
|
||||
## Step 1b — boot all three machines from install media
|
||||
|
||||
```
|
||||
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.13.9/metal-amd64.iso
|
||||
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.13.10/metal-amd64.iso
|
||||
```
|
||||
|
||||
Write it to a USB stick and boot each MS-01 from it. Each comes up in
|
||||
|
||||
@@ -976,6 +976,66 @@ data:
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# wiki-gate: the wiki service (ns ai) behind the same passkey flows as
|
||||
# llama-gate. The service trusts X-Authentik-Username only from pod
|
||||
# addresses, and its CNP admits only this outpost, so the gate is the
|
||||
# whole access control for wiki.renner.uno.
|
||||
- id: wiki-gate-provider
|
||||
model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
name: wiki-gate
|
||||
state: present
|
||||
attrs:
|
||||
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
mode: proxy
|
||||
internal_host: http://wiki.ai.svc.cluster.local:8080
|
||||
internal_host_ssl_validation: false
|
||||
external_host: https://wiki.renner.uno
|
||||
cookie_domain: renner.uno
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: wiki-gate
|
||||
state: present
|
||||
attrs:
|
||||
name: Wiki
|
||||
slug: wiki-gate
|
||||
provider: !KeyOf wiki-gate-provider
|
||||
meta_launch_url: https://wiki.renner.uno
|
||||
meta_description: "Notes wiki: search, notes, answers, maintenance"
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: wiki-gate
|
||||
state: present
|
||||
attrs:
|
||||
type: proxy
|
||||
service_connection: !KeyOf k8s-conn
|
||||
providers:
|
||||
- !KeyOf wiki-gate-provider
|
||||
config:
|
||||
log_level: info
|
||||
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
|
||||
authentik_host_insecure: false
|
||||
authentik_host_browser: "https://auth.renner.uno/"
|
||||
refresh_interval: "minutes=5"
|
||||
container_image: null
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
kubernetes_service_type: ClusterIP
|
||||
kubernetes_disabled_components: ["ingress"]
|
||||
kubernetes_image_pull_secrets: []
|
||||
kubernetes_ingress_annotations: {}
|
||||
kubernetes_ingress_class_name: null
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: authentik Embedded Outpost
|
||||
|
||||
@@ -16,6 +16,7 @@ resources:
|
||||
- prometheus-gate-egress.yaml
|
||||
- wordpress-gate-egress.yaml
|
||||
- llama-gate-egress.yaml
|
||||
- wiki-gate-egress.yaml
|
||||
- ldap-outpost-cert.yaml
|
||||
- ldap-outpost-lb.yaml
|
||||
- ldap-outpost-cnp.yaml
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Lets the wiki-gate outpost reach the wiki service. The authentik namespace
|
||||
# baseline CNP otherwise stops any authentik pod from entering ai. Scoped to
|
||||
# this outpost's pod label; the matching ingress rule is in ai-ns.yaml.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: wiki-gate-egress
|
||||
namespace: authentik
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: wiki-gate
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: wiki
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
@@ -46,7 +46,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gatus
|
||||
image: twinproduction/gatus:v5.36.0 # {"$imagepolicy": "flux-system:gatus"}
|
||||
image: twinproduction/gatus:v5.37.0 # {"$imagepolicy": "flux-system:gatus"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,7 +6,6 @@ kind: Kustomization
|
||||
# and verified. See MIGRATION-gitea-woodpecker.md.
|
||||
resources:
|
||||
- statefulset.yaml
|
||||
- pdb.yaml
|
||||
- service.yaml
|
||||
- ingressroute-ssh.yaml
|
||||
- ingress.yaml
|
||||
|
||||
@@ -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,23 +0,0 @@
|
||||
---
|
||||
# Eviction protection for the single-replica Gitea StatefulSet.
|
||||
#
|
||||
# Gitea runs on SQLite (single writer) and its repos + DB live on a single
|
||||
# ReadWriteOnce PVC, so it is deliberately replicas:1 and cannot fail over.
|
||||
# With no PDB, a node drain / maintenance evicts the pod abruptly, mid-write,
|
||||
# with zero coordination. minAvailable:1 on a 1-replica workload makes the
|
||||
# eviction API refuse *voluntary* evictions: `kubectl drain` blocks and
|
||||
# surfaces this pod, forcing a conscious decision (drain with
|
||||
# --disable-eviction, or scale down first) instead of yanking the live DB.
|
||||
# It does NOT block involuntary disruptions (node crash, OOM).
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: gitea
|
||||
namespace: apps
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gitea
|
||||
namespace: apps
|
||||
namespace: gitea
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea
|
||||
spec:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,7 +4,9 @@ metadata:
|
||||
name: gf-pg
|
||||
namespace: monitoring
|
||||
spec:
|
||||
instances: 1
|
||||
instances: 2
|
||||
smartShutdownTimeout: 10
|
||||
stopDelay: 60
|
||||
imageName: ghcr.io/cloudnative-pg/postgresql:16
|
||||
storage:
|
||||
storageClass: ceph-block
|
||||
|
||||
@@ -30,7 +30,9 @@ metadata:
|
||||
name: ha-pg
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
instances: 1
|
||||
instances: 2
|
||||
smartShutdownTimeout: 10
|
||||
stopDelay: 60
|
||||
imageName: ghcr.io/cloudnative-pg/postgresql:16
|
||||
storage:
|
||||
storageClass: ceph-block
|
||||
@@ -124,8 +126,7 @@ spec:
|
||||
# padding rather than data.
|
||||
#
|
||||
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
|
||||
# its volume are lost outright. This is a single-instance cluster, so the
|
||||
# archive is the only off-node copy.
|
||||
# its volumes are lost outright.
|
||||
archive_timeout: "30min"
|
||||
# Fewer checkpoints => fewer full-page writes, since a page is only
|
||||
# fully-logged on its first modification after a checkpoint.
|
||||
|
||||
@@ -59,8 +59,7 @@ spec:
|
||||
# padding rather than data.
|
||||
#
|
||||
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
|
||||
# its volume are lost outright. This is a single-instance cluster, so the
|
||||
# archive is the only off-node copy.
|
||||
# its volumes are lost outright.
|
||||
archive_timeout: "30min"
|
||||
# Write-reduction tuning. These databases are largely idle, and the
|
||||
# Postgres defaults favour throughput over storage that wears out.
|
||||
|
||||
@@ -61,6 +61,9 @@ spec:
|
||||
enabled: true
|
||||
storageClass: ceph-block
|
||||
size: 50Gi
|
||||
# A minAvailable:1 PDB on a single replica blocks every node drain.
|
||||
pdb:
|
||||
create: false
|
||||
# Bootstrap admin/org/bucket on the fresh PVC. Admin password + token come
|
||||
# from the influxdb-bootstrap Secret (OpenBao via ESO), deterministic across
|
||||
# rebuilds so the bootstrap token is the live admin token. `influx setup`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -57,10 +57,14 @@ spec:
|
||||
# the ClusterSecretStore goes InvalidProviderConfig and no
|
||||
# ExternalSecret can refresh.
|
||||
#
|
||||
# No window: below quorum the secret store is down and every
|
||||
# ExternalSecret stalls with it, so waiting only delays the page.
|
||||
# Short window: readiness is only a proxy for quorum. A one-second
|
||||
# node NotReady flap makes the node lifecycle controller mark that
|
||||
# node's pods not-ready for ~30s while raft keeps its leader and
|
||||
# serves (issue #920, during a rolling reboot with one replica
|
||||
# already evicted). A really sealed or crashed majority persists,
|
||||
# and existing Secrets keep their last values, so 2m costs little.
|
||||
expr: max by (namespace, statefulset) (kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"}) < 2
|
||||
for: 0m
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
|
||||
@@ -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
|
||||
@@ -305,6 +304,10 @@ spec:
|
||||
- matchers:
|
||||
- alertname = "KubeCPUOvercommit"
|
||||
receiver: 'null'
|
||||
# Helper alert that only drives the info inhibit rule below.
|
||||
- matchers:
|
||||
- alertname = "InfoInhibitor"
|
||||
receiver: 'null'
|
||||
# Flux notification-controller posts info-severity events
|
||||
# here (e.g. image-automation commits). Keep them visible
|
||||
# but don't repeat-spam every 12h on the same event.
|
||||
@@ -314,6 +317,23 @@ spec:
|
||||
group_wait: 1m
|
||||
group_interval: 10m
|
||||
repeat_interval: 24h
|
||||
# From kube-prometheus manifests/alertmanager-secret.yaml.
|
||||
inhibit_rules:
|
||||
- source_matchers:
|
||||
- severity = "critical"
|
||||
target_matchers:
|
||||
- severity =~ "warning|info"
|
||||
equal: ['namespace', 'alertname']
|
||||
- source_matchers:
|
||||
- severity = "warning"
|
||||
target_matchers:
|
||||
- severity = "info"
|
||||
equal: ['namespace', 'alertname']
|
||||
- source_matchers:
|
||||
- alertname = "InfoInhibitor"
|
||||
target_matchers:
|
||||
- severity = "info"
|
||||
equal: ['namespace']
|
||||
receivers:
|
||||
- name: email
|
||||
email_configs:
|
||||
|
||||
@@ -11,7 +11,6 @@ resources:
|
||||
- protonmail-bridge.yaml
|
||||
- ftp-scanner.yaml
|
||||
- syncthing.yaml
|
||||
- pdb.yaml
|
||||
- network-policy.yaml
|
||||
# pprl-pg CNPG Postgres + data/media/consume/redis PVCs (moved here from the
|
||||
# retired seed-paperless KS; each carries prune: disabled so ownership
|
||||
|
||||
@@ -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,37 +0,0 @@
|
||||
---
|
||||
# Eviction protection for the single-replica stateful paperless sidecars.
|
||||
#
|
||||
# protonmail-bridge and syncthing each hold state on a single RWO PVC and run
|
||||
# replicas:1. minAvailable:1 makes the eviction API refuse voluntary evictions,
|
||||
# so a node drain blocks and surfaces these pods instead of evicting them
|
||||
# abruptly. Involuntary disruptions (node crash) are not affected.
|
||||
#
|
||||
# NOTE: the other single-replica paperless pods (paperless, redis, gotenberg,
|
||||
# tika, ftp-scanner) are intentionally omitted — they are caches / stateless
|
||||
# converters where a blocking PDB would only wedge node drains with no
|
||||
# recoverability benefit.
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: protonmail-bridge
|
||||
namespace: paperless
|
||||
labels:
|
||||
app.kubernetes.io/name: protonmail-bridge
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: protonmail-bridge
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: syncthing
|
||||
namespace: paperless
|
||||
labels:
|
||||
app.kubernetes.io/name: syncthing
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: syncthing
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ metadata:
|
||||
name: tr-pg
|
||||
namespace: trmnl
|
||||
spec:
|
||||
instances: 1
|
||||
instances: 2
|
||||
smartShutdownTimeout: 10
|
||||
stopDelay: 60
|
||||
# PG 18, NOT the repo-standard 16. Terminus ships a committed
|
||||
@@ -47,8 +47,7 @@ spec:
|
||||
# padding rather than data.
|
||||
#
|
||||
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
|
||||
# its volume are lost outright. This is a single-instance cluster, so the
|
||||
# archive is the only off-node copy.
|
||||
# its volumes are lost outright.
|
||||
archive_timeout: "30min"
|
||||
# Write-reduction tuning. These databases are largely idle, and the
|
||||
# Postgres defaults favour throughput over storage that wears out.
|
||||
|
||||
@@ -134,7 +134,7 @@ spec:
|
||||
# is a device-bricking class of risk. Bumps are a reviewed, manual
|
||||
# act — see kubernetes/infrastructure/image-automation/README.md.
|
||||
- name: terminus
|
||||
image: ghcr.io/usetrmnl/terminus:0.74.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
image: ghcr.io/usetrmnl/terminus:0.75.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 2345
|
||||
@@ -197,7 +197,7 @@ spec:
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
- name: sidekiq
|
||||
image: ghcr.io/usetrmnl/terminus:0.74.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
image: ghcr.io/usetrmnl/terminus:0.75.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
command: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
# ServiceAccount is the OpenBao identity (auth/kubernetes/role/wiki-index); no
|
||||
# Kubernetes API access needed.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: wiki-index
|
||||
namespace: ai
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki-index
|
||||
---
|
||||
# Index (~17 MB JSON for 1700 notes). Survives restarts so a pod start does not
|
||||
# re-embed the wiki; a lost volume only costs one full sync (~5 min).
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: wiki-index-data
|
||||
namespace: ai
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ceph-block
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
# Embedding index over the Notes repo's wiki/ (ci/images/wiki-index). Syncs from
|
||||
# Gitea on push webhook and every RESYNC_SECONDS, embeds via wiki-embed, serves
|
||||
# POST /rank to ~/Notes/bin/wiki_rank.py through wiki-index.renner.uno.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: wiki-index
|
||||
namespace: ai
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki-index
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: wiki-index
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki-index
|
||||
spec:
|
||||
serviceAccountName: wiki-index
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
fsGroup: 65534
|
||||
seccompProfile:
|
||||
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
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
env:
|
||||
- name: GITEA_URL
|
||||
value: https://gitea.renner.uno
|
||||
- name: NOTES_REPO
|
||||
value: k.renner/notes
|
||||
- name: NOTES_BRANCH
|
||||
value: main
|
||||
- name: EMBED_URL
|
||||
value: http://wiki-embed.ai.svc.cluster.local:8080
|
||||
- name: BAO_ADDR
|
||||
value: http://openbao.openbao.svc.cluster.local:8200
|
||||
- name: BAO_ROLE
|
||||
value: wiki-index
|
||||
- name: BAO_SECRET_PATH
|
||||
value: homelab/data/apps/ai/wiki-index
|
||||
- name: INDEX_PATH
|
||||
value: /data/index.json
|
||||
- name: RESYNC_SECONDS
|
||||
value: "1800"
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
# Pure-Python cosine over 1700 x 1024 floats per query; the index itself is ~60 MB in memory.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 512Mi
|
||||
# /health: the process serves (liveness). /ready: an index exists; a cold
|
||||
# start on an empty PVC embeds every note (~5 min) before traffic arrives.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
periodSeconds: 5
|
||||
failureThreshold: 12
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
periodSeconds: 60
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: wiki-index-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: wiki-index
|
||||
namespace: ai
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki-index
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: wiki-index
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
# wiki-index.renner.uno — LAN-only (no dns.renner.uno/public label), machine
|
||||
# client (bin/wiki_rank.py), so Traefik BasicAuth like wiki-embed.renner.uno.
|
||||
# Gitea's webhook does not pass here; it targets the ClusterIP Service directly.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: wiki-index-basic-auth
|
||||
namespace: ai
|
||||
spec:
|
||||
basicAuth:
|
||||
secret: wiki-index-basic-auth
|
||||
realm: wiki-index
|
||||
---
|
||||
# OpenBao key apps/ai/wiki-index-basic-auth, field `users` = htpasswd line.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: wiki-index-basic-auth
|
||||
namespace: ai
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: wiki-index-basic-auth
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/ai/wiki-index-basic-auth
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: wiki-index
|
||||
namespace: ai
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
traefik.ingress.kubernetes.io/router.middlewares: ai-wiki-index-basic-auth@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- wiki-index.renner.uno
|
||||
secretName: wiki-index-tls
|
||||
rules:
|
||||
- host: wiki-index.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: wiki-index
|
||||
port:
|
||||
number: 8080
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- ingress.yaml
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
# Index snapshots per embedding model and the document catalog. A lost volume
|
||||
# costs one full sync from Gitea (~10 min).
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: wiki-data
|
||||
namespace: ai
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ceph-block
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
# Wiki service (konairius/Wiki): mirrors the Notes repo from Gitea, embeds
|
||||
# wiki/ via wiki-embed, serves /rank, /api/v1 and the MudBlazor UI. Adapters
|
||||
# are selected in appsettings (Wiki__* env overrides); secrets arrive as env
|
||||
# from ExternalSecrets, the pod has no OpenBao access.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: wiki
|
||||
namespace: ai
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: wiki
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
fsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: wiki
|
||||
# build.<unix-ts> tags from the Wiki repo's release pipeline (commit 6ff8d4b).
|
||||
image: gitea.renner.uno/konairius/wiki:build.1790250017
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
env:
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
- name: Wiki__Index__File__Directory
|
||||
value: /data
|
||||
- name: Wiki__NoteStore__Gitea__BaseUrl
|
||||
value: https://gitea.renner.uno
|
||||
- name: Wiki__NoteStore__Gitea__Repository
|
||||
value: k.renner/notes
|
||||
- name: Wiki__NoteStore__Gitea__Branch
|
||||
value: main
|
||||
- name: Wiki__Embeddings__Models__bge-m3__Url
|
||||
value: http://wiki-embed.ai.svc.cluster.local:8080
|
||||
# Pod networks: the CNP admits only the wiki-gate outpost and Gitea.
|
||||
- name: Auth__ReverseProxy__TrustedProxies__0
|
||||
value: fd10:244::/48
|
||||
- name: Auth__ReverseProxy__TrustedProxies__1
|
||||
value: 10.244.0.0/16
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: wiki-gitea
|
||||
- secretRef:
|
||||
name: wiki-api-keys
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 1Gi
|
||||
# /health: the process serves (liveness). /ready: catalog synced once and the
|
||||
# active index loaded; a cold start syncs the whole vault first (~10 min).
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
failureThreshold: 120
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
periodSeconds: 60
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: wiki-data
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: wiki
|
||||
namespace: ai
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: wiki
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
# Gitea read token and webhook secret, shared with the Python wiki-index until
|
||||
# that is retired. Secret keys are the configuration paths the host reads.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: wiki-gitea
|
||||
namespace: ai
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: wiki-gitea
|
||||
data:
|
||||
- secretKey: Wiki__NoteStore__Gitea__Token
|
||||
remoteRef:
|
||||
key: apps/ai/wiki-index
|
||||
property: gitea_token
|
||||
- secretKey: Wiki__Webhook__Secret
|
||||
remoteRef:
|
||||
key: apps/ai/wiki-index
|
||||
property: webhook_secret
|
||||
---
|
||||
# One API key per machine identity: OpenBao apps/ai/wiki-api-keys, field = name.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: wiki-api-keys
|
||||
namespace: ai
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: wiki-api-keys
|
||||
data:
|
||||
- secretKey: Auth__ApiKeys__workstation
|
||||
remoteRef:
|
||||
key: apps/ai/wiki-api-keys
|
||||
property: workstation
|
||||
@@ -0,0 +1,20 @@
|
||||
# ExternalName mirror so the ai-namespace Ingress can backend to the wiki-gate
|
||||
# outpost in the authentik namespace.
|
||||
#
|
||||
# browser → traefik (wiki.renner.uno)
|
||||
# → ak-outpost-wiki-gate (ExternalName → authentik ns)
|
||||
# → outpost validates the session + passkey step-up, sets X-Authentik-Username
|
||||
# → http://wiki.ai.svc.cluster.local:8080
|
||||
#
|
||||
# In-cluster clients (CI agents, the Gitea webhook) address the Service directly.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ak-outpost-wiki-gate
|
||||
namespace: ai
|
||||
spec:
|
||||
type: ExternalName
|
||||
externalName: ak-outpost-wiki-gate.authentik.svc.cluster.local
|
||||
ports:
|
||||
- port: 9000
|
||||
targetPort: 9000
|
||||
@@ -0,0 +1,26 @@
|
||||
# wiki.renner.uno — LAN-only (no dns.renner.uno/public label). Traefik backends
|
||||
# the wiki-gate outpost, never the Service; the ai CNP admits only that outpost.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: wiki
|
||||
namespace: ai
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- wiki.renner.uno
|
||||
secretName: wiki-tls
|
||||
rules:
|
||||
- host: wiki.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ak-outpost-wiki-gate
|
||||
port:
|
||||
number: 9000
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- externalsecrets.yaml
|
||||
- deployment.yaml
|
||||
- gate-service.yaml
|
||||
- ingress.yaml
|
||||
@@ -22,7 +22,9 @@ metadata:
|
||||
# migration deliberately withheld this while a rollback still existed.
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
instances: 1
|
||||
instances: 2
|
||||
smartShutdownTimeout: 10
|
||||
stopDelay: 60
|
||||
imageName: ghcr.io/cloudnative-pg/postgresql:16
|
||||
storage:
|
||||
storageClass: ceph-block
|
||||
@@ -91,8 +93,7 @@ spec:
|
||||
# padding rather than data.
|
||||
#
|
||||
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
|
||||
# its volume are lost outright. This is a single-instance cluster, so the
|
||||
# archive is the only off-node copy.
|
||||
# its volumes are lost outright.
|
||||
archive_timeout: "30min"
|
||||
resources:
|
||||
requests:
|
||||
|
||||
@@ -3,7 +3,6 @@ kind: Kustomization
|
||||
resources:
|
||||
- rbac.yaml
|
||||
- server.yaml
|
||||
- pdb.yaml
|
||||
- agent.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
@@ -22,7 +21,7 @@ resources:
|
||||
# OpenBao access, no NetworkPolicy needed. server.yaml/agent.yaml keep their
|
||||
# Reloader annotations; externalsecret.yaml keeps the 10m refreshInterval.
|
||||
# --- namespace migration, stages 1-2 (issue #433) ---
|
||||
# Stage 2 has moved the workloads above (server, agent, service, ingress, pdb,
|
||||
# Stage 2 has moved the workloads above (server, agent, service, ingress,
|
||||
# rbac, secrets, policies) into the `woodpecker` namespace and pointed the
|
||||
# server's DSN at wp-pg-rw.woodpecker.svc. Stage 3 retired the `apps`
|
||||
# originals; these four now describe the surviving cluster. History and the
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
# Eviction protection for the single-replica Woodpecker server StatefulSet.
|
||||
#
|
||||
# The server is SQLite-backed (single writer) on a single RWO PVC, so it is
|
||||
# deliberately replicas:1. minAvailable:1 makes the eviction API refuse
|
||||
# voluntary evictions so a node drain blocks and surfaces this pod rather
|
||||
# than yanking the live CI DB mid-write. Involuntary disruptions are not
|
||||
# affected.
|
||||
#
|
||||
# The selector MUST include app.kubernetes.io/component: server: the agent
|
||||
# Deployment in this same namespace shares app.kubernetes.io/name: woodpecker
|
||||
# (component: agent), and a name-only selector would wrongly also cover the
|
||||
# stateless, disposable agents.
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: woodpecker-server
|
||||
namespace: woodpecker
|
||||
labels:
|
||||
app.kubernetes.io/name: woodpecker
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: woodpecker
|
||||
app.kubernetes.io/component: server
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,22 @@
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: wiki-index
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 1m
|
||||
# First start on an empty PVC embeds every note (~5 min) before /health is 200.
|
||||
timeout: 20m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/wiki-index
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
- name: infrastructure-ceph-cluster
|
||||
# wiki-embed (the embedding backend) lives in the llama-server app.
|
||||
- name: llama-server
|
||||
@@ -0,0 +1,22 @@
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: wiki
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 1m
|
||||
# First start on an empty PVC syncs and embeds the whole vault before /ready is 200.
|
||||
timeout: 25m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/wiki
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
- name: infrastructure-ceph-cluster
|
||||
# wiki-embed (the embedding backend) lives in the llama-server app.
|
||||
- name: llama-server
|
||||
@@ -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; };
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ spec:
|
||||
tier: node
|
||||
app: bird
|
||||
spec:
|
||||
# Carries pod east-west once the routing rules land. Without it table 48
|
||||
# stays empty and the rules fall through to main, i.e. back to VLAN 40.
|
||||
# Owns table 47, where all pod traffic is routed. Without it the table
|
||||
# stays empty and the rules fall through to main, unseparated.
|
||||
priorityClassName: system-node-critical
|
||||
# Writes host routes and peers on host addresses.
|
||||
hostNetwork: true
|
||||
|
||||
@@ -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,11 +1,16 @@
|
||||
apiVersion: source.toolkit.fluxcd.io/v1
|
||||
kind: HelmRepository
|
||||
kind: OCIRepository
|
||||
metadata:
|
||||
name: k8s-gateway
|
||||
namespace: kube-system
|
||||
spec:
|
||||
interval: 24h
|
||||
url: https://k8s-gateway.github.io/k8s_gateway/
|
||||
url: oci://codeberg.org/k8s-gateway/charts/k8s-gateway
|
||||
ref:
|
||||
semver: ">=2.4.0"
|
||||
layerSelector:
|
||||
mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip"
|
||||
operation: copy
|
||||
---
|
||||
# In-cluster authoritative resolver for renner.uno.
|
||||
#
|
||||
@@ -28,14 +33,10 @@ metadata:
|
||||
namespace: kube-system
|
||||
spec:
|
||||
interval: 30m
|
||||
chart:
|
||||
spec:
|
||||
chart: k8s-gateway
|
||||
version: ">=2.4.0"
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: k8s-gateway
|
||||
namespace: kube-system
|
||||
chartRef:
|
||||
kind: OCIRepository
|
||||
name: k8s-gateway
|
||||
namespace: kube-system
|
||||
install:
|
||||
remediation:
|
||||
retries: 3
|
||||
|
||||
@@ -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.
|
||||
|
||||
+23
-23
@@ -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"
|
||||
@@ -148,3 +148,63 @@ spec:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
---
|
||||
# wiki: only the wiki-gate outpost (humans, after Authentik) and Gitea (push
|
||||
# webhook) may reach it; Traefik is not admitted, so the X-Authentik-Username
|
||||
# header the service trusts cannot arrive from the LAN. Gitea and wiki-embed
|
||||
# are reached through the namespace egress above; no OpenBao access.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: wiki
|
||||
namespace: ai
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: wiki
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": authentik
|
||||
goauthentik.io/outpost-name: wiki-gate
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea
|
||||
app.kubernetes.io/name: gitea
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
---
|
||||
# wiki-index: Traefik (LAN clients behind BasicAuth) and Gitea (push webhook)
|
||||
# may reach it; it reaches OpenBao for its credentials. Gitea itself is reached
|
||||
# through Traefik, which the namespace egress above already allows.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: wiki-index
|
||||
namespace: ai
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: wiki-index
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea
|
||||
app.kubernetes.io/name: gitea
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": openbao
|
||||
app.kubernetes.io/name: openbao
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8200"
|
||||
protocol: TCP
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
# 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 and the wiki service.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: wiki-index
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: wiki
|
||||
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
|
||||
@@ -31,6 +31,26 @@ spec:
|
||||
- ports:
|
||||
- port: "8200"
|
||||
protocol: TCP
|
||||
# wiki-index (ns ai) logs in via auth/kubernetes/role/wiki-index and reads
|
||||
# its Gitea token and webhook secret on every sync.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: wiki-index
|
||||
toPorts:
|
||||
- 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.)
|
||||
@@ -117,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
|
||||
@@ -132,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:
|
||||
@@ -140,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:
|
||||
@@ -157,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"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# OpenBao policy: wiki-index
|
||||
#
|
||||
# The wiki-index Deployment (namespace ai) reads its Gitea token and the
|
||||
# webhook HMAC secret on every use. Read-only on that one path.
|
||||
#
|
||||
# NOT applied by Flux. Apply ONCE with an admin token:
|
||||
#
|
||||
# bao policy write wiki-index kubernetes/infrastructure/openbao/policies/wiki-index.hcl
|
||||
# bao write auth/kubernetes/role/wiki-index \
|
||||
# bound_service_account_names=wiki-index \
|
||||
# bound_service_account_namespaces=ai \
|
||||
# policies=wiki-index ttl=15m
|
||||
#
|
||||
# Secret: homelab/apps/ai/wiki-index, keys `gitea_token` (read:repository on
|
||||
# the notes repo) and `webhook_secret` (the Gitea webhook's secret).
|
||||
#
|
||||
# Do NOT set token_no_default_policy=true (kv-v2 preflight needs the default policy).
|
||||
|
||||
path "homelab/data/apps/ai/wiki-index" {
|
||||
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
|
||||
|
||||
@@ -21,17 +21,29 @@ spec:
|
||||
modules:
|
||||
- name: pg_autoscaler
|
||||
enabled: true
|
||||
# Rotates daemon keys to aes256k for CVE-2025-30156 (#527). Opt-in; disabled
|
||||
# by default. Increment keyGeneration to rotate again.
|
||||
#
|
||||
# Daemon keys only — aes256k over the CSI mount path needs kernel 7.0+ and
|
||||
# these nodes run 6.18, so CSI stays on aes and the client-key checks stay
|
||||
# raised. keyType is unset so Rook picks it.
|
||||
# CephX keys on aes256k for CVE-2025-30156 (#527). Increment keyGeneration
|
||||
# to rotate again. CSI kernel mounts need aes256k in libceph: Talos
|
||||
# >= v1.13.10 backports it to 6.18 (siderolabs/pkgs 2da2cdc).
|
||||
security:
|
||||
cephx:
|
||||
# auth_allowed_ciphers. Every key must be aes256k first, or the cluster
|
||||
# locks itself out; recovery is in Rook's cephx-key-rotation doc.
|
||||
allowedCiphers:
|
||||
- aes256k
|
||||
daemon:
|
||||
keyRotationPolicy: KeyGeneration
|
||||
keyGeneration: 2
|
||||
csi:
|
||||
keyRotationPolicy: KeyGeneration
|
||||
keyGeneration: 2
|
||||
keyType: aes256k
|
||||
# 0 deletes prior keys. Set 1 before the next rotation so PVCs
|
||||
# mounted with the old key keep working until they are remounted.
|
||||
keepPriorKeyCountMax: 0
|
||||
rbdMirrorPeer:
|
||||
keyRotationPolicy: KeyGeneration
|
||||
keyGeneration: 2
|
||||
keyType: aes256k
|
||||
# CephFS cloner pool raised from the default 4. Velero clones a
|
||||
# VolumeSnapshot per PVC, and 4 threads stall a multi-PVC backup with
|
||||
# `rados: "all cloner threads are busy"`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -24,7 +24,7 @@ machine:
|
||||
# The Kubernetes version is a separate decision, moved by
|
||||
# `talosctl upgrade-k8s`.
|
||||
install:
|
||||
image: factory.talos.dev/metal-installer/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9:v1.13.9
|
||||
image: factory.talos.dev/metal-installer/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9:v1.13.10
|
||||
features:
|
||||
rbac: true
|
||||
# stableHostname is NOT here: it is the v1alpha1 spelling of what the
|
||||
@@ -58,6 +58,12 @@ machine:
|
||||
imageGCHighThresholdPercent: 70
|
||||
imageGCLowThresholdPercent: 60
|
||||
cluster:
|
||||
# etcd peers on node0, VLAN 46 (#576). Pinned: Talos never updates an
|
||||
# existing member's peer URLs, so changing this subnet means leave + rejoin
|
||||
# per member (talosctl etcd leave, reboot).
|
||||
etcd:
|
||||
advertisedSubnets:
|
||||
- 10.2.46.0/24
|
||||
# controlPlane.endpoint is NOT here: `talosctl gen config` takes it as its
|
||||
# second argument, and bootstrap.sh already passes https://k8s.renner.uno:6443
|
||||
# there. Setting it again in this patch restated the same value in a second
|
||||
|
||||
@@ -17,6 +17,8 @@ machine:
|
||||
# pod0 is a closed L2 fabric between the three nodes: statically
|
||||
# addressed, no gateway, nothing to learn from an RA.
|
||||
net.ipv6.conf.pod0.accept_ra: "0"
|
||||
# node0 is closed the same way.
|
||||
net.ipv6.conf.node0.accept_ra: "0"
|
||||
# ingress0 keeps RAs, at the default accept_ra of 2: an IPv6 default
|
||||
# router is a link-local address learned only from the RA, and with
|
||||
# net.ipv6.conf.all.forwarding=1 — which Cilium sets — Linux ignores RAs
|
||||
@@ -122,8 +124,8 @@ kind: LinkConfig
|
||||
name: uplink
|
||||
up: true
|
||||
---
|
||||
# Management, VLAN 40. Carries the node IP, etcd, the API and Ceph's public
|
||||
# network.
|
||||
# Management, VLAN 40: world↔node. The API VIP, admin access to the nodes, and
|
||||
# node egress (DNS, NTP, image pulls). Node↔node traffic is on node0 (#576).
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: mgmt0
|
||||
@@ -177,6 +179,19 @@ addresses:
|
||||
- address: 10.2.48.11/24
|
||||
- address: fd02:48::11/64
|
||||
---
|
||||
# Node network, VLAN 46: traffic among the three nodes themselves (#576) — the
|
||||
# node IP, etcd, apiserver↔kubelet. Closed like pod0: statically addressed, no
|
||||
# gateway, never routed via the UDM.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: node0
|
||||
vlanID: 46
|
||||
parent: uplink
|
||||
up: true
|
||||
addresses:
|
||||
- address: fd02:46::11/64
|
||||
- address: 10.2.46.11/24
|
||||
---
|
||||
# Ingress network, VLAN 47. World-to-pod: the BGP session, the next hop for
|
||||
# every advertised LoadBalancer VIP, and the masquerade source for pod egress.
|
||||
# Publicly routed, which is what separates it from pod0.
|
||||
|
||||
@@ -16,6 +16,7 @@ machine:
|
||||
# ingress0 keeps RAs: it learns its IPv6 router and its address from them,
|
||||
# and only the default route is held back — see k8s-1.yaml.
|
||||
net.ipv6.conf.pod0.accept_ra: "0"
|
||||
net.ipv6.conf.node0.accept_ra: "0"
|
||||
net.ipv6.conf.ingress0.accept_ra_defrtr: "0"
|
||||
# Answer ARP only on the link that owns the address — see k8s-1.yaml (#793).
|
||||
net.ipv4.conf.all.arp_ignore: "1"
|
||||
@@ -82,7 +83,7 @@ kind: LinkConfig
|
||||
name: uplink
|
||||
up: true
|
||||
---
|
||||
# Management, VLAN 40. Node IP, etcd, the API and Ceph's public network.
|
||||
# Management, VLAN 40: world↔node — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: mgmt0
|
||||
@@ -122,6 +123,17 @@ addresses:
|
||||
- address: 10.2.48.12/24
|
||||
- address: fd02:48::12/64
|
||||
---
|
||||
# Node network, VLAN 46 — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: node0
|
||||
vlanID: 46
|
||||
parent: uplink
|
||||
up: true
|
||||
addresses:
|
||||
- address: fd02:46::12/64
|
||||
- address: 10.2.46.12/24
|
||||
---
|
||||
# Ingress network, VLAN 47. World-to-pod, publicly routed, statically
|
||||
# addressed — see k8s-1.yaml for why it is not leased.
|
||||
apiVersion: v1alpha1
|
||||
|
||||
@@ -12,6 +12,7 @@ machine:
|
||||
# ingress0 keeps RAs: it learns its IPv6 router and its address from them,
|
||||
# and only the default route is held back — see k8s-1.yaml.
|
||||
net.ipv6.conf.pod0.accept_ra: "0"
|
||||
net.ipv6.conf.node0.accept_ra: "0"
|
||||
net.ipv6.conf.ingress0.accept_ra_defrtr: "0"
|
||||
# Answer ARP only on the link that owns the address — see k8s-1.yaml (#793).
|
||||
net.ipv4.conf.all.arp_ignore: "1"
|
||||
@@ -109,6 +110,17 @@ addresses:
|
||||
- address: 10.2.48.13/24
|
||||
- address: fd02:48::13/64
|
||||
---
|
||||
# Node network, VLAN 46 — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: node0
|
||||
vlanID: 46
|
||||
parent: uplink
|
||||
up: true
|
||||
addresses:
|
||||
- address: fd02:46::13/64
|
||||
- address: 10.2.46.13/24
|
||||
---
|
||||
# Ingress network, VLAN 47. World-to-pod, publicly routed, statically
|
||||
# addressed — see k8s-1.yaml for why it is not leased.
|
||||
apiVersion: v1alpha1
|
||||
|
||||
@@ -7,19 +7,15 @@
|
||||
# part of the config that could not be true anywhere but here.
|
||||
machine:
|
||||
kubelet:
|
||||
# Constrain kubelet's auto-picked node IP to the mgmt LAN. Without this,
|
||||
# kubelet picks the first non-loopback IP, which could land on the v30iot
|
||||
# NIC (ens19, 10.2.30.0/24). Control-plane traffic and hostNetwork pod IPs
|
||||
# must stay on mgmt LAN.
|
||||
# fd40::/64 is the mgmt LAN ULA, and it is listed so the Node object carries
|
||||
# an IPv6 InternalIP at all. Without one, Cilium falls back to detecting the
|
||||
# node's IPv6 from mgmt0 and picks the SLAAC address from the dynamic
|
||||
# provider prefix, which it then probes until the agent restarts (#789).
|
||||
# The GUA is not a control-plane address: BGP peers on fd40::/64 too.
|
||||
# The node IP lives on node0, VLAN 46: the closed node↔node network (#576).
|
||||
# Both families are listed so the Node object carries an IPv6 InternalIP at
|
||||
# all; without one Cilium detects the node's IPv6 itself and picks a SLAAC
|
||||
# address from the dynamic provider prefix (#789). Talos picks one address
|
||||
# per family, IPv4 first, whatever the order here.
|
||||
nodeIP:
|
||||
validSubnets:
|
||||
- 10.2.40.0/24
|
||||
- fd40::/64
|
||||
- fd02:46::/64
|
||||
- 10.2.46.0/24
|
||||
# The UDM on the ingress segment. Published here rather than as a route
|
||||
# because bird owns table 47 and originates the default from it; this is the
|
||||
# one place the address is stated.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user