Compare commits
122
Commits
@@ -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.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -408,3 +408,28 @@ steps:
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/bird:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/bird:latest
|
||||
|
||||
# Orchestrator for the monthly backup-verify CronJob (velero ns).
|
||||
- name: build-backup-verify
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/backup-verify
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/backup-verify/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- 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)
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/ci-backup-verify:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/ci-backup-verify:latest
|
||||
|
||||
@@ -35,18 +35,24 @@ when:
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
- name: promtool-rules-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
commands:
|
||||
- sh scripts/ci/test-promtool-rules.sh
|
||||
|
||||
- name: yaml-citations-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
commands:
|
||||
- sh scripts/ci/test-yaml-citations.sh
|
||||
|
||||
- name: backup-verify-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-10
|
||||
commands:
|
||||
- bash kubernetes/infrastructure/velero/backup-verify/tests/run.sh
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
when:
|
||||
# Deploy only on master (merge) + manual — never auto-deploy from a branch.
|
||||
- event: push
|
||||
branch: master
|
||||
path:
|
||||
- "ansible/roles/mqtt-bridge/**"
|
||||
- "ansible/plays/edge-mqtt-bridge.yml"
|
||||
- "ansible/inventory/group_vars/edge_host/**"
|
||||
- ".woodpecker/deploy-edge-mqtt-bridge.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-edge-mqtt-bridge"'
|
||||
|
||||
timeout: 30
|
||||
|
||||
steps:
|
||||
- name: deploy-edge-mqtt-bridge
|
||||
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
|
||||
environment:
|
||||
ANSIBLE_FORCE_COLOR: "true"
|
||||
ANSIBLE_HOST_KEY_CHECKING: "false"
|
||||
ANSIBLE_ROLES_PATH: "../roles:./roles"
|
||||
SSH_PRIVATE_KEY:
|
||||
from_secret: ssh_private_key
|
||||
VAULT_PASS:
|
||||
from_secret: vault_pass
|
||||
commands:
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
|
||||
- cd ansible
|
||||
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
|
||||
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
|
||||
# Local MQTT bridge on the edge hosts only (REN-1307); restarts only the
|
||||
# local mosquitto on edge-1, never the cluster broker.
|
||||
- ansible-playbook -i inventory/ plays/edge-mqtt-bridge.yml -v
|
||||
+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.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
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.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
+25
-285
@@ -1,301 +1,41 @@
|
||||
# PR review bot — event-driven. Woodpecker fires this on `pull_request` (which
|
||||
# covers BOTH "PR opened" and "new commits pushed to an open PR"), so a review
|
||||
# is produced the moment a PR appears or is updated — no polling, no cron.
|
||||
# PR review bot — per-repo STUB. All logic lives centrally in
|
||||
# k.renner/homelab: ci/agents/pr-review/run.sh (plus prompt.md,
|
||||
# settings.json and scripts/ci/). This file is deployed byte-identical into every
|
||||
# reviewed repo (k.renner/homelab, shelly-manager, authentik-samba-bridge,
|
||||
# homeassistant) and holds NO logic, so the copies cannot drift apart: change the
|
||||
# review in homelab and every repo picks it up on its next PR event. Only edit this
|
||||
# stub if the trigger, the image or the secrets change — then change all copies in
|
||||
# one go.
|
||||
#
|
||||
# This file is deployed UNCHANGED into every repo the fleet wants reviewed
|
||||
# (k.renner/homelab, shelly-manager, authentik-samba-bridge, homeassistant).
|
||||
# Woodpecker events are per-repo, so each repo triggers a review of ITS OWN PR
|
||||
# only; the triggering PR is identified from the CI event (CI_REPO +
|
||||
# CI_COMMIT_PULL_REQUEST) instead of the old hourly cross-repo discovery loop.
|
||||
# Because it runs on the PR event, Woodpecker's built-in clone hands the step
|
||||
# THIS repo already checked out at the PR head — so, unlike the old cron model,
|
||||
# there is no manual clone of the target repo (the source of an earlier token
|
||||
# leak); the agents read the checkout in the workspace directly.
|
||||
# Triggers on pull_request: PR opened AND new commits pushed to an open PR.
|
||||
# Approves only when the review found nothing red/yellow (see run.sh); humans merge.
|
||||
#
|
||||
# Prompts: the two agent prompts live once, in k.renner/homelab under
|
||||
# ci/agents/pr-review/. The homelab checkout already has them; the other repos
|
||||
# don't, so the step falls back to a shallow clone of public homelab@master.
|
||||
# One source of truth for the review rubric across all repos.
|
||||
# The central code is cloned from public homelab@master on every run, never taken
|
||||
# from the PR checkout, so a PR cannot alter the rubric or helper scripts it is
|
||||
# reviewed with.
|
||||
#
|
||||
# Model selection: no model id — and no model family — is baked into this file.
|
||||
# scripts/ci/fetch-models.sh pulls the LIVE catalog (Anthropic /v1/models, via
|
||||
# CLAUDE_CODE_OAUTH_TOKEN), and a triage agent reads the PR diff + stats and
|
||||
# picks whichever currently-available model best fits the review, judged on the
|
||||
# catalog's own capability metadata. A model released tomorrow is eligible the
|
||||
# day it appears. The shell validates the pick against the catalog snapshot taken
|
||||
# BEFORE the triage runs; if the fetch fails, the pick is rejected, or the review
|
||||
# is complex/security-sensitive (where the injectable pick is deliberately
|
||||
# ignored), it leaves the model unset and the review runs on the CLI's default
|
||||
# model. A wrong-way miss only over/under-spends one review.
|
||||
# Image: ci-claude:latest — the refresh pipeline keeps it on current Claude Code,
|
||||
# and a pinned tag would be the one thing in this file that diverges per repo.
|
||||
#
|
||||
# Posture: posted as a Gitea PR review, event COMMENT normally or
|
||||
# REQUEST_CHANGES on a serious defect. It NEVER approves — a bot must not
|
||||
# satisfy branch protection. Humans still merge.
|
||||
# Secrets (both must be granted the pull_request event):
|
||||
# claude_code_oauth_token -> CLAUDE_CODE_OAUTH_TOKEN
|
||||
# review_bot_token -> REVIEW_TOKEN (review-bot PAT, write:repository)
|
||||
#
|
||||
# Dedup (guards pipeline retries/restarts, not cron re-runs anymore): the PR is
|
||||
# skipped when review-bot already left a review carrying the marker
|
||||
# "<!-- pr-review:sha=<head_sha> -->" for the PR's CURRENT head commit. A pushed
|
||||
# commit changes the head sha and fires a fresh pull_request event -> fresh
|
||||
# review.
|
||||
#
|
||||
# Tokens:
|
||||
# CLAUDE_CODE_OAUTH_TOKEN (claude_code_oauth_token) — Claude subscription
|
||||
# OAuth, how the `claude` CLI authenticates (same as the other agents).
|
||||
# REVIEW_TOKEN (review_bot_token) — a dedicated `review-bot` Gitea account's
|
||||
# PAT, scope write:repository. Reads the PR + diff and POSTs the review, all
|
||||
# over the Gitea API (ephemeral curl Authorization headers — the token is
|
||||
# never written to disk; the repo is NOT cloned). review-bot must be a
|
||||
# collaborator (read) on every reviewed repo.
|
||||
# Both secrets MUST be granted the `pull_request` event, or Woodpecker will
|
||||
# withhold them from this pipeline. Because homelab is PUBLIC, it is also set
|
||||
# to require approval for forked-PR pipelines, so an untrusted fork cannot
|
||||
# rewrite this file to exfiltrate the tokens.
|
||||
#
|
||||
# Triggering: pull_request only — automatic on PR open + push-to-open-PR. To
|
||||
# re-review a PR, push a commit (the new head sha clears the dedup marker below
|
||||
# and earns a fresh review). There is deliberately no on-demand trigger for a
|
||||
# specific PR: Woodpecker exposes injected variables to YAML substitution and
|
||||
# `when` filters, NOT to a step's shell environment, so a PR=<n> re-review would
|
||||
# silently no-op (the step would find no PR number and exit green).
|
||||
# (Note: keep literal dollar-brace out of THIS file entirely, even in comments —
|
||||
# Woodpecker runs its variable substitution over the whole YAML text, comments
|
||||
# included, and an unparseable one errors the pipeline before any step runs.)
|
||||
# (Note: keep literal dollar-brace out of THIS file, even in comments — Woodpecker
|
||||
# runs its variable substitution over the whole YAML text.)
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: pr-review-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:latest
|
||||
pull: true
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
REVIEW_TOKEN:
|
||||
from_secret: review_bot_token
|
||||
commands:
|
||||
- |
|
||||
# ---- Resolve the single triggering PR --------------------------------
|
||||
# Woodpecker hands us the repo (CI_REPO, canonical owner/repo) and the PR
|
||||
# number (CI_COMMIT_PULL_REQUEST, a pull_request-event built-in). Build
|
||||
# /tmp/pr-review/worklist.jsonl with 0 or 1 entries; an empty file makes
|
||||
# the next block a no-op (the agent never starts).
|
||||
set -eu
|
||||
API="https://gitea.renner.uno/api/v1"
|
||||
AUTH="Authorization: token $$REVIEW_TOKEN"
|
||||
SLUG="$$CI_REPO"
|
||||
NUM="$${CI_COMMIT_PULL_REQUEST:-}"
|
||||
REVIEW_BOT_LOGIN="review-bot"
|
||||
mkdir -p /tmp/pr-review
|
||||
: > /tmp/pr-review/worklist.jsonl
|
||||
|
||||
if [ -z "$$NUM" ]; then
|
||||
echo "No PR number in CI env (event=$${CI_PIPELINE_EVENT:-?}) — nothing to review."
|
||||
exit 0
|
||||
fi
|
||||
echo "Triggered for $$SLUG#$$NUM (event $${CI_PIPELINE_EVENT:-?})."
|
||||
|
||||
if ! curl -sf -H "$$AUTH" "$$API/repos/$$SLUG/pulls/$$NUM" > /tmp/pr-review/pr.json; then
|
||||
echo "Cannot fetch $$SLUG#$$NUM (review-bot not a collaborator / PR gone?) — skipping."
|
||||
exit 0
|
||||
fi
|
||||
STATE=$$(jq -r '.state' /tmp/pr-review/pr.json)
|
||||
DRAFT=$$(jq -r '.draft // false' /tmp/pr-review/pr.json)
|
||||
TITLE=$$(jq -r '.title' /tmp/pr-review/pr.json)
|
||||
HEAD=$$(jq -r '.head.sha' /tmp/pr-review/pr.json)
|
||||
SHORT=$$(printf '%s' "$$HEAD" | cut -c1-8)
|
||||
|
||||
# Skip closed/merged, drafts, and WIP-titled PRs — not ready for review.
|
||||
case "$$STATE" in open) ;; *) echo "#$$NUM is $$STATE — skip."; exit 0;; esac
|
||||
case "$$DRAFT" in true) echo "#$$NUM draft — skip."; exit 0;; esac
|
||||
case "$$TITLE" in [Ww][Ii][Pp]:*|[Ww][Ii][Pp]\ *) echo "#$$NUM WIP — skip."; exit 0;; esac
|
||||
|
||||
# Dedup: already reviewed at THIS head sha? (guards re-runs / restarts)
|
||||
REVIEWED=$$(curl -sf -H "$$AUTH" "$$API/repos/$$SLUG/pulls/$$NUM/reviews?limit=50" \
|
||||
| jq -r --arg u "$$REVIEW_BOT_LOGIN" --arg m "pr-review:sha=$$HEAD" \
|
||||
'[.[] | select(.user.login == $$u) | select(.body | contains($$m))] | length' \
|
||||
|| echo 0)
|
||||
if [ "$${REVIEWED:-0}" -gt 0 ]; then
|
||||
echo "#$$NUM already reviewed at $$SHORT — skip."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq -c --arg slug "$$SLUG" \
|
||||
'{slug: $$slug, number: .number, head: .head.sha, base: .base.sha,
|
||||
title: .title, author: .user.login}' \
|
||||
/tmp/pr-review/pr.json >> /tmp/pr-review/worklist.jsonl
|
||||
echo "#$$NUM enqueued (head $$SHORT)."
|
||||
- |
|
||||
# ---- Clone, pick model, review, post ---------------------------------
|
||||
set -eu
|
||||
[ -s /tmp/pr-review/worklist.jsonl ] || { echo "Nothing to review."; exit 0; }
|
||||
API="https://gitea.renner.uno/api/v1"
|
||||
AUTH="Authorization: token $$REVIEW_TOKEN"
|
||||
WS="$$PWD" # workspace = this repo, checked out at the PR head by Woodpecker
|
||||
MAX_DIFF_LINES=20000
|
||||
|
||||
# Agent prompts: use the local homelab checkout if present, else clone
|
||||
# public homelab@master for them (one source of truth for the rubric).
|
||||
PROMPTS="$$WS/ci/agents/pr-review"
|
||||
HOMELAB="$$WS" # repo root holding ci/ AND scripts/ci/
|
||||
# NB: for a homelab PR, HOMELAB is the PR head checkout, so the
|
||||
# fetch-models.sh run below executes branch-supplied shell. It is wrapped
|
||||
# in `env -u REVIEW_TOKEN`, so the only credential it can see is
|
||||
# CLAUDE_CODE_OAUTH_TOKEN. This is not a new exposure regardless: on
|
||||
# pull_request Woodpecker already takes .woodpecker/*.yml itself from the
|
||||
# PR branch, so a branch can run arbitrary commands in this step anyway.
|
||||
if [ ! -f "$$PROMPTS/prompt.md" ]; then
|
||||
rm -rf /tmp/pr-review/homelab
|
||||
git clone --quiet --depth 1 https://gitea.renner.uno/k.renner/homelab \
|
||||
/tmp/pr-review/homelab
|
||||
PROMPTS=/tmp/pr-review/homelab/ci/agents/pr-review
|
||||
HOMELAB=/tmp/pr-review/homelab
|
||||
fi
|
||||
echo "Using prompts from $$PROMPTS"
|
||||
|
||||
gitea_post() { # method url datafile ; echoes+checks HTTP
|
||||
code=$$(curl -s -o /tmp/pr-review/resp.json -w '%{http_code}' \
|
||||
-X "$$1" -H "$$AUTH" -H "Content-Type: application/json" \
|
||||
--data @"$$3" "$$2")
|
||||
echo "$$1 $$2 -> HTTP $$code"
|
||||
if [ "$$code" -lt 200 ] || [ "$$code" -ge 300 ]; then
|
||||
echo "Gitea API error body:"; cat /tmp/pr-review/resp.json; echo; return 1
|
||||
fi
|
||||
}
|
||||
|
||||
while IFS= read -r ITEM; do
|
||||
SLUG=$$(printf '%s' "$$ITEM" | jq -r '.slug')
|
||||
NUM=$$(printf '%s' "$$ITEM" | jq -r '.number')
|
||||
HEAD=$$(printf '%s' "$$ITEM" | jq -r '.head')
|
||||
TITLE=$$(printf '%s' "$$ITEM" | jq -r '.title')
|
||||
SHORT=$$(printf '%s' "$$HEAD" | cut -c1-8)
|
||||
echo "=== Reviewing $$SLUG#$$NUM ($$SHORT) ==="
|
||||
D="/tmp/pr-review/$$NUM"
|
||||
rm -rf "$$D"; mkdir -p "$$D"
|
||||
|
||||
# PR metadata + unified diff (authenticated; private repos need it).
|
||||
curl -sf -H "$$AUTH" "$$API/repos/$$SLUG/pulls/$$NUM" > "$$D/pr.json"
|
||||
curl -sf -H "$$AUTH" "$$API/repos/$$SLUG/pulls/$$NUM.diff" > "$$D/diff.full.patch" \
|
||||
|| { echo "Could not fetch diff for $$SLUG#$$NUM — skipping."; continue; }
|
||||
FILES=$$(grep -c '^diff --git ' "$$D/diff.full.patch" || true)
|
||||
ADDED=$$(grep -c '^+[^+]' "$$D/diff.full.patch" || true)
|
||||
DELETED=$$(grep -c '^-[^-]' "$$D/diff.full.patch" || true)
|
||||
TOTAL=$$(wc -l < "$$D/diff.full.patch")
|
||||
if [ "$$TOTAL" -gt "$$MAX_DIFF_LINES" ]; then
|
||||
head -n "$$MAX_DIFF_LINES" "$$D/diff.full.patch" > "$$D/diff.patch"
|
||||
printf '\n... [diff truncated at %s of %s lines] ...\n' \
|
||||
"$$MAX_DIFF_LINES" "$$TOTAL" >> "$$D/diff.patch"
|
||||
else
|
||||
cp "$$D/diff.full.patch" "$$D/diff.patch"
|
||||
fi
|
||||
printf 'repo: %s\nPR: #%s\nfiles_changed: %s\nlines_added: %s\nlines_deleted: %s\ndiff_total_lines: %s\n' \
|
||||
"$$SLUG" "$$NUM" "$$FILES" "$$ADDED" "$$DELETED" "$$TOTAL" > "$$D/stats.txt"
|
||||
|
||||
# Cheaper inputs for the TRIAGE agent (which now runs on a capable
|
||||
# default model): the COMPLETE changed-file list, plus a diff capped far
|
||||
# smaller than the review's. Triage only gauges size/shape/sensitivity,
|
||||
# so it never needs the full (up to 20k-line) review diff — this keeps
|
||||
# "triage is cheap" true now that its model is no longer pinned to Haiku.
|
||||
grep '^diff --git ' "$$D/diff.full.patch" | sed 's|^diff --git a/||; s| b/.*$$||' \
|
||||
| sort -u > "$$D/files.txt"
|
||||
head -n 4000 "$$D/diff.patch" > "$$D/diff.triage.patch"
|
||||
|
||||
# No manual clone of the target repo: this runs on a pull_request
|
||||
# event, so Woodpecker's built-in clone already checked THIS repo out
|
||||
# at the PR head in $$WS — the agents (which cd into $$WS) read it
|
||||
# directly for context. The old cron model had to clone each target
|
||||
# repo by hand (its workspace was homelab@master), and that clone
|
||||
# embedded REVIEW_TOKEN in the URL, persisting a write-scoped PAT in
|
||||
# work/target/.git/config — readable by the review agent whose report
|
||||
# is posted publicly. Per-repo pull_request triggering makes the manual
|
||||
# clone redundant AND keeps the token off disk (it now lives only in
|
||||
# the ephemeral curl Authorization headers used for the API calls).
|
||||
|
||||
# Inputs the agents read live at ./.pr-review (symlink into $$D).
|
||||
rm -f "$$WS/.pr-review"; ln -s "$$D" "$$WS/.pr-review"
|
||||
|
||||
# --- Triage: an agent picks the model from the LIVE catalog, so no
|
||||
# model id and no model family is baked into this file.
|
||||
# fetch-models.sh asks the Anthropic /v1/models list (via the same
|
||||
# CLAUDE_CODE_OAUTH_TOKEN) and we hand the triage the result at
|
||||
# ./.pr-review/models.txt. If the fetch fails the file is empty and
|
||||
# MODEL stays empty below, so the review runs on the CLI default.
|
||||
if MODELS_TABLE=$$(env -u REVIEW_TOKEN sh "$$HOMELAB/scripts/ci/fetch-models.sh" --table); then
|
||||
printf '%s\n' "$$MODELS_TABLE" > "$$D/models.txt"
|
||||
echo "Live model catalog ($$(wc -l < "$$D/models.txt") models) written for triage."
|
||||
else
|
||||
: > "$$D/models.txt"
|
||||
echo "Model catalog fetch failed — triage/review will use the CLI default model."
|
||||
fi
|
||||
# env -u REVIEW_TOKEN: the agents never call Gitea (the shell does, via
|
||||
# $$AUTH), so the write:repository PAT must not sit in the agent's env
|
||||
# where a prompt-injected diff could read /proc/self/environ. Defence in
|
||||
# depth alongside the settings.json Read-sandbox. Triage itself runs on
|
||||
# the CLI default model (no --model) — we don't name a family even to
|
||||
# bootstrap the pick.
|
||||
cat "$$PROMPTS/triage-prompt.md" | env -u REVIEW_TOKEN claude \
|
||||
--permission-mode acceptEdits \
|
||||
--allowedTools "Read,Glob,Grep,Write" \
|
||||
--settings "$$PROMPTS/settings.json" \
|
||||
-p || echo "(triage agent exited non-zero)"
|
||||
# Use the triage's pick only if it is a currently-available id;
|
||||
# otherwise (hallucinated id, or empty catalog) leave MODEL empty so
|
||||
# the review runs on the CLI default — a run never dies on selection.
|
||||
MODEL=$$(jq -r '.model // empty' "$$D/triage.json" 2>/dev/null || true)
|
||||
CPLX=$$(jq -r '.complexity // "unknown"' "$$D/triage.json" 2>/dev/null || echo unknown)
|
||||
# triage.json is UNTRUSTED (the triage reads the attacker-controlled
|
||||
# diff and its Write is not path-scoped). valid-model.sh is the single,
|
||||
# unit-tested gate (scripts/ci/test-model-selection.sh): it echoes the id
|
||||
# only if it is a safe [A-Za-z0-9._-] token present in the catalog, else
|
||||
# nothing — so a value like "claude-x\n--dangerously-skip-permissions"
|
||||
# can never reach the unquoted --model expansion below. Empty -> default.
|
||||
# CRITICAL: validate against $$MODELS_TABLE — the catalog captured in a
|
||||
# shell variable BEFORE the triage ran — NOT the on-disk ./.pr-review/
|
||||
# models.txt, which the triage's unscoped Write could overwrite to smuggle
|
||||
# an attacker-chosen token past the membership check (the triage reads the
|
||||
# file, but only $$MODELS_TABLE is trusted). This mirrors pick-model.sh,
|
||||
# which validates its pick against its in-memory table for the same reason.
|
||||
# env -u REVIEW_TOKEN: like fetch-models.sh above, this runs a script from
|
||||
# $$HOMELAB (branch-supplied for a homelab PR) — it must not see the PAT.
|
||||
# `|| true`: keep the "a run never dies on selection" invariant even if
|
||||
# the script is absent (e.g. a PR that moves scripts/ci/).
|
||||
MODEL=$$(printf '%s\n' "$$MODELS_TABLE" | env -u REVIEW_TOKEN sh "$$HOMELAB/scripts/ci/valid-model.sh" "$$MODEL" || true)
|
||||
[ -n "$$MODEL" ] || echo "Triage model rejected or not in live catalog — using the CLI default model."
|
||||
# Defence in depth: for a complex / security-sensitive review, do NOT
|
||||
# trust the triage's pick (it read the attacker-controlled diff and could
|
||||
# be injected into naming the weakest catalog entry to get a shallow
|
||||
# review). Fall back to the CLI default — the strongest configured model.
|
||||
if [ "$$CPLX" = complex ] && [ -n "$$MODEL" ]; then
|
||||
echo "complex review — ignoring triage pick '$$MODEL', using the CLI default (strongest) model."
|
||||
MODEL=
|
||||
fi
|
||||
if [ -n "$$MODEL" ]; then echo "Triage: complexity=$$CPLX -> model=$$MODEL"; else echo "Triage: complexity=$$CPLX -> CLI default model"; fi
|
||||
|
||||
# --- Review with the chosen model ---
|
||||
( cd "$$WS" && cat "$$PROMPTS/prompt.md" | env -u REVIEW_TOKEN claude $${MODEL:+--model $$MODEL} \
|
||||
--permission-mode acceptEdits \
|
||||
--allowedTools "Read,Glob,Grep,Write" \
|
||||
--settings "$$PROMPTS/settings.json" \
|
||||
-p ) || echo "(review agent exited non-zero)"
|
||||
|
||||
if [ ! -s "$$D/report.md" ]; then
|
||||
echo "Agent wrote no report for $$SLUG#$$NUM — skipping post."
|
||||
continue
|
||||
fi
|
||||
EVENT=$$(jq -r '.event // "COMMENT"' "$$D/verdict.json" 2>/dev/null || echo COMMENT)
|
||||
case "$$EVENT" in REQUEST_CHANGES|COMMENT) ;; *) EVENT=COMMENT;; esac # never APPROVE
|
||||
|
||||
# Assemble the review body: header + report + marker footer.
|
||||
{
|
||||
printf '🤖 **Automated review** — model `%s` (complexity: %s), commit `%s`.\n\n' \
|
||||
"$${MODEL:-CLI default}" "$$CPLX" "$${HEAD}"
|
||||
printf '> Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review.\n\n---\n\n'
|
||||
cat "$$D/report.md"
|
||||
printf '\n\n<!-- pr-review:sha=%s -->\n' "$$HEAD"
|
||||
} > "$$D/body.md"
|
||||
jq -n --rawfile b "$$D/body.md" --arg e "$$EVENT" --arg c "$$HEAD" \
|
||||
'{body: $$b, event: $$e, commit_id: $$c}' > "$$D/review.json"
|
||||
gitea_post POST "$$API/repos/$$SLUG/pulls/$$NUM/reviews" "$$D/review.json" \
|
||||
&& echo "Posted $$EVENT review on $$SLUG#$$NUM." \
|
||||
|| echo "FAILED to post review on $$SLUG#$$NUM (continuing)."
|
||||
rm -f "$$WS/.pr-review"
|
||||
done < /tmp/pr-review/worklist.jsonl
|
||||
echo "PR review run complete."
|
||||
- rm -rf /tmp/pr-review-central
|
||||
- git clone --quiet --depth 1 https://gitea.renner.uno/k.renner/homelab /tmp/pr-review-central
|
||||
- sh /tmp/pr-review-central/ci/agents/pr-review/run.sh
|
||||
|
||||
@@ -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.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
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.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -17,7 +17,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: checkout-notes
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
pull: true
|
||||
environment:
|
||||
WIKI_BOT_TOKEN:
|
||||
@@ -54,7 +54,7 @@ steps:
|
||||
- bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- name: distill
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
pull: true
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
@@ -89,14 +89,14 @@ steps:
|
||||
-p
|
||||
|
||||
- name: audit
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
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.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
pull: true
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
@@ -145,7 +145,7 @@ steps:
|
||||
exit $$rc
|
||||
|
||||
- name: push-notes
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
pull: true
|
||||
environment:
|
||||
WIKI_BOT_TOKEN:
|
||||
|
||||
@@ -58,7 +58,8 @@ target `build-agent-runtime`), versions bumped by Renovate. Both also
|
||||
carry `claude-agent-acp` for the ACP engine, pinned and patched like
|
||||
Paperclip ships it, SDK override included
|
||||
(`ci/images/agent-runtime-common/`); bump it by hand
|
||||
together with Paperclip. All tools are on the default `PATH`.
|
||||
together with Paperclip. All tools are on the default `PATH`. Both add
|
||||
`tzdata` (the base has no zoneinfo); the default zone stays UTC.
|
||||
|
||||
| Image | `imageRegistry` | Adds |
|
||||
|---|---|---|
|
||||
|
||||
@@ -62,7 +62,6 @@ inventory change.
|
||||
|---|---|---|
|
||||
| Innomaker USB2CAN (Vitocal/VX3 + E380) | `heating_can` | `plays/install-heating-can.yml` |
|
||||
| Nabu Casa ZWA-2 Z-Wave radio | `zwave_gateway` | `plays/install-zwave-gateway.yml` |
|
||||
| FTDI serial power-meter head | `power_meter` | `plays/install-power-meter.yml` |
|
||||
|
||||
Add the host to the matching group, then run that playbook. They depend on
|
||||
the base play having run: all three need the local MQTT bridge, and two of
|
||||
@@ -87,7 +86,6 @@ kubectl -n home-assistant get secret zwave-js-session -o jsonpath='{.data.sessio
|
||||
|
||||
```yaml
|
||||
# vault.yml
|
||||
vault_edge_sml2mqtt_mqtt_password: "..."
|
||||
vault_edge_open3e_mqtt_password: "..."
|
||||
vault_edge_zwavejs_session_secret: "..."
|
||||
vault_edge_loki_password: "..." # dedicated edge-1 Loki push cred (see "Logging & eMMC")
|
||||
@@ -238,6 +236,21 @@ cluster-side change was needed**: one authenticates as the `open3e` user
|
||||
out). The local broker is `allow_anonymous` on loopback — the trust boundary
|
||||
is the host; nothing off-host can reach it.
|
||||
|
||||
**Bridge credential (REN-1307).** The two vault-password bridges are being
|
||||
replaced by one bridge as the register account `edge-1`
|
||||
(`kubernetes/apps/home-assistant/mqtt-accounts.yaml`). Its password is pushed
|
||||
by mqtt-account-controller to `edge-mqtt-cred` (`:8443`, the controller's
|
||||
esp32 API over HTTPS). That endpoint accepts only a client certificate from
|
||||
the MQTT CA with CN `mqtt-account-controller`; edge-1 holds the public CA and
|
||||
its own server key, nothing else. On a push it writes
|
||||
`/etc/mosquitto/bridge.d/edge-1.conf`, deletes `bridge.d/legacy.conf` and
|
||||
restarts the local mosquitto. The controller sets a new password whenever the
|
||||
bridge reports "not connected" (also after a reinstall), so no play run is
|
||||
needed for a rotation. `.woodpecker/deploy-edge-mqtt-bridge.yml` runs
|
||||
`plays/edge-mqtt-bridge.yml` on merge. Diagnosis: `journalctl -u
|
||||
edge-mqtt-cred`, and `mosquitto_sub -t '$SYS/broker/connection/edge-1/state'`
|
||||
(retained `1` = connected).
|
||||
|
||||
**eMMC:** the local broker writes **nothing** to flash — `persistence false`
|
||||
(retained/queued messages in RAM) and `log_dest stdout` (to the volatile
|
||||
journal → Loki). Do not add persistence or log-file settings to
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# Secret indirection — real values live in vault.yml (ansible-vault) next
|
||||
# to this file; see docs/edge-host.md for how to populate it from
|
||||
# the cluster secrets.
|
||||
edge_sml2mqtt_mqtt_password: "{{ vault_edge_sml2mqtt_mqtt_password }}"
|
||||
edge_open3e_mqtt_password: "{{ vault_edge_open3e_mqtt_password }}"
|
||||
edge_zwavejs_session_secret: "{{ vault_edge_zwavejs_session_secret }}"
|
||||
# Loki push credential (dedicated 'edge-1' user, Traefik basic-auth on the
|
||||
@@ -39,8 +38,8 @@ chrony_hwtimestamp: false
|
||||
|
||||
# --- Local MQTT contract -------------------------------------------------
|
||||
# Shared between the broker and its clients, so it belongs to the inventory
|
||||
# rather than to either side's role. mqtt-bridge listens here; heating-can and
|
||||
# power-meter-sml publish here; and the bridge authenticates to the cluster
|
||||
# rather than to either side's role. mqtt-bridge listens here; heating-can
|
||||
# publishes here; and the bridge authenticates to the cluster
|
||||
# broker AS the open3e user, so it needs that identity too.
|
||||
#
|
||||
# Keeping these in one role's defaults only worked while a single play loaded
|
||||
|
||||
@@ -41,13 +41,9 @@ edge-1.iot.renner.uno
|
||||
[heating_can]
|
||||
edge-1.iot.renner.uno
|
||||
|
||||
[power_meter]
|
||||
edge-1.iot.renner.uno
|
||||
|
||||
[edge_host:children]
|
||||
zwave_gateway
|
||||
heating_can
|
||||
power_meter
|
||||
|
||||
# NTP servers. edge-1 was added here so the role would survive the PVE hosts
|
||||
# being retired for bare-metal Talos, which cannot run chrony or keepalived
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
# Local MQTT bridge and its credential receiver only (REN-1307). Run by
|
||||
# .woodpecker/deploy-edge-mqtt-bridge.yml on merge, so a change to this role
|
||||
# reaches edge-1 without a play run by hand. The full base play
|
||||
# (install-edge-host.yml) carries the same role.
|
||||
- name: Converge the edge MQTT bridge
|
||||
hosts: edge_host
|
||||
become: true
|
||||
|
||||
# The SML power-meter reader (sml2mqtt) is retired for good: owner decision
|
||||
# 2026-09-19, grid data comes from the E380 on the CAN bus. Its role and
|
||||
# play are gone; this removes the leftover compose project from edge-1 on
|
||||
# the next pipeline run. Idempotent: a no-op once the directory is gone.
|
||||
pre_tasks:
|
||||
- name: Check for the retired sml2mqtt compose project
|
||||
ansible.builtin.stat:
|
||||
path: /opt/edge-host/sml2mqtt/docker-compose.yml
|
||||
register: edge_sml2mqtt_compose
|
||||
|
||||
- name: Stop the retired sml2mqtt container
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose down --remove-orphans
|
||||
chdir: /opt/edge-host/sml2mqtt
|
||||
when: edge_sml2mqtt_compose.stat.exists
|
||||
changed_when: true
|
||||
|
||||
- name: Remove the retired sml2mqtt directory
|
||||
ansible.builtin.file:
|
||||
path: /opt/edge-host/sml2mqtt
|
||||
state: absent
|
||||
|
||||
roles:
|
||||
- mqtt-bridge
|
||||
@@ -9,7 +9,6 @@
|
||||
#
|
||||
# plays/install-zwave-gateway.yml [zwave_gateway] ZWA-2 radio
|
||||
# plays/install-heating-can.yml [heating_can] Innomaker USB2CAN
|
||||
# plays/install-power-meter.yml [power_meter] FTDI meter head
|
||||
#
|
||||
# Splitting the roles without splitting the plays would have kept the coupling:
|
||||
# one play applying every role to one group is the monolith again, a layer up.
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
# Power-meter reader (sml2mqtt) for hosts with the FTDI serial meter head
|
||||
# attached.
|
||||
#
|
||||
# Targets [power_meter]: membership means the meter head is physically present.
|
||||
#
|
||||
# Requires the base play (plays/install-edge-host.yml) to have run: it needs
|
||||
# docker and publishes to the local MQTT bridge.
|
||||
- name: Install power-meter reader
|
||||
hosts: power_meter
|
||||
become: true
|
||||
|
||||
roles:
|
||||
- power-meter-sml
|
||||
@@ -10,3 +10,14 @@
|
||||
edge_mqtt_cluster_host: mqtt.renner.uno
|
||||
edge_mqtt_cluster_tls_port: 8883
|
||||
edge_mqtt_bridge_cafile: /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
# --- Bridge credential from mqtt-account-controller (REN-1307) ------------
|
||||
# The cluster account the bridge logs in as (register entry in
|
||||
# kubernetes/apps/home-assistant/mqtt-accounts.yaml). The controller pushes
|
||||
# its password to edge-mqtt-cred and rotates it when the bridge reports
|
||||
# "not connected"; there is no copy in ansible-vault.
|
||||
edge_mqtt_bridge_account: edge-1
|
||||
# HTTPS port of edge-mqtt-cred (esp32 device API, mTLS). Only a client
|
||||
# certificate from the MQTT CA with this CN is accepted.
|
||||
edge_mqtt_cred_port: 8443
|
||||
edge_mqtt_cred_allowed_cn: mqtt-account-controller
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
# Managed by ansible (roles/mqtt-bridge). Receives the cluster bridge password
|
||||
# from mqtt-account-controller (REN-1307) so no plaintext copy lives in
|
||||
# ansible-vault and no play run is needed to rotate it.
|
||||
#
|
||||
# Speaks the controller's esp32 device API over HTTPS:
|
||||
# GET /api/status -> {"mqtt": {"connected": bool}}
|
||||
# PUT /api/settings <- {"mqtt": {"user": "...", "pass": "..."}}
|
||||
# A client certificate from the MQTT CA with CN mqtt-account-controller is
|
||||
# required; nothing else may call it. The server key is generated on this
|
||||
# host and never leaves it (the controller pins it on first contact).
|
||||
#
|
||||
# PUT writes the bridge file (template + credentials), removes the legacy
|
||||
# vault-password bridges and restarts the local mosquitto. The password is
|
||||
# kept only in that 0640 root:mosquitto file.
|
||||
import grp
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
CONF = json.load(open(sys.argv[1]))
|
||||
WRITE = threading.Lock()
|
||||
|
||||
|
||||
def connected():
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["mosquitto_sub", "-h", "127.0.0.1", "-p", str(CONF["local_port"]),
|
||||
"-t", CONF["state_topic"], "-C", "1", "-W", "3"],
|
||||
capture_output=True, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return out.strip() == "1"
|
||||
|
||||
|
||||
def write_bridge(user, password):
|
||||
with open(CONF["template"]) as f:
|
||||
body = f.read()
|
||||
body += f"remote_username {user}\nremote_password {password}\n"
|
||||
d = os.path.dirname(CONF["bridge_file"])
|
||||
fd, tmp = tempfile.mkstemp(dir=d, prefix=".edge-mqtt-cred.")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(body)
|
||||
os.chown(tmp, 0, grp.getgrnam("mosquitto").gr_gid)
|
||||
os.chmod(tmp, 0o640)
|
||||
os.replace(tmp, CONF["bridge_file"])
|
||||
for legacy in CONF["legacy_files"]:
|
||||
if os.path.exists(legacy):
|
||||
os.remove(legacy)
|
||||
subprocess.run(["systemctl", "restart", "mosquitto"], check=True, timeout=60)
|
||||
|
||||
|
||||
def valid(s):
|
||||
# Controller passwords are random URL-safe tokens; anything with
|
||||
# whitespace or a control character would break the config line.
|
||||
return isinstance(s, str) and 0 < len(s) <= 256 and s.isprintable() and not any(c.isspace() for c in s)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def authorized(self):
|
||||
cert = self.connection.getpeercert() or {}
|
||||
cns = [v for rdn in cert.get("subject", ()) for k, v in rdn if k == "commonName"]
|
||||
if cns != [CONF["allowed_cn"]]:
|
||||
self.send_error(403)
|
||||
return False
|
||||
return True
|
||||
|
||||
def reply(self, code, obj=None):
|
||||
data = b"" if obj is None else json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
if data:
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self):
|
||||
if not self.authorized():
|
||||
return
|
||||
if self.path != "/api/status":
|
||||
return self.send_error(404)
|
||||
self.reply(200, {"mqtt": {"connected": connected()}})
|
||||
|
||||
def do_PUT(self):
|
||||
if not self.authorized():
|
||||
return
|
||||
if self.path != "/api/settings":
|
||||
return self.send_error(404)
|
||||
try:
|
||||
n = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < n <= 4096:
|
||||
raise ValueError("body size")
|
||||
m = json.loads(self.rfile.read(n))["mqtt"]
|
||||
user, password = m["user"], m["pass"]
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return self.send_error(400)
|
||||
if user != CONF["user"] or not valid(password):
|
||||
return self.send_error(400)
|
||||
with WRITE:
|
||||
write_bridge(user, password)
|
||||
self.log_message("bridge credentials for %s replaced, mosquitto restarted", user)
|
||||
self.reply(204)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("%s %s\n" % (self.address_string(), fmt % args))
|
||||
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
address_family = socket.AF_INET6
|
||||
|
||||
def server_bind(self):
|
||||
self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
|
||||
super().server_bind()
|
||||
|
||||
def get_request(self):
|
||||
# The TLS handshake runs in the request thread, with a timeout, so a
|
||||
# stalled peer cannot block the accept loop.
|
||||
sock, addr = self.socket.accept()
|
||||
sock.settimeout(10)
|
||||
return self.ctx.wrap_socket(sock, server_side=True, do_handshake_on_connect=False), addr
|
||||
|
||||
|
||||
def main():
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
ctx.load_cert_chain(CONF["cert"], CONF["key"])
|
||||
ctx.load_verify_locations(CONF["ca"])
|
||||
ctx.verify_mode = ssl.CERT_REQUIRED
|
||||
srv = Server(("::", CONF["port"]), Handler)
|
||||
srv.ctx = ctx
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBdTCCARygAwIBAgIUN8ne4k4Wif9O83GDyKiBP0Ma7EIwCgYIKoZIzj0EAwIw
|
||||
GTEXMBUGA1UEAxMOUmVubmVyIE1RVFQgQ0EwHhcNMjYxMDA2MTUzMDE1WhcNMzYx
|
||||
MDAzMTUzMDE1WjAZMRcwFQYDVQQDEw5SZW5uZXIgTVFUVCBDQTBZMBMGByqGSM49
|
||||
AgEGCCqGSM49AwEHA0IABOZhZF2JDoyvVg2oIXSbJAuAWDcoG22RxPA4oMKRMA/b
|
||||
XzIY566D2k6cVnWqbil9s/q4O2mj+Epo7plBw8NwuOOjQjBAMA4GA1UdDwEB/wQE
|
||||
AwICpDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQcqSDDiLZzhrvqq9f16Y08
|
||||
CO7QfzAKBggqhkjOPQQDAgNHADBEAiBFkxd9pXnqIcjReEcOTvCBZ3HxKu2jNQGL
|
||||
/le7gunTGgIgZapWAcu4awa+wmZcR/z2luOjD2GhIT15um14Z+sstJk=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -5,3 +5,9 @@
|
||||
ansible.builtin.service:
|
||||
name: mosquitto
|
||||
state: restarted
|
||||
|
||||
- name: Restart edge-mqtt-cred
|
||||
ansible.builtin.systemd:
|
||||
name: edge-mqtt-cred
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
||||
@@ -11,13 +11,39 @@
|
||||
name: mosquitto
|
||||
state: present
|
||||
|
||||
- name: Deploy local mosquitto bridge config
|
||||
- name: Create the bridge directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/mosquitto/bridge.d
|
||||
state: directory
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0750"
|
||||
|
||||
- name: Check for the controller-managed bridge file
|
||||
ansible.builtin.stat:
|
||||
path: "/etc/mosquitto/bridge.d/{{ edge_mqtt_bridge_account }}.conf"
|
||||
register: edge_mqtt_bridge_managed
|
||||
|
||||
# Until mqtt-account-controller has pushed the first password, keep the
|
||||
# vault-password bridges so the cluster never loses edge-1's data. Once the
|
||||
# managed file exists, never render them again (edge-mqtt-cred removed them).
|
||||
- name: Deploy legacy vault-password bridges
|
||||
ansible.builtin.template:
|
||||
src: edge-mqtt-bridge-legacy.conf.j2
|
||||
dest: /etc/mosquitto/bridge.d/legacy.conf
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0640" # carries the legacy cluster bridge credentials
|
||||
when: not edge_mqtt_bridge_managed.stat.exists
|
||||
notify: Restart mosquitto
|
||||
|
||||
- name: Deploy local mosquitto config
|
||||
ansible.builtin.template:
|
||||
src: edge-mqtt-bridge.conf.j2
|
||||
dest: /etc/mosquitto/mosquitto.conf
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0640" # carries the cluster bridge credentials
|
||||
mode: "0640"
|
||||
notify: Restart mosquitto
|
||||
|
||||
- name: Enable local mosquitto bridge
|
||||
@@ -25,3 +51,59 @@
|
||||
name: mosquitto
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# --- edge-mqtt-cred (REN-1307): receives the bridge password from
|
||||
# mqtt-account-controller. edge-1 holds only the public MQTT CA and its own
|
||||
# server key, generated here and never copied off the host.
|
||||
- name: Create edge-mqtt-cred config directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/edge-mqtt-cred
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Generate edge-mqtt-cred server key and certificate
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1
|
||||
-nodes -days 36500 -subj "/CN={{ inventory_hostname }}"
|
||||
-keyout /etc/edge-mqtt-cred/tls.key -out /etc/edge-mqtt-cred/tls.crt
|
||||
creates: /etc/edge-mqtt-cred/tls.key
|
||||
notify: Restart edge-mqtt-cred
|
||||
|
||||
- name: Deploy edge-mqtt-cred files
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- {src: edge-mqtt-cred.py, dest: /usr/local/sbin/edge-mqtt-cred, mode: "0755"}
|
||||
# Public CA of the cluster's MQTT client certificates
|
||||
# (kubernetes/infrastructure/post-crds/mqtt-ca.yaml), valid until 2036-10-03.
|
||||
- {src: mqtt-ca.crt, dest: /etc/edge-mqtt-cred/mqtt-ca.crt, mode: "0644"}
|
||||
notify: Restart edge-mqtt-cred
|
||||
|
||||
- name: Deploy edge-mqtt-cred config and bridge template
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- {src: edge-mqtt-cred.json.j2, dest: /etc/edge-mqtt-cred/config.json, mode: "0600"}
|
||||
# Read only on the controller's next push: a change here reaches the
|
||||
# running bridge when the password is next set.
|
||||
- {src: edge-1-bridge.conf.in.j2, dest: /etc/edge-mqtt-cred/bridge.conf.in, mode: "0600"}
|
||||
- {src: edge-mqtt-cred.service.j2, dest: /etc/systemd/system/edge-mqtt-cred.service, mode: "0644"}
|
||||
notify: Restart edge-mqtt-cred
|
||||
|
||||
- name: Enable edge-mqtt-cred
|
||||
ansible.builtin.systemd:
|
||||
name: edge-mqtt-cred
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Managed by roles/mqtt-bridge (REN-1307). edge-mqtt-cred appends
|
||||
# remote_username/remote_password to this and writes the result to
|
||||
# /etc/mosquitto/bridge.d/{{ edge_mqtt_bridge_account }}.conf. One bridge for
|
||||
# open3e, as the register account `{{ edge_mqtt_bridge_account }}`
|
||||
# (ACL readwrite open3e/#). sml2mqtt is retired (SML reader, 2026-09-19).
|
||||
connection {{ edge_mqtt_bridge_account }}
|
||||
address {{ edge_mqtt_cluster_host }}:{{ edge_mqtt_cluster_tls_port }}
|
||||
bridge_cafile {{ edge_mqtt_bridge_cafile }}
|
||||
bridge_insecure false
|
||||
remote_clientid edge1-bridge
|
||||
cleansession false
|
||||
try_private true
|
||||
restart_timeout 5 30
|
||||
queue_qos0_messages true
|
||||
# The bridge state goes to the LOCAL broker only (retained 1/0); edge-mqtt-cred
|
||||
# reads it to answer the controller's "connected?".
|
||||
notifications true
|
||||
notifications_local_only true
|
||||
notification_topic $SYS/broker/connection/{{ edge_mqtt_bridge_account }}/state
|
||||
topic open3e/# out 0
|
||||
topic open3e/cmnd in 0
|
||||
@@ -0,0 +1,21 @@
|
||||
# Managed by roles/mqtt-bridge. Legacy vault-password bridges, rendered only
|
||||
# while /etc/mosquitto/bridge.d/{{ edge_mqtt_bridge_account }}.conf does not
|
||||
# exist; edge-mqtt-cred deletes this file on the controller's first push
|
||||
# (REN-1307). Remove with the vault keys once edge-1 is on the register account.
|
||||
# --- Bridge 1: open3e datapoints + command topic, as the cluster `open3e`
|
||||
# user (its ACL is `readwrite open3e/#`, which covers publishing datapoints
|
||||
# and subscribing to open3e/cmnd). e3oncan also publishes under open3e/#.
|
||||
connection edge1-open3e
|
||||
address {{ edge_mqtt_cluster_host }}:{{ edge_mqtt_cluster_tls_port }}
|
||||
bridge_cafile {{ edge_mqtt_bridge_cafile }}
|
||||
bridge_insecure false
|
||||
remote_username {{ edge_open3e_mqtt_user }}
|
||||
remote_password {{ edge_open3e_mqtt_password }}
|
||||
remote_clientid edge1-bridge-open3e
|
||||
cleansession false
|
||||
try_private true
|
||||
notifications false
|
||||
restart_timeout 5 30
|
||||
queue_qos0_messages true
|
||||
topic open3e/# out 0
|
||||
topic open3e/cmnd in 0
|
||||
@@ -22,36 +22,9 @@ log_type notice
|
||||
listener 1883 127.0.0.1
|
||||
allow_anonymous true
|
||||
|
||||
# --- Bridge 1: open3e datapoints + command topic, as the cluster `open3e`
|
||||
# user (its ACL is `readwrite open3e/#`, which covers publishing datapoints
|
||||
# and subscribing to open3e/cmnd). e3oncan also publishes under open3e/#.
|
||||
connection edge1-open3e
|
||||
address {{ edge_mqtt_cluster_host }}:{{ edge_mqtt_cluster_tls_port }}
|
||||
bridge_cafile {{ edge_mqtt_bridge_cafile }}
|
||||
bridge_insecure false
|
||||
remote_username {{ edge_open3e_mqtt_user }}
|
||||
remote_password {{ edge_open3e_mqtt_password }}
|
||||
remote_clientid edge1-bridge-open3e
|
||||
cleansession false
|
||||
try_private true
|
||||
notifications false
|
||||
restart_timeout 5 30
|
||||
queue_qos0_messages true
|
||||
topic open3e/# out 0
|
||||
topic open3e/cmnd in 0
|
||||
|
||||
# --- Bridge 2: power-meter topics, as the cluster `sml2mqtt` user (ACL
|
||||
# `write sml2mqtt/#`). Publish-only, so no `in` topics.
|
||||
connection edge1-sml2mqtt
|
||||
address {{ edge_mqtt_cluster_host }}:{{ edge_mqtt_cluster_tls_port }}
|
||||
bridge_cafile {{ edge_mqtt_bridge_cafile }}
|
||||
bridge_insecure false
|
||||
remote_username sml2mqtt
|
||||
remote_password {{ edge_sml2mqtt_mqtt_password }}
|
||||
remote_clientid edge1-bridge-sml2mqtt
|
||||
cleansession false
|
||||
try_private true
|
||||
notifications false
|
||||
restart_timeout 5 30
|
||||
queue_qos0_messages true
|
||||
topic sml2mqtt/# out 0
|
||||
# --- Bridges to the cluster broker (REN-1307). bridge.d holds either
|
||||
# {{ edge_mqtt_bridge_account }}.conf, written by edge-mqtt-cred with the password
|
||||
# mqtt-account-controller pushed, or, until that first push, legacy.conf with
|
||||
# the two vault-password bridges. edge-mqtt-cred deletes legacy.conf when it
|
||||
# writes the new file.
|
||||
include_dir /etc/mosquitto/bridge.d
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{{ {
|
||||
"port": edge_mqtt_cred_port,
|
||||
"allowed_cn": edge_mqtt_cred_allowed_cn,
|
||||
"user": edge_mqtt_bridge_account,
|
||||
"cert": "/etc/edge-mqtt-cred/tls.crt",
|
||||
"key": "/etc/edge-mqtt-cred/tls.key",
|
||||
"ca": "/etc/edge-mqtt-cred/mqtt-ca.crt",
|
||||
"template": "/etc/edge-mqtt-cred/bridge.conf.in",
|
||||
"bridge_file": "/etc/mosquitto/bridge.d/" ~ edge_mqtt_bridge_account ~ ".conf",
|
||||
"legacy_files": ["/etc/mosquitto/bridge.d/legacy.conf"],
|
||||
"local_port": edge_mqtt_port,
|
||||
"state_topic": "$SYS/broker/connection/" ~ edge_mqtt_bridge_account ~ "/state",
|
||||
} | to_nice_json }}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Managed by roles/mqtt-bridge (REN-1307). See /usr/local/sbin/edge-mqtt-cred.
|
||||
[Unit]
|
||||
Description=Receives the MQTT bridge credential from mqtt-account-controller
|
||||
After=network-online.target mosquitto.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /usr/local/sbin/edge-mqtt-cred /etc/edge-mqtt-cred/config.json
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# Root only to write the bridge file and restart mosquitto.
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/etc/mosquitto/bridge.d
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
# Power-meter reader: sml2mqtt against the FTDI serial meter head.
|
||||
|
||||
# Service root. Kept per-role so the role is usable on its own.
|
||||
edge_base_dir: /opt/edge-host
|
||||
|
||||
# Own compose project directory. Unchanged from the path used under the
|
||||
# previous shared project.
|
||||
sml_base_dir: "{{ edge_base_dir }}/sml2mqtt"
|
||||
|
||||
# The compose file that used to hold both this service and zwave-js. Removed
|
||||
# once, so the fixed container_name is released for the new projects.
|
||||
edge_legacy_compose: "{{ edge_base_dir }}/docker-compose.yml"
|
||||
|
||||
# Stable by-id symlink -- see the zwave-gateway role.
|
||||
edge_stromzaehler_device: /dev/serial/by-id/usb-FTDI_FT230X_Basic_UART_DK0E1GP5-if00-port0
|
||||
|
||||
# Hand-pinned on purpose: critical meter bridge, deliberately excluded from
|
||||
# image automation.
|
||||
edge_sml2mqtt_image: spacemanspiff2007/sml2mqtt:3.6.0
|
||||
|
||||
# Secret from inventory/group_vars/edge_host/vault.yml:
|
||||
# edge_sml2mqtt_mqtt_password
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
- name: Restart sml2mqtt
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d --force-recreate
|
||||
chdir: "{{ sml_base_dir }}"
|
||||
changed_when: true
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
# Power-meter reader: sml2mqtt against the FTDI serial meter head.
|
||||
#
|
||||
# Its own compose project. It shared a compose file with the Z-Wave gateway,
|
||||
# which meant a change to either recreated both -- two unrelated services bound
|
||||
# to different USB devices. Data paths are unchanged.
|
||||
|
||||
- name: Create sml2mqtt directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ sml_base_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
# sml2mqtt reads only a config file and has no env override for the password,
|
||||
# so the rendered config carries it -- 0600, root-owned, same trust boundary as
|
||||
# the k8s init-container approach it replaced.
|
||||
- name: Deploy sml2mqtt config
|
||||
ansible.builtin.template:
|
||||
src: sml2mqtt-config.yml.j2
|
||||
dest: "{{ sml_base_dir }}/config.yml"
|
||||
mode: "0600"
|
||||
notify: Restart sml2mqtt
|
||||
|
||||
- name: Deploy sml2mqtt compose project
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ sml_base_dir }}/docker-compose.yml"
|
||||
mode: "0644"
|
||||
notify: Restart sml2mqtt
|
||||
|
||||
- name: Pull sml2mqtt image
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose pull
|
||||
chdir: "{{ sml_base_dir }}"
|
||||
changed_when: false
|
||||
|
||||
# Tolerates a missing USB device, as for the Z-Wave gateway.
|
||||
- name: Start sml2mqtt
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d
|
||||
chdir: "{{ sml_base_dir }}"
|
||||
register: sml_compose_up
|
||||
failed_when: >-
|
||||
sml_compose_up.rc != 0
|
||||
and 'error gathering device information' not in sml_compose_up.stderr
|
||||
changed_when: >-
|
||||
'Started' in sml_compose_up.stderr or 'Created' in sml_compose_up.stderr
|
||||
or 'Started' in sml_compose_up.stdout or 'Creating' in sml_compose_up.stdout
|
||||
|
||||
- name: Warn if sml2mqtt is waiting for its USB device
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
sml2mqtt could not start -- the FTDI meter head is not present:
|
||||
{{ sml_compose_up.stderr_lines | select('search', 'device') | list }}
|
||||
when: sml_compose_up.rc != 0
|
||||
@@ -1,21 +0,0 @@
|
||||
# Managed by ansible (roles/power-meter-sml).
|
||||
#
|
||||
# Its own compose project -- see the role tasks for why it no longer shares a
|
||||
# file with the Z-Wave gateway.
|
||||
#
|
||||
# Outbound-only (serial in, MQTT out). Host networking sidesteps the docker
|
||||
# NAT66 gap: the broker LB is IPv6-first and bridge-network v6 SYNs leave
|
||||
# unmasqueraded and die. The host stack reaches it fine (open3e proves it
|
||||
# continuously).
|
||||
services:
|
||||
sml2mqtt:
|
||||
image: {{ edge_sml2mqtt_image }}
|
||||
container_name: sml2mqtt
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
# Serial exclusivity: this is the ONLY reader of the meter port --
|
||||
# HA's edl21 integration stays disabled.
|
||||
devices:
|
||||
- "{{ edge_stromzaehler_device }}:/dev/stromzaehler"
|
||||
volumes:
|
||||
- ./config.yml:/sml2mqtt/config.yml:ro
|
||||
@@ -1,32 +0,0 @@
|
||||
# Managed by ansible (roles/power-meter-sml). Ported 1:1 from the k8s ConfigMap
|
||||
# sml2mqtt-config; only host and password delivery differ (broker is now
|
||||
# reached via its LoadBalancer hostname, password rendered by ansible
|
||||
# instead of an init container).
|
||||
logging:
|
||||
level: INFO
|
||||
file: stdout # 'stdout' -> docker logs (NOT '' — that opens the dir)
|
||||
|
||||
mqtt:
|
||||
connection:
|
||||
identifier: sml2mqtt-meter
|
||||
host: {{ edge_mqtt_host }}
|
||||
port: {{ edge_mqtt_port }}
|
||||
user: sml2mqtt
|
||||
password: {{ edge_sml2mqtt_mqtt_password }}
|
||||
topic prefix: sml2mqtt
|
||||
defaults:
|
||||
qos: 0
|
||||
retain: false
|
||||
last will:
|
||||
topic: status
|
||||
|
||||
general:
|
||||
Wh in kWh: false # keep raw Wh to match edl21 history — HA long-term
|
||||
# stats continue with zero unit discontinuity
|
||||
republish after: 120 # re-emit unchanged values every 120 s
|
||||
|
||||
inputs:
|
||||
- type: serial
|
||||
url: /dev/stromzaehler # FTDI FT230X, mapped by docker compose
|
||||
timeout: 3
|
||||
# 9600 8N1 are sml2mqtt defaults for SML/EDL21 — no override needed.
|
||||
@@ -54,16 +54,35 @@ Look for, in priority order:
|
||||
|
||||
Ground every finding in a specific location: `path:line` (or a hunk) from the
|
||||
diff or the checkout. **Do not invent** bugs, file paths, or behavior you have
|
||||
not confirmed by reading the code. The whole repo is on disk in your working
|
||||
directory, so verify: if a judgement depends on a caller, the rest of a
|
||||
function, or config elsewhere, open it. If after checking you still cannot
|
||||
confirm a suspicion, say so explicitly and give the one check that would settle
|
||||
it — do not state a hypothesis as fact. A concern you could have checked by
|
||||
reading the checkout but did not is a weakness in your review.
|
||||
not confirmed.
|
||||
|
||||
**Verify every claim yourself before you write it down. Never hand the
|
||||
verification to the author.** "I could not find X", "X may not exist", "this is
|
||||
unverified" is not a finding — it is unfinished work. For each suspicion:
|
||||
|
||||
1. Check the checkout: callers, the rest of the function, config elsewhere,
|
||||
existing uses of the same construct, the repo's `CLAUDE.md`/`README`.
|
||||
Shallow read-only clones of the other fleet repos that are publicly
|
||||
readable (at least `homelab`) are under `./.pr-review/context/<name>/` —
|
||||
look there when the change depends on how a sibling repo does something.
|
||||
A repo missing there simply was not readable; do not treat that as a finding.
|
||||
2. If it depends on an external API, syntax, device behavior or product
|
||||
version (a Home Assistant trigger/action, a Shelly generation, a Kubernetes
|
||||
field, a library call), read the authoritative documentation or source with
|
||||
WebFetch/WebSearch (official docs, the project's own repo or README). Your
|
||||
training data is NOT evidence: this repo targets current releases that may
|
||||
postdate it, and "I have not seen this syntax" says nothing about whether
|
||||
it exists.
|
||||
3. Only after both: state what you found, with the source (`path:line` or URL).
|
||||
|
||||
If after a real attempt you still cannot settle it, list it under
|
||||
"Unverified" with what you checked and why that was not enough. An unverified
|
||||
suspicion is never Blocking.
|
||||
|
||||
Distinguish severity honestly. A blocking issue is a concrete defect — a bug
|
||||
that will misbehave, a security hole, or a breaking change — that you can point
|
||||
at. Style preferences and optional improvements are not blocking.
|
||||
that will misbehave, a security hole, or a breaking change — that you
|
||||
**verified** and can point at. Style preferences, optional improvements and
|
||||
anything unverified are not blocking.
|
||||
|
||||
## Output
|
||||
|
||||
@@ -84,6 +103,11 @@ Structure it as:
|
||||
why it is wrong. Omit this whole section if there are none — and say so in the
|
||||
Summary.>
|
||||
|
||||
## ⚪ Unverified
|
||||
|
||||
<Suspicions you could not settle after checking the checkout and the docs, each
|
||||
with what you checked. Omit if none.>
|
||||
|
||||
## 🟡 Suggestions
|
||||
|
||||
<Non-blocking improvements worth considering, each anchored to a location.>
|
||||
@@ -110,17 +134,21 @@ even if the PR added one; refer to it by location and flag it as blocking.
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "COMMENT | REQUEST_CHANGES",
|
||||
"event": "APPROVE | COMMENT | REQUEST_CHANGES",
|
||||
"headline": "<short one-line summary of the outcome>"
|
||||
}
|
||||
```
|
||||
|
||||
- Use **`REQUEST_CHANGES`** only when your report lists at least one real
|
||||
**Blocking** issue.
|
||||
- Use **`COMMENT`** otherwise (including when you have only suggestions/nits, or
|
||||
the PR looks good).
|
||||
- You cannot approve — there is no APPROVE option, by design. A clean PR gets a
|
||||
`COMMENT` review saying so.
|
||||
- Use **`APPROVE`** when the report has no 🔴 Blocking, no 🟡 Suggestions and no
|
||||
⚪ Unverified section — i.e. you checked the change and found nothing to
|
||||
raise. Nits (🟢) alone do not prevent it.
|
||||
- Use **`COMMENT`** otherwise.
|
||||
- The pipeline sets the final review event from the sections present in your
|
||||
report, so keep the section headings exactly as above and omit a section
|
||||
entirely when it is empty. Do not add a 🟡/🔴/⚪ heading unless you have a
|
||||
finding under it.
|
||||
|
||||
If `pr.json` or `diff.patch` is missing or empty, write a `report.md` stating
|
||||
you had no diff to review and a `verdict.json` with `event: "COMMENT"`, then
|
||||
|
||||
Executable
+191
@@ -0,0 +1,191 @@
|
||||
#!/bin/sh
|
||||
# PR review bot — the ONE implementation, run by every reviewed repo's
|
||||
# .woodpecker/pr-review.yml stub (k.renner/homelab, shelly-manager,
|
||||
# authentik-samba-bridge, homeassistant). Edit it here only; the stubs hold no
|
||||
# logic. Design notes (event-driven trigger, dedup marker, tokens, model
|
||||
# selection, posture) are in the stub and in prompt.md.
|
||||
#
|
||||
# ---- Resolve the single triggering PR --------------------------------
|
||||
# Woodpecker hands us the repo (CI_REPO, canonical owner/repo) and the PR
|
||||
# number (CI_COMMIT_PULL_REQUEST, a pull_request-event built-in). Build
|
||||
# /tmp/pr-review/worklist.jsonl with 0 or 1 entries; an empty file makes
|
||||
# the next block a no-op (the agent never starts).
|
||||
set -eu
|
||||
API="https://gitea.renner.uno/api/v1"
|
||||
AUTH="Authorization: token $REVIEW_TOKEN"
|
||||
SLUG="$CI_REPO"
|
||||
NUM="${CI_COMMIT_PULL_REQUEST:-}"
|
||||
REVIEW_BOT_LOGIN="review-bot"
|
||||
mkdir -p /tmp/pr-review
|
||||
: > /tmp/pr-review/worklist.jsonl
|
||||
|
||||
if [ -z "$NUM" ]; then
|
||||
echo "No PR number in CI env (event=${CI_PIPELINE_EVENT:-?}) — nothing to review."
|
||||
exit 0
|
||||
fi
|
||||
echo "Triggered for $SLUG#$NUM (event ${CI_PIPELINE_EVENT:-?})."
|
||||
|
||||
if ! curl -sf -H "$AUTH" "$API/repos/$SLUG/pulls/$NUM" > /tmp/pr-review/pr.json; then
|
||||
echo "Cannot fetch $SLUG#$NUM (review-bot not a collaborator / PR gone?) — skipping."
|
||||
exit 0
|
||||
fi
|
||||
STATE=$(jq -r '.state' /tmp/pr-review/pr.json)
|
||||
DRAFT=$(jq -r '.draft // false' /tmp/pr-review/pr.json)
|
||||
TITLE=$(jq -r '.title' /tmp/pr-review/pr.json)
|
||||
HEAD=$(jq -r '.head.sha' /tmp/pr-review/pr.json)
|
||||
SHORT=$(printf '%s' "$HEAD" | cut -c1-8)
|
||||
|
||||
# Skip closed/merged, drafts, and WIP-titled PRs — not ready for review.
|
||||
case "$STATE" in open) ;; *) echo "#$NUM is $STATE — skip."; exit 0;; esac
|
||||
case "$DRAFT" in true) echo "#$NUM draft — skip."; exit 0;; esac
|
||||
case "$TITLE" in [Ww][Ii][Pp]:*|[Ww][Ii][Pp]\ *) echo "#$NUM WIP — skip."; exit 0;; esac
|
||||
|
||||
# Dedup: already reviewed at THIS head sha? (guards re-runs / restarts)
|
||||
REVIEWED=$(curl -sf -H "$AUTH" "$API/repos/$SLUG/pulls/$NUM/reviews?limit=50" \
|
||||
| jq -r --arg u "$REVIEW_BOT_LOGIN" --arg m "pr-review:sha=$HEAD" \
|
||||
'[.[] | select(.user.login == $u) | select(.body | contains($m))] | length' \
|
||||
|| echo 0)
|
||||
if [ "${REVIEWED:-0}" -gt 0 ]; then
|
||||
echo "#$NUM already reviewed at $SHORT — skip."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq -c --arg slug "$SLUG" \
|
||||
'{slug: $slug, number: .number, head: .head.sha, base: .base.sha,
|
||||
title: .title, author: .user.login}' \
|
||||
/tmp/pr-review/pr.json >> /tmp/pr-review/worklist.jsonl
|
||||
echo "#$NUM enqueued (head $SHORT)."
|
||||
# ---- Clone, pick model, review, post ---------------------------------
|
||||
set -eu
|
||||
[ -s /tmp/pr-review/worklist.jsonl ] || { echo "Nothing to review."; exit 0; }
|
||||
API="https://gitea.renner.uno/api/v1"
|
||||
AUTH="Authorization: token $REVIEW_TOKEN"
|
||||
WS="$PWD" # workspace = this repo, checked out at the PR head by Woodpecker
|
||||
MAX_DIFF_LINES=20000
|
||||
|
||||
# This script, the prompts and scripts/ci/ all come from the homelab@master
|
||||
# clone the per-repo stub made (see .woodpecker/pr-review.yml) — never from the
|
||||
# PR checkout, so a PR cannot change the rubric or the helper scripts it is
|
||||
# reviewed with.
|
||||
PROMPTS="$(cd "$(dirname "$0")" && pwd)"
|
||||
HOMELAB="$(cd "$PROMPTS/../../.." && pwd)" # repo root holding ci/ AND scripts/ci/
|
||||
echo "Using prompts from $PROMPTS"
|
||||
|
||||
gitea_post() { # method url datafile ; echoes+checks HTTP
|
||||
code=$(curl -s -o /tmp/pr-review/resp.json -w '%{http_code}' \
|
||||
-X "$1" -H "$AUTH" -H "Content-Type: application/json" \
|
||||
--data @"$3" "$2")
|
||||
echo "$1 $2 -> HTTP $code"
|
||||
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
|
||||
echo "Gitea API error body:"; cat /tmp/pr-review/resp.json; echo; return 1
|
||||
fi
|
||||
}
|
||||
|
||||
while IFS= read -r ITEM; do
|
||||
SLUG=$(printf '%s' "$ITEM" | jq -r '.slug')
|
||||
NUM=$(printf '%s' "$ITEM" | jq -r '.number')
|
||||
HEAD=$(printf '%s' "$ITEM" | jq -r '.head')
|
||||
TITLE=$(printf '%s' "$ITEM" | jq -r '.title')
|
||||
SHORT=$(printf '%s' "$HEAD" | cut -c1-8)
|
||||
echo "=== Reviewing $SLUG#$NUM ($SHORT) ==="
|
||||
D="/tmp/pr-review/$NUM"
|
||||
rm -rf "$D"; mkdir -p "$D"
|
||||
|
||||
# PR metadata + unified diff (authenticated; private repos need it).
|
||||
curl -sf -H "$AUTH" "$API/repos/$SLUG/pulls/$NUM" > "$D/pr.json"
|
||||
curl -sf -H "$AUTH" "$API/repos/$SLUG/pulls/$NUM.diff" > "$D/diff.full.patch" \
|
||||
|| { echo "Could not fetch diff for $SLUG#$NUM — skipping."; continue; }
|
||||
FILES=$(grep -c '^diff --git ' "$D/diff.full.patch" || true)
|
||||
ADDED=$(grep -c '^+[^+]' "$D/diff.full.patch" || true)
|
||||
DELETED=$(grep -c '^-[^-]' "$D/diff.full.patch" || true)
|
||||
TOTAL=$(wc -l < "$D/diff.full.patch")
|
||||
if [ "$TOTAL" -gt "$MAX_DIFF_LINES" ]; then
|
||||
head -n "$MAX_DIFF_LINES" "$D/diff.full.patch" > "$D/diff.patch"
|
||||
printf '\n... [diff truncated at %s of %s lines] ...\n' \
|
||||
"$MAX_DIFF_LINES" "$TOTAL" >> "$D/diff.patch"
|
||||
else
|
||||
cp "$D/diff.full.patch" "$D/diff.patch"
|
||||
fi
|
||||
printf 'repo: %s\nPR: #%s\nfiles_changed: %s\nlines_added: %s\nlines_deleted: %s\ndiff_total_lines: %s\n' \
|
||||
"$SLUG" "$NUM" "$FILES" "$ADDED" "$DELETED" "$TOTAL" > "$D/stats.txt"
|
||||
|
||||
# No manual clone of the target repo: this runs on a pull_request
|
||||
# event, so Woodpecker's built-in clone already checked THIS repo out
|
||||
# at the PR head in $WS — the agents (which cd into $WS) read it
|
||||
# directly for context. The old cron model had to clone each target
|
||||
# repo by hand (its workspace was homelab@master), and that clone
|
||||
# embedded REVIEW_TOKEN in the URL, persisting a write-scoped PAT in
|
||||
# work/target/.git/config — readable by the review agent whose report
|
||||
# is posted publicly. Per-repo pull_request triggering makes the manual
|
||||
# clone redundant AND keeps the token off disk (it now lives only in
|
||||
# the ephemeral curl Authorization headers used for the API calls).
|
||||
|
||||
# Inputs the agents read live at ./.pr-review (symlink into $D).
|
||||
rm -f "$WS/.pr-review"; ln -s "$D" "$WS/.pr-review"
|
||||
|
||||
# Cross-repo context for the review agent: shallow, UNAUTHENTICATED clones of
|
||||
# the other fleet repos at ./.pr-review/context/<name> (a change here often
|
||||
# depends on how a sibling repo does it). No credential is involved, so only
|
||||
# publicly readable repos come through; a repo that needs auth fails fast
|
||||
# (GIT_TERMINAL_PROMPT=0) and is skipped, never fatal.
|
||||
CTX="$D/context"; mkdir -p "$CTX"
|
||||
for REPO in k.renner/homelab k.renner/homeassistant konairius/shelly-manager k.renner/authentik-samba-bridge; do
|
||||
[ "$REPO" = "$SLUG" ] && continue
|
||||
GIT_TERMINAL_PROMPT=0 git clone --quiet --depth 1 "https://gitea.renner.uno/$REPO" "$CTX/${REPO#*/}" 2>/dev/null \
|
||||
|| echo "No public context clone of $REPO (skipped)."
|
||||
done
|
||||
|
||||
# --- Model: always the most capable CURRENT one, from the live catalog. ---
|
||||
# No model id, no family name and no complexity triage is baked in here: a
|
||||
# triage that downgrades "trivial" PRs to a small model is exactly what made
|
||||
# reviews shallow, and reviews are rare enough that the strongest model is the
|
||||
# right default. pick-model.sh reads the live catalog (Anthropic /v1/models via
|
||||
# CLAUDE_CODE_OAUTH_TOKEN), prefers the newest of comparable models, validates
|
||||
# its pick against that catalog and echoes nothing on any failure — then the
|
||||
# review runs on the CLI's own default model. A model released tomorrow is
|
||||
# eligible the day the API lists it. REVIEW_TOKEN is stripped: the picker never
|
||||
# needs the write-scoped PAT.
|
||||
MODEL=$(env -u REVIEW_TOKEN sh "$HOMELAB/scripts/ci/pick-model.sh" \
|
||||
"careful, high-stakes code review of a pull request: verify claims against the code and external documentation, find real defects, avoid false positives; always pick the most capable and newest model available, regardless of PR size" || true)
|
||||
if [ -n "$MODEL" ]; then echo "Review model: $MODEL"; else echo "Review model: CLI default (catalog/picker unavailable)"; fi
|
||||
|
||||
# --- Review with the chosen model ---
|
||||
( cd "$WS" && cat "$PROMPTS/prompt.md" | env -u REVIEW_TOKEN claude ${MODEL:+--model $MODEL} \
|
||||
--permission-mode acceptEdits \
|
||||
--allowedTools "Read,Glob,Grep,Write,WebFetch,WebSearch" \
|
||||
--settings "$PROMPTS/settings.json" \
|
||||
-p ) || echo "(review agent exited non-zero)"
|
||||
|
||||
if [ ! -s "$D/report.md" ]; then
|
||||
echo "Agent wrote no report for $SLUG#$NUM — skipping post."
|
||||
continue
|
||||
fi
|
||||
# Review event, derived from the report's own severity sections (not trusted
|
||||
# from verdict.json): any red (Blocking) -> REQUEST_CHANGES; no red, yellow
|
||||
# (Suggestions) or Unverified section on a non-empty diff -> APPROVE; else
|
||||
# COMMENT. An empty diff or a report that names no section never approves.
|
||||
if grep -q '^## 🔴' "$D/report.md"; then
|
||||
EVENT=REQUEST_CHANGES
|
||||
elif [ "$TOTAL" -gt 0 ] && ! grep -q -e '^## 🟡' -e '^## ⚪' "$D/report.md" \
|
||||
&& grep -q '^## Recommendation' "$D/report.md"; then
|
||||
EVENT=APPROVE
|
||||
else
|
||||
EVENT=COMMENT
|
||||
fi
|
||||
|
||||
# Assemble the review body: header + report + marker footer.
|
||||
{
|
||||
printf '🤖 **Automated review** — model `%s`, commit `%s`.\n\n' \
|
||||
"${MODEL:-CLI default}" "${HEAD}"
|
||||
printf '> Automated review by the `review-bot` pipeline: it approves only when it found nothing red or yellow, and a human still merges. Push new commits to get a fresh review.\n\n---\n\n'
|
||||
cat "$D/report.md"
|
||||
printf '\n\n<!-- pr-review:sha=%s -->\n' "$HEAD"
|
||||
} > "$D/body.md"
|
||||
jq -n --rawfile b "$D/body.md" --arg e "$EVENT" --arg c "$HEAD" \
|
||||
'{body: $b, event: $e, commit_id: $c}' > "$D/review.json"
|
||||
gitea_post POST "$API/repos/$SLUG/pulls/$NUM/reviews" "$D/review.json" \
|
||||
&& echo "Posted $EVENT review on $SLUG#$NUM." \
|
||||
|| echo "FAILED to post review on $SLUG#$NUM (continuing)."
|
||||
rm -f "$WS/.pr-review"
|
||||
done < /tmp/pr-review/worklist.jsonl
|
||||
echo "PR review run complete."
|
||||
@@ -30,6 +30,15 @@ RUN ln -s /usr/local/go/bin/go /usr/local/bin/go \
|
||||
&& ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
USER 1000:1000
|
||||
|
||||
# Time zones (REN-1196): the base has no /usr/share/zoneinfo, so
|
||||
# TZ=Europe/Berlin silently prints UTC and Python ZoneInfo() raises. The
|
||||
# default zone stays UTC; agents set TZ where they need local time.
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
USER 1000:1000
|
||||
|
||||
# ACP server the Paperclip ACP engine starts in the pod (REN-302). Version and
|
||||
# patch match what Paperclip ships: packages/adapters/claude-local/package.json
|
||||
# pins 0.73.0 and applies patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
|
||||
@@ -63,6 +72,8 @@ ENV PATH=/usr/local/go/bin:$PATH \
|
||||
|
||||
# Base user (1000:1000), entrypoint (tini) and cmd (agent shim) are inherited.
|
||||
# The PATH check uses the base image's default PATH, not the ENV above.
|
||||
RUN go version && golangci-lint version \
|
||||
RUN TZ=Europe/Berlin date +%Z | grep -Eq '^CES?T$' \
|
||||
&& python3 -c "from zoneinfo import ZoneInfo; ZoneInfo('Europe/Berlin')" \
|
||||
&& go version && golangci-lint version \
|
||||
&& env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
sh -ec 'for t in go gofmt golangci-lint govulncheck claude-agent-acp; do command -v "$t"; done'
|
||||
|
||||
@@ -21,6 +21,16 @@ COPY --from=flux /usr/local/bin/flux /usr/local/bin/flux
|
||||
COPY --from=kustomize /app/kustomize /usr/local/bin/kustomize
|
||||
COPY --from=helm /usr/bin/helm /usr/local/bin/helm
|
||||
|
||||
# Time zones (REN-1196): the base has no /usr/share/zoneinfo, so
|
||||
# TZ=Europe/Berlin silently prints UTC and Python ZoneInfo() raises. The
|
||||
# default zone stays UTC; agents set TZ where they need local time.
|
||||
# jq (REN-1345): the agents' duty lists and the Paperclip skill use it.
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends tzdata jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
USER 1000:1000
|
||||
|
||||
# ACP server the Paperclip ACP engine starts in the pod (REN-302). Version and
|
||||
# patch match what Paperclip ships: packages/adapters/claude-local/package.json
|
||||
# pins 0.73.0 and applies patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
|
||||
@@ -46,6 +56,8 @@ USER 1000:1000
|
||||
# write only under $HOME, which the sandbox pod mounts writable. No ENV needed.
|
||||
|
||||
# Base user (1000:1000), entrypoint (tini) and cmd (agent shim) are inherited.
|
||||
RUN kubectl version --client && flux version --client && kustomize version && helm version \
|
||||
RUN TZ=Europe/Berlin date +%Z | grep -Eq '^CES?T$' \
|
||||
&& python3 -c "from zoneinfo import ZoneInfo; ZoneInfo('Europe/Berlin')" \
|
||||
&& kubectl version --client && flux version --client && kustomize version && helm version && jq --version \
|
||||
&& env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
sh -ec 'for t in kubectl flux kustomize helm claude-agent-acp; do command -v "$t"; done'
|
||||
sh -ec 'for t in kubectl flux kustomize helm jq claude-agent-acp; do command -v "$t"; done'
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM hetznercloud/cli:v1.69.0@sha256:febb8898949b31a2249384aa9728f2254dfb752ff2f69be709178c4a3285b05f AS hcloud
|
||||
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
jq \
|
||||
kubectl \
|
||||
openssh-client \
|
||||
openssh-keygen \
|
||||
tar
|
||||
|
||||
COPY --from=hcloud /ko-app/hcloud /usr/local/bin/hcloud
|
||||
|
||||
RUN adduser -D -u 65532 -h /work verify
|
||||
|
||||
RUN hcloud version && kubectl version --client && jq --version && ssh -V && ssh-keygen -t ed25519 -N '' -q -f /tmp/k && rm -f /tmp/k /tmp/k.pub
|
||||
@@ -1,7 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""netbird-service-sync: reconcile NetBird reverse-proxy services from Ingresses.
|
||||
|
||||
Source of truth: every Ingress carrying the public label (dns.renner.uno/public).
|
||||
Source of truth: every Ingress carrying one of the public labels.
|
||||
dns.renner.uno/public also takes the host out of the LAN DNS (external-dns-unifi
|
||||
skips it); netbird.renner.uno/public publishes on the proxy only and leaves the
|
||||
LAN record in place.
|
||||
For each host on such an Ingress the NetBird management gets one reverse-proxy
|
||||
service in TLS-passthrough mode on :443, targeting Traefik's ClusterIP through a
|
||||
subnet resource that covers the cluster's service CIDR. SNI and Host reach
|
||||
@@ -42,7 +45,7 @@ NETWORK_NAME = os.environ.get("NETWORK_NAME", "k8s public ingress")
|
||||
ROUTER_GROUP = os.environ.get("ROUTER_GROUP_NAME", "k8s-ingress-router")
|
||||
SOURCE_GROUP = os.environ.get("SOURCE_GROUP_NAME", "All")
|
||||
TRAEFIK_NS, TRAEFIK_SVC = os.environ.get("TRAEFIK_SERVICE", "ingress/traefik").split("/", 1)
|
||||
PUBLIC_LABEL = os.environ.get("PUBLIC_LABEL", "dns.renner.uno/public")
|
||||
PUBLIC_LABELS = os.environ.get("PUBLIC_LABELS", "dns.renner.uno/public,netbird.renner.uno/public").split(",")
|
||||
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "443"))
|
||||
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
|
||||
HTTP_TIMEOUT = 20
|
||||
@@ -86,10 +89,12 @@ def k8s(path):
|
||||
|
||||
|
||||
def desired_hosts():
|
||||
sel = urllib.parse.quote(f"{PUBLIC_LABEL}=true")
|
||||
ingresses = k8s(f"/apis/networking.k8s.io/v1/ingresses?labelSelector={sel}")
|
||||
items = []
|
||||
for label in PUBLIC_LABELS:
|
||||
sel = urllib.parse.quote(f"{label}=true")
|
||||
items += k8s(f"/apis/networking.k8s.io/v1/ingresses?labelSelector={sel}").get("items", [])
|
||||
hosts = {}
|
||||
for ing in ingresses.get("items", []):
|
||||
for ing in items:
|
||||
ref = f'{ing["metadata"]["namespace"]}/{ing["metadata"]["name"]}'
|
||||
for rule in ing.get("spec", {}).get("rules", []):
|
||||
host = rule.get("host")
|
||||
|
||||
@@ -52,6 +52,14 @@ Job (and, on destroy, the pod) reads 404, polling for up to 20 s, below the
|
||||
server's 30 s plugin RPC timeout. If that check times out or fails, the hook
|
||||
returns no receipt, as before. No upstream PR yet.
|
||||
|
||||
Agents in k8s pods cannot read their own secrets (REN-1345): the sandbox
|
||||
callback bridge answers `403 Route not allowed` for `/api/agents/me/secrets`,
|
||||
although upstream's own Paperclip skill documents those routes for runs.
|
||||
`ren-1345-bridge-agent-secrets.patch` adds the five `/api/agents/me` secret
|
||||
routes (list, read one value, list/create/withdraw proposals) to the bridge
|
||||
allowlist. The server still resolves the agent from the run JWT and checks
|
||||
the binding; company-wide secret routes stay denied. No upstream PR yet.
|
||||
|
||||
## Files
|
||||
|
||||
- `upstream.env` — pinned commit and the version string stamped into the
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts
|
||||
index 4419bee..d57de8d 100644
|
||||
--- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts
|
||||
+++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts
|
||||
@@ -1452,6 +1452,11 @@ describe("sandbox callback bridge", () => {
|
||||
{ method: "POST", path: "/api/routines/r-1/triggers" },
|
||||
{ method: "PATCH", path: "/api/routine-triggers/t-1" },
|
||||
{ method: "DELETE", path: "/api/routine-triggers/t-1" },
|
||||
+ { method: "GET", path: "/api/agents/me/secrets" },
|
||||
+ { method: "POST", path: "/api/agents/me/secrets/github_token/value" },
|
||||
+ { method: "GET", path: "/api/agents/me/secret-proposals" },
|
||||
+ { method: "POST", path: "/api/agents/me/secret-proposals" },
|
||||
+ { method: "DELETE", path: "/api/agents/me/secret-proposals/prop-1" },
|
||||
];
|
||||
for (const request of allowed) {
|
||||
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBeNull();
|
||||
@@ -1491,6 +1496,11 @@ describe("sandbox callback bridge", () => {
|
||||
{ method: "POST", path: "/api/companies/co-1/logo" },
|
||||
{ method: "GET", path: "/api/companies/co-1/secrets" },
|
||||
{ method: "PATCH", path: "/api/secrets/secret-1" },
|
||||
+ { method: "GET", path: "/api/agents/agent-1/secrets" },
|
||||
+ { method: "POST", path: "/api/agents/me/secrets/a/b/value" },
|
||||
+ { method: "GET", path: "/api/agents/me/secrets/github_token/value" },
|
||||
+ { method: "GET", path: "/api/companies/co-1/secret-proposals" },
|
||||
+ { method: "POST", path: "/api/companies/co-1/secret-proposals/prop-1/approve" },
|
||||
];
|
||||
for (const request of denied) {
|
||||
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBe(
|
||||
diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts
|
||||
index 40776a3..e825197 100644
|
||||
--- a/packages/adapter-utils/src/sandbox-callback-bridge.ts
|
||||
+++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts
|
||||
@@ -133,6 +133,15 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa
|
||||
{ method: "POST", path: /^\/api\/agents\/[^/]+\/skills\/sync$/ },
|
||||
{ method: "PATCH", path: /^\/api\/agents\/[^/]+\/instructions-path$/ },
|
||||
|
||||
+ // Run-scoped secrets of the calling agent (`/agents/me` only). The server
|
||||
+ // resolves the agent from the run JWT and checks bindings; values are
|
||||
+ // registered for run-log redaction. Company-wide secret routes stay denied.
|
||||
+ { method: "GET", path: /^\/api\/agents\/me\/secrets$/ },
|
||||
+ { method: "POST", path: /^\/api\/agents\/me\/secrets\/[^/]+\/value$/ },
|
||||
+ { method: "GET", path: /^\/api\/agents\/me\/secret-proposals$/ },
|
||||
+ { method: "POST", path: /^\/api\/agents\/me\/secret-proposals$/ },
|
||||
+ { method: "DELETE", path: /^\/api\/agents\/me\/secret-proposals\/[^/]+$/ },
|
||||
+
|
||||
// Read-only schema discovery for validated control-plane requests.
|
||||
{ method: "GET", path: /^\/api\/openapi\.json$/ },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,443 @@
|
||||
# Gitea MCP Server Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Deploy the official Gitea MCP server in-cluster at `https://gitea-mcp.renner.uno/mcp`, published on the NetBird proxy, authenticating every request with the caller's own Gitea PAT and rejecting tokenless requests.
|
||||
|
||||
**Architecture:** One stateless Deployment in a new `gitea-mcp` namespace, Flux-managed through its own Kustomization. Traefik Ingress with the proxy-only public label; netbird-service-sync publishes it. gitea-mcp runs in `-oauth` mode purely to get the 401-on-missing-token gate; it talks to Gitea via the in-cluster Service.
|
||||
|
||||
**Tech Stack:** Kubernetes manifests, Flux (Kustomization, image automation), Cilium CiliumNetworkPolicy, Traefik Ingress, cert-manager.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-11-gitea-mcp-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Image `docker.gitea.com/gitea-mcp-server`, initial tag `1.8.1`, ImagePolicy semver `>=1.8.1`, marker `# {"$imagepolicy": "flux-system:gitea-mcp"}`.
|
||||
- Args exactly: `-t http -p 8080 -oauth -oauth-public-url https://gitea-mcp.renner.uno -H http://gitea.gitea.svc.cluster.local:3000`. No `-T`/`GITEA_ACCESS_TOKEN`, no `-d`.
|
||||
- Host `gitea-mcp.renner.uno`; Ingress label `netbird.renner.uno/public: "true"` (not `dns.renner.uno/public`); cert issuer `letsencrypt-cluster`; TLS secret `gitea-mcp-tls`.
|
||||
- Pod label `app.kubernetes.io/name: gitea-mcp`; namespace `gitea-mcp`; Service `gitea-mcp:8080`.
|
||||
- No `world` egress from `gitea-mcp`.
|
||||
- Manifest comments: one terse factual line at most; rationale goes in commit/PR text.
|
||||
- Commits as claude-bot: `git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit …`; each git command its own Bash call.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Tokenless `POST /mcp` from the internet → must be 401, never anonymous Gitea data (Task 4 step 4).
|
||||
2. Request with an invalid/revoked PAT → 401 `invalid_token` (Task 4 step 5).
|
||||
3. Kubelet probes with `Host: <podIP>` → `/healthz` must stay 200 despite the oauth Host check (Task 3 readiness, Task 4 step 2).
|
||||
4. LAN client resolving `gitea-mcp.renner.uno` → must reach Traefik VIP directly, not the proxy (Task 4 step 3).
|
||||
5. Gitea refusing gitea-mcp's connection (missing CNP half) → MCP calls fail; authenticated `list_my_repos` must succeed (Task 4 step 6).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Namespace and network policies
|
||||
|
||||
**Files:**
|
||||
- Modify: `kubernetes/infrastructure/namespaces/namespaces.yaml` (append after the `gitea` Namespace)
|
||||
- Create: `kubernetes/infrastructure/network-policies/gitea-mcp-ns.yaml`
|
||||
- Modify: `kubernetes/infrastructure/network-policies/kustomization.yaml` (add entry after `gitea-ns.yaml`)
|
||||
- Modify: `kubernetes/apps/gitea/networkpolicy.yaml` (append CNP)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: namespace `gitea-mcp`; ingress to pods labelled `app.kubernetes.io/name: gitea-mcp` on 8080 from Traefik; egress from `gitea-mcp` to `gitea` pod :3000; Gitea-side ingress allowance from namespace `gitea-mcp` on :3000.
|
||||
|
||||
- [ ] **Step 1: Add the Namespace** — insert after the `name: gitea` Namespace document in `namespaces.yaml`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
# Gitea MCP server. Declared here so it exists before infrastructure-crds applies its CNPs.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `gitea-mcp-ns.yaml`**
|
||||
|
||||
```yaml
|
||||
---
|
||||
# Baseline policy for the `gitea-mcp` namespace.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
namespace: gitea-mcp
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea-mcp
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- 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-mcp
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea
|
||||
app.kubernetes.io/name: gitea
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3000"
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: gitea-mcp-allow-traefik
|
||||
namespace: gitea-mcp
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register it** — in `network-policies/kustomization.yaml` add ` - gitea-mcp-ns.yaml` directly after ` - gitea-ns.yaml`.
|
||||
|
||||
- [ ] **Step 4: Gitea-side allowance** — append to `kubernetes/apps/gitea/networkpolicy.yaml`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
# gitea-mcp reaches the in-cluster Service; egress half is in network-policies/gitea-mcp-ns.yaml.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: gitea-allow-gitea-mcp
|
||||
namespace: gitea
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea-mcp
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3000"
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Validate builds**
|
||||
|
||||
Run each separately:
|
||||
`kubectl kustomize kubernetes/infrastructure/namespaces` → output contains `name: gitea-mcp`
|
||||
`kubectl kustomize kubernetes/infrastructure/network-policies` → output contains `gitea-mcp-allow-traefik`
|
||||
`kubectl kustomize kubernetes/apps/gitea` → output contains `gitea-allow-gitea-mcp`
|
||||
|
||||
- [ ] **Step 6: Server-side dry-run of the Gitea-side CNP** (namespace already exists):
|
||||
`kubectl apply --dry-run=server -f kubernetes/apps/gitea/networkpolicy.yaml`
|
||||
Expected: every CNP `configured (server dry run)` or `unchanged`, `gitea-allow-gitea-mcp created (server dry run)`.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
`git add kubernetes/infrastructure/namespaces/namespaces.yaml kubernetes/infrastructure/network-policies/gitea-mcp-ns.yaml kubernetes/infrastructure/network-policies/kustomization.yaml kubernetes/apps/gitea/networkpolicy.yaml`
|
||||
`git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit -m "feat(gitea-mcp): namespace and network policies" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||||
|
||||
### Task 2: Image automation
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/infrastructure/image-automation/gitea-mcp.yaml`
|
||||
- Modify: `kubernetes/infrastructure/image-automation/kustomization.yaml` (insert ` - gitea-mcp.yaml` alphabetically after ` - gatus.yaml`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: ImagePolicy `flux-system:gitea-mcp`, referenced by the marker in Task 3.
|
||||
|
||||
- [ ] **Step 1: Create `gitea-mcp.yaml`**
|
||||
|
||||
```yaml
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: docker.gitea.com/gitea-mcp-server
|
||||
interval: 1h
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: gitea-mcp
|
||||
policy:
|
||||
semver:
|
||||
range: ">=1.8.1"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register it** in `image-automation/kustomization.yaml` after ` - gatus.yaml`.
|
||||
|
||||
- [ ] **Step 3: Validate**
|
||||
`kubectl kustomize kubernetes/infrastructure/image-automation` → contains `docker.gitea.com/gitea-mcp-server`
|
||||
`kubectl apply --dry-run=server -f kubernetes/infrastructure/image-automation/gitea-mcp.yaml` → both objects `created (server dry run)`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
`git add kubernetes/infrastructure/image-automation/gitea-mcp.yaml kubernetes/infrastructure/image-automation/kustomization.yaml`
|
||||
`git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit -m "feat(gitea-mcp): track docker.gitea.com/gitea-mcp-server via image automation" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||||
|
||||
### Task 3: App manifests and Flux Kustomization
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/apps/gitea-mcp/deployment.yaml`, `service.yaml`, `ingress.yaml`, `kustomization.yaml`
|
||||
- Create: `kubernetes/clusters/homelab/gitea-mcp.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: namespace + CNPs (Task 1), ImagePolicy `flux-system:gitea-mcp` (Task 2).
|
||||
- Produces: `https://gitea-mcp.renner.uno/mcp` and `/healthz`.
|
||||
|
||||
- [ ] **Step 1: `deployment.yaml`**
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: gitea-mcp
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gitea-mcp
|
||||
image: docker.gitea.com/gitea-mcp-server:1.8.1 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
args:
|
||||
- -t
|
||||
- http
|
||||
- -p
|
||||
- "8080"
|
||||
# Only for the 401 on tokenless requests; clients send their own Gitea PAT.
|
||||
- -oauth
|
||||
- -oauth-public-url
|
||||
- https://gitea-mcp.renner.uno
|
||||
- -H
|
||||
- http://gitea.gitea.svc.cluster.local:3000
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 128Mi
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 16Mi
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `service.yaml`**
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: gitea-mcp
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `ingress.yaml`**
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: gitea-mcp
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
# Published on the NetBird proxy; LAN DNS stays on the Traefik VIP.
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: gitea-mcp.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gitea-mcp
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- secretName: gitea-mcp-tls
|
||||
hosts:
|
||||
- gitea-mcp.renner.uno
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `kustomization.yaml`**
|
||||
|
||||
```yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `kubernetes/clusters/homelab/gitea-mcp.yaml`**
|
||||
|
||||
```yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/gitea-mcp
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Validate**
|
||||
`kubectl kustomize kubernetes/apps/gitea-mcp` → renders Deployment, Service, Ingress, all in namespace `gitea-mcp`.
|
||||
`kubectl apply --dry-run=server -f kubernetes/clusters/homelab/gitea-mcp.yaml` → `created (server dry run)`.
|
||||
Confirm `kubernetes/clusters/homelab/` is picked up without a listing file: `ls kubernetes/clusters/homelab/flux-system` and read its `kustomization.yaml`; if it lists files explicitly, add `gitea-mcp.yaml` where `gitea.yaml` is listed.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
`git add kubernetes/apps/gitea-mcp kubernetes/clusters/homelab/gitea-mcp.yaml`
|
||||
`git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit -m "feat(gitea-mcp): deploy the Gitea MCP server at gitea-mcp.renner.uno" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||||
|
||||
### Task 4: PR, rollout and live verification
|
||||
|
||||
- [ ] **Step 1: Push and open the PR**
|
||||
|
||||
`git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push -u origin feat/gitea-mcp`
|
||||
`tea pr create --login claude-bot --repo k.renner/homelab --base master --head feat/gitea-mcp --title "feat(gitea-mcp): in-cluster Gitea MCP server on the NetBird proxy" --description "<summary of spec + verification plan; ends with 🤖 Generated with [Claude Code](https://claude.com/claude-code)>"`
|
||||
Wait for `ci-manifests-test` green (`woodpecker` CLI). Owner merges; never self-merge.
|
||||
|
||||
- [ ] **Step 2: After merge — rollout**
|
||||
`flux get kustomization gitea-mcp` → Ready True. `kubectl -n gitea-mcp get pods` → 1/1 Running, 0 restarts.
|
||||
`curl -sS -o /dev/stdout -w "\nHTTP %{http_code}\n" https://gitea-mcp.renner.uno/healthz` → `ok`, HTTP 200.
|
||||
|
||||
- [ ] **Step 3: DNS paths**
|
||||
`getent ahosts gitea-mcp.renner.uno` from the LAN → Traefik VIP (fd99::86 / 10.2.99.86).
|
||||
Public answer: query Cloudflare's authoritative NS directly (UDM intercepts `@1.1.1.1`) → NetBird proxy address. NetBird management lists a reverse-proxy service for `gitea-mcp.renner.uno` (netbird-service-sync logs via `logcli`).
|
||||
|
||||
- [ ] **Step 4: Tokenless request is rejected**
|
||||
|
||||
```
|
||||
curl -sS -i https://gitea-mcp.renner.uno/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
|
||||
```
|
||||
Expected: `HTTP/2 401` with `WWW-Authenticate: Bearer resource_metadata=…`.
|
||||
|
||||
- [ ] **Step 5: Invalid token is rejected** — same request plus `-H 'Authorization: Bearer invalid'` → 401 with `error="invalid_token"`.
|
||||
|
||||
- [ ] **Step 6: Authenticated calls work** — same `initialize` request with `--oauth2-bearer "$(cat /home/krenner/.config/claude-bot/gitea-token)"` → 200 with `serverInfo`. Then `tools/call` `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_my_repos","arguments":{}}}` with the same token → non-empty repo list; `list_my_repos` resolves the caller from the token, so a result proves the PAT was used. If the server demands `Mcp-Protocol-Version`/`Mcp-Method` headers, take the required values from the error and resend.
|
||||
|
||||
- [ ] **Step 7: Image policy** — `flux get image policy gitea-mcp` → latest image `docker.gitea.com/gitea-mcp-server:1.8.1` or newer.
|
||||
|
||||
- [ ] **Step 8: Wiki** — route through `wiki-ingest` → `wiki-distill`: netbird-service-sync label mechanism (`dns.renner.uno/public` vs `netbird.renner.uno/public`), and gitea-mcp deployment facts (oauth-mode-as-auth-gate, PAT passthrough, `/healthz` outside the Host check).
|
||||
@@ -0,0 +1,180 @@
|
||||
# Backup verification on an ephemeral Hetzner Cloud VM — design
|
||||
|
||||
Date: 2026-10-10
|
||||
Status: draft, awaiting review
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the monthly Velero `restore-test` with a check that proves every PVC
|
||||
backup is readable and complete, **without writing restored data to the
|
||||
homelab Ceph OSDs**. The work runs on a throwaway Hetzner Cloud VM next to the
|
||||
backup bucket; the cluster only orchestrates.
|
||||
|
||||
Full disaster-recovery rehearsals (Velero restores of manifests into a fresh
|
||||
cluster) are a later, separate project. This spec only covers data
|
||||
verifiability.
|
||||
|
||||
## Why
|
||||
|
||||
- `restore-test` restores every schedule into the production Ceph pools. On
|
||||
2026-10-01 the immich restore took the fullest OSD from 74% to 95% and Ceph
|
||||
went `OSD_FULL` (REN-451). The test is a large share of the OSD write budget.
|
||||
- Its file-count parity compares the restored PVC against the *live* volume.
|
||||
That needs a source-side snapshot probe per StorageClass (it broke for
|
||||
`ceph-filesystem-bulk`, see PR #1224) and volatile-path exclusions.
|
||||
- The restore only covers `persistentvolumeclaims`/`persistentvolumes`, so a
|
||||
data-level check loses no coverage that the current test has.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| What is verified | Data only (option A): `kopia snapshot verify --verify-files-percent=100` on the latest Kopia snapshot of every DataUpload of the latest `Completed` backup per schedule, plus a size check against Velero's own `DataUpload.status.progress.totalBytes`. No restore, no Kubernetes on the VM. |
|
||||
| Where it runs | Ephemeral Hetzner Cloud VM in `fsn1` (same location as bucket `renner-uno-k8s-velero`). Bucket traffic stays inside Hetzner's eu-central zone. |
|
||||
| Orchestrator | In-cluster CronJob `backup-verify` (namespace `velero`), monthly `0 8 1 * *` UTC. It reads Kubernetes, drives the VM via the Hetzner API and SSH, and writes nothing but logs. |
|
||||
| Alerting | Exit 1 on any `FAIL` → existing `KubeJobFailed`. Results in the Job log (Loki). |
|
||||
| Coverage | All DataUploads, including CNPG volumes (read-only, no cost). Schedules discovered by label `app.kubernetes.io/part-of=homelab-backup`. |
|
||||
| SSH source restriction | None. Key-only auth with a per-run key, pinned host key, password auth off, VM lives ~1 h. |
|
||||
| Kopia version | CLI pinned to **0.16.0**, the version Velero 1.18.2 vendors (`project-velero/kopia` fork of 0.16.0). sha256 pinned per arch. |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CronJob backup-verify (velero ns) Hetzner project homelab-backup-verify
|
||||
image ci-backup-verify ┌──────────────────────────────┐
|
||||
├─ 1 sweep stale resources by label ───────▶ │ servers / ssh-keys / firewalls│
|
||||
├─ 2 build plan from Backups+DataUploads │ label managed-by=backup-verify
|
||||
├─ 3 create ssh-key, firewall, server ─────▶ │ VM cx23 (fallback cax11), │
|
||||
├─ 4 ssh: run vm-verify.sh, creds on stdin ▶ │ debian-12, fsn1 │
|
||||
│ ◀─ OK/FAIL lines ─────────────────────│ kopia 0.16.0 --readonly ─────┼─▶ S3 renner-uno-k8s-velero
|
||||
├─ 5 summary, exit code └──────────────────────────────┘ kopia/<ns>/
|
||||
└─ 6 trap: delete server, key, firewall
|
||||
```
|
||||
|
||||
### 1. Sweep
|
||||
|
||||
Delete every server, SSH key and firewall in the project labelled
|
||||
`managed-by=backup-verify`. `concurrencyPolicy: Forbid` guarantees no other run
|
||||
is live, so everything carrying the label is residue.
|
||||
|
||||
### 2. Plan
|
||||
|
||||
For each Schedule labelled `app.kubernetes.io/part-of=homelab-backup`:
|
||||
|
||||
- newest Backup with `status.phase == Completed` for that schedule
|
||||
(`PartiallyFailed` → the schedule is reported `FAIL` without verifying);
|
||||
- its DataUploads (`velero.io/backup-name` label): `spec.sourceNamespace`,
|
||||
`spec.sourcePVC`, `status.snapshotID`, `status.progress.totalBytes`,
|
||||
`status.phase` (anything but `Completed` → `FAIL`);
|
||||
- a schedule with no DataUploads (e.g. `authentik`) is `SKIP` (nothing to verify).
|
||||
|
||||
`ONLY_NAMESPACES` (comma list, optional) limits the plan for test runs.
|
||||
An empty plan is `FAIL`.
|
||||
|
||||
### 3. VM
|
||||
|
||||
- Per-run ed25519 client key → `hcloud ssh-key create`.
|
||||
- Per-run ed25519 **host** key, injected via cloud-init `ssh_keys`; the
|
||||
orchestrator writes the matching `known_hosts` entry and connects with
|
||||
`StrictHostKeyChecking=yes`.
|
||||
- `hcloud firewall create` + inbound rule `tcp/22` from `0.0.0.0/0, ::/0`.
|
||||
- `hcloud server create --type cx23 --image debian-12 --location fsn1`
|
||||
with labels; if `cx23` is not orderable in `fsn1`, retry once with `cax11`
|
||||
(ARM). The chosen arch selects the Kopia download.
|
||||
- cloud-init: disable password auth, mount a tmpfs at `/run/verify`,
|
||||
`shutdown -h +240` as a backstop (a halted server is still billed; the next
|
||||
run's sweep deletes it).
|
||||
- No credentials in user-data.
|
||||
|
||||
### 4. Verify (on the VM, `vm-verify.sh`)
|
||||
|
||||
- Download Kopia 0.16.0 release tarball for the arch, check sha256, install.
|
||||
- Credentials arrive on SSH stdin (S3 access/secret key, repository password)
|
||||
and live only in `/run/verify` (tmpfs).
|
||||
- Per namespace in the plan:
|
||||
- `kopia repository connect s3 --readonly --bucket renner-uno-k8s-velero
|
||||
--prefix kopia/<ns>/ --endpoint fsn1.your-objectstorage.com --region fsn1`,
|
||||
config and cache under `/run/verify`, cache hard limits set small.
|
||||
- `kopia snapshot list --all --json` once; per planned `snapshotID`, find the
|
||||
manifest by `id` and take its root object ID and `TotalFileSize`.
|
||||
- `kopia snapshot verify --directory-id <root> --verify-files-percent=100`.
|
||||
- Emit one line per PVC: `OK|FAIL <ns>/<pvc> snapshot=<id> bytes=<kopia>/<velero> <reason>`.
|
||||
- `kopia repository disconnect`.
|
||||
- Size check: Kopia `TotalFileSize` must equal DataUpload `totalBytes` (both
|
||||
come from the same upload; Velero reports `manifest.Stats.TotalFileSize`).
|
||||
`incrementalBytes` is not used (observed negative values).
|
||||
|
||||
Unverified details the first test run must confirm before the check is
|
||||
trusted: the JSON field names in `snapshot list --json`, that `--directory-id`
|
||||
verifies exactly that snapshot, and the exit code of `verify` on error.
|
||||
|
||||
### 5. Summary
|
||||
|
||||
PASS/FAIL/SKIP counts, the list of failures and skips, wall time, server type
|
||||
used. Exit 1 on any `FAIL`.
|
||||
|
||||
### 6. Teardown
|
||||
|
||||
A `trap` on EXIT and TERM deletes the server, SSH key and firewall by label.
|
||||
`activeDeadlineSeconds` 4 h, `terminationGracePeriodSeconds` 120 s (enough for
|
||||
the hcloud deletes).
|
||||
|
||||
## Security
|
||||
|
||||
- **Hetzner Cloud token**: dedicated project `homelab-backup-verify`
|
||||
(Read & Write; tokens cannot be scoped finer, so the project is the
|
||||
boundary) with a budget alert. OpenBao `homelab/infrastructure/hcloud-backup-verify`,
|
||||
key `token`, synced by an ExternalSecret into `velero`.
|
||||
- **S3 + repository password**: existing Secrets `cloud-credentials-hetzner`
|
||||
and `velero-repo-credentials` mounted into the pod. Hetzner S3 keys are
|
||||
project-wide and not read-only; mitigated by `--readonly`, tmpfs-only
|
||||
storage on the VM, and the VM's ~1 h lifetime.
|
||||
- **RBAC**: ServiceAccount `backup-verify`: `get/list` on `schedules`,
|
||||
`backups`, `datauploads` (`velero.io`). No access to Secrets via the API.
|
||||
- **Network**: CiliumNetworkPolicy for the pod: egress to the kube-apiserver,
|
||||
DNS, `api.hetzner.cloud:443` (FQDN), and `tcp/22` to world (VM address is
|
||||
not known in advance).
|
||||
|
||||
## Image
|
||||
|
||||
`ci/images/backup-verify/Containerfile`: Alpine with `bash`, `kubectl`, `jq`,
|
||||
`openssh-client`; `hcloud` copied from `hetznercloud/cli:v1.69.0`. Built by
|
||||
`.woodpecker/build-ci-images.yml`, published as
|
||||
`gitea.renner.uno/gitea_admin/ci-backup-verify`, with ImageRepository,
|
||||
ImagePolicy and setter marker in the first PR.
|
||||
|
||||
Scripts `backup-verify.sh` and `vm-verify.sh` in
|
||||
`kubernetes/infrastructure/velero/`, packaged into a ConfigMap by
|
||||
`configMapGenerator` like `restore-test.sh`.
|
||||
|
||||
## Testing
|
||||
|
||||
1. `ONLY_NAMESPACES=apps` manual run (`kubectl create job --from=cronjob/backup-verify`):
|
||||
confirms the unverified Kopia details above.
|
||||
2. Negative test: a forged `snapshotID` must end `FAIL` with exit 1.
|
||||
3. Abort test: delete the Job mid-run; afterwards no labelled resource may
|
||||
remain in the Hetzner project.
|
||||
4. Full run: record wall time and cost.
|
||||
|
||||
## Rollout
|
||||
|
||||
- **PR 1**: image + image automation, scripts, CronJob `backup-verify` with
|
||||
`suspend: true`, RBAC, ExternalSecret, CiliumNetworkPolicy. `restore-test`
|
||||
unchanged. Tests 1–4 run manually after merge.
|
||||
- **PR 2** (after tests pass, before 2026-11-01): unsuspend `backup-verify`;
|
||||
remove `restore-test` (CronJob, RBAC, script, its rule in
|
||||
`network-policies/monitoring-ns.yaml`); rewrite the restore-test section of
|
||||
`kubernetes/RECOVERY.md`. The failed `restore-test-29847360` Job goes with
|
||||
the CronJob, clearing the open `KubeJobFailed` (velero) alert.
|
||||
- Close PR #1224 as superseded.
|
||||
- The `*-snapshot-readonly` StorageClasses stay (the node-agent uses them for
|
||||
backups).
|
||||
- After the first green full run: ingest the run facts into the wiki.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Disaster-recovery rehearsal (manifests, Velero restore into a fresh
|
||||
cluster) — separate project.
|
||||
- CNPG logical restore checks (`RECOVERY.md` "CNPG restore-test").
|
||||
- Whether the Velero repository password is still the upstream default
|
||||
`static-passw0rd` — noted for a separate review.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Gitea MCP server — design
|
||||
|
||||
Date: 2026-10-11
|
||||
Status: draft, awaiting review
|
||||
|
||||
## Goal
|
||||
|
||||
Run the official Gitea MCP server (`gitea.com/gitea/gitea-mcp`) in the cluster
|
||||
and publish it on the NetBird proxy, so MCP clients anywhere (Claude Code on
|
||||
the workstation, Paperclip agents, other machines) can use Gitea through MCP
|
||||
with their own Gitea personal access token.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Hosting | In-cluster HTTP service, own namespace `gitea-mcp`, one stateless replica. |
|
||||
| Exposure | Public via NetBird proxy, host `gitea-mcp.renner.uno`, label `netbird.renner.uno/public` (proxy-only; the LAN record on the Traefik VIP stays, so LAN clients don't hairpin). |
|
||||
| Client auth | Bearer PAT per request (`Authorization: Bearer <pat>` or `token <pat>`). The server holds no token. Every call runs with exactly the caller's Gitea permissions. |
|
||||
| Anonymous access | Rejected with 401. Achieved by running with `-oauth`: in that mode a missing token is refused, a present token is passed through unchanged and checked with one `GET /api/v1/user`. No OAuth app or client secret is needed server-side. |
|
||||
| Gitea backend | `http://gitea.gitea.svc.cluster.local:3000` (in-cluster Service, no hairpin via the ingress LB). |
|
||||
| Image | `docker.gitea.com/gitea-mcp-server`, starting at `1.8.1`, tracked by Flux image automation (semver). |
|
||||
| Read-only mode | Off; write tools exposed. Write access is bounded by each caller's token. |
|
||||
|
||||
## Why the `-oauth` flag with PATs
|
||||
|
||||
Gitea answers anonymous API calls: `GET /api/v1/repos/search` without
|
||||
credentials returns `k.renner/homelab`, `konairius/Wiki` and
|
||||
`gitea_admin/_cargo-index`. In plain HTTP mode (no `-oauth`, no server token)
|
||||
gitea-mcp forwards tokenless requests as an anonymous Gitea client, so the MCP
|
||||
endpoint would serve those repos to anyone. Upstream has no "require token"
|
||||
flag outside OAuth mode.
|
||||
|
||||
With `-oauth -oauth-public-url https://gitea-mcp.renner.uno` (upstream
|
||||
`pkg/oauth/oauth.go`):
|
||||
|
||||
- `/mcp` without a token → 401 with an RFC 9728 `WWW-Authenticate` challenge.
|
||||
- `/mcp` with a token → `TokenRejected` calls Gitea `GET /user` with it; only a
|
||||
401 there rejects. The token is then used as-is for the API calls, so a plain
|
||||
PAT works. There is no JWT/introspection/audience check.
|
||||
- The server only publishes `/.well-known/oauth-protected-resource` metadata;
|
||||
config is the public URL and the Gitea URL, nothing else.
|
||||
- `/mcp` additionally checks `Host` against the public URL's host (or
|
||||
loopback) and returns 421 otherwise. `/healthz` is outside both checks.
|
||||
|
||||
Costs: one extra `/user` call per MCP request. The metadata document advertises
|
||||
the in-cluster Gitea URL as authorization server; this only matters for
|
||||
OAuth-flow clients (claude.ai connectors), which are out of scope.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
client ──HTTPS, Authorization: Bearer <pat>──▶ gitea-mcp.renner.uno
|
||||
├─ internet: NetBird proxy (TLS passthrough, PROXY v2) ─▶ Traefik
|
||||
└─ LAN: Traefik VIP (LAN DNS record) ─▶ Traefik
|
||||
Traefik (TLS termination, letsencrypt-cluster cert) ──HTTP :8080──▶ gitea-mcp pod
|
||||
gitea-mcp ──HTTP :3000, same token──▶ gitea.gitea.svc (Gitea API)
|
||||
```
|
||||
|
||||
Traefik forwards `Host` and the `Mcp-*` headers unchanged by default, which the
|
||||
Host check and the MCP transport need. `/mcp` is stateless POST only, so no
|
||||
sticky sessions or long-lived streams.
|
||||
|
||||
## Components
|
||||
|
||||
All paths under `kubernetes/`.
|
||||
|
||||
- `infrastructure/namespaces/namespaces.yaml` — Namespace `gitea-mcp`.
|
||||
- `clusters/homelab/gitea-mcp.yaml` — Flux Kustomization, path
|
||||
`./kubernetes/apps/gitea-mcp`, `prune: true`, `wait: true`, dependsOn
|
||||
`infrastructure`, `infrastructure-crds`.
|
||||
- `apps/gitea-mcp/`
|
||||
- `deployment.yaml` — 1 replica; label `app.kubernetes.io/name: gitea-mcp`;
|
||||
args `-t http -p 8080 -oauth -oauth-public-url https://gitea-mcp.renner.uno
|
||||
-H http://gitea.gitea.svc.cluster.local:3000`; no `GITEA_ACCESS_TOKEN`, no
|
||||
`-d` (debug may log tokens via the SDK); pod/container securityContext as in
|
||||
gatus (non-root 65532, drop ALL, no privilege escalation, RuntimeDefault
|
||||
seccomp) plus `readOnlyRootFilesystem: true` with a disk-backed `emptyDir`
|
||||
(`sizeLimit: 2Gi`) at `/tmp` for the app's own rotated log file; liveness/readiness `httpGet /healthz` on `http`;
|
||||
requests `cpu: 10m, memory: 32Mi`, limit `memory: 128Mi`; image line
|
||||
carries the `$imagepolicy` marker.
|
||||
- `service.yaml` — ClusterIP `gitea-mcp`, port 8080 → `http`.
|
||||
- `ingress.yaml` — host `gitea-mcp.renner.uno`, `ingressClassName: traefik`,
|
||||
`cert-manager.io/cluster-issuer: letsencrypt-cluster`, TLS secret
|
||||
`gitea-mcp-tls`, label `netbird.renner.uno/public: "true"`.
|
||||
- `kustomization.yaml`.
|
||||
- `infrastructure/network-policies/gitea-mcp-ns.yaml` (+ entry in that
|
||||
directory's `kustomization.yaml`) — default-deny CNP following
|
||||
`gitea-ns.yaml`: ingress from same namespace, `monitoring`, `host`/
|
||||
`remote-node`, ICMP; ingress from Traefik (`ingress` ns,
|
||||
`app.kubernetes.io/name: traefik`) on 8080; egress to kube-dns and to
|
||||
`gitea` ns `app.kubernetes.io/name: gitea` on 3000. No `world` egress.
|
||||
- `apps/gitea/networkpolicy.yaml` — new CNP `gitea-allow-gitea-mcp`, modelled on
|
||||
`gitea-allow-flux-rotator`: ingress to Gitea on 3000 from the `gitea-mcp`
|
||||
namespace.
|
||||
- `infrastructure/image-automation/gitea-mcp.yaml` (+ kustomization entry) —
|
||||
ImageRepository `docker.gitea.com/gitea-mcp-server`, ImagePolicy semver
|
||||
`>=1.8.1`.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Invalid/expired token → Gitea `/user` 401 → gitea-mcp returns 401
|
||||
`invalid_token`.
|
||||
- Gitea unreachable → upstream's token check fails open (transport errors are
|
||||
not treated as rejection), but the subsequent API call fails, so the client
|
||||
gets a tool error, never data.
|
||||
- Pod down → Traefik 503; Flux `wait` surfaces a failed rollout as
|
||||
`FluxResourceNotReady`.
|
||||
|
||||
## Verification (after Flux reconciles)
|
||||
|
||||
1. `gitea-mcp` Kustomization Ready; pod Ready.
|
||||
2. `https://gitea-mcp.renner.uno/healthz` → 200, from the LAN and through the
|
||||
NetBird proxy (public resolution).
|
||||
3. `POST /mcp` `initialize` without a token → 401.
|
||||
4. `POST /mcp` with the `claude-bot` PAT: `initialize`, then `tools/call`
|
||||
`list_my_repos` → returns repos.
|
||||
5. NetBird management lists a reverse-proxy service for
|
||||
`gitea-mcp.renner.uno`.
|
||||
6. ImagePolicy `gitea-mcp` resolves `1.8.1` (or newer).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Registering the server in Claude Code / other clients.
|
||||
- OAuth browser flow for claude.ai connectors (would need a Gitea OAuth2 app
|
||||
and `-H https://gitea.renner.uno`).
|
||||
- Read-only mode, tool filtering (`-O`/`-S`).
|
||||
+21
-14
@@ -165,26 +165,33 @@ permanent recovery.
|
||||
|
||||
---
|
||||
|
||||
## Velero restore-test: 80% OSD capacity gate
|
||||
## Velero backup verification (backup-verify)
|
||||
|
||||
The monthly Velero restore-test restores into the production Ceph pools. Before
|
||||
each schedule's restore it projects the restore's raw size (DataUpload
|
||||
`totalBytes` x the pool's replication/EC factor, spread over all OSDs) onto
|
||||
current OSD usage from Prometheus. If the fullest OSD would go above **80%**
|
||||
(`OSD_MAX_PCT` in `restore-test-cronjob.yaml`), that schedule is not restored:
|
||||
the Job log shows it as **SKIP** with the projected %, and the summary lists it
|
||||
under "Skipped (backup NOT restore-tested)". A SKIP does not fail the Job, so
|
||||
read the summary each month. If the projection cannot be made (Prometheus
|
||||
down, unsized DataUpload), the schedule is not restored and counts as FAIL.
|
||||
Background: REN-441, REN-442.
|
||||
Monthly (1st, 08:00 UTC) CronJob `velero/backup-verify`. It creates a throwaway
|
||||
Hetzner Cloud VM in fsn1 (project `homelab-backup-verify`) and runs
|
||||
`kopia snapshot verify --verify-files-percent=100` read-only against the latest
|
||||
snapshot of every DataUpload of each schedule's newest finished backup. Snapshot
|
||||
sizes are checked against `DataUpload.status.progress.totalBytes`. Nothing is
|
||||
restored into the cluster. A full run takes about an hour (2026-10-10: 39 PVCs,
|
||||
~410 GB, 61 min, cx23).
|
||||
|
||||
- Results: `kubectl -n velero logs job/<backup-verify-...>`; `FAIL` lines name the
|
||||
backup, PVC, snapshot and cause.
|
||||
- A failed run fires `KubeJobFailed` (velero).
|
||||
- Manual run: `kubectl -n velero create job bv-manual --from=cronjob/backup-verify`;
|
||||
limit it with env `ONLY_NAMESPACES=apps,gitea`.
|
||||
- Every run first deletes everything labelled `managed-by=backup-verify` in the
|
||||
Hetzner project, so a leftover VM is cleaned up by the next run. Do not start a
|
||||
manual run while another one is running: its sweep deletes the other run's VM.
|
||||
- This checks data readability, not a full restore. Disaster-recovery rehearsal is separate.
|
||||
|
||||
---
|
||||
|
||||
## CNPG restore-test (verify backups are restorable — non-destructive)
|
||||
|
||||
The monthly Velero restore-test (`kubernetes/infrastructure/velero/`) exercises
|
||||
PVC restores but **explicitly does not touch CNPG** (barman-cloud is a separate
|
||||
code path). Run this drill periodically to prove the CNPG backups are actually
|
||||
The monthly Velero backup verification (`kubernetes/infrastructure/velero/`)
|
||||
checks the Velero PVC backups but **does not cover CNPG** (barman-cloud is a
|
||||
separate code path). Run this drill periodically to prove the CNPG backups are actually
|
||||
restorable, **without touching the live clusters**. It restores into a throwaway
|
||||
cluster in the *same* namespace (so it can reference the existing namespaced
|
||||
`ObjectStore` and creds) and declares the barman plugin with
|
||||
|
||||
@@ -1591,7 +1591,7 @@ data:
|
||||
# return entries instead of short-circuiting. Built from
|
||||
# the samba-bridge repo at
|
||||
# gitea.renner.uno/k.renner/authentik-samba-bridge .
|
||||
container_image: gitea.renner.uno/k.renner/authentik-samba-bridge-ldap-outpost:2026.5.3-f02b945
|
||||
container_image: gitea.renner.uno/k.renner/authentik-samba-bridge-ldap-outpost:2026.8.3-872f754
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
@@ -2189,6 +2189,23 @@ data:
|
||||
negate: false
|
||||
failure_result: false
|
||||
|
||||
# OpenProject, decommissioned. Application first: Application.provider
|
||||
# is on_delete=PROTECT. Its policy binding cascades with it.
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: openproject
|
||||
state: absent
|
||||
|
||||
- model: authentik_providers_oauth2.oauth2provider
|
||||
identifiers:
|
||||
name: Provider for OpenProject
|
||||
state: absent
|
||||
|
||||
- model: authentik_core.group
|
||||
identifiers:
|
||||
name: OpenProject Users
|
||||
state: absent
|
||||
|
||||
# Superseded by the single passkey-webauthn-validation stage above.
|
||||
# Their flowstagebindings are FK-CASCADEd away with them; the
|
||||
# identification stage's webauthn_stage FK is SET_NULL and is
|
||||
|
||||
@@ -20,7 +20,7 @@ spec:
|
||||
# Pinned to match the samba-bridge fork's app image (server.Dockerfile
|
||||
# AUTHENTIK_VERSION). Bump in lockstep with the bridge rebuild — a
|
||||
# floating range drifts the chart ahead of the pinned app image.
|
||||
version: "2026.5.3"
|
||||
version: "2026.8.3"
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: authentik
|
||||
@@ -58,7 +58,7 @@ spec:
|
||||
# 2. Pinning means the deployed binary is traceable to a
|
||||
# specific repo commit. Bump the tag whenever a new build
|
||||
# is published from gitea.renner.uno/k.renner/authentik-samba-bridge .
|
||||
tag: "2026.5.3-f02b945"
|
||||
tag: "2026.8.3-872f754"
|
||||
envFrom:
|
||||
# SAMBA_BRIDGE_DOMAIN_SID / _ENABLED_GROUP / _UID_OFFSET
|
||||
# consumed by the bridge module at Django app startup. Missing
|
||||
@@ -100,6 +100,13 @@ spec:
|
||||
secretKeyRef:
|
||||
name: ldap-outpost-tls
|
||||
key: tls.key
|
||||
# 2026.8 honours X-Forwarded-Proto/-Host/-For only from these
|
||||
# networks. Traefik reaches authentik-server (IPv4 single-stack
|
||||
# Service) from a pod IP; same ranges Traefik itself trusts for
|
||||
# forwarded headers. Without them, HTTPS is seen as HTTP and SSO
|
||||
# breaks. Comma-separated, no spaces (authentik list parsing).
|
||||
- name: AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS
|
||||
value: "10.244.0.0/16,fd10:244::/48,127.0.0.0/8,::1/128"
|
||||
|
||||
authentik:
|
||||
log_level: info
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: gitea-mcp
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gitea-mcp
|
||||
image: docker.gitea.com/gitea-mcp-server:1.8.1 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
# The image has no ENTRYPOINT; args alone would replace its CMD.
|
||||
command:
|
||||
- /app/gitea-mcp
|
||||
args:
|
||||
- -t
|
||||
- http
|
||||
- -p
|
||||
- "8080"
|
||||
# Only for the 401 on tokenless requests; clients send their own Gitea PAT.
|
||||
- -oauth
|
||||
- -oauth-public-url
|
||||
- https://gitea-mcp.renner.uno
|
||||
- -H
|
||||
- http://gitea.gitea.svc.cluster.local:3000
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 128Mi
|
||||
volumes:
|
||||
# Holds the app's own rotated log file (100MB x 11 max); stdout carries the same lines.
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 2Gi
|
||||
@@ -0,0 +1,28 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: gitea-mcp
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
# Published on the NetBird proxy; LAN DNS stays on the Traefik VIP.
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: gitea-mcp.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gitea-mcp
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- secretName: gitea-mcp-tls
|
||||
hosts:
|
||||
- gitea-mcp.renner.uno
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: gitea-mcp
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
@@ -5,6 +5,9 @@ metadata:
|
||||
namespace: gitea
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea
|
||||
# Published on the NetBird proxy; LAN DNS stays on the Traefik VIP so
|
||||
# SSH (gitea-ssh) and node image pulls stay local.
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
# Long-running git fetch/push and container-registry blob uploads:
|
||||
|
||||
@@ -8,7 +8,16 @@ resources:
|
||||
- statefulset.yaml
|
||||
# The live size of the data volume; see the file (REN-583).
|
||||
- pvc.yaml
|
||||
# Before the version upgrade (REN-1367); remove after it.
|
||||
- volumesnapshot-pre-upgrade.yaml
|
||||
- service.yaml
|
||||
- ingressroute-ssh.yaml
|
||||
- ingress.yaml
|
||||
- networkpolicy.yaml
|
||||
# Nightly prune of old CI image builds (REN-1203).
|
||||
- package-retention.yaml
|
||||
configMapGenerator:
|
||||
- name: gitea-package-retention
|
||||
namespace: gitea
|
||||
files:
|
||||
- package-retention.py
|
||||
|
||||
@@ -49,3 +49,46 @@ spec:
|
||||
- ports:
|
||||
- port: "3000"
|
||||
protocol: TCP
|
||||
---
|
||||
# Package retention CronJob (package-retention.yaml, REN-1203). It lists pods
|
||||
# to find the running image tags. The namespace baseline does not open the API
|
||||
# server: `kube-apiserver` is its own entity in Cilium (see
|
||||
# network-policies/paperclip-renner-ns.yaml); port is the post-DNAT 6443. Its
|
||||
# Gitea calls go to the in-cluster Service, which the baseline already allows
|
||||
# inside the namespace.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: gitea-package-retention-kube-apiserver
|
||||
namespace: gitea
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea-package-retention
|
||||
egress:
|
||||
- toEntities:
|
||||
- kube-apiserver
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "6443"
|
||||
protocol: TCP
|
||||
---
|
||||
# gitea-mcp reaches the in-cluster Service; egress half is in network-policies/gitea-mcp-ns.yaml.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: gitea-allow-gitea-mcp
|
||||
namespace: gitea
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea-mcp
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3000"
|
||||
protocol: TCP
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Prune old CI image builds from Gitea's container registry (REN-1203).
|
||||
|
||||
See package-retention.yaml for what is pruned, what is kept, and why.
|
||||
Standard library only.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
GITEA = os.environ["GITEA_URL"].rstrip("/")
|
||||
TOKEN = os.environ["GITEA_TOKEN"]
|
||||
REGISTRY_HOST = os.environ["REGISTRY_HOST"]
|
||||
OWNERS = os.environ["OWNERS"].split(",")
|
||||
KEEP = int(os.environ["KEEP_IMAGES"])
|
||||
GROUP_SECONDS = int(os.environ["GROUP_SECONDS"])
|
||||
MAX_DELETES = int(os.environ["MAX_DELETES"])
|
||||
EXCLUDE = re.compile(os.environ["EXCLUDE_PACKAGES"])
|
||||
DRY_RUN = os.environ.get("DRY_RUN", "false") == "true"
|
||||
|
||||
# A package is a CI build package if at least one of its tags looks like this.
|
||||
BUILD_TAG = re.compile(r"^(build\.[0-9]+|main-[0-9a-f]{7,40})$")
|
||||
ALWAYS_KEEP = {"main", "latest"}
|
||||
|
||||
SA = "/var/run/secrets/kubernetes.io/serviceaccount"
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def gitea(method, path):
|
||||
req = urllib.request.Request(
|
||||
GITEA + path, method=method, headers={"Authorization": "token " + TOKEN}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
body = resp.read()
|
||||
return json.loads(body) if body else None
|
||||
|
||||
|
||||
def ts(s):
|
||||
# Gitea returns RFC 3339, e.g. 2026-10-07T18:07:32Z or +02:00.
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
|
||||
|
||||
|
||||
def running_images():
|
||||
"""Tags in use by any pod, per package. Packages referenced by digest only
|
||||
cannot be matched to a tag, so they are returned in `unsafe` and skipped."""
|
||||
ctx = ssl.create_default_context(cafile=SA + "/ca.crt")
|
||||
with open(SA + "/token") as f:
|
||||
sa_token = f.read().strip()
|
||||
req = urllib.request.Request(
|
||||
"https://kubernetes.default.svc/api/v1/pods",
|
||||
headers={"Authorization": "Bearer " + sa_token},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60, context=ctx) as resp:
|
||||
pods = json.load(resp)["items"]
|
||||
in_use, unsafe = {}, set()
|
||||
prefix = REGISTRY_HOST + "/"
|
||||
for pod in pods:
|
||||
spec = pod["spec"]
|
||||
for c in (
|
||||
spec.get("containers", [])
|
||||
+ spec.get("initContainers", [])
|
||||
+ spec.get("ephemeralContainers", [])
|
||||
):
|
||||
image = c["image"]
|
||||
if not image.startswith(prefix):
|
||||
continue
|
||||
ref = image[len(prefix):].split("@", 1)[0]
|
||||
repo, _, tag = ref.rpartition(":")
|
||||
if not repo or "/" in tag:
|
||||
unsafe.add(ref)
|
||||
continue
|
||||
in_use.setdefault(repo, set()).add(tag)
|
||||
return in_use, unsafe
|
||||
|
||||
|
||||
def list_packages(owner):
|
||||
out, page = [], 1
|
||||
while True:
|
||||
batch = gitea(
|
||||
"GET", f"/api/v1/packages/{owner}?type=container&limit=50&page={page}"
|
||||
)
|
||||
if not batch:
|
||||
return out
|
||||
out.extend(batch)
|
||||
page += 1
|
||||
|
||||
|
||||
def plan(versions, protected):
|
||||
"""Group tags into images (one push writes all tags of an image within a
|
||||
few seconds) and return the tags of the images to delete."""
|
||||
tags = sorted(
|
||||
(v for v in versions if not v["version"].startswith("sha256:")),
|
||||
key=lambda v: ts(v["created_at"]),
|
||||
reverse=True,
|
||||
)
|
||||
images, last = [], None
|
||||
for v in tags:
|
||||
t = ts(v["created_at"])
|
||||
if last is None or last - t > GROUP_SECONDS:
|
||||
images.append([])
|
||||
images[-1].append(v["version"])
|
||||
last = t
|
||||
delete = []
|
||||
for i, image in enumerate(images):
|
||||
if i < KEEP or set(image) & (ALWAYS_KEEP | protected):
|
||||
continue
|
||||
delete.extend(image)
|
||||
return images, delete
|
||||
|
||||
|
||||
def main():
|
||||
in_use, unsafe = running_images()
|
||||
log(f"running tags from {REGISTRY_HOST}: {sum(len(t) for t in in_use.values())}")
|
||||
work = []
|
||||
for owner in OWNERS:
|
||||
by_name = {}
|
||||
for v in list_packages(owner):
|
||||
by_name.setdefault(v["name"], []).append(v)
|
||||
for name, versions in sorted(by_name.items()):
|
||||
full = f"{owner}/{name}"
|
||||
if EXCLUDE.search(full):
|
||||
log(f"skip {full}: excluded")
|
||||
continue
|
||||
if not any(BUILD_TAG.match(v["version"]) for v in versions):
|
||||
continue
|
||||
if full in unsafe:
|
||||
log(f"skip {full}: a pod pins it by digest only")
|
||||
continue
|
||||
protected = in_use.get(full, set())
|
||||
images, delete = plan(versions, protected)
|
||||
log(f"{full}: {len(images)} images, {len(delete)} tags to delete, running {sorted(protected)}")
|
||||
work += [(owner, name, tag) for tag in delete]
|
||||
if len(work) > MAX_DELETES:
|
||||
log(f"abort: {len(work)} deletes exceed MAX_DELETES={MAX_DELETES}; nothing deleted")
|
||||
return 1
|
||||
failed = 0
|
||||
for owner, name, tag in work:
|
||||
if DRY_RUN:
|
||||
log(f"dry-run: would delete {owner}/{name}:{tag}")
|
||||
continue
|
||||
path = "/api/v1/packages/{}/container/{}/{}".format(
|
||||
owner, urllib.parse.quote(name, safe=""), urllib.parse.quote(tag, safe="")
|
||||
)
|
||||
try:
|
||||
gitea("DELETE", path)
|
||||
log(f"deleted {owner}/{name}:{tag}")
|
||||
except Exception as e: # keep going; report at the end
|
||||
failed += 1
|
||||
log(f"FAILED {owner}/{name}:{tag}: {e}")
|
||||
log(f"done: {len(work) - failed} deleted, {failed} failed, dry_run={DRY_RUN}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,165 @@
|
||||
# Nightly retention for CI image builds in Gitea's container registry (REN-1203).
|
||||
#
|
||||
# Why: nothing pruned old builds, so the data volume filled up (REN-580, at
|
||||
# 40Gi) and was down to 24% free again four days after the grow to 60Gi
|
||||
# (REN-1203, about 2.2 GB/day). Gitea 1.21 has no API for per-owner cleanup
|
||||
# rules, and the owners are human accounts, so the rules cannot live in Gitea.
|
||||
# This job applies the rule Hermes ran by hand in REN-1204.
|
||||
#
|
||||
# What it prunes, per package of the owners in OWNERS:
|
||||
# - only CI build packages: at least one tag is `build.<n>` or `main-<sha>`.
|
||||
# Date-tagged utility images (alpine-jq, cni-plugins, ...) are not touched.
|
||||
# - one image carries several tags (build.N, short and long sha, sometimes
|
||||
# main/latest). Tags pushed within GROUP_SECONDS are one image, and the job
|
||||
# deletes all tags of an image, because a sha tag left behind keeps the
|
||||
# blobs alive.
|
||||
# - keeps the KEEP_IMAGES newest images, any image tagged main or latest, and
|
||||
# any image whose tag a pod in the cluster runs now. A package that a pod
|
||||
# pins by digest only is skipped as a whole.
|
||||
# - never touches gitea_admin/* (CI base images) or EXCLUDE_PACKAGES. The
|
||||
# agent-runtime images are excluded because Paperclip pins their tag in its
|
||||
# own config, not in a pod that exists between runs.
|
||||
# - aborts without deleting if the plan exceeds MAX_DELETES.
|
||||
# Freed blobs are removed by Gitea's own `cleanup_packages` cron (daily).
|
||||
#
|
||||
# Token: `gitea-hermes-bot-package-retention-token` on hermes-bot (site admin),
|
||||
# scope `write:package` only (REN-1204). Admin and repo routes return 403 with
|
||||
# it. Hermes owns the account and the token.
|
||||
#
|
||||
# Kubernetes access: list pods, cluster-wide, to find the running tags. Nothing
|
||||
# else.
|
||||
#
|
||||
# Dry run: set DRY_RUN to "true"; the job then only logs the plan.
|
||||
# Stop it: `spec.suspend: true` in git. Revert: delete this file, the
|
||||
# configMapGenerator entry and the policy in networkpolicy.yaml. Deleted images
|
||||
# cannot be restored; CI rebuilds one from its commit.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: gitea-package-retention
|
||||
namespace: gitea
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: gitea-package-retention
|
||||
data:
|
||||
- secretKey: GITEA_TOKEN
|
||||
remoteRef:
|
||||
key: infrastructure/gitea/hermes-bot
|
||||
property: gitea-hermes-bot-package-retention-token
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: gitea-package-retention
|
||||
namespace: gitea
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: gitea-package-retention
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["list"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: gitea-package-retention
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: gitea-package-retention
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: gitea-package-retention
|
||||
namespace: gitea
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: gitea-package-retention
|
||||
namespace: gitea
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-package-retention
|
||||
spec:
|
||||
# Quiet hour for CI pushes. Gitea's own daily cleanup_packages cron frees
|
||||
# the blobs afterwards.
|
||||
schedule: "30 22 * * *"
|
||||
timeZone: Etc/UTC
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 2
|
||||
failedJobsHistoryLimit: 3
|
||||
startingDeadlineSeconds: 3600
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 1800
|
||||
ttlSecondsAfterFinished: 604800
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-package-retention
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
enableServiceLinks: false
|
||||
serviceAccountName: gitea-package-retention
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: retention
|
||||
image: docker.io/library/python:3.13-alpine@sha256:2d9aefe2fef018a7eb2c13064c89c71929800fd2e5dccdbf52ea5da5bb8d929a
|
||||
command: ["python3", "/app/package-retention.py"]
|
||||
env:
|
||||
# In-cluster Service; the public name hairpins off the ingress
|
||||
# LB from a pod (see networkpolicy.yaml).
|
||||
- name: GITEA_URL
|
||||
value: http://gitea.gitea.svc:3000
|
||||
- name: REGISTRY_HOST
|
||||
value: gitea.renner.uno
|
||||
- name: OWNERS
|
||||
value: k.renner,konairius
|
||||
- name: KEEP_IMAGES
|
||||
value: "5"
|
||||
# Tags of one push land within seconds; the smallest gap
|
||||
# between two images was 379 s (REN-1204).
|
||||
- name: GROUP_SECONDS
|
||||
value: "60"
|
||||
- name: MAX_DELETES
|
||||
value: "300"
|
||||
- name: EXCLUDE_PACKAGES
|
||||
value: "^konairius/agent-runtime"
|
||||
- name: DRY_RUN
|
||||
value: "false"
|
||||
- name: PYTHONDONTWRITEBYTECODE
|
||||
value: "1"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: gitea-package-retention
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 128Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: script
|
||||
mountPath: /app
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: script
|
||||
configMap:
|
||||
name: gitea-package-retention
|
||||
@@ -34,10 +34,12 @@ spec:
|
||||
# RUN_USER check -> fatal). The migrated /var/lib/gitea/data drops in as-is,
|
||||
# but the LXC's /etc/gitea/app.ini is RELOCATED to custom/conf/app.ini
|
||||
# (GITEA_APP_INI below) and its RUN_USER must read `git`, not `gitea` —
|
||||
# both handled as explicit steps in MIGRATION-gitea-woodpecker.md. Pinned
|
||||
# to match the LXC for a clean SQLite lift; no imagepolicy marker (an
|
||||
# auto-bump across versions on live SQLite data must never be applied).
|
||||
image: gitea/gitea:1.21.4-rootless@sha256:600f1f1e1d090c763866db16882b5eac5c6a80b7664ee53bec0806c656c0033f
|
||||
# both handled as explicit steps in MIGRATION-gitea-woodpecker.md. No
|
||||
# imagepolicy marker: a minor bump migrates the live SQLite database and
|
||||
# needs a snapshot first (volumesnapshot-pre-upgrade.yaml). 1.21.4 ->
|
||||
# 1.27.3 in REN-1367; the
|
||||
# next step, 28.x, changes the outgoing-request (webhook) host lists.
|
||||
image: gitea/gitea:1.27.3-rootless@sha256:1c17ecaead42eb3b5391553d8708103a4beb0e86edf5b9ebc1eb269c318845f2
|
||||
env:
|
||||
- name: GITEA_WORK_DIR
|
||||
value: /var/lib/gitea
|
||||
@@ -113,11 +115,23 @@ spec:
|
||||
# Nothing here authenticates via query tokens (CI uses webhooks/OAuth).
|
||||
- name: GITEA__security__DISABLE_QUERY_AUTH_TOKEN
|
||||
value: "true"
|
||||
# Webhook targets Gitea may call; every configured webhook host.
|
||||
- name: GITEA__security__ALLOWED_HOST_LIST
|
||||
value: "ci.renner.uno,paperclip-api.renner.uno,wiki.ai.svc.cluster.local"
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
- name: ssh
|
||||
containerPort: 2222
|
||||
# The first start on a new minor version runs the database migrations
|
||||
# before the HTTP server listens. Give that up to 15 minutes; without
|
||||
# it the liveness probe kills Gitea in the middle of a migration.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/healthz
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
failureThreshold: 90
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/healthz
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
# Point-in-time copy of the Gitea data PVC (SQLite DB, repositories, LFS,
|
||||
# packages, app.ini), taken before the 1.21.4 -> 1.27.3 upgrade (REN-1367).
|
||||
# Merge this first and see the SnapshotReady event in namespace gitea before
|
||||
# the image bump: 1.22+ migrates the database, and a migrated database cannot
|
||||
# run on 1.21 again. The way back is a restore from this snapshot.
|
||||
#
|
||||
# Crash-consistent (taken from the live RBD volume), like the velero CSI
|
||||
# snapshots; SQLite recovers from its journal on open. The velero
|
||||
# `gitea-daily` schedule holds a second, off-cluster copy.
|
||||
#
|
||||
# Restore (only if the upgrade fails):
|
||||
# 1. Revert the image bump PR and set `replicas: 0` on the StatefulSet in git.
|
||||
# 2. In git, replace pvc.yaml with a claim `data-gitea-0` that has
|
||||
# `dataSource: {kind: VolumeSnapshot, apiGroup: snapshot.storage.k8s.io,
|
||||
# name: gitea-data-pre-upgrade}` and no volumeName; the old claim must be
|
||||
# deleted first (prune is disabled on it, so this is a cluster-write step).
|
||||
# 3. Set `replicas: 1` again; gitea-0 starts 1.21.4 on the restored data.
|
||||
#
|
||||
# Delete this file once the upgrade has run cleanly; the snapshot goes with it
|
||||
# (prune).
|
||||
apiVersion: snapshot.storage.k8s.io/v1
|
||||
kind: VolumeSnapshot
|
||||
metadata:
|
||||
name: gitea-data-pre-upgrade
|
||||
namespace: gitea
|
||||
spec:
|
||||
volumeSnapshotClassName: ceph-block-snapclass
|
||||
source:
|
||||
persistentVolumeClaimName: data-gitea-0
|
||||
@@ -95,7 +95,7 @@ spec:
|
||||
memory: 32Mi
|
||||
containers:
|
||||
- name: home-assistant
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.9.4 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.10.1 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
@@ -187,6 +187,55 @@ spec:
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
# MQTT client certificate for Home Assistant (REN-953). HA keeps
|
||||
# client certificates only as PEMs uploaded into its MQTT config
|
||||
# entry, so a cert-manager renewal (~60 days) could never reach it
|
||||
# without a reconfigure flow and an admin token. Instead HA's config
|
||||
# entry points at 127.0.0.1:11883 (plain, loopback only) and this
|
||||
# sidecar forwards to the broker's mTLS listener :8884 with the
|
||||
# certificate of the register account `home-assistant`. The broker
|
||||
# takes the username from the certificate CN; whatever user/password
|
||||
# HA sends is ignored. --timed-reload picks up a renewed certificate
|
||||
# for new connections without a restart; an open connection stays up
|
||||
# (checked locally with ghostunnel v1.11.3 and mosquitto 2.0.22).
|
||||
#
|
||||
# Probes: liveness only, on the HTTPS status port (process alive). No
|
||||
# readiness probe on purpose: a broker outage must not take HA out of
|
||||
# its Service endpoints.
|
||||
- name: mqtt-tls
|
||||
image: ghostunnel/ghostunnel:v1.11.3@sha256:2599b8a04bae16d70a4209495618dea61b46a00a9a05fcf74da282688ad3517f
|
||||
args:
|
||||
- client
|
||||
- --listen=127.0.0.1:11883
|
||||
- --target=mosquitto.home-assistant.svc.cluster.local:8884
|
||||
- --cert=/mqtt-tls/tls.crt
|
||||
- --key=/mqtt-tls/tls.key
|
||||
- --cacert=/mqtt-tls/ca.crt
|
||||
- --timed-reload=10m
|
||||
- --status=0.0.0.0:16060
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 16060
|
||||
periodSeconds: 30
|
||||
failureThreshold: 4
|
||||
volumeMounts:
|
||||
- name: mqtt-tls
|
||||
mountPath: /mqtt-tls
|
||||
readOnly: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 5m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
# Browser-based VS Code editing /config in-place. Sidecar (not
|
||||
# separate Deployment) because the config PVC is RWO ceph-block.
|
||||
# Exposed LAN-only on vscode.home.renner.uno; --auth=none means
|
||||
@@ -297,6 +346,14 @@ spec:
|
||||
- name: proxy-config
|
||||
configMap:
|
||||
name: ha-proxy-config
|
||||
# Client certificate of the register account home-assistant, issued
|
||||
# by mqtt-account-controller. Not subPath, so renewals land in place.
|
||||
# 0444 because the sidecar runs as 65534; only this pod mounts it.
|
||||
# Not in the Reloader annotation: a renewal must not restart HA.
|
||||
- name: mqtt-tls
|
||||
secret:
|
||||
secretName: mqtt-home-assistant-tls
|
||||
defaultMode: 0444
|
||||
# Writable emptyDir for the nginx entrypoint's envsubst output
|
||||
# (/etc/nginx/conf.d/default.conf rendered from the .template).
|
||||
- name: proxy-conf-d
|
||||
|
||||
@@ -16,9 +16,6 @@ resources:
|
||||
# probes use the cert account mosquitto-probe, REN-953); removed with its
|
||||
# rotator in the REN-802 cleanup step.
|
||||
- mosquitto-health-credentials-externalsecret.yaml
|
||||
# open3e-int broker credential: open3e-esp32 gateway on the Vitocal internal
|
||||
# CAN; OpenBao-backed + Class-A rotated.
|
||||
- mosquitto-open3e-int-credentials-externalsecret.yaml
|
||||
- mosquitto-config.yaml
|
||||
- mosquitto-certificate.yaml
|
||||
- mosquitto-service.yaml
|
||||
|
||||
@@ -177,77 +177,6 @@ spec:
|
||||
limits:
|
||||
memory: 64Mi
|
||||
---
|
||||
# Second broker user: `shellies`. Separate Secret (not a second key on
|
||||
# mosquitto-credentials) so each credential's lifecycle is independent —
|
||||
# rotating one never touches the other, and existing clusters whose
|
||||
# mosquitto-credentials was already generated for `homeassistant` only
|
||||
# don't end up with a half-populated Secret.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: mosquitto-shellies-credentials-bootstrap
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
# Job pod templates are immutable; force-recreate so image bumps
|
||||
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
|
||||
kustomize.toolkit.fluxcd.io/force: enabled
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-bootstrap
|
||||
spec:
|
||||
serviceAccountName: mosquitto-bootstrap
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
apk add --no-cache curl jq >/dev/null
|
||||
|
||||
NS=home-assistant
|
||||
NAME=mosquitto-shellies-credentials
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
API=https://kubernetes.default.svc
|
||||
|
||||
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
|
||||
echo "$NAME already exists; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
|
||||
|
||||
body=$(jq -nc \
|
||||
--arg name "$NAME" \
|
||||
--arg ns "$NS" \
|
||||
--arg pw "$PW_B64" \
|
||||
'{apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {name: $name, namespace: $ns},
|
||||
type: "Opaque",
|
||||
data: {password: $pw}}')
|
||||
|
||||
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
--data "$body" \
|
||||
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
|
||||
|
||||
echo "generated $NAME"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
---
|
||||
# Fourth broker user: `sml2mqtt` — the power-meter bridge. Publish-only
|
||||
# under `sml2mqtt/#` (ACL in mosquitto-deployment.yaml). Own credential so
|
||||
# its lifecycle is independent of the others. The sml2mqtt pod reads this
|
||||
@@ -392,7 +321,7 @@ spec:
|
||||
---
|
||||
# Broker user `zigbee2mqtt` — the Zigbee2MQTT bridge (ZHA replacement). Scoped
|
||||
# to its own `zigbee2mqtt/#` subtree plus `write homeassistant/#` (so it can
|
||||
# publish HA MQTT-discovery configs, exactly like the `shellies` user) and
|
||||
# publish HA MQTT-discovery configs) and
|
||||
# `read homeassistant/status` (HA's birth/will topic; on `online` Z2M
|
||||
# republishes cached device states, NOT discovery configs — ACL and the full
|
||||
# reasoning in mosquitto-deployment.yaml). Own credential so its lifecycle is
|
||||
|
||||
@@ -5,14 +5,6 @@ metadata:
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto
|
||||
annotations:
|
||||
# REQUIRED for credential rotation to take effect. The startup shim rebuilds
|
||||
# the password file only on pod start, so without this a rotated Secret sits
|
||||
# inert while the old password stays valid indefinitely.
|
||||
#
|
||||
# Listed per-Secret rather than with the blanket auto annotation, so that
|
||||
# enrolling another broker user is a visible edit to this line.
|
||||
secret.reloader.stakater.com/reload: mosquitto-open3e-int-credentials
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
@@ -74,23 +66,14 @@ spec:
|
||||
set -eu
|
||||
rm -f /mosquitto/auth/passwd
|
||||
mosquitto_passwd -c -b /mosquitto/auth/passwd homeassistant "$HA_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd shellies "$SHELLY_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd admin "$ADMIN_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd sml2mqtt "$SML2MQTT_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd open3e "$OPEN3E_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd open3e-int "$OPEN3E_INT_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd evcc "$EVCC_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd zigbee2mqtt "$ZIGBEE2MQTT_PASSWORD"
|
||||
# ACL: HA keeps full access (subscribes to everything); the
|
||||
# shellies user is `readwrite shellies/#` (devices publish state
|
||||
# and subscribe to their command topics) + `write homeassistant/#`
|
||||
# so Shelly Gen2 firmwares can publish HA MQTT-discovery configs.
|
||||
# It is WRITE-only on homeassistant/# on purpose: a publish-only
|
||||
# client never needs to read the whole discovery/state tree, and
|
||||
# denying read removes device-inventory recon for a compromised
|
||||
# Shelly. (Full per-device write scoping isn't expressible with
|
||||
# the flat discovery topic layout; that would need a dynamic-auth
|
||||
# plugin — out of scope.)
|
||||
# ACL: HA keeps full access (subscribes to everything). Shellies
|
||||
# are not legacy users any more: each has its own Dynamic Security
|
||||
# account from mqtt-accounts.yaml (REN-802, REN-1017).
|
||||
# `admin` is a workstation/debugging credential — full
|
||||
# readwrite, kept on its own credential so its password can
|
||||
# be rotated independently of the HA session.
|
||||
@@ -111,10 +94,6 @@ spec:
|
||||
user homeassistant
|
||||
topic readwrite #
|
||||
|
||||
user shellies
|
||||
topic readwrite shellies/#
|
||||
topic write homeassistant/#
|
||||
|
||||
user admin
|
||||
topic readwrite #
|
||||
|
||||
@@ -124,12 +103,6 @@ spec:
|
||||
user open3e
|
||||
topic readwrite open3e/#
|
||||
|
||||
# open3e-esp32 gateway on the Vitocal internal CAN: own subtree
|
||||
# under open3e/ so edge-1's listener on open3e/cmnd never sees
|
||||
# its commands.
|
||||
user open3e-int
|
||||
topic readwrite open3e/int/#
|
||||
|
||||
user evcc
|
||||
topic read open3e/#
|
||||
topic readwrite evcc/#
|
||||
@@ -137,9 +110,8 @@ spec:
|
||||
|
||||
# `zigbee2mqtt` is the Zigbee2MQTT bridge (the ZHA replacement).
|
||||
# Owns its own `zigbee2mqtt/#` subtree; may WRITE `homeassistant/#`
|
||||
# to publish HA MQTT-discovery configs (write-only there, like the
|
||||
# shellies user, to deny discovery-tree recon from a compromised
|
||||
# bridge); and READs `homeassistant/status`, HA's birth/will topic.
|
||||
# to publish HA MQTT-discovery configs (write-only there, to deny
|
||||
# discovery-tree recon from a compromised bridge); and READs `homeassistant/status`, HA's birth/will topic.
|
||||
#
|
||||
# That read grant does NOT buy a discovery re-publish: on
|
||||
# `online`, Z2M 2.12.1 republishes bridge/state and cached device
|
||||
@@ -176,11 +148,6 @@ spec:
|
||||
secretKeyRef:
|
||||
name: mosquitto-credentials
|
||||
key: homeassistant-password
|
||||
- name: SHELLY_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-shellies-credentials
|
||||
key: password
|
||||
- name: ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -196,13 +163,6 @@ spec:
|
||||
secretKeyRef:
|
||||
name: mosquitto-open3e-credentials
|
||||
key: password
|
||||
# open3e-int: OpenBao-backed + Class-A rotated (see
|
||||
# mosquitto-open3e-int-credentials-externalsecret.yaml).
|
||||
- name: OPEN3E_INT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-open3e-int-credentials
|
||||
key: password
|
||||
- name: EVCC_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Projects the mosquitto `open3e-int` broker credential (open3e-esp32 gateway
|
||||
# on the Vitocal internal CAN) from OpenBao into the Secret the broker
|
||||
# consumes. Rotator: kubernetes/infrastructure/openbao/rotation/mosquitto-open3e-int.yaml.
|
||||
#
|
||||
# creationPolicy is implicitly Owner: deleting this ExternalSecret deletes the
|
||||
# live Secret. Set creationPolicy: Orphan first to detach without destroying.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: mosquitto-open3e-int-credentials
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: mosquitto-open3e-int-credentials
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/home-assistant/mosquitto-open3e-int-credentials
|
||||
@@ -85,6 +85,8 @@ subjects:
|
||||
# that creates, because the name is in the request path).
|
||||
# mqtt-<name>-tls: delete on revocation, one entry per cert account
|
||||
# delivered to this namespace. Add the name with the register entry.
|
||||
# mqtt-ha-cert-sync: sha256 of the client certificate last accepted by
|
||||
# Home Assistant's MQTT config entry (HA cert sync, REN-953/REN-1284).
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
@@ -95,7 +97,7 @@ metadata:
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames: ["mqtt-account-hashes", "mqtt-dynsec-seed"]
|
||||
resourceNames: ["mqtt-account-hashes", "mqtt-dynsec-seed", "mqtt-ha-cert-sync"]
|
||||
verbs: ["get", "create", "patch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
@@ -252,7 +254,7 @@ spec:
|
||||
- name: controller
|
||||
# Woodpecker builds main-<sha> on every merge to main; no sortable
|
||||
# tag, so bumps are manual (same as ical-proxy).
|
||||
image: gitea.renner.uno/konairius/mqtt-account-controller:main-982231a@sha256:851dd025a8aea352061ac818f9968e1c4a074c8f33af405efde3424382e77ae1
|
||||
image: gitea.renner.uno/konairius/mqtt-account-controller:main-cb96b1a@sha256:9d9b9e83a9c0f9f567e57edacab11cf4c58e8c0e49e38e8d7b51421cd1242911
|
||||
env:
|
||||
- name: BROKER_URL
|
||||
value: ssl://mosquitto.home-assistant.svc.cluster.local:8884
|
||||
@@ -263,6 +265,22 @@ spec:
|
||||
value: http://shelly-manager-api.home-assistant.svc.cluster.local
|
||||
- name: SHELLY_MANAGER_API_KEY_FILE
|
||||
value: /etc/shelly-manager/api-key
|
||||
# HA cert sync (REN-953, REN-1284): keeps Home Assistant's MQTT
|
||||
# config entry on mosquitto:8884 with the CA and client
|
||||
# certificate of the register account `home-assistant`. After
|
||||
# each loop the controller compares tls.crt (mounted below) with
|
||||
# the last certificate HA accepted (Secret mqtt-ha-cert-sync) and,
|
||||
# only inside 02:00-05:00 Europe/Berlin, runs HA's reconfigure
|
||||
# flow. HA tests the connection before it saves. Defaults:
|
||||
# broker mosquitto.home-assistant.svc.cluster.local:8884, cert dir
|
||||
# /etc/mqtt-home-assistant-tls. Off while HA_MQTT_ENTRY_ID is unset.
|
||||
- name: HA_MQTT_ENTRY_ID
|
||||
value: 01KRFXD4046FEBWQ0EX0GNE418
|
||||
# HA itself (:8123), not the :8124 XFF proxy; same namespace.
|
||||
- name: HA_URL
|
||||
value: http://home-assistant-bgp.home-assistant.svc.cluster.local:8123
|
||||
- name: HA_TOKEN_FILE
|
||||
value: /etc/ha-token/token
|
||||
# Not :8080: several :8080 ingress grants in the home-assistant
|
||||
# default-deny apply to every pod in the namespace.
|
||||
- name: LISTEN
|
||||
@@ -293,6 +311,12 @@ spec:
|
||||
- name: shelly-manager-key
|
||||
mountPath: /etc/shelly-manager
|
||||
readOnly: true
|
||||
- name: ha-mqtt-tls
|
||||
mountPath: /etc/mqtt-home-assistant-tls
|
||||
readOnly: true
|
||||
- name: ha-token
|
||||
mountPath: /etc/ha-token
|
||||
readOnly: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 5m
|
||||
@@ -315,3 +339,35 @@ spec:
|
||||
items:
|
||||
- key: api-key
|
||||
path: api-key
|
||||
# Client certificate HA gets (cert-manager renews it in place; not a
|
||||
# subPath mount, so the next loop sees the new file).
|
||||
- name: ha-mqtt-tls
|
||||
secret:
|
||||
secretName: mqtt-home-assistant-tls
|
||||
# HA long-lived token of the service user `MQTT Cert Sync`
|
||||
# (system-admin; config flows need admin). REN-1268.
|
||||
- name: ha-token
|
||||
secret:
|
||||
secretName: mqtt-account-controller-ha-token
|
||||
---
|
||||
# HA admin token for the HA cert sync (see the Deployment env). Minted by
|
||||
# Hermes on a dedicated HA service user, not the owner's account (REN-1268).
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: mqtt-account-controller-ha-token
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: mqtt-account-controller-ha-token
|
||||
data:
|
||||
- secretKey: token
|
||||
remoteRef:
|
||||
key: apps/home-assistant/mqtt-cert-sync-token
|
||||
property: token
|
||||
|
||||
@@ -59,11 +59,25 @@
|
||||
auth: cert
|
||||
deliver: {namespace: paperclip}
|
||||
acl: ["read open3e/#", "read zigbee2mqtt/#", "read homeassistant/#"]
|
||||
# edge-1's local mosquitto bridge (REN-1307): one bridge for open3e/e3oncan
|
||||
# and sml2mqtt, replacing the legacy password users `open3e` and `sml2mqtt`
|
||||
# (different name, so it cannot shadow their logins). The controller pushes
|
||||
# the password to edge-mqtt-cred on edge-1 (ansible role mqtt-bridge) over
|
||||
# HTTPS with its own client certificate, and pins edge-1's self-generated
|
||||
# server certificate on first contact (key edge-1.pin in mqtt-account-hashes;
|
||||
# delete it after a reinstall of edge-1).
|
||||
- name: edge-1
|
||||
auth: password
|
||||
deliver: {esp32: "https://edge-1.iot.renner.uno:8443"}
|
||||
# No sml2mqtt/#: the SML meter reader is retired for good (owner,
|
||||
# 2026-09-19; grid data comes from the E380 on the CAN bus).
|
||||
acl: ["readwrite open3e/#"]
|
||||
# Shellies (REN-1017): one password account per device, pushed by the
|
||||
# controller through shelly-manager's per-device API. Name shelly-<id> =
|
||||
# shelly-manager device id. Same ACL as the legacy user `shellies`, which
|
||||
# keeps working until every device has moved. Gen2+ devices reboot once
|
||||
# when their password is pushed.
|
||||
# shelly-manager device id. ACL as the former shared user `shellies`
|
||||
# (removed in REN-1017 step 4). Gen2+ devices reboot once when their password
|
||||
# is pushed. A new Shelly needs its entry here first: shelly-manager's MQTT
|
||||
# template user no longer exists on the broker.
|
||||
- name: shelly-5 # Plus i4, the API test device of REN-929
|
||||
auth: password
|
||||
deliver: {shelly-manager: 5}
|
||||
@@ -181,6 +195,10 @@
|
||||
auth: password
|
||||
deliver: {shelly-manager: 44} # HiluX DS8 34b7da894604, Toilette (standalone, no mesh)
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-30
|
||||
auth: password
|
||||
deliver: {shelly-manager: 30} # Plus 2PM b48a0a226c28 (reachable again, REN-1108)
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
# Sleeping H&Ts (REN-1107): awake only 8-13 s every 1-2 h, so the
|
||||
# controller pushes when the device publishes `online` on its wake topic
|
||||
# (shelly-manager topic prefix `shellies/<hostname>`), not in the 2-min loop.
|
||||
|
||||
@@ -61,7 +61,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: shelly-manager
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1791385485 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1791426752 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: immich-machine-learning
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v3.2.4 # {"$imagepolicy": "flux-system:immich-machine-learning"}
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v3.3.1 # {"$imagepolicy": "flux-system:immich-machine-learning"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3003
|
||||
|
||||
@@ -87,7 +87,7 @@ spec:
|
||||
mountPath: /ca
|
||||
containers:
|
||||
- name: immich-server
|
||||
image: ghcr.io/immich-app/immich-server:v3.2.4 # {"$imagepolicy": "flux-system:immich-server"}
|
||||
image: ghcr.io/immich-app/immich-server:v3.3.1 # {"$imagepolicy": "flux-system:immich-server"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3001
|
||||
|
||||
@@ -15,9 +15,6 @@ spec:
|
||||
# Per-check rather than ceph_health_status, so one permanently-active
|
||||
# check cannot mask every other Ceph warning.
|
||||
#
|
||||
# AUTH_INSECURE_* is excluded: it cannot clear until the nodes run a
|
||||
# kernel supporting aes256k over the CSI mount path (#527).
|
||||
#
|
||||
# POOL_NO_REDUNDANCY is in scope. An unreplicated pool makes
|
||||
# `ceph osd ok-to-stop` refuse for every OSD, so it reports blocked
|
||||
# maintenance and not just data risk — do not exclude it for a pool
|
||||
@@ -34,20 +31,14 @@ spec:
|
||||
# both episodes in the 30d to 2026-08-23 ran ~26h and cleared
|
||||
# untouched, the first ~24h after the last stall.
|
||||
#
|
||||
# Both began inside the Velero restore-test sweep — a ~5h sustained
|
||||
# restore that outruns the SLC cache on these QLC NVMe. The sweep
|
||||
# runs monthly, 08:00-14:00Z on the 1st.
|
||||
#
|
||||
# Expected for the hardware, but not excludable:
|
||||
# this is also the earliest signal of a genuinely failing drive, and
|
||||
# all three are at 80-87% endurance used (see cephcluster.yaml).
|
||||
expr: ceph_health_detail{name!~"AUTH_INSECURE_.+"} == 1
|
||||
# Not excludable: it is also the earliest signal of a failing drive.
|
||||
expr: ceph_health_detail == 1
|
||||
for: 6h
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Ceph health check {{ $labels.name }} active for 6h"
|
||||
description: "Ceph health check {{ $labels.name }} has been active for six hours. Only AUTH_INSECURE_* is excluded (kernel-blocked, see #527). BLUESTORE_SLOW_OP_ALERT is sticky for 24h after the last stall and clears itself: check whether the stall fell inside the monthly Velero restore-test window (08:00-14:00Z on the 1st), which is expected on these QLC drives — one outside that window, or a repeat on the same OSD, means check that drive (#557). Detail: `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph health detail`."
|
||||
description: "Ceph health check {{ $labels.name }} has been active for six hours. BLUESTORE_SLOW_OP_ALERT stays for 24h after the last stall even when latency is back to normal; find the OSD in the detail output and check that drive's SMART data (#557). Detail: `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph health detail`."
|
||||
|
||||
- alert: CephPoolLostRedundancy
|
||||
# Names the pool, which POOL_NO_REDUNDANCY does not.
|
||||
|
||||
@@ -174,18 +174,18 @@ spec:
|
||||
forward_to = [loki.process.udm_cef.receiver]
|
||||
}
|
||||
|
||||
// CEF parser for UDM SIEM payload. One regex with named groups
|
||||
// for header (vendor|product|product_version|sig_id|name|sev) and
|
||||
// extension (UNIFIhost / UNIFIdeviceName / -Model / -Ip / -Mac /
|
||||
// -Version / msg). Only deviceName may contain spaces — use a
|
||||
// non-greedy match anchored to the literal next-key boundary.
|
||||
// Keep the full raw line as the log content so unparsed senders
|
||||
// (or future CEF format drift) still land queryably.
|
||||
// CEF parser for UDM SIEM payload. The listener also receives the
|
||||
// UDM's plain syslog (log_all_contents), which does not match and
|
||||
// passes through unlabelled. CEF extension keys differ per event
|
||||
// type, so the regex anchors only on the header
|
||||
// (vendor|product|product_version|sig_id|name|sev), UNIFIcategory,
|
||||
// UNIFIhost and the trailing msg. The full raw line stays the log
|
||||
// content.
|
||||
loki.process "udm_cef" {
|
||||
forward_to = [loki.write.default.receiver]
|
||||
|
||||
stage.regex {
|
||||
expression = "CEF:\\d+\\|(?P<vendor>[^|]*)\\|(?P<product>[^|]*)\\|(?P<product_version>[^|]*)\\|(?P<signature_id>[^|]*)\\|(?P<event_name>[^|]*)\\|(?P<cef_severity>[^|]*)\\|UNIFIhost=(?P<unifi_host>\\S+) UNIFIdeviceName=(?P<device_name>.+?) UNIFIdeviceModel=(?P<device_model>\\S+) UNIFIdeviceIp=(?P<device_ip>\\S+) UNIFIdeviceMac=(?P<device_mac>\\S+) UNIFIdeviceVersion=(?P<device_version>\\S+) msg=(?P<msg>.+)$"
|
||||
expression = "CEF:\\d+\\|(?P<vendor>[^|]*)\\|(?P<product>[^|]*)\\|(?P<product_version>[^|]*)\\|(?P<signature_id>[^|]*)\\|(?P<event_name>[^|]*)\\|(?P<cef_severity>[^|]*)\\|UNIFIcategory=(?P<category>.+?) UNIFIhost=(?P<unifi_host>.+?) \\S+=.* msg=(?P<msg>.+)$"
|
||||
}
|
||||
|
||||
stage.labels {
|
||||
@@ -194,8 +194,7 @@ spec:
|
||||
product = "product",
|
||||
signature_id = "signature_id",
|
||||
event_name = "event_name",
|
||||
device_model = "device_model",
|
||||
host = "device_name",
|
||||
category = "category",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ spec:
|
||||
# stdlib only; Flux keeps the tag in lockstep via the shared image
|
||||
# policy) — no extra dependency.
|
||||
- name: reconcile-publish-ip
|
||||
image: ghcr.io/music-assistant/server:2.10.5 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.6 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
@@ -65,7 +65,7 @@ spec:
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: music-assistant
|
||||
image: ghcr.io/music-assistant/server:2.10.5 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.6 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
env:
|
||||
- name: LOG_LEVEL
|
||||
value: info
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
# `paperclip-prometheus-read`. Atlas, Iris, Hestia and Argus get exactly what
|
||||
# they hold today through that shared account: built-in `view` cluster-wide
|
||||
# (no Secrets, no write verb) plus the `monitoring/paperclip-prometheus-read`
|
||||
# Role (see prometheus-read-rbac.yaml). Hermes gets only `get` on the five
|
||||
# token Secrets below, to issue and rotate the kubeconfigs.
|
||||
# Role (see prometheus-read-rbac.yaml). Hermes gets `get` on the five token
|
||||
# Secrets below, to issue and rotate the kubeconfigs, plus that Prometheus
|
||||
# Role (REN-1345).
|
||||
#
|
||||
# Token Secrets carry no data in git; kube-controller-manager fills
|
||||
# `data.token`. Flux applies server-side and does not own `data`, so it does
|
||||
@@ -159,7 +160,9 @@ roleRef:
|
||||
# Existing Role in prometheus-read-rbac.yaml, unchanged.
|
||||
name: paperclip-prometheus-read
|
||||
# Subjects live in `paperclip`, the binding in `monitoring`: `namespace:` is
|
||||
# load-bearing.
|
||||
# load-bearing. agent-hermes is bound here too (REN-1345): its duty list reads
|
||||
# Prometheus, and this Role is `get` on three named read-only proxies only.
|
||||
# It does not get `view`.
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: agent-atlas
|
||||
@@ -173,6 +176,9 @@ subjects:
|
||||
- kind: ServiceAccount
|
||||
name: agent-argus
|
||||
namespace: paperclip
|
||||
- kind: ServiceAccount
|
||||
name: agent-hermes
|
||||
namespace: paperclip
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
|
||||
@@ -34,7 +34,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: paperclip
|
||||
image: gitea.renner.uno/konairius/paperclip:build.1791297080@sha256:0764e882521410d8ce8ed1359f8a1aa64eaa7746aa909653ef70050c239ca195 # {"$imagepolicy": "flux-system:paperclip"}
|
||||
image: gitea.renner.uno/konairius/paperclip:build.1791487850@sha256:ff81da300e6c35581f34fad61a55da771b0e991d2fb2dcb624a0361263f01a37 # {"$imagepolicy": "flux-system:paperclip"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3100
|
||||
|
||||
@@ -50,11 +50,9 @@ data:
|
||||
PAPERLESS_TIKA_ENABLED: "1"
|
||||
PAPERLESS_TIKA_ENDPOINT: "http://tika:9998"
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: "http://gotenberg:3000"
|
||||
# AI suggestions against the cluster's own LLM (kubernetes/apps/llama-server).
|
||||
# Nothing leaves the cluster: both endpoints are ClusterIP Services, so
|
||||
# document text is never sent to a third party and there is no API key to
|
||||
# rotate — llama-server has no authentication, and the ai namespace's network
|
||||
# policy is what restricts who may reach it.
|
||||
# AI suggestions against Mistral AI's hosted API (OpenAI-compatible). Document
|
||||
# text is sent to Mistral. The key is PAPERLESS_AI_LLM_API_KEY, from the
|
||||
# paperless-secrets Secret (OpenBao apps/paperless, field minstral-token).
|
||||
#
|
||||
# CAVEAT, and it is a real one: paperless reads these from an
|
||||
# ApplicationConfiguration row in the database and falls back to the
|
||||
@@ -63,20 +61,14 @@ data:
|
||||
# here — and leave those fields untouched in the UI.
|
||||
PAPERLESS_AI_ENABLED: "true"
|
||||
PAPERLESS_AI_LLM_BACKEND: "openai-like"
|
||||
PAPERLESS_AI_LLM_ENDPOINT: "http://llama-server.ai.svc.cluster.local:8080/v1"
|
||||
# Must match --alias in kubernetes/apps/llama-server/deployment.yaml. The
|
||||
# server ignores the field on the wire, but paperless sends it and the value
|
||||
# is what appears in its logs.
|
||||
PAPERLESS_AI_LLM_MODEL: "qwen3.6-35b-a3b"
|
||||
# ClusterIP addresses are private, and paperless blocks non-public endpoints
|
||||
# unless this is set. Default is already true; explicit because the whole
|
||||
# integration silently stops working if it ever flips.
|
||||
PAPERLESS_AI_LLM_ENDPOINT: "https://api.mistral.ai/v1"
|
||||
PAPERLESS_AI_LLM_MODEL: "mistral-small-2603"
|
||||
# The embedding endpoint is the in-cluster llama-embed, a ClusterIP address,
|
||||
# and paperless blocks non-public endpoints unless this is set. Default is
|
||||
# already true; explicit because the embedding integration silently stops
|
||||
# working if it ever flips.
|
||||
PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS: "true"
|
||||
# Under llama-server's own 16384, leaving room for the response.
|
||||
PAPERLESS_AI_LLM_CONTEXT_SIZE: "12288"
|
||||
# The 120s default is too tight at ~11 tok/s: a classification is roughly
|
||||
# 2-4k prompt tokens plus a second round trip for localisation.
|
||||
PAPERLESS_AI_LLM_REQUEST_TIMEOUT: "300"
|
||||
# Matched against Django's settings.LANGUAGES, so the region is required —
|
||||
# "de" alone does not resolve. Without it the model returns German tags but
|
||||
# English document types, which was reproducible against this endpoint.
|
||||
@@ -86,6 +78,9 @@ data:
|
||||
# primarily English archives — the better fit for a German corpus.
|
||||
PAPERLESS_AI_LLM_EMBEDDING_BACKEND: "openai-like"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT: "http://llama-embed.ai.svc.cluster.local:8080/v1"
|
||||
# The embedding key falls back to PAPERLESS_AI_LLM_API_KEY when unset, which
|
||||
# would send the Mistral token to llama-embed. llama-embed has no auth.
|
||||
PAPERLESS_AI_LLM_EMBEDDING_API_KEY: "unused"
|
||||
# Must match --alias in kubernetes/apps/llama-server/embed.yaml. Changing the
|
||||
# embedding model invalidates every stored vector, so a swap means the LLM
|
||||
# index has to be rebuilt.
|
||||
@@ -200,6 +195,11 @@ spec:
|
||||
secretKeyRef:
|
||||
name: paperless-secrets
|
||||
key: secret-key
|
||||
- name: PAPERLESS_AI_LLM_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: paperless-secrets
|
||||
key: minstral-token
|
||||
- name: PAPERLESS_DBUSER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -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.76.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
image: ghcr.io/usetrmnl/terminus:0.77.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.76.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
image: ghcr.io/usetrmnl/terminus:0.77.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
command: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@@ -34,7 +34,7 @@ spec:
|
||||
# Image automation, range 3.x (see image-automation/woodpecker.yaml).
|
||||
# MUST stay version-matched with woodpecker-server (gRPC parity) — both
|
||||
# track the same range/filter so they bump together on each release.
|
||||
image: woodpeckerci/woodpecker-agent:v3.18.1 # {"$imagepolicy": "flux-system:woodpecker-agent"}
|
||||
image: woodpeckerci/woodpecker-agent:v3.19.0 # {"$imagepolicy": "flux-system:woodpecker-agent"}
|
||||
env:
|
||||
- name: WOODPECKER_SERVER
|
||||
value: woodpecker.woodpecker.svc.cluster.local:9000 # in-cluster gRPC, replaces LXC :9000
|
||||
|
||||
@@ -40,7 +40,7 @@ spec:
|
||||
# minor + patch auto-update within 3.x; only the 4.0 major is held for
|
||||
# a reviewed bump. Safe now the DB is postgres (the old sqlite-surprise
|
||||
# -bump hazard is gone). Stays in lockstep with the agent.
|
||||
image: woodpeckerci/woodpecker-server:v3.18.1 # {"$imagepolicy": "flux-system:woodpecker-server"}
|
||||
image: woodpeckerci/woodpecker-server:v3.19.0 # {"$imagepolicy": "flux-system:woodpecker-server"}
|
||||
env:
|
||||
- name: WOODPECKER_HOST
|
||||
value: https://ci.renner.uno
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/gitea-mcp
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
@@ -4,7 +4,9 @@ metadata:
|
||||
name: alertmanager
|
||||
namespace: flux-system
|
||||
spec:
|
||||
# The notification controller appends `/api/v2/alerts/` itself, so
|
||||
# spec.address is the bare base URL with trailing slash.
|
||||
# The notification controller POSTs to spec.address verbatim (it does
|
||||
# not append a path), so the address must be the full Alertmanager v2
|
||||
# alerts endpoint. The bare base URL is answered with 405 Method Not
|
||||
# Allowed and every Flux notification is dropped.
|
||||
type: alertmanager
|
||||
address: http://kube-prometheus-stack-alertmanager.monitoring.svc.cluster.local:9093/
|
||||
address: http://kube-prometheus-stack-alertmanager.monitoring.svc.cluster.local:9093/api/v2/alerts/
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: backup-verify
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: gitea.renner.uno/gitea_admin/ci-backup-verify
|
||||
interval: 30m
|
||||
---
|
||||
# build-ci-images.yml tags each build YYYY-MM-DD; newest date wins.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: backup-verify
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: backup-verify
|
||||
filterTags:
|
||||
pattern: '^\d{4}-\d{2}-\d{2}$'
|
||||
policy:
|
||||
alphabetical:
|
||||
order: asc
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: docker.gitea.com/gitea-mcp-server
|
||||
interval: 1h
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: gitea-mcp
|
||||
policy:
|
||||
semver:
|
||||
range: ">=1.8.1"
|
||||
@@ -3,9 +3,11 @@ kind: Kustomization
|
||||
resources:
|
||||
- update-automation.yaml
|
||||
- audiobookshelf.yaml
|
||||
- backup-verify.yaml
|
||||
- ceph.yaml
|
||||
- cnpg-barman-plugin.yaml
|
||||
- gatus.yaml
|
||||
- gitea-mcp.yaml
|
||||
- gotenberg.yaml
|
||||
- home-assistant.yaml
|
||||
- immich.yaml
|
||||
|
||||
@@ -198,6 +198,12 @@ kind: Namespace
|
||||
metadata:
|
||||
name: gitea
|
||||
---
|
||||
# Gitea MCP server. Declared here so it exists before infrastructure-crds applies its CNPs.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
---
|
||||
# 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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Reconciles NetBird reverse-proxy services from Ingresses that carry
|
||||
# dns.renner.uno/public: one TLS-passthrough service per host on :443,
|
||||
# dns.renner.uno/public or netbird.renner.uno/public (proxy only, LAN DNS
|
||||
# untouched): one TLS-passthrough service per host on :443,
|
||||
# targeting Traefik's ClusterIP through a subnet resource over the service
|
||||
# CIDR (ci/images/netbird-service-sync/controller.py).
|
||||
#
|
||||
@@ -80,7 +81,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-service-sync:2026-09-23@sha256:64f6057c508640e1dbc50457dc26e9b2de058a111fd46ca096021d7444b5bbff
|
||||
image: gitea.renner.uno/gitea_admin/netbird-service-sync:2026-10-11@sha256:2e8c5e45cffd515cc93e04dc9fd47403a7910368fc7516e94c78d8f53dcd5272
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
# Baseline policy for the `gitea-mcp` namespace.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
namespace: gitea-mcp
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea-mcp
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- 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-mcp
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea
|
||||
app.kubernetes.io/name: gitea
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3000"
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: gitea-mcp-allow-traefik
|
||||
namespace: gitea-mcp
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
@@ -25,6 +25,7 @@ resources:
|
||||
- woodpecker-ci-ns.yaml
|
||||
- woodpecker-ns.yaml
|
||||
- gitea-ns.yaml
|
||||
- gitea-mcp-ns.yaml
|
||||
- netbird-service-sync-ns.yaml
|
||||
- trmnl-ns.yaml
|
||||
- matrix-ns.yaml
|
||||
|
||||
@@ -50,17 +50,6 @@ spec:
|
||||
- ports:
|
||||
- port: "9090"
|
||||
protocol: TCP
|
||||
# velero restore-test Job: its capacity gate reads OSD usage from
|
||||
# Prometheus :9090 (restore-test.sh, preflight_capacity). Scoped to the
|
||||
# Job's pod label; the velero namespace has no egress policy.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": velero
|
||||
app.kubernetes.io/name: restore-test
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "9090"
|
||||
protocol: TCP
|
||||
# Kubelet probes + control-plane (PrometheusRule/ServiceMonitor admission
|
||||
# webhooks via kube-prometheus-stack-operator are called by kube-apiserver).
|
||||
# Also covers external alloy-syslog traffic — ETP=Cluster on the LB SNATs
|
||||
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
# Intra-namespace — velero server ↔ node-agent ↔ restore-test job.
|
||||
# Intra-namespace — velero server ↔ node-agent.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": velero
|
||||
@@ -42,3 +42,44 @@ spec:
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
---
|
||||
# backup-verify CronJob: kube-apiserver, DNS, Hetzner Cloud API, SSH to its VM.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: backup-verify-egress
|
||||
namespace: velero
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: backup-verify
|
||||
egress:
|
||||
- toEntities:
|
||||
- kube-apiserver
|
||||
- host
|
||||
- remote-node
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toFQDNs:
|
||||
- matchName: "api.hetzner.cloud"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "22"
|
||||
protocol: TCP
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# OpenBao policy: rotator-mosquitto-open3e-int
|
||||
#
|
||||
# Least-privilege policy for the mosquitto `open3e-int` broker-credential
|
||||
# rotation CronJob (kubernetes auth role `rotator-mosquitto-open3e-int`).
|
||||
# Per-secret isolation, same template as rotator-mosquitto-health.
|
||||
#
|
||||
# NOT applied by Flux. Apply ONCE with an admin token:
|
||||
#
|
||||
# bao policy write rotator-mosquitto-open3e-int \
|
||||
# kubernetes/infrastructure/openbao/policies/rotator-mosquitto-open3e-int.hcl
|
||||
#
|
||||
# bao write auth/kubernetes/role/rotator-mosquitto-open3e-int \
|
||||
# bound_service_account_names=mosquitto-open3e-int-secret-rotator \
|
||||
# bound_service_account_namespaces=openbao \
|
||||
# policies=rotator-mosquitto-open3e-int ttl=15m
|
||||
#
|
||||
# Do NOT set token_no_default_policy=true (kv-v2 patch preflight needs default).
|
||||
#
|
||||
# BOOTSTRAP: `random` mode rotates an existing field and never creates one.
|
||||
# Seed the path once with a `password` field before the first rotation:
|
||||
#
|
||||
# bao kv put -mount=homelab apps/home-assistant/mosquitto-open3e-int-credentials \
|
||||
# password=<generated>
|
||||
#
|
||||
# The consumer is the open3e-esp32 gateway on the heat pump's internal CAN
|
||||
# (NVS key mq_pass); a rotation takes effect on the device only after its
|
||||
# MQTT settings are updated (web UI or NVS image).
|
||||
path "homelab/data/apps/home-assistant/mosquitto-open3e-int-credentials" {
|
||||
capabilities = ["read", "patch"]
|
||||
}
|
||||
@@ -48,7 +48,6 @@ resources:
|
||||
- review-bot.yaml
|
||||
- claude-oauth.yaml
|
||||
- mosquitto-health.yaml
|
||||
- mosquitto-open3e-int.yaml
|
||||
# NOT ROTATED, deliberately — recorded here because the whole point of this
|
||||
# directory is that unrotated credentials should be a visible choice:
|
||||
#
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
# Class-A rotation of the mosquitto `open3e-int` broker credential: the
|
||||
# open3e-esp32 gateway on the Vitocal internal CAN (ECU 0x6e0). Same
|
||||
# rotator -> OpenBao -> ESO -> Reloader chain as mosquitto-health.yaml.
|
||||
#
|
||||
# The consumer is an embedded device: after a rotation its MQTT password must
|
||||
# be updated on the device (web UI Einstellungen, or an NVS image over USB).
|
||||
#
|
||||
# ON-DEMAND: kubectl -n openbao create job --from=cronjob/mosquitto-open3e-int-secret-rotate mosquitto-open3e-int-secret-rotate-now
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: mosquitto-open3e-int-secret-rotator
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto
|
||||
app.kubernetes.io/component: secret-rotator
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: mosquitto-open3e-int-secret-rotate
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto
|
||||
app.kubernetes.io/component: secret-rotator
|
||||
spec:
|
||||
# Quarterly, own month offset (paperless: 1,4,7,10; health: 2,5,8,11).
|
||||
schedule: "0 4 1 3,6,9,12 *"
|
||||
# Suspended (REN-802): the ESP32 only gets its password by hand, so a
|
||||
# rotation would take the gateway offline. MQTT credentials move to the
|
||||
# git-defined account register; this rotator is removed in that cleanup.
|
||||
suspend: true
|
||||
timeZone: Europe/Berlin
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 2
|
||||
failedJobsHistoryLimit: 5
|
||||
startingDeadlineSeconds: 3600
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
activeDeadlineSeconds: 300
|
||||
ttlSecondsAfterFinished: 259200 # 3 d
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto
|
||||
app.kubernetes.io/component: secret-rotator
|
||||
spec:
|
||||
serviceAccountName: mosquitto-open3e-int-secret-rotator
|
||||
restartPolicy: Never
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: rotate
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-07-30
|
||||
args: ["random"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
env:
|
||||
- name: BAO_ADDR
|
||||
value: http://openbao.openbao.svc.cluster.local:8200
|
||||
- name: BAO_ROLE
|
||||
value: rotator-mosquitto-open3e-int
|
||||
- name: BAO_PATH
|
||||
value: homelab/apps/home-assistant/mosquitto-open3e-int-credentials
|
||||
- name: BAO_FIELDS
|
||||
value: "password"
|
||||
- name: RANDOM_BYTES
|
||||
value: "32"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 48Mi
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: hcloud-backup-verify
|
||||
namespace: velero
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: hcloud-backup-verify
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: infrastructure/hcloud-backup-verify
|
||||
@@ -25,6 +25,7 @@ resources:
|
||||
- netbird-ingress-externalsecret.yaml
|
||||
- velero-externalsecret.yaml
|
||||
- velero-hetzner-creds-externalsecret.yaml
|
||||
- hcloud-backup-verify-externalsecret.yaml
|
||||
# traefik.io CRDs, same reasoning — installed by the Traefik HelmRelease in
|
||||
# infrastructure. Traefik is deliberately absent from that Kustomization's
|
||||
# healthChecks, so this KS may dry-run before the chart lands; it retries on
|
||||
|
||||
@@ -22,6 +22,7 @@ spec:
|
||||
- crowdsec
|
||||
- evcc
|
||||
- flux-system # flux-system-git-auth: syncs the rotated flux gitea PAT into Flux's git secret
|
||||
- gitea # gitea-package-retention token (apps/gitea/package-retention.yaml, REN-1203)
|
||||
- home-assistant
|
||||
- immich
|
||||
- influxdb
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: backup-verify
|
||||
namespace: velero
|
||||
spec:
|
||||
schedule: "0 8 1 * *"
|
||||
timeZone: Etc/UTC
|
||||
suspend: false
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 2
|
||||
failedJobsHistoryLimit: 3
|
||||
startingDeadlineSeconds: 3600
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 14400
|
||||
ttlSecondsAfterFinished: 3456000
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: backup-verify
|
||||
spec:
|
||||
serviceAccountName: backup-verify
|
||||
restartPolicy: Never
|
||||
terminationGracePeriodSeconds: 120
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: backup-verify
|
||||
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-10 # {"$imagepolicy": "flux-system:backup-verify"}
|
||||
command: ["bash", "/script/backup-verify.sh"]
|
||||
env:
|
||||
- name: HOME
|
||||
value: /work
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- { name: script, mountPath: /script, readOnly: true }
|
||||
- { name: work, mountPath: /work }
|
||||
- { name: hcloud, mountPath: /secrets/hcloud, readOnly: true }
|
||||
- { name: s3, mountPath: /secrets/s3, readOnly: true }
|
||||
- { name: repo, mountPath: /secrets/repo, readOnly: true }
|
||||
resources:
|
||||
requests:
|
||||
cpu: 20m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 384Mi
|
||||
volumes:
|
||||
- name: script
|
||||
configMap:
|
||||
name: backup-verify-scripts
|
||||
defaultMode: 0755
|
||||
- name: work
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 128Mi
|
||||
- name: hcloud
|
||||
secret:
|
||||
secretName: hcloud-backup-verify
|
||||
- name: s3
|
||||
secret:
|
||||
secretName: cloud-credentials-hetzner
|
||||
- name: repo
|
||||
secret:
|
||||
secretName: velero-repo-credentials
|
||||
@@ -0,0 +1,29 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: backup-verify
|
||||
namespace: velero
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: backup-verify
|
||||
namespace: velero
|
||||
rules:
|
||||
- apiGroups: ["velero.io"]
|
||||
resources: ["schedules", "backups", "datauploads"]
|
||||
verbs: ["get", "list"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: backup-verify
|
||||
namespace: velero
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: backup-verify
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: backup-verify
|
||||
namespace: velero
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
# Monthly backup verification: plan from Velero, verify on a throwaway
|
||||
# Hetzner Cloud VM, summarize, always delete the VM.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=${SCRIPT_DIR:-/script}
|
||||
WORK=${WORK:-/work}
|
||||
LABEL=managed-by=backup-verify
|
||||
LOCATION=${LOCATION:-fsn1}
|
||||
SERVER_IMAGE=${SERVER_IMAGE:-debian-12}
|
||||
SERVER_TYPES=${SERVER_TYPES:-cx23 cax11}
|
||||
ONLY_NAMESPACES=${ONLY_NAMESPACES:-}
|
||||
SSH_WAIT_S=${SSH_WAIT_S:-600}
|
||||
HCLOUD_TOKEN_FILE=${HCLOUD_TOKEN_FILE:-/secrets/hcloud/token}
|
||||
S3_CREDENTIALS_FILE=${S3_CREDENTIALS_FILE:-/secrets/s3/cloud}
|
||||
REPO_PASSWORD_FILE=${REPO_PASSWORD_FILE:-/secrets/repo/repository-password}
|
||||
RUN_ID=${RUN_ID:-$(date +%s)}
|
||||
NAME="backup-verify-$RUN_ID"
|
||||
SSH_OPTS=()
|
||||
VM_ADDR=""
|
||||
SERVER_TYPE=none
|
||||
START_S=$(date +%s)
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; }
|
||||
|
||||
delete_kind() {
|
||||
local id
|
||||
for id in $(hcloud "$1" list -l "$LABEL" -o noheader -o columns=id); do
|
||||
log "deleting $1 $id"
|
||||
hcloud "$1" delete "$id" >&2 || log "WARN: could not delete $1 $id"
|
||||
done
|
||||
}
|
||||
|
||||
sweep() {
|
||||
local kind
|
||||
for kind in server firewall ssh-key; do delete_kind "$kind"; done
|
||||
}
|
||||
|
||||
build_plan() {
|
||||
local only
|
||||
only=$(jq -cn --arg s "$ONLY_NAMESPACES" '$s | split(",") | map(select(. != ""))')
|
||||
kubectl -n velero get schedules.velero.io -l app.kubernetes.io/part-of=homelab-backup -o json > "$WORK/schedules.json"
|
||||
kubectl -n velero get backups.velero.io -o json > "$WORK/backups.json"
|
||||
kubectl -n velero get datauploads.velero.io -o json > "$WORK/datauploads.json"
|
||||
jq -n --slurpfile s "$WORK/schedules.json" --slurpfile b "$WORK/backups.json" --slurpfile d "$WORK/datauploads.json" \
|
||||
'{schedules: $s[0].items, backups: $b[0].items, datauploads: $d[0].items}' \
|
||||
| jq --argjson only "$only" -f "$SCRIPT_DIR/plan.jq" \
|
||||
| jq --arg f "${FORGE_SNAPSHOT_ID:-}" \
|
||||
'if $f == "" then . else map(if .status == "VERIFY" then .snapshotID = $f else . end) end' \
|
||||
> "$WORK/plan.json"
|
||||
if [ -n "${FORGE_SNAPSHOT_ID:-}" ]; then log "NEGATIVE TEST: every snapshotID replaced by $FORGE_SNAPSHOT_ID"; fi
|
||||
log "plan: $(jq -r 'group_by(.status) | map("\(.[0].status)=\(length)") | join(" ")' "$WORK/plan.json")"
|
||||
}
|
||||
|
||||
# cloud-config as JSON (a YAML subset), so jq does the escaping.
|
||||
render_user_data() {
|
||||
printf '#cloud-config\n'
|
||||
jq -n --rawfile priv "$WORK/host_key" --arg pub "$(cat "$WORK/host_key.pub")" '{
|
||||
ssh_pwauth: false,
|
||||
ssh_deletekeys: true,
|
||||
ssh_genkeytypes: [],
|
||||
ssh_keys: {ed25519_private: $priv, ed25519_public: $pub},
|
||||
packages: ["jq", "curl", "ca-certificates"],
|
||||
runcmd: [
|
||||
"mkdir -p /run/verify && mount -t tmpfs -o size=1500m,mode=0700 tmpfs /run/verify",
|
||||
["shutdown", "-h", "+240"]
|
||||
]
|
||||
}'
|
||||
}
|
||||
|
||||
create_vm() {
|
||||
local type
|
||||
ssh-keygen -q -t ed25519 -N '' -C "$NAME" -f "$WORK/client_key"
|
||||
ssh-keygen -q -t ed25519 -N '' -C "$NAME-host" -f "$WORK/host_key"
|
||||
render_user_data > "$WORK/user-data.yaml"
|
||||
hcloud ssh-key create --name "$NAME" --public-key-from-file "$WORK/client_key.pub" --label "$LABEL" >&2
|
||||
hcloud firewall create --name "$NAME" --label "$LABEL" >&2
|
||||
hcloud firewall add-rule "$NAME" --direction in --protocol tcp --port 22 --source-ips 0.0.0.0/0 --source-ips ::/0 >&2
|
||||
for type in $SERVER_TYPES; do
|
||||
if hcloud server create --name "$NAME" --type "$type" --image "$SERVER_IMAGE" --location "$LOCATION" \
|
||||
--ssh-key "$NAME" --firewall "$NAME" --label "$LABEL" \
|
||||
--user-data-from-file "$WORK/user-data.yaml" -o json > "$WORK/server.json"; then
|
||||
log "server $NAME created (type $type)"
|
||||
SERVER_TYPE=$type
|
||||
return 0
|
||||
fi
|
||||
log "server type $type not available"
|
||||
# A create that timed out may still have left the server behind under $NAME.
|
||||
delete_kind server
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_ssh() {
|
||||
local v4 v6 hostkey addr deadline rc
|
||||
v6=$(hcloud server ip -6 "$NAME")
|
||||
v4=$(hcloud server ip "$NAME")
|
||||
hostkey=$(cut -d' ' -f1,2 "$WORK/host_key.pub")
|
||||
printf '%s %s\n%s %s\n' "$v6" "$hostkey" "$v4" "$hostkey" > "$WORK/known_hosts"
|
||||
SSH_OPTS=(-i "$WORK/client_key" -o "UserKnownHostsFile=$WORK/known_hosts" -o StrictHostKeyChecking=yes
|
||||
-o ConnectTimeout=10 -o BatchMode=yes -o ServerAliveInterval=30)
|
||||
deadline=$(( $(date +%s) + SSH_WAIT_S ))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
for addr in "$v6" "$v4"; do
|
||||
# Background + wait so a TERM is handled while cloud-init is still running.
|
||||
ssh "${SSH_OPTS[@]}" "root@$addr" 'cloud-init status --wait > /run/cloud-init-wait.log' < /dev/null &
|
||||
if wait $!; then
|
||||
VM_ADDR=$addr
|
||||
log "ssh ready via $addr"
|
||||
return 0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
# 255 is an ssh connection error; anything else is cloud-init itself failing.
|
||||
if [ "$rc" -ne 255 ]; then
|
||||
log "cloud-init failed on $addr (exit $rc):"
|
||||
ssh "${SSH_OPTS[@]}" "root@$addr" 'cloud-init status --long' < /dev/null >&2 || true
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
sleep 10
|
||||
done
|
||||
log "ssh not reachable within ${SSH_WAIT_S}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
secrets() {
|
||||
awk -F' *= *' '/^aws_access_key_id/ {print "AWS_ACCESS_KEY_ID=" $2} /^aws_secret_access_key/ {print "AWS_SECRET_ACCESS_KEY=" $2}' "$S3_CREDENTIALS_FILE"
|
||||
printf 'KOPIA_PASSWORD=%s\n' "$(cat "$REPO_PASSWORD_FILE")"
|
||||
}
|
||||
|
||||
push() { ssh "${SSH_OPTS[@]}" "root@$VM_ADDR" "cat > /run/verify/$2" < "$1"; }
|
||||
|
||||
run_vm() {
|
||||
jq '[.[] | select(.status == "VERIFY")]' "$WORK/plan.json" > "$WORK/vm-plan.json"
|
||||
if ! { ssh "${SSH_OPTS[@]}" "root@$VM_ADDR" 'mkdir -p /run/verify' < /dev/null \
|
||||
&& push "$WORK/vm-plan.json" plan.json \
|
||||
&& push "$SCRIPT_DIR/check.jq" check.jq \
|
||||
&& push "$SCRIPT_DIR/vm-verify.sh" vm-verify.sh; }; then
|
||||
log "could not stage files on the VM; every VERIFY entry will report no result"
|
||||
return 0
|
||||
fi
|
||||
# Background + wait: bash runs the TERM trap during `wait`, not during a foreground child.
|
||||
( secrets | ssh "${SSH_OPTS[@]}" "root@$VM_ADDR" 'bash /run/verify/vm-verify.sh /run/verify/plan.json' \
|
||||
| tee "$WORK/vm.out" >&2 ) &
|
||||
wait $! || log "vm-verify exited non-zero"
|
||||
}
|
||||
|
||||
summarize() {
|
||||
{ grep $'^RESULT\t' "$WORK/vm.out" || true; } \
|
||||
| jq -R 'split("\t") | {status: .[1], namespace: .[2], pvc: .[3], snapshotID: .[4], reason: .[5]}' \
|
||||
| jq -s . > "$WORK/results.json"
|
||||
jq -n --slurpfile p "$WORK/plan.json" --slurpfile r "$WORK/results.json" '{plan: $p[0], results: $r[0]}' \
|
||||
| jq -f "$SCRIPT_DIR/summary.jq" > "$WORK/summary.json"
|
||||
jq -r '.entries[] | "\(.status)\t\(.schedule // "-")\t\(.backup // "-")\t\(.namespace // "-")/\(.pvc // "-")\tsnapshot=\(.snapshotID // "-")\t\(.reason // "")"' "$WORK/summary.json"
|
||||
jq -r '"PASS=\(.pass) FAIL=\(.fail) SKIP=\(.skip)"' "$WORK/summary.json"
|
||||
printf 'elapsed=%ss server_type=%s\n' "$(( $(date +%s) - START_S ))" "$SERVER_TYPE"
|
||||
[ "$(jq .fail "$WORK/summary.json")" -eq 0 ]
|
||||
}
|
||||
|
||||
main() {
|
||||
mkdir -p "$WORK"
|
||||
: > "$WORK/vm.out"
|
||||
HCLOUD_TOKEN=$(cat "$HCLOUD_TOKEN_FILE")
|
||||
export HCLOUD_TOKEN
|
||||
trap sweep EXIT
|
||||
trap 'log "TERM received, tearing down"; exit 143' TERM INT
|
||||
sweep
|
||||
build_plan
|
||||
if [ "$(jq '[.[] | select(.status == "VERIFY")] | length' "$WORK/plan.json")" -gt 0 ]; then
|
||||
if create_vm && wait_ssh; then
|
||||
run_vm
|
||||
else
|
||||
log "VM setup failed; every VERIFY entry will report no result"
|
||||
fi
|
||||
fi
|
||||
summarize
|
||||
}
|
||||
|
||||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi
|
||||
@@ -0,0 +1,21 @@
|
||||
# kopia `snapshot list --json` -> verdict per $plan entry (one namespace).
|
||||
(map({key: .id, value: .}) | from_entries) as $m
|
||||
| [ $plan[] as $e
|
||||
| $m[$e.snapshotID] as $s
|
||||
| if $s == null then
|
||||
$e + {status: "FAIL", reason: "snapshot not in repository"}
|
||||
else
|
||||
($s.stats.totalSize // $s.rootEntry.summ.size) as $kb
|
||||
| $e + {rootID: ($s.rootEntry.obj // ""), kopiaBytes: $kb}
|
||||
| if ($s.incomplete // "") != "" then
|
||||
. + {status: "FAIL", reason: "snapshot incomplete: \($s.incomplete)"}
|
||||
elif ($s.rootEntry.summ.numFailed // 0) > 0 then
|
||||
. + {status: "FAIL", reason: "\($s.rootEntry.summ.numFailed) entries failed at backup time"}
|
||||
elif .rootID == "" then
|
||||
. + {status: "FAIL", reason: "snapshot has no root object"}
|
||||
elif $kb != $e.totalBytes then
|
||||
. + {status: "FAIL", reason: "size mismatch kopia=\($kb) velero=\($e.totalBytes)"}
|
||||
else
|
||||
. + {status: "VERIFY"}
|
||||
end
|
||||
end ]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Velero objects -> plan entries. Input: {schedules, backups, datauploads}
|
||||
# (kubectl .items). $only: namespaces to keep; [] keeps all.
|
||||
def finished: . as $p | ["Completed", "PartiallyFailed", "Failed", "FailedValidation"] | index($p) != null;
|
||||
|
||||
.backups as $backups
|
||||
| .datauploads as $dus
|
||||
| [ .schedules | sort_by(.metadata.name)[]
|
||||
| .metadata.name as $s
|
||||
| ([ $backups[]
|
||||
| select(.metadata.labels["velero.io/schedule-name"] == $s)
|
||||
| select((.status.phase // "") | finished) ]
|
||||
| sort_by(.metadata.creationTimestamp) | last) as $b
|
||||
| if $b == null then
|
||||
{schedule: $s, status: "FAIL", reason: "no finished backup"}
|
||||
elif $b.status.phase != "Completed" then
|
||||
{schedule: $s, backup: $b.metadata.name, status: "FAIL",
|
||||
reason: "latest finished backup phase=\($b.status.phase)"}
|
||||
else
|
||||
[ $dus[] | select(.metadata.labels["velero.io/backup-name"] == $b.metadata.name) ] as $mine
|
||||
| if ($mine | length) == 0 then
|
||||
{schedule: $s, backup: $b.metadata.name, status: "SKIP", reason: "no DataUploads"}
|
||||
else
|
||||
$mine | sort_by(.spec.sourceNamespace, .spec.sourcePVC)[]
|
||||
| {schedule: $s, backup: $b.metadata.name,
|
||||
namespace: .spec.sourceNamespace, pvc: .spec.sourcePVC,
|
||||
snapshotID: (.status.snapshotID // ""),
|
||||
totalBytes: (.status.progress.totalBytes // 0)}
|
||||
+ if .status.phase != "Completed" then
|
||||
{status: "FAIL", reason: "DataUpload phase=\(.status.phase)"}
|
||||
elif (.status.snapshotID // "") == "" then
|
||||
{status: "FAIL", reason: "DataUpload has no snapshotID"}
|
||||
else
|
||||
{status: "VERIFY"}
|
||||
end
|
||||
end
|
||||
end ]
|
||||
| if ($only | length) > 0 then map(select(.namespace as $n | $only | index($n) != null)) else . end
|
||||
@@ -0,0 +1,15 @@
|
||||
# {plan, results} -> {entries, pass, fail, skip}.
|
||||
(.results | map({key: "\(.namespace)/\(.pvc)/\(.snapshotID)", value: .}) | from_entries) as $r
|
||||
| [ .plan[]
|
||||
| if .status == "VERIFY" then
|
||||
$r["\(.namespace)/\(.pvc)/\(.snapshotID)"] as $x
|
||||
| if $x == null then . + {status: "FAIL", reason: "no result from VM"}
|
||||
else . + {status: $x.status, reason: $x.reason} end
|
||||
else . end ] as $e
|
||||
| {entries: $e,
|
||||
pass: ([$e[] | select(.status == "OK")] | length),
|
||||
fail: ([$e[] | select(.status == "FAIL")] | length),
|
||||
skip: ([$e[] | select(.status == "SKIP")] | length)}
|
||||
| if (.pass + .fail) == 0 then
|
||||
.fail += 1 | .entries += [{status: "FAIL", reason: "nothing verified"}]
|
||||
else . end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user