Compare commits
359
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.283
|
||||
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: build-agent-runtime
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images
|
||||
# Context is ci/images (not the variant dir) so both Containerfiles can
|
||||
# COPY the shared agent-runtime-common/ files.
|
||||
|
||||
@@ -43,7 +43,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: build-ansible
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/ansible
|
||||
depends_on: []
|
||||
# Only rebuild when THIS image's source (or the pipeline file) changed, or
|
||||
@@ -71,7 +71,7 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/ci-ansible:latest
|
||||
|
||||
- name: build-k8s
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/k8s
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -95,7 +95,7 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
|
||||
|
||||
- name: build-vmtest
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/vmtest
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -119,7 +119,7 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/ci-vmtest:latest
|
||||
|
||||
- name: build-tofu
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/tofu
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -150,7 +150,7 @@ steps:
|
||||
# Runtime image, not CI tooling: a static musl binary in a FROM-scratch image,
|
||||
# used as the base for secret-rotation CronJobs. Replaces per-job inline Python.
|
||||
- name: build-secret-rotator
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: secret-rotator
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -201,7 +201,7 @@ steps:
|
||||
#
|
||||
# The gitea_admin/ copy stays until nothing references it any more.
|
||||
- name: build-alpine-jq
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/alpine-jq
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -254,7 +254,7 @@ steps:
|
||||
# note on build-alpine-jq. Anything an app needs while the cluster is still
|
||||
# being built cannot live only in that cluster's own registry.
|
||||
- name: build-cni-plugins
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/cni-plugins
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -296,7 +296,7 @@ steps:
|
||||
# gitea.renner.uno is not there to serve it. A cold-rebuild rehearsal left
|
||||
# paperless in Init:ErrImagePull on exactly this image.
|
||||
- name: build-alpine-openssl
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/alpine-openssl
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -333,7 +333,7 @@ steps:
|
||||
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
|
||||
# builds the same way and lands in the same registry so the audit sees it.
|
||||
- name: build-heating-can
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/heating-can
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -360,7 +360,7 @@ steps:
|
||||
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
|
||||
# Runtime artifact like heating-can; same registry so the audit tracks it.
|
||||
- name: build-netbird-service-sync
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/netbird-service-sync
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -386,7 +386,7 @@ steps:
|
||||
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
|
||||
# artifact like heating-can; same registry so the audit tracks it.
|
||||
- name: build-bird
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/bird
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Paperclip server image (REN-401): upstream paperclipai/paperclip at a pinned
|
||||
# master commit plus the patches in ci/images/paperclip/. Needed while the k8s
|
||||
# sandbox fix (upstream PR #13733) is not merged; see the README there for how
|
||||
# to go back to upstream nightly.
|
||||
#
|
||||
# Own workflow for the reason given in build-agent-runtime.yml: a step in
|
||||
# build-ci-images.yml runs on every change to that file and competes for the
|
||||
# woodpecker-ci-quota. Re-run alone with a deployment to target
|
||||
# `build-paperclip`.
|
||||
#
|
||||
# Tag `build.<unix-ts>` is what the Flux ImagePolicy `paperclip` sorts on
|
||||
# (image-automation/paperclip.yaml); image automation then rolls it out.
|
||||
# konairius/ and ci_images_token as for the agent-runtime images.
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
path:
|
||||
- "ci/images/paperclip/**"
|
||||
- ".woodpecker/build-paperclip.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-paperclip"'
|
||||
|
||||
steps:
|
||||
- name: prepare-source
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
commands:
|
||||
- sh ci/images/paperclip/prepare.sh build/paperclip-src
|
||||
|
||||
- name: build-paperclip
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: build/paperclip-src
|
||||
depends_on: [prepare-source]
|
||||
# Rust runner + UI + server build in one pod. The server build alone sets
|
||||
# a 4 GiB Node heap, so the memory limit is the LimitRange max (8Gi).
|
||||
backend_options:
|
||||
kubernetes:
|
||||
resources:
|
||||
requests:
|
||||
cpu: "1"
|
||||
memory: 2Gi
|
||||
limits:
|
||||
cpu: "4"
|
||||
memory: 8Gi
|
||||
environment:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
commands:
|
||||
- . ../../ci/images/paperclip/upstream.env
|
||||
- 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=build.$(date +%s)
|
||||
# Same target and build args as upstream's docker.yml for nightly.
|
||||
# --compressed-caching=false: pipeline 11717 was killed at the 8Gi limit
|
||||
# (exit 137) in the server build; kaniko otherwise holds compressed
|
||||
# layer tarballs in memory next to the 4 GiB Node heap.
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Dockerfile.kaniko \
|
||||
--target production \
|
||||
--skip-unused-stages \
|
||||
--snapshot-mode=redo \
|
||||
--compressed-caching=false \
|
||||
--build-arg PAPERCLIP_BUILD_COMMIT=$PAPERCLIP_COMMIT \
|
||||
--build-arg PAPERCLIP_BUILD_VERSION=$PAPERCLIP_VERSION \
|
||||
--build-arg CLI_TOOLS_CACHE_EPOCH=$(date -u +%G-W%V) \
|
||||
--destination gitea.renner.uno/konairius/paperclip:$TAG
|
||||
@@ -131,7 +131,7 @@ steps:
|
||||
# --build-arg to the resolved version (not "latest") guarantees the tag and
|
||||
# the image content agree even if a new release lands mid-run.
|
||||
- name: build
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/claude
|
||||
depends_on: [decide]
|
||||
environment:
|
||||
|
||||
@@ -172,7 +172,7 @@ steps:
|
||||
# whenever it trails the deployed server. If you bump the server, bump here.
|
||||
- name: woodpecker-lint
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: woodpeckerci/woodpecker-cli:v3.18.0-alpine
|
||||
image: woodpeckerci/woodpecker-cli:v3.18.1-alpine@sha256:7eac3c4254befc1ba1e65269c3304c370b6032530b7386fc0c7648378fdd0516
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
@@ -199,7 +199,7 @@ steps:
|
||||
# separately rather than one this step quietly implies is covered.
|
||||
- name: shellcheck
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: koalaman/shellcheck-alpine:v0.11.0
|
||||
image: koalaman/shellcheck-alpine:v0.11.0@sha256:9955be09ea7f0dbf7ae942ac1f2094355bb30d96fffba0ec09f5432207544002
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
@@ -319,7 +319,7 @@ steps:
|
||||
# writes its stripped mirror under mktemp and never into the workspace.
|
||||
- name: promtool
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: prom/prometheus:v3.15.0
|
||||
image: prom/prometheus:v3.15.0@sha256:efd719c99d83b060d9daefdcf00360461adf279f45ef5391f8d111892118753e
|
||||
commands:
|
||||
- sh scripts/ci/promtool-rules.sh
|
||||
|
||||
|
||||
@@ -35,18 +35,24 @@ when:
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
|
||||
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.283
|
||||
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.283
|
||||
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
|
||||
@@ -13,7 +13,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: validate-secrets
|
||||
image: alpine:3.24.1
|
||||
image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
environment:
|
||||
TF_VAR_proxmox_api_url:
|
||||
from_secret: proxmox_api_url
|
||||
|
||||
+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.283
|
||||
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.283
|
||||
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.283
|
||||
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
|
||||
|
||||
@@ -18,12 +18,12 @@ when:
|
||||
|
||||
steps:
|
||||
- name: iac-scan
|
||||
image: aquasec/trivy:latest
|
||||
image: aquasec/trivy:latest@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
|
||||
commands:
|
||||
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
|
||||
|
||||
- name: image-scans
|
||||
image: aquasec/trivy:latest
|
||||
image: aquasec/trivy:latest@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
|
||||
commands:
|
||||
- trivy image --download-db-only
|
||||
- |
|
||||
@@ -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.283
|
||||
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.283
|
||||
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.283
|
||||
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.283
|
||||
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.283
|
||||
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.283
|
||||
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.283
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
pull: true
|
||||
environment:
|
||||
WIKI_BOT_TOKEN:
|
||||
|
||||
@@ -58,11 +58,12 @@ 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 |
|
||||
|---|---|---|
|
||||
| `gitea.renner.uno/konairius/agent-runtime-go/agent-runtime-claude:v1` | `gitea.renner.uno/konairius/agent-runtime-go` | Go toolchain, golangci-lint |
|
||||
| `gitea.renner.uno/konairius/agent-runtime-go/agent-runtime-claude:v1` | `gitea.renner.uno/konairius/agent-runtime-go` | Go toolchain, golangci-lint, govulncheck |
|
||||
| `gitea.renner.uno/konairius/agent-runtime-ops/agent-runtime-claude:v1` | `gitea.renner.uno/konairius/agent-runtime-ops` | kubectl, flux, kustomize, helm |
|
||||
|
||||
The plugin keeps the name `agent-runtime-claude:v1` and swaps only host and
|
||||
|
||||
@@ -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."
|
||||
@@ -7,6 +7,13 @@
|
||||
#
|
||||
# Both binaries are static; they run on the Ubuntu 22.04 base unchanged.
|
||||
FROM golang:1.27.1-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS go
|
||||
|
||||
# govulncheck (REN-488) has no upstream image: build it in the go stage, static.
|
||||
# The version pins the module; go verifies it against sum.golang.org. Renovate
|
||||
# does not track it (no regex manager here): bump by hand.
|
||||
FROM go AS govulncheck
|
||||
RUN CGO_ENABLED=0 GOBIN=/out go install golang.org/x/vuln/cmd/govulncheck@v1.8.0
|
||||
|
||||
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS golangci-lint
|
||||
|
||||
# upstream publishes no `v1` tag, only git-<sha> tags. Renovate keeps the
|
||||
@@ -15,6 +22,7 @@ FROM ghcr.io/paperclipai/agent-runtime-claude:git-38d8f371722b315d2fb3bbaa512518
|
||||
|
||||
COPY --from=go /usr/local/go /usr/local/go
|
||||
COPY --from=golangci-lint /usr/bin/golangci-lint /usr/local/bin/golangci-lint
|
||||
COPY --from=govulncheck /out/govulncheck /usr/local/bin/govulncheck
|
||||
# go and gofmt also in /usr/local/bin: a process started with the default PATH
|
||||
# (not the image ENV) still finds them.
|
||||
USER root
|
||||
@@ -22,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
|
||||
@@ -55,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 claude-agent-acp; do command -v "$t"; done'
|
||||
sh -ec 'for t in go gofmt golangci-lint govulncheck claude-agent-acp; do command -v "$t"; done'
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
#
|
||||
# kubectl and flux match the ci-k8s image. All four binaries are static; they
|
||||
# run on the Ubuntu 22.04 base unchanged.
|
||||
FROM registry.k8s.io/kubectl:v1.36.5@sha256:0667e3f3e6ef435d7cd4c16684244703ebd67314c17511c2a9f87ea44f6cdf6b AS kubectl
|
||||
FROM ghcr.io/fluxcd/flux-cli:v2.9.5@sha256:704d5529535570a495249b643159c93a9aa88ffca07813c974a54b7ba6edbd86 AS flux
|
||||
FROM registry.k8s.io/kubectl:v1.37.1@sha256:b7cab618e281b1ee7484e7b706a96e2135fbb6e072c2a573a7dab4e87d7f2385 AS kubectl
|
||||
FROM ghcr.io/fluxcd/flux-cli:v2.9.6@sha256:b1ac18156f227af9a524b842a96f2c20986c7a779ef721df3ba4e4540f449d76 AS flux
|
||||
FROM registry.k8s.io/kustomize/kustomize:v5.8.1@sha256:899fcd3bc898160e62bcaf82932b0cb29ba38d16272353db2e7acbba82129429 AS kustomize
|
||||
FROM alpine/helm:4.3.0@sha256:a6cf54599ccb99d90cf0712b30f03fdb3cab062e6b94e0418cc4db7e8a1464b2 AS helm
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
# Alpine plus jq. Nothing application-specific — a general-purpose base for any
|
||||
# workload that needs to read JSON from a shell script.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
# Alpine plus the openssl CLI. Nothing application-specific — named for what it
|
||||
# is, so anything needing `openssl s_client` / `x509` from a shell script can use
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
RUN apk add --no-cache \
|
||||
ansible \
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
# bird: BGP daemon for the pod-network mesh over pod0. Distro package, no build
|
||||
# step — the image is the binary plus its runtime.
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
# amd64 only, matching the ARCH the installer already hardcoded and the cluster's
|
||||
# node architecture. Add a second stage if a different arch is ever needed.
|
||||
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d AS fetch
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 AS fetch
|
||||
|
||||
ARG CNI_PLUGINS_VERSION=v1.5.1
|
||||
ARG CNI_PLUGINS_SHA256=77baa2f669980a82255ffa2f2717de823992480271ee778aa51a9c60ae89ff9b
|
||||
ARG CNI_PLUGINS_VERSION=v1.9.1
|
||||
ARG CNI_PLUGINS_SHA256=b98f74a0f8522f0a83867178729c1aa70f2158f90c45a2ca8fa791db1c76b303
|
||||
|
||||
# busybox wget and tar are already in the base; nothing is installed here
|
||||
# either. sha256sum -c fails the BUILD if the artifact does not match, which is
|
||||
@@ -61,7 +61,7 @@ RUN set -eu; \
|
||||
[ -x "/plugins/$p" ] || { echo "FATAL: /plugins/$p missing or not executable" >&2; exit 1; }; \
|
||||
done
|
||||
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
# Alpine rather than scratch: the installer needs a shell to copy the binaries
|
||||
# onto the host. busybox covers cp/mv/chown/ls, so nothing is added.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
# namespace, so can0 is only reachable with network_mode: host. That is also
|
||||
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
|
||||
# no capabilities, so both run unprivileged.
|
||||
FROM python:3.13-slim AS build
|
||||
FROM python:3.14-slim@sha256:c3e521df8b2b498a7a682e7e18676771cb80c6b75b8699af886b2d554ce40151 AS build
|
||||
|
||||
# Pins live here, in the artifact, rather than in Ansible variables.
|
||||
#
|
||||
@@ -56,7 +56,7 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
|
||||
&& rm -rf /opt/e3oncan/.git
|
||||
|
||||
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.14-slim@sha256:c3e521df8b2b498a7a682e7e18676771cb80c6b75b8699af886b2d554ce40151
|
||||
|
||||
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
|
||||
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
ARG TALOS_VERSION=v1.13.10
|
||||
ARG VELERO_VERSION=v1.16.0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
|
||||
# public label. Standard library only, so the image is the interpreter plus one
|
||||
# file; runs unprivileged as its own user.
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.14-slim@sha256:c3e521df8b2b498a7a682e7e18676771cb80c6b75b8699af886b2d554ce40151
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY controller.py /opt/controller.py
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Paperclip server image (REN-401)
|
||||
|
||||
Upstream `paperclipai/paperclip` at the commit in `upstream.env`, plus every
|
||||
`*.patch` in this directory. Built by `.woodpecker/build-paperclip.yml`,
|
||||
pushed as `gitea.renner.uno/konairius/paperclip:build.<unix-ts>`, rolled out
|
||||
by Flux image automation (`kubernetes/infrastructure/image-automation/paperclip.yaml`).
|
||||
|
||||
## Why
|
||||
|
||||
k8s sandbox runs fail: the run path never sets the lease `remoteCwd`, so file
|
||||
sync into the pod fails (paperclipai/paperclip#13864). The fix is
|
||||
paperclipai/paperclip#13733, open and not merged.
|
||||
`pr-13733-lease-remote-cwd.patch` is that PR's diff (head `5f320bf6`).
|
||||
|
||||
#13733 covers only the run path. Adapter environment tests (binding an agent
|
||||
to a k8s environment) sync managed config before any workspace is realized,
|
||||
so the lease still has no `remoteCwd` and the Claude test fails with
|
||||
`claude_managed_config_dir_failed` (REN-409). The fix is
|
||||
paperclipai/paperclip#14769, also open: the plugin sets `remoteCwd:
|
||||
/workspace` when it acquires or resumes a lease.
|
||||
`pr-14769-probe-remote-cwd.patch` is that PR's diff (head `10dc7371`).
|
||||
|
||||
Claude auto memory is lost after every run (REN-541): it lives under
|
||||
`CLAUDE_CONFIG_DIR`, which Paperclip makes fresh per run and deletes.
|
||||
`ren-541-claude-auto-memory.patch` makes the ACPX engine write
|
||||
`autoMemoryDirectory: <AGENT_HOME>/memory` into the per-run
|
||||
`.claude/settings.local.json`. `AGENT_HOME` is restored before and saved after
|
||||
every run, local and in k8s pods. The value is not part of the session
|
||||
fingerprint, so resume is unchanged. No upstream PR yet; rename the patch to
|
||||
`pr-<n>-…` once there is one.
|
||||
|
||||
Claude sessions never resume with a managed AI connection (REN-563): the
|
||||
server gives every run a fresh temp home (`HOME`, `XDG_*`,
|
||||
`CLAUDE_CONFIG_DIR`, …) and deletes it after the run.
|
||||
`ren-563-acpx-claude-session-resume.patch` fixes both halves for local runs:
|
||||
the ACPX engine hashes a fixed placeholder for those paths in the session
|
||||
fingerprint (`adapterEnvHash`), as the server's own fingerprint already does,
|
||||
and links `CLAUDE_CONFIG_DIR/projects` (the Claude transcripts) to
|
||||
`<stateDir>/claude-projects`, which persists. Both must ship together: a
|
||||
stable fingerprint without the transcript makes the resumed turn fail with
|
||||
"Resource not found" (homelab#1111, reverted in #1113). Remote runs are
|
||||
unchanged. No upstream PR yet.
|
||||
|
||||
A cancelled or failed k8s pod run blocks its card for good (REN-841). The
|
||||
owner's next comment waits on `remote_cleanup`, because the server continues
|
||||
only when every remote lease carries a provider termination receipt
|
||||
(`explicit-native-continuation.ts`), and the Kubernetes plugin's
|
||||
`onEnvironmentReleaseLease` / `onEnvironmentDestroyLease` returned `void`.
|
||||
`ren-841-k8s-termination-receipt.patch` makes both return
|
||||
`{ providerLeaseId, state: "destroyed" }`, but only after the Sandbox CR or
|
||||
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
|
||||
image. Bump both together.
|
||||
- `prepare.sh` — fetches the commit, applies the patches, writes
|
||||
`Dockerfile.kaniko` (upstream's `COPY --parents` line split into plain
|
||||
`COPY` lines; kaniko does not support `--parents`), and adds the build of
|
||||
the Kubernetes sandbox provider plugin to the `build` stage (REN-405):
|
||||
upstream's `production` target ships only its source, so the plugin fails
|
||||
with "no longer exposes a Paperclip manifest". The build fails if
|
||||
`dist/manifest.js` is missing.
|
||||
- `*.patch` — applied in name order with `git apply`.
|
||||
|
||||
## Moving to a newer upstream commit
|
||||
|
||||
Set `PAPERCLIP_COMMIT` to the new master commit and `PAPERCLIP_VERSION` to
|
||||
`git describe --tags --match 'v*' --long <commit>`, then run
|
||||
`sh prepare.sh /tmp/src`: it fails if a patch no longer applies.
|
||||
|
||||
## Exit: when every patch here is merged upstream
|
||||
|
||||
Renovate cannot watch an upstream PR, so this is a manual step. Once
|
||||
upstream nightly contains all of them:
|
||||
|
||||
1. `kubernetes/infrastructure/image-automation/paperclip.yaml`: back to
|
||||
`ghcr.io/paperclipai/paperclip` with `filterTags.pattern: '^nightly$'`
|
||||
(the version before REN-401 in git history).
|
||||
2. `renovate.json`: swap `gitea.renner.uno/konairius/paperclip` back to
|
||||
`ghcr.io/paperclipai/paperclip` in the image-automation exclusion list.
|
||||
3. Delete this directory and `.woodpecker/build-paperclip.yml`.
|
||||
|
||||
Image automation then rewrites `kubernetes/apps/paperclip/paperclip.yaml`
|
||||
to the nightly digest by itself.
|
||||
@@ -0,0 +1,93 @@
|
||||
diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts
|
||||
index f3d1fc659..812cae357 100644
|
||||
--- a/server/src/__tests__/environment-run-orchestrator.test.ts
|
||||
+++ b/server/src/__tests__/environment-run-orchestrator.test.ts
|
||||
@@ -430,6 +430,51 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
|
||||
expect(result.persistedExecutionWorkspace).toEqual(updatedEw);
|
||||
});
|
||||
|
||||
+ it("persists the realized cwd as remoteCwd when realization returns no workspaceRealization payload", async () => {
|
||||
+ const runtime = makeMockRuntime({
|
||||
+ realizeWorkspace: vi.fn().mockResolvedValue({
|
||||
+ cwd: "/workspace",
|
||||
+ metadata: { provider: "kubernetes", remoteCwd: "/workspace" },
|
||||
+ }),
|
||||
+ } as Partial<EnvironmentRuntimeService>);
|
||||
+ mockResolveEnvironmentExecutionTarget.mockResolvedValue({ kind: "local", environmentId: "env-1", leaseId: "lease-1" });
|
||||
+
|
||||
+ const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
|
||||
+
|
||||
+ await orchestrator.realizeForRun(
|
||||
+ makeRealizeInput({ environment: makeEnvironment("sandbox") }),
|
||||
+ );
|
||||
+
|
||||
+ expect(mockUpdateLeaseMetadata).toHaveBeenCalledWith(
|
||||
+ "lease-1",
|
||||
+ expect.objectContaining({ remoteCwd: "/workspace" }),
|
||||
+ );
|
||||
+ // Nothing to record about the realization shape, so the execution workspace
|
||||
+ // row is left untouched.
|
||||
+ expect(mockUpdateExecutionWorkspace).not.toHaveBeenCalled();
|
||||
+ });
|
||||
+
|
||||
+ it("does not rewrite lease metadata when the realized cwd already matches the lease", async () => {
|
||||
+ const runtime = makeMockRuntime({
|
||||
+ realizeWorkspace: vi.fn().mockResolvedValue({
|
||||
+ cwd: "/workspace",
|
||||
+ metadata: { provider: "kubernetes", remoteCwd: "/workspace" },
|
||||
+ }),
|
||||
+ } as Partial<EnvironmentRuntimeService>);
|
||||
+ mockResolveEnvironmentExecutionTarget.mockResolvedValue({ kind: "local", environmentId: "env-1", leaseId: "lease-1" });
|
||||
+
|
||||
+ const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
|
||||
+
|
||||
+ await orchestrator.realizeForRun(
|
||||
+ makeRealizeInput({
|
||||
+ environment: makeEnvironment("sandbox"),
|
||||
+ lease: makeLease({ metadata: { remoteCwd: "/workspace" } }),
|
||||
+ }),
|
||||
+ );
|
||||
+
|
||||
+ expect(mockUpdateLeaseMetadata).not.toHaveBeenCalled();
|
||||
+ });
|
||||
+
|
||||
it("runs a remote provision command after workspace realization when configured", async () => {
|
||||
mockBuildWorkspaceRealizationRequest.mockReturnValue({
|
||||
version: 1,
|
||||
diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts
|
||||
index 97cca667e..e76f64913 100644
|
||||
--- a/server/src/services/environment-run-orchestrator.ts
|
||||
+++ b/server/src/services/environment-run-orchestrator.ts
|
||||
@@ -485,16 +485,29 @@ export function environmentRunOrchestrator(
|
||||
}
|
||||
|
||||
// Step 3: Persist realization metadata on lease and execution workspace
|
||||
- if (Object.keys(workspaceRealization).length > 0) {
|
||||
+ const hasWorkspaceRealization = Object.keys(workspaceRealization).length > 0;
|
||||
+ // A driver that realizes the workspace reports the directory it prepared
|
||||
+ // through the realization `cwd`. Persist it on the lease as `remoteCwd`, so
|
||||
+ // every later step that receives only the lease — native file sync, a
|
||||
+ // resumed lease, a second run against the same lease — confines itself to
|
||||
+ // the same root. Without it a driver whose realization returns no
|
||||
+ // `workspaceRealization` payload leaves the lease without a root at all,
|
||||
+ // and the sync step fails even though realization succeeded.
|
||||
+ const currentRemoteCwd =
|
||||
+ typeof lease.metadata?.remoteCwd === "string" ? lease.metadata.remoteCwd.trim() : "";
|
||||
+ const realizedRemoteCwd =
|
||||
+ realizedWorkspaceCwd && realizedWorkspaceCwd !== currentRemoteCwd ? realizedWorkspaceCwd : null;
|
||||
+ if (hasWorkspaceRealization || realizedRemoteCwd) {
|
||||
const nextLeaseMetadata = {
|
||||
...(lease.metadata ?? {}),
|
||||
- workspaceRealization,
|
||||
+ ...(hasWorkspaceRealization ? { workspaceRealization } : {}),
|
||||
+ ...(realizedRemoteCwd ? { remoteCwd: realizedRemoteCwd } : {}),
|
||||
};
|
||||
const updatedLease = await environmentsSvc.updateLeaseMetadata(lease.id, nextLeaseMetadata);
|
||||
if (updatedLease) {
|
||||
lease = updatedLease;
|
||||
}
|
||||
- if (persistedExecutionWorkspace) {
|
||||
+ if (hasWorkspaceRealization && persistedExecutionWorkspace) {
|
||||
const updatedEw = await executionWorkspacesSvc.update(persistedExecutionWorkspace.id, {
|
||||
metadata: {
|
||||
...(persistedExecutionWorkspace.metadata ?? {}),
|
||||
@@ -0,0 +1,86 @@
|
||||
diff --git a/packages/plugins/sandbox-providers/kubernetes/README.md b/packages/plugins/sandbox-providers/kubernetes/README.md
|
||||
index a51c6ecee60e..72618cb11296 100644
|
||||
--- a/packages/plugins/sandbox-providers/kubernetes/README.md
|
||||
+++ b/packages/plugins/sandbox-providers/kubernetes/README.md
|
||||
@@ -140,6 +140,8 @@ Every agent pod is:
|
||||
|
||||
Plus per-namespace `pod-security.kubernetes.io/enforce: restricted` and a deny-all NetworkPolicy baseline with explicit egress allow-list (DNS, paperclip-server, configured FQDNs/CIDRs).
|
||||
|
||||
+The lease starts with `/workspace` as the native file-sync confinement root. Adapter environment tests can upload managed config before the server realizes a workspace. When resuming a lease, the provider keeps a previously recorded `remoteCwd` so a custom realized workspace is not replaced with the default.
|
||||
+
|
||||
The per-run Secret carrying the bootstrap token and adapter API keys has `ownerReferences` pointing at the owning Job, so a single `kubectl delete job …` cascades cleanly to the Pod and Secret.
|
||||
|
||||
## Optional Kata-FC microVM isolation
|
||||
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
|
||||
index 8ae47de8ecac..98016385da05 100644
|
||||
--- a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
|
||||
+++ b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
|
||||
@@ -454,6 +454,9 @@ const plugin = definePlugin({
|
||||
secretName,
|
||||
phase: "Pending",
|
||||
backend: config.backend,
|
||||
+ // Adapter environment probes sync managed assets before workspace
|
||||
+ // realization. Give native sync the pod's mounted confinement root now.
|
||||
+ remoteCwd: "/workspace",
|
||||
scopedNetworkPolicyName,
|
||||
scopedNetworkEgress,
|
||||
// Native file sync streams over a pod exec; only the sandbox-cr backend
|
||||
@@ -529,6 +532,10 @@ const plugin = definePlugin({
|
||||
secretName,
|
||||
phase: check.phase,
|
||||
backend: leaseBackend,
|
||||
+ remoteCwd:
|
||||
+ typeof params.leaseMetadata?.remoteCwd === "string" && params.leaseMetadata.remoteCwd.trim()
|
||||
+ ? params.leaseMetadata.remoteCwd.trim()
|
||||
+ : "/workspace",
|
||||
scopedNetworkPolicyName:
|
||||
typeof params.leaseMetadata?.scopedNetworkPolicyName === "string"
|
||||
? params.leaseMetadata.scopedNetworkPolicyName
|
||||
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/types.ts b/packages/plugins/sandbox-providers/kubernetes/src/types.ts
|
||||
index 5626daa93846..ef3d0d889a4c 100644
|
||||
--- a/packages/plugins/sandbox-providers/kubernetes/src/types.ts
|
||||
+++ b/packages/plugins/sandbox-providers/kubernetes/src/types.ts
|
||||
@@ -90,6 +90,8 @@ export interface KubernetesLeaseMetadata {
|
||||
phase: "Pending" | "Running" | "Succeeded" | "Failed";
|
||||
/** Which backend provisioned this lease. */
|
||||
backend: "sandbox-cr" | "job";
|
||||
+ /** Native sync confinement root; present before workspace realization for probes. */
|
||||
+ remoteCwd: string;
|
||||
scopedNetworkPolicyName: string | null;
|
||||
scopedNetworkEgress: {
|
||||
allowFqdns: string[];
|
||||
diff --git a/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts b/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
|
||||
index 7ad0ca4807e5..4c23410d4d15 100644
|
||||
--- a/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
|
||||
+++ b/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
|
||||
@@ -80,6 +80,7 @@ describe("onEnvironmentResumeLease", () => {
|
||||
secretName: "pc-abc-env",
|
||||
phase: "Running",
|
||||
backend: "sandbox-cr",
|
||||
+ remoteCwd: "/workspace",
|
||||
resumedLease: true,
|
||||
// sandbox-cr has a pod-exec channel, so native file sync stays enabled.
|
||||
nativeFileSyncUnsupported: false,
|
||||
@@ -87,6 +88,22 @@ describe("onEnvironmentResumeLease", () => {
|
||||
);
|
||||
});
|
||||
|
||||
+ it("keeps a custom realized cwd when resuming a sandbox lease", async () => {
|
||||
+ h.clients = {
|
||||
+ custom: { getNamespacedCustomObject: vi.fn().mockResolvedValue(readySandboxCr("pc-abc-pod")) },
|
||||
+ core: { readNamespacedPod: vi.fn().mockResolvedValue({ status: { phase: "Running" } }) },
|
||||
+ };
|
||||
+ const lease = await plugin.definition.onEnvironmentResumeLease!({
|
||||
+ driverKey: "kubernetes",
|
||||
+ companyId: "acme",
|
||||
+ environmentId: "env-1",
|
||||
+ config: CONFIG,
|
||||
+ providerLeaseId: "pc-abc",
|
||||
+ leaseMetadata: leaseMetadata({ remoteCwd: "/workspace/custom" }),
|
||||
+ });
|
||||
+ expect(lease.metadata?.remoteCwd).toBe("/workspace/custom");
|
||||
+ });
|
||||
+
|
||||
it("flags a resumed job-backend lease as native-sync-unsupported so the server keeps the base64 fallback", async () => {
|
||||
h.clients = {
|
||||
batch: {
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/sh
|
||||
# Fetch upstream Paperclip at the pinned commit, apply our patches and make
|
||||
# the Dockerfile buildable with kaniko. Usage: prepare.sh <target-dir>
|
||||
set -eu
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
. "$HERE/upstream.env"
|
||||
OUT=$1
|
||||
|
||||
rm -rf "$OUT"
|
||||
git init -q "$OUT"
|
||||
cd "$OUT"
|
||||
git fetch -q --depth 1 https://github.com/paperclipai/paperclip.git "$PAPERCLIP_COMMIT"
|
||||
git -c advice.detachedHead=false checkout -q FETCH_HEAD
|
||||
test "$(git rev-parse HEAD)" = "$PAPERCLIP_COMMIT"
|
||||
|
||||
for p in "$HERE"/*.patch; do
|
||||
echo "applying $(basename "$p")"
|
||||
git apply "$p"
|
||||
done
|
||||
|
||||
# kaniko does not know `COPY --parents` (BuildKit only). Upstream uses it for
|
||||
# one line: every sandbox provider's package.json. Replace it with one plain
|
||||
# COPY per provider directory, which copies the same files to the same place.
|
||||
grep -c -- '^COPY --parents packages/plugins/sandbox-providers/\./\*/package.json packages/plugins/sandbox-providers/$' Dockerfile | grep -qx 1
|
||||
awk '
|
||||
/^COPY --parents packages\/plugins\/sandbox-providers\// {
|
||||
while (("ls -d packages/plugins/sandbox-providers/*/package.json" | getline f) > 0) {
|
||||
d = f; sub(/package.json$/, "", d)
|
||||
print "COPY " f " " d
|
||||
}
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' Dockerfile > Dockerfile.kaniko
|
||||
if grep -q -- '--parents' Dockerfile.kaniko; then
|
||||
echo "Dockerfile.kaniko still uses COPY --parents" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the Kubernetes sandbox provider into the image (REN-405). Upstream's
|
||||
# `production` target ships only its source; the plugin loader needs
|
||||
# dist/manifest.js. Same commands as upstream's `cloud-plugins` stage, run in
|
||||
# the `build` stage right after the server build, so production's
|
||||
# `COPY --from=build /app /app` carries dist/ and node_modules along. No extra
|
||||
# stage: kaniko would snapshot the whole build stage again for it.
|
||||
export ANCHOR='RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)'
|
||||
grep -c -F -x -- "$ANCHOR" Dockerfile.kaniko | grep -qx 1
|
||||
K=packages/plugins/sandbox-providers/kubernetes
|
||||
awk -v k="$K" '
|
||||
{ print }
|
||||
$0 == ENVIRON["ANCHOR"] {
|
||||
print "RUN pnpm -C " k " install --ignore-workspace --no-lockfile \\"
|
||||
print " && pnpm -C " k " build \\"
|
||||
print " && test -f " k "/dist/manifest.js"
|
||||
}
|
||||
' Dockerfile.kaniko > Dockerfile.kaniko.new
|
||||
mv Dockerfile.kaniko.new Dockerfile.kaniko
|
||||
grep -q -- "test -f $K/dist/manifest.js" Dockerfile.kaniko
|
||||
rm -rf .git
|
||||
@@ -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$/ },
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts
|
||||
index a26f980..112e2eb 100644
|
||||
--- a/packages/adapter-utils/src/acpx-engine/execute.test.ts
|
||||
+++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts
|
||||
@@ -2544,6 +2544,37 @@ describe("shared ACPX engine runtime behavior", () => {
|
||||
expect(note).toBeTruthy();
|
||||
});
|
||||
|
||||
+ it("points Claude auto memory at the run's AGENT_HOME without changing the session fingerprint", async () => {
|
||||
+ const root = await makeTempRoot();
|
||||
+ const stateDir = path.join(root, "state");
|
||||
+ const cwd = path.join(root, "worktree");
|
||||
+ await fs.mkdir(cwd, { recursive: true });
|
||||
+ const stableHome = path.join(root, "agent-home");
|
||||
+ const runAgentHome = (runId: string) => path.join(root, "file-sync", "runs", runId, "live");
|
||||
+ const readSettings = async () =>
|
||||
+ JSON.parse(await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8")) as {
|
||||
+ autoMemoryDirectory?: string;
|
||||
+ permissions?: { additionalDirectories?: string[] };
|
||||
+ };
|
||||
+
|
||||
+ const first = await runExecutor(
|
||||
+ { agent: "claude", stateDir, cwd },
|
||||
+ { context: { paperclipWorkspace: { cwd, agentHome: runAgentHome("a"), agentHomeForPermissions: stableHome } } },
|
||||
+ );
|
||||
+ expect((await readSettings()).autoMemoryDirectory).toBe(path.join(runAgentHome("a"), "memory"));
|
||||
+
|
||||
+ const second = await runExecutor(
|
||||
+ { agent: "claude", stateDir, cwd },
|
||||
+ { context: { paperclipWorkspace: { cwd, agentHome: runAgentHome("b"), agentHomeForPermissions: stableHome } } },
|
||||
+ );
|
||||
+ const written = await readSettings();
|
||||
+ expect(written.autoMemoryDirectory).toBe(path.join(runAgentHome("b"), "memory"));
|
||||
+ expect(written.permissions?.additionalDirectories).toContain(stableHome);
|
||||
+ expect(written.permissions?.additionalDirectories).not.toContain(runAgentHome("b"));
|
||||
+ expect(second.result.sessionParams?.configFingerprint).toBeTypeOf("string");
|
||||
+ expect(second.result.sessionParams?.configFingerprint).toBe(first.result.sessionParams?.configFingerprint);
|
||||
+ });
|
||||
+
|
||||
it("merges Paperclip allowlist into an existing .claude/settings.local.json without losing user entries", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts
|
||||
index b1dc569..e5681b8 100644
|
||||
--- a/packages/adapter-utils/src/acpx-engine/execute.ts
|
||||
+++ b/packages/adapter-utils/src/acpx-engine/execute.ts
|
||||
@@ -1558,6 +1558,7 @@ interface PaperclipClaudeSettingsResult {
|
||||
additionalDirectories: string[];
|
||||
defaultMode: string;
|
||||
overrodeDontAsk: boolean;
|
||||
+ autoMemoryDirectory: string | null;
|
||||
}
|
||||
|
||||
function uniqueSorted(values: Array<string | null | undefined>): string[] {
|
||||
@@ -1571,10 +1572,19 @@ function uniqueSorted(values: Array<string | null | undefined>): string[] {
|
||||
// denies every non-allowlisted tool and never reaches `canUseTool`), and we
|
||||
// widen the SDK's Read sandbox to include the Paperclip state dirs the agent
|
||||
// needs to talk to its own control plane.
|
||||
+//
|
||||
+// It also points Claude's auto memory at `<AGENT_HOME>/memory`. The default
|
||||
+// location lives under CLAUDE_CONFIG_DIR, which is a per-run temp home, so
|
||||
+// memories were lost after every run. AGENT_HOME is restored before and saved
|
||||
+// after each run, locally and in remote sandboxes. Claude ignores
|
||||
+// `autoMemoryDirectory` only in checked-in project settings, not local ones.
|
||||
async function writePaperclipClaudeSettings(input: {
|
||||
cwd: string;
|
||||
stateDir: string;
|
||||
agentHome: string;
|
||||
+ // The run's actual AGENT_HOME (the in-sandbox path for remote runs). It may
|
||||
+ // differ from `agentHome`, which stays stable for session identity.
|
||||
+ runAgentHome: string;
|
||||
companyId: string;
|
||||
}): Promise<PaperclipClaudeSettingsResult> {
|
||||
const filePath = path.join(input.cwd, ".claude", "settings.local.json");
|
||||
@@ -1630,7 +1640,14 @@ async function writePaperclipClaudeSettings(input: {
|
||||
additionalDirectories: mergedAdditionalDirectories,
|
||||
defaultMode,
|
||||
};
|
||||
- const next: Record<string, unknown> = { ...existing, permissions: nextPermissions };
|
||||
+ const autoMemoryDirectory = input.runAgentHome
|
||||
+ ? path.posix.join(input.runAgentHome, "memory")
|
||||
+ : null;
|
||||
+ const next: Record<string, unknown> = {
|
||||
+ ...existing,
|
||||
+ permissions: nextPermissions,
|
||||
+ ...(autoMemoryDirectory ? { autoMemoryDirectory } : {}),
|
||||
+ };
|
||||
await writeFileAtomically({
|
||||
target: filePath,
|
||||
contents: `${JSON.stringify(next, null, 2)}\n`,
|
||||
@@ -1642,6 +1659,7 @@ async function writePaperclipClaudeSettings(input: {
|
||||
additionalDirectories: mergedAdditionalDirectories,
|
||||
defaultMode,
|
||||
overrodeDontAsk,
|
||||
+ autoMemoryDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2064,12 +2082,18 @@ async function buildRuntime(input: {
|
||||
// by the company root already granted in these settings; the process env
|
||||
// still receives this run's actual AGENT_HOME.
|
||||
agentHome: asString(workspaceContext.agentHomeForPermissions, agentHome),
|
||||
+ // Auto memory follows the actual AGENT_HOME so it is saved with the run.
|
||||
+ // It is not part of the session fingerprint: a moved memory directory
|
||||
+ // must not invalidate a resumable conversation.
|
||||
+ runAgentHome: agentHome,
|
||||
companyId: agent.companyId,
|
||||
});
|
||||
skillCommandNotes.push(
|
||||
`Wrote Paperclip-managed Claude settings to ${paperclipClaudeSettings.filePath} (defaultMode=${paperclipClaudeSettings.defaultMode}${
|
||||
paperclipClaudeSettings.overrodeDontAsk ? "; overrode user dontAsk" : ""
|
||||
- }, +${paperclipClaudeSettings.additionalDirectories.length} read root(s), +${paperclipClaudeSettings.allow.length} allow rule(s)).`,
|
||||
+ }, +${paperclipClaudeSettings.additionalDirectories.length} read root(s), +${paperclipClaudeSettings.allow.length} allow rule(s)${
|
||||
+ paperclipClaudeSettings.autoMemoryDirectory ? `, autoMemoryDirectory=${paperclipClaudeSettings.autoMemoryDirectory}` : ""
|
||||
+ }).`,
|
||||
);
|
||||
} else if (acpxAgent === "codex") {
|
||||
// Step 2 — codex-home.seed: the codex managed-home + skills preparation.
|
||||
@@ -0,0 +1,128 @@
|
||||
diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts
|
||||
index 112e2eb..004baf0 100644
|
||||
--- a/packages/adapter-utils/src/acpx-engine/execute.test.ts
|
||||
+++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts
|
||||
@@ -1857,6 +1857,45 @@ describe("shared ACPX engine runtime behavior", () => {
|
||||
expect(changed.result.sessionParams?.configFingerprint).not.toBe(first.result.sessionParams?.configFingerprint);
|
||||
});
|
||||
|
||||
+ it("keeps the per-run managed AI home out of session identity and keeps Claude transcripts", async () => {
|
||||
+ const root = await makeTempRoot();
|
||||
+ const stateDir = path.join(root, "state");
|
||||
+ const config = { agentCommand: "node ./fake-acp.js", stateDir };
|
||||
+ async function withManagedHome(home: string, claudeConfigDir = path.join(home, "provider")) {
|
||||
+ return runExecutor({
|
||||
+ ...config,
|
||||
+ managedAiConnection: { identity: "grant-1:shared:abc" },
|
||||
+ env: {
|
||||
+ HOME: home,
|
||||
+ XDG_CONFIG_HOME: path.join(home, "config"),
|
||||
+ XDG_DATA_HOME: path.join(home, "data"),
|
||||
+ CODEX_HOME: path.join(home, "provider"),
|
||||
+ GROK_HOME: path.join(home, "provider"),
|
||||
+ CLAUDE_CONFIG_DIR: claudeConfigDir,
|
||||
+ },
|
||||
+ }, { context: { taskId: "issue-1" } });
|
||||
+ }
|
||||
+ const fp = (run: Awaited<ReturnType<typeof runExecutor>>) => run.result.sessionParams?.configFingerprint;
|
||||
+ const first = await withManagedHome(path.join(root, "paperclip-ai-a"));
|
||||
+ const second = await withManagedHome(path.join(root, "paperclip-ai-b"));
|
||||
+ const overridden = await withManagedHome(path.join(root, "paperclip-ai-c"), path.join(root, "custom-claude"));
|
||||
+ expect(fp(first)).toBeTypeOf("string");
|
||||
+ expect(fp(second)).toBe(fp(first));
|
||||
+ expect(fp(overridden)).not.toBe(fp(first));
|
||||
+
|
||||
+ // The transcript dir of each managed home points at one dir in the state
|
||||
+ // dir, so a resumed session finds its transcript after the home is deleted.
|
||||
+ const transcripts = path.join(stateDir, "claude-projects");
|
||||
+ expect(await fs.readlink(path.join(root, "paperclip-ai-a", "provider", "projects"))).toBe(transcripts);
|
||||
+ expect(await fs.readlink(path.join(root, "paperclip-ai-b", "provider", "projects"))).toBe(transcripts);
|
||||
+ await expect(fs.lstat(path.join(root, "custom-claude", "projects"))).rejects.toThrow();
|
||||
+
|
||||
+ // Without a managed AI connection the same paths are user config and still count.
|
||||
+ const plainA = await runExecutor({ ...config, env: { HOME: path.join(root, "home-a") } }, { context: { taskId: "issue-1" } });
|
||||
+ const plainB = await runExecutor({ ...config, env: { HOME: path.join(root, "home-b") } }, { context: { taskId: "issue-1" } });
|
||||
+ expect(fp(plainB)).not.toBe(fp(plainA));
|
||||
+ });
|
||||
+
|
||||
it("busts the session fingerprint when a stable configured PAPERCLIP_* value rotates", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts
|
||||
index e5681b8..0fd486c 100644
|
||||
--- a/packages/adapter-utils/src/acpx-engine/execute.ts
|
||||
+++ b/packages/adapter-utils/src/acpx-engine/execute.ts
|
||||
@@ -1663,6 +1663,26 @@ async function writePaperclipClaudeSettings(input: {
|
||||
};
|
||||
}
|
||||
|
||||
+// Claude keeps conversation transcripts in CLAUDE_CONFIG_DIR/projects. A
|
||||
+// managed AI home is a per-run temp dir that the server deletes after the run,
|
||||
+// so the next run could not resume ("Resource not found"). Point `projects` at
|
||||
+// a directory in the agent's state dir, like ~/.claude/projects on a normal
|
||||
+// install. The server's recursive cleanup removes the link, not its target.
|
||||
+async function linkManagedClaudeTranscripts(input: {
|
||||
+ claudeConfigDir: string;
|
||||
+ stateDir: string;
|
||||
+}): Promise<string> {
|
||||
+ const target = path.join(input.stateDir, "claude-projects");
|
||||
+ await fs.mkdir(input.claudeConfigDir, { recursive: true, mode: 0o700 });
|
||||
+ await fs.mkdir(target, { recursive: true, mode: 0o700 });
|
||||
+ const link = path.join(input.claudeConfigDir, "projects");
|
||||
+ const current = await fs.readlink(link).catch(() => null);
|
||||
+ if (current === target) return target;
|
||||
+ await fs.rm(link, { recursive: true, force: true });
|
||||
+ await fs.symlink(target, link, "dir");
|
||||
+ return target;
|
||||
+}
|
||||
+
|
||||
// Cross the CLI's staging seam for a runner-backed remote sandbox: ship the
|
||||
// workspace (and, in PR 2, the per-adapter managed-home `assets`) into the
|
||||
// sandbox and obtain the in-sandbox `workspaceRemoteDir` plus the non-null
|
||||
@@ -2027,6 +2047,30 @@ async function buildRuntime(input: {
|
||||
// are absent from tempKeysApplied and keep their compatibility protection.
|
||||
if (!scratchKeys.has(key) || value !== scratch.dir) resolvedAdapterEnv[key] = value;
|
||||
}
|
||||
+ // A managed AI connection gets a fresh server-made temp home on every run
|
||||
+ // (HOME, XDG_*, and the provider home under it). Hash a fixed placeholder for
|
||||
+ // those paths so the session can resume; the server's own fingerprint does
|
||||
+ // the same (managedAiSessionFingerprintConfig). Values that differ from the
|
||||
+ // managed layout are real overrides and stay in the hash. A Claude session
|
||||
+ // can only resume if its transcript survives the run, so this pairs with
|
||||
+ // linkManagedClaudeTranscripts below. Remote runs keep their transcript in
|
||||
+ // the sandbox and are left as they are.
|
||||
+ const managedHome = config.managedAiConnection && !executionTargetIsRemote ? resolvedAdapterEnv.HOME : undefined;
|
||||
+ if (managedHome) {
|
||||
+ const managedLayout: Record<string, string> = {
|
||||
+ HOME: "",
|
||||
+ XDG_CONFIG_HOME: "config",
|
||||
+ XDG_DATA_HOME: "data",
|
||||
+ CODEX_HOME: "provider",
|
||||
+ GROK_HOME: "provider",
|
||||
+ CLAUDE_CONFIG_DIR: "provider",
|
||||
+ };
|
||||
+ for (const [key, sub] of Object.entries(managedLayout)) {
|
||||
+ if (resolvedAdapterEnv[key] === (sub ? path.join(managedHome, sub) : managedHome)) {
|
||||
+ resolvedAdapterEnv[key] = sub ? `<managed-ai-home>/${sub}` : "<managed-ai-home>";
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
if (authToken) env.PAPERCLIP_API_KEY = authToken;
|
||||
// For the claude agent, set model via ANTHROPIC_MODEL at startup rather than
|
||||
// via session/set_config_option — the ACP server's set_config_option handler
|
||||
@@ -2088,6 +2132,15 @@ async function buildRuntime(input: {
|
||||
runAgentHome: agentHome,
|
||||
companyId: agent.companyId,
|
||||
});
|
||||
+ // Same condition as the fingerprint placeholder above: a resumable session
|
||||
+ // must find its transcript in the next run's managed home.
|
||||
+ if (resolvedAdapterEnv.CLAUDE_CONFIG_DIR === "<managed-ai-home>/provider" && env.CLAUDE_CONFIG_DIR) {
|
||||
+ const transcriptsDir = await linkManagedClaudeTranscripts({
|
||||
+ claudeConfigDir: env.CLAUDE_CONFIG_DIR,
|
||||
+ stateDir,
|
||||
+ });
|
||||
+ skillCommandNotes.push(`Linked Claude transcripts to ${transcriptsDir}.`);
|
||||
+ }
|
||||
skillCommandNotes.push(
|
||||
`Wrote Paperclip-managed Claude settings to ${paperclipClaudeSettings.filePath} (defaultMode=${paperclipClaudeSettings.defaultMode}${
|
||||
paperclipClaudeSettings.overrodeDontAsk ? "; overrode user dontAsk" : ""
|
||||
@@ -0,0 +1,384 @@
|
||||
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts b/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts
|
||||
index 87e18ae..2224e60 100644
|
||||
--- a/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts
|
||||
+++ b/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts
|
||||
@@ -21,6 +21,7 @@ import { deleteJob, findPodForJob, getJobStatus } from "./job-orchestrator.js";
|
||||
import {
|
||||
deleteSandboxCr,
|
||||
findPodForSandbox,
|
||||
+ getSandboxCrStatus,
|
||||
waitForSandboxReady,
|
||||
} from "./sandbox-cr-orchestrator.js";
|
||||
|
||||
@@ -191,3 +192,60 @@ export async function destroyLeaseResources(
|
||||
);
|
||||
}
|
||||
}
|
||||
+
|
||||
+export interface WorkloadGoneInput {
|
||||
+ namespace: string;
|
||||
+ /** Workload resource name (Sandbox CR name or Job name) == providerLeaseId. */
|
||||
+ name: string;
|
||||
+ backend: "sandbox-cr" | "job";
|
||||
+ podName?: string | null;
|
||||
+ /** Stays below the server's 30 s default plugin RPC timeout. */
|
||||
+ timeoutMs?: number;
|
||||
+ pollMs?: number;
|
||||
+}
|
||||
+
|
||||
+/**
|
||||
+ * Poll until the workload (and the named pod, if any) returns 404. Deletes use
|
||||
+ * Foreground propagation, so the workload disappears only after its pod is
|
||||
+ * gone. True is the evidence behind a `destroyed` termination receipt; false
|
||||
+ * (still present at the deadline, or a read failed) means the caller must not
|
||||
+ * claim one. Never throws: the delete itself already succeeded.
|
||||
+ */
|
||||
+export async function waitForWorkloadGone(
|
||||
+ clients: KubeClients,
|
||||
+ input: WorkloadGoneInput,
|
||||
+): Promise<boolean> {
|
||||
+ const deadline = Date.now() + (input.timeoutMs ?? 20_000);
|
||||
+ const pollMs = input.pollMs ?? 500;
|
||||
+ const gone = async (read: () => Promise<unknown>): Promise<boolean> => {
|
||||
+ try {
|
||||
+ await read();
|
||||
+ return false;
|
||||
+ } catch (err) {
|
||||
+ if (isKubeNotFoundError(err)) return true;
|
||||
+ throw err;
|
||||
+ }
|
||||
+ };
|
||||
+ try {
|
||||
+ return await pollUntilGone();
|
||||
+ } catch {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
+ async function pollUntilGone(): Promise<boolean> {
|
||||
+ for (;;) {
|
||||
+ const workloadGone = await gone(() =>
|
||||
+ input.backend === "sandbox-cr"
|
||||
+ ? getSandboxCrStatus(clients, input.namespace, input.name)
|
||||
+ : getJobStatus(clients, input.namespace, input.name),
|
||||
+ );
|
||||
+ const podName = input.podName;
|
||||
+ const podGone = !podName || await gone(() =>
|
||||
+ clients.core.readNamespacedPod({ namespace: input.namespace, name: podName }),
|
||||
+ );
|
||||
+ if (workloadGone && podGone) return true;
|
||||
+ if (Date.now() >= deadline) return false;
|
||||
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
|
||||
index 9801638..bf5ce0a 100644
|
||||
--- a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
|
||||
+++ b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
PluginEnvironmentSyncInParams,
|
||||
PluginEnvironmentSyncOutParams,
|
||||
PluginEnvironmentSyncResult,
|
||||
+ PluginEnvironmentTerminationReceipt,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentValidationResult,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
@@ -38,7 +39,11 @@ import {
|
||||
} from "./sandbox-cr-orchestrator.js";
|
||||
import { execInPod, execInPodStreaming, wrapCommandWithEnv } from "./pod-exec.js";
|
||||
import { performSyncIn, performSyncOut, type PodStreamExec } from "./file-sync.js";
|
||||
-import { checkLeaseResumable, destroyLeaseResources } from "./lease-lifecycle.js";
|
||||
+import {
|
||||
+ checkLeaseResumable,
|
||||
+ destroyLeaseResources,
|
||||
+ waitForWorkloadGone,
|
||||
+} from "./lease-lifecycle.js";
|
||||
import {
|
||||
appendNetworkEgressDenyHint,
|
||||
createScopedNetworkEgressPolicyOrReleaseWorkload,
|
||||
@@ -578,7 +583,7 @@ const plugin = definePlugin({
|
||||
|
||||
async onEnvironmentReleaseLease(
|
||||
params: PluginEnvironmentReleaseLeaseParams,
|
||||
- ): Promise<void> {
|
||||
+ ): Promise<PluginEnvironmentTerminationReceipt | void> {
|
||||
if (!params.providerLeaseId) return;
|
||||
const config = kubernetesProviderConfigSchema.parse(params.config);
|
||||
const namespace =
|
||||
@@ -613,11 +618,22 @@ const plugin = definePlugin({
|
||||
?? (err as { code?: number; statusCode?: number }).statusCode;
|
||||
if (code !== 404) throw err;
|
||||
}
|
||||
+
|
||||
+ // The server grants continuation after a stopped run only with a
|
||||
+ // termination receipt; without one a cancelled run's queued comment
|
||||
+ // waits on `remote_cleanup` forever (REN-841). Claim `destroyed` only
|
||||
+ // once the workload is confirmed gone.
|
||||
+ const gone = await waitForWorkloadGone(clients, {
|
||||
+ namespace,
|
||||
+ name: params.providerLeaseId,
|
||||
+ backend: leaseBackend,
|
||||
+ });
|
||||
+ if (gone) return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
||||
},
|
||||
|
||||
async onEnvironmentDestroyLease(
|
||||
params: PluginEnvironmentDestroyLeaseParams,
|
||||
- ): Promise<void> {
|
||||
+ ): Promise<PluginEnvironmentTerminationReceipt | void> {
|
||||
if (!params.providerLeaseId) return;
|
||||
const config = kubernetesProviderConfigSchema.parse(params.config);
|
||||
const namespace =
|
||||
@@ -658,6 +674,13 @@ const plugin = definePlugin({
|
||||
podName,
|
||||
secretName,
|
||||
});
|
||||
+ const gone = await waitForWorkloadGone(clients, {
|
||||
+ namespace,
|
||||
+ name: params.providerLeaseId,
|
||||
+ backend: leaseBackend,
|
||||
+ podName,
|
||||
+ });
|
||||
+ if (gone) return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
||||
},
|
||||
|
||||
async onEnvironmentExecute(
|
||||
diff --git a/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts b/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts
|
||||
index af0875f..d775799 100644
|
||||
--- a/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts
|
||||
+++ b/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
checkLeaseResumable,
|
||||
destroyLeaseResources,
|
||||
isKubeNotFoundError,
|
||||
+ waitForWorkloadGone,
|
||||
} from "../../src/lease-lifecycle.js";
|
||||
|
||||
const SANDBOX_GROUP = "agents.x-k8s.io";
|
||||
@@ -346,3 +347,64 @@ describe("destroyLeaseResources", () => {
|
||||
).rejects.toThrow("forbidden");
|
||||
});
|
||||
});
|
||||
+
|
||||
+describe("waitForWorkloadGone", () => {
|
||||
+ const input = { namespace: "ns", name: "pc-abc", backend: "sandbox-cr" as const, pollMs: 1 };
|
||||
+
|
||||
+ it("is true once the Sandbox CR and the pod return 404", async () => {
|
||||
+ const getCr = vi.fn()
|
||||
+ .mockResolvedValueOnce(readySandboxCr("pc-abc-pod"))
|
||||
+ .mockRejectedValue(notFound());
|
||||
+ const readPod = vi.fn().mockRejectedValue(notFound());
|
||||
+ const clients = { custom: { getNamespacedCustomObject: getCr }, core: { readNamespacedPod: readPod } };
|
||||
+
|
||||
+ await expect(
|
||||
+ waitForWorkloadGone(clients as never, { ...input, podName: "pc-abc-pod" }),
|
||||
+ ).resolves.toBe(true);
|
||||
+ expect(getCr).toHaveBeenCalledTimes(2);
|
||||
+ expect(getCr).toHaveBeenCalledWith(expect.objectContaining({
|
||||
+ group: SANDBOX_GROUP, version: SANDBOX_VERSION, plural: SANDBOX_PLURAL, namespace: "ns", name: "pc-abc",
|
||||
+ }));
|
||||
+ });
|
||||
+
|
||||
+ it("is false while the workload still exists at the deadline", async () => {
|
||||
+ const clients = {
|
||||
+ custom: { getNamespacedCustomObject: vi.fn().mockResolvedValue(readySandboxCr()) },
|
||||
+ };
|
||||
+
|
||||
+ await expect(
|
||||
+ waitForWorkloadGone(clients as never, { ...input, timeoutMs: 5 }),
|
||||
+ ).resolves.toBe(false);
|
||||
+ });
|
||||
+
|
||||
+ it("is false while the pod still exists even if the workload is gone", async () => {
|
||||
+ const clients = {
|
||||
+ custom: { getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()) },
|
||||
+ core: { readNamespacedPod: vi.fn().mockResolvedValue({ status: { phase: "Running" } }) },
|
||||
+ };
|
||||
+
|
||||
+ await expect(
|
||||
+ waitForWorkloadGone(clients as never, { ...input, podName: "pc-abc-pod", timeoutMs: 5 }),
|
||||
+ ).resolves.toBe(false);
|
||||
+ });
|
||||
+
|
||||
+ it("checks the Job for job-backend leases", async () => {
|
||||
+ const readJob = vi.fn().mockRejectedValue(notFound());
|
||||
+ const clients = { batch: { readNamespacedJobStatus: readJob } };
|
||||
+
|
||||
+ await expect(
|
||||
+ waitForWorkloadGone(clients as never, { ...input, backend: "job" }),
|
||||
+ ).resolves.toBe(true);
|
||||
+ expect(readJob).toHaveBeenCalledWith({ namespace: "ns", name: "pc-abc" });
|
||||
+ });
|
||||
+
|
||||
+ it("is false (never throws) when a read fails with another error", async () => {
|
||||
+ const clients = {
|
||||
+ custom: {
|
||||
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(Object.assign(new Error("x"), { code: 403 })),
|
||||
+ },
|
||||
+ };
|
||||
+
|
||||
+ await expect(waitForWorkloadGone(clients as never, input)).resolves.toBe(false);
|
||||
+ });
|
||||
+});
|
||||
diff --git a/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts b/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
|
||||
index 4c23410..f4fd073 100644
|
||||
--- a/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
|
||||
+++ b/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
|
||||
@@ -184,12 +184,19 @@ describe("onEnvironmentDestroyLease", () => {
|
||||
const deletePod = vi.fn().mockResolvedValue({});
|
||||
const deleteSecret = vi.fn().mockResolvedValue({});
|
||||
h.clients = {
|
||||
- custom: { deleteNamespacedCustomObject: deleteCr },
|
||||
- core: { deleteNamespacedPod: deletePod, deleteNamespacedSecret: deleteSecret },
|
||||
+ custom: {
|
||||
+ deleteNamespacedCustomObject: deleteCr,
|
||||
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
|
||||
+ },
|
||||
+ core: {
|
||||
+ deleteNamespacedPod: deletePod,
|
||||
+ deleteNamespacedSecret: deleteSecret,
|
||||
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
|
||||
+ },
|
||||
batch: { deleteNamespacedJob: vi.fn() },
|
||||
};
|
||||
|
||||
- await plugin.definition.onEnvironmentDestroyLease!({
|
||||
+ const receipt = await plugin.definition.onEnvironmentDestroyLease!({
|
||||
driverKey: "kubernetes",
|
||||
companyId: "acme",
|
||||
environmentId: "env-1",
|
||||
@@ -209,14 +216,47 @@ describe("onEnvironmentDestroyLease", () => {
|
||||
namespace: "paperclip-acme",
|
||||
name: "pc-abc-env",
|
||||
});
|
||||
+ expect(receipt).toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
|
||||
});
|
||||
|
||||
it("is idempotent: resolves cleanly when every resource is already gone (404)", async () => {
|
||||
h.clients = {
|
||||
- custom: { deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()) },
|
||||
+ custom: {
|
||||
+ deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
|
||||
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
|
||||
+ },
|
||||
core: {
|
||||
deleteNamespacedPod: vi.fn().mockRejectedValue(notFound()),
|
||||
deleteNamespacedSecret: vi.fn().mockRejectedValue(notFound()),
|
||||
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
|
||||
+ },
|
||||
+ batch: { deleteNamespacedJob: vi.fn() },
|
||||
+ };
|
||||
+
|
||||
+ await expect(
|
||||
+ plugin.definition.onEnvironmentDestroyLease!({
|
||||
+ driverKey: "kubernetes",
|
||||
+ companyId: "acme",
|
||||
+ environmentId: "env-1",
|
||||
+ config: CONFIG,
|
||||
+ providerLeaseId: "pc-abc",
|
||||
+ leaseMetadata: leaseMetadata(),
|
||||
+ }),
|
||||
+ ).resolves.toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
|
||||
+ });
|
||||
+
|
||||
+ it("returns no receipt when the gone check cannot read the cluster", async () => {
|
||||
+ h.clients = {
|
||||
+ custom: {
|
||||
+ deleteNamespacedCustomObject: vi.fn().mockResolvedValue({}),
|
||||
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(
|
||||
+ Object.assign(new Error("forbidden"), { code: 403 }),
|
||||
+ ),
|
||||
+ },
|
||||
+ core: {
|
||||
+ deleteNamespacedPod: vi.fn().mockResolvedValue({}),
|
||||
+ deleteNamespacedSecret: vi.fn().mockResolvedValue({}),
|
||||
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
|
||||
},
|
||||
batch: { deleteNamespacedJob: vi.fn() },
|
||||
};
|
||||
@@ -261,8 +301,12 @@ describe("onEnvironmentDestroyLease", () => {
|
||||
core: {
|
||||
deleteNamespacedPod: vi.fn().mockResolvedValue({}),
|
||||
deleteNamespacedSecret: vi.fn().mockResolvedValue({}),
|
||||
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
|
||||
+ },
|
||||
+ batch: {
|
||||
+ deleteNamespacedJob: deleteJob,
|
||||
+ readNamespacedJobStatus: vi.fn().mockRejectedValue(notFound()),
|
||||
},
|
||||
- batch: { deleteNamespacedJob: deleteJob },
|
||||
};
|
||||
|
||||
await plugin.definition.onEnvironmentDestroyLease!({
|
||||
@@ -280,3 +324,71 @@ describe("onEnvironmentDestroyLease", () => {
|
||||
expect(deleteCr).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+
|
||||
+describe("onEnvironmentReleaseLease", () => {
|
||||
+ it("returns a destroyed receipt once the Sandbox CR is gone (REN-841)", async () => {
|
||||
+ const deleteCr = vi.fn().mockResolvedValue({});
|
||||
+ h.clients = {
|
||||
+ custom: {
|
||||
+ deleteNamespacedCustomObject: deleteCr,
|
||||
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
|
||||
+ },
|
||||
+ };
|
||||
+
|
||||
+ const receipt = await plugin.definition.onEnvironmentReleaseLease!({
|
||||
+ driverKey: "kubernetes",
|
||||
+ companyId: "acme",
|
||||
+ environmentId: "env-1",
|
||||
+ config: CONFIG,
|
||||
+ providerLeaseId: "pc-abc",
|
||||
+ leaseMetadata: leaseMetadata(),
|
||||
+ });
|
||||
+
|
||||
+ expect(deleteCr).toHaveBeenCalledWith(
|
||||
+ expect.objectContaining({ namespace: "paperclip-acme", name: "pc-abc" }),
|
||||
+ );
|
||||
+ expect(receipt).toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
|
||||
+ });
|
||||
+
|
||||
+ it("returns a destroyed receipt when the Sandbox CR was already deleted", async () => {
|
||||
+ h.clients = {
|
||||
+ custom: {
|
||||
+ deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
|
||||
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
|
||||
+ },
|
||||
+ };
|
||||
+
|
||||
+ await expect(
|
||||
+ plugin.definition.onEnvironmentReleaseLease!({
|
||||
+ driverKey: "kubernetes",
|
||||
+ companyId: "acme",
|
||||
+ environmentId: "env-1",
|
||||
+ config: CONFIG,
|
||||
+ providerLeaseId: "pc-abc",
|
||||
+ leaseMetadata: leaseMetadata(),
|
||||
+ }),
|
||||
+ ).resolves.toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
|
||||
+ });
|
||||
+
|
||||
+ it("returns no receipt when the gone check fails", async () => {
|
||||
+ h.clients = {
|
||||
+ custom: {
|
||||
+ deleteNamespacedCustomObject: vi.fn().mockResolvedValue({}),
|
||||
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(
|
||||
+ Object.assign(new Error("server error"), { code: 500 }),
|
||||
+ ),
|
||||
+ },
|
||||
+ };
|
||||
+
|
||||
+ await expect(
|
||||
+ plugin.definition.onEnvironmentReleaseLease!({
|
||||
+ driverKey: "kubernetes",
|
||||
+ companyId: "acme",
|
||||
+ environmentId: "env-1",
|
||||
+ config: CONFIG,
|
||||
+ providerLeaseId: "pc-abc",
|
||||
+ leaseMetadata: leaseMetadata(),
|
||||
+ }),
|
||||
+ ).resolves.toBeUndefined();
|
||||
+ });
|
||||
+});
|
||||
@@ -0,0 +1,7 @@
|
||||
# Upstream paperclipai/paperclip commit this image is built from (REN-401).
|
||||
# a027f76 is the master commit the nightly image ran when this was pinned, so
|
||||
# the only change against that image is the patch next to this file.
|
||||
# PAPERCLIP_VERSION is `git describe --tags --match 'v*' --long` of that
|
||||
# commit, the value upstream stamps into its own images. Bump both together.
|
||||
PAPERCLIP_COMMIT=a027f76a726e8a556674eded807c2de0e55f5cc0
|
||||
PAPERCLIP_VERSION=v2026.916.0-219-ga027f76a7
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
ARG TOFU_VERSION=1.11.0
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
# Everything `bootstrap.sh vmtest` needs, so the rebuild test can run as a Job in
|
||||
# the cluster instead of on a workstation. The workstation has 31G and the test
|
||||
|
||||
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).
|
||||
@@ -16,7 +16,7 @@ credentials are out of scope (see "Deferred").
|
||||
|---|---|
|
||||
| Execution model | **B — server-local runs** (upstream default): agent CLIs are child processes of the server, in the server container. Trial only. |
|
||||
| Upgrade path | If Paperclip is kept: **C** — `@paperclipai/plugin-kubernetes` sandbox provider + kubernetes-sigs agent-sandbox controller (`agents.x-k8s.io`), per-run Pods and Secrets. Separate spec. |
|
||||
| Claude auth | **One shared Claude subscription AI connection**, created once via Paperclip's local sign-in (`claude auth login` into `ai-local-logins/<id>`); Paperclip stores and refreshes it. Server-env `CLAUDE_CODE_OAUTH_TOKEN` is not used: `stripAiAuthBindings` blanks it for managed runs, and upstream accepts no pasted setup-token (the device-code flow needs a Daytona sandbox). |
|
||||
| Claude auth | **One shared Claude subscription AI connection**, created once via Paperclip's local sign-in (`claude auth login` into `ai-local-logins/<id>`), then its value replaced with a long-lived `claude setup-token` token. Paperclip stores the value as company secret "My Claude subscription" and injects it per run as `CLAUDE_CODE_OAUTH_TOKEN`; it never refreshes Anthropic credentials, so the login's ~8h access token expires (runs then fail with "ACP agent reported a terminal access failure", 0 tokens). Renew by rotating that secret with a new setup-token (~yearly). Server-env `CLAUDE_CODE_OAUTH_TOKEN` is not used: `stripAiAuthBindings` blanks it for managed runs, and upstream accepts no pasted setup-token (the device-code flow needs a Daytona sandbox). |
|
||||
| Bot credentials | Out of scope. Future "AIR-Manager" Paperclip role handles permission requests. |
|
||||
| Exposure | LAN-only, behind an Authentik forward-auth gate, then Paperclip's own login. |
|
||||
|
||||
@@ -189,7 +189,8 @@ available since v0.35.0). First use of digest reflection in this repo.
|
||||
**as the node user** (the entrypoint makes exec sessions root, and root-owned 0600
|
||||
credentials are unreadable to the server):
|
||||
`kubectl exec -it -n paperclip deploy/paperclip -- gosu node sh -c 'export CLAUDE_CONFIG_DIR=<shown path> && mkdir -p "$CLAUDE_CONFIG_DIR" && claude auth login'`
|
||||
Repeat after loss of the PVC or database.
|
||||
Then Settings → Secrets → "My Claude subscription": replace the value with the
|
||||
output of `claude setup-token`. Repeat both after loss of the database.
|
||||
|
||||
## Verification
|
||||
- Flux Kustomization `paperclip` Ready; CNPG `pclp-pg` healthy; first ScheduledBackup
|
||||
|
||||
@@ -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`).
|
||||
+25
-3
@@ -165,11 +165,33 @@ permanent recovery.
|
||||
|
||||
---
|
||||
|
||||
## Velero backup verification (backup-verify)
|
||||
|
||||
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
|
||||
|
||||
@@ -48,7 +48,7 @@ spec:
|
||||
# cert when doing server-side OIDC token exchange. Bundle both
|
||||
# roots into one PEM since NODE_EXTRA_CA_CERTS accepts one file.
|
||||
- name: fetch-le-staging-roots
|
||||
image: curlimages/curl:8.10.1
|
||||
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -70,7 +70,7 @@ spec:
|
||||
mountPath: /ca
|
||||
containers:
|
||||
- name: audiobookshelf
|
||||
image: ghcr.io/advplyr/audiobookshelf:2.37.0 # {"$imagepolicy": "flux-system:audiobookshelf"}
|
||||
image: ghcr.io/advplyr/audiobookshelf:2.37.1 # {"$imagepolicy": "flux-system:audiobookshelf"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
|
||||
@@ -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
|
||||
@@ -2121,6 +2121,91 @@ data:
|
||||
- url: https://dash.renner.uno/login/generic_oauth
|
||||
matching_mode: strict
|
||||
|
||||
# Matrix (REN-567): upstream login for MAS (account.matrix.renner.uno).
|
||||
# Git owns this provider, its application and the member list.
|
||||
# Public client + PKCE, so there is no client secret to share.
|
||||
# RS256 with the built-in self-signed keypair: a public client has no
|
||||
# secret for HS256, and MAS verifies the ID token signature.
|
||||
- id: matrix-users-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: Matrix Users
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
# Every member can sign in to Matrix and gets an account named
|
||||
# after their Authentik username. Add/remove members HERE.
|
||||
users:
|
||||
- !Find [authentik_core.user, [username, k.renner]]
|
||||
|
||||
- id: matrix-mas-provider
|
||||
model: authentik_providers_oauth2.oauth2provider
|
||||
identifiers:
|
||||
name: Matrix (MAS)
|
||||
state: present
|
||||
attrs:
|
||||
authentication_flow: !KeyOf passkey-passwordless-flow
|
||||
authorization_flow: !KeyOf passkey-stepup-flow
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
client_type: public
|
||||
client_id: matrix-mas
|
||||
# Authentik 2026.5 checks the grant type against this list, and a
|
||||
# provider created by a blueprint starts with it empty, so every
|
||||
# authorize fails with invalid_request ("Invalid grant_type for
|
||||
# provider"). Providers made in the UI get the defaults.
|
||||
grant_types:
|
||||
- authorization_code
|
||||
- refresh_token
|
||||
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
|
||||
property_mappings:
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
|
||||
redirect_uris:
|
||||
# The path ends in the provider ULID from apps/matrix/mas.yaml.
|
||||
- url: https://account.matrix.renner.uno/upstream/callback/01M4480KWZGN9HBNB5SSNGT2M1
|
||||
matching_mode: strict
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: matrix
|
||||
state: present
|
||||
attrs:
|
||||
name: Matrix
|
||||
slug: matrix
|
||||
provider: !KeyOf matrix-mas-provider
|
||||
meta_launch_url: https://element.renner.uno
|
||||
meta_description: "Matrix chat (Element)"
|
||||
|
||||
# Only the Matrix Users group passes.
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, matrix]]
|
||||
group: !KeyOf matrix-users-group
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
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
|
||||
|
||||
@@ -5,6 +5,11 @@ metadata:
|
||||
namespace: evcc
|
||||
labels:
|
||||
app.kubernetes.io/name: evcc
|
||||
annotations:
|
||||
# The MQTT client certificate reaches evcc as env (below), which does not
|
||||
# follow a renewal. cert-manager renews it every ~60 days; Reloader then
|
||||
# restarts evcc so it picks up the new PEMs before the old ones expire.
|
||||
secret.reloader.stakater.com/reload: mqtt-evcc-client-tls
|
||||
spec:
|
||||
replicas: 1
|
||||
# RWO PVC (`evcc-data`, ceph-block) can't be mounted twice, so the
|
||||
@@ -23,13 +28,36 @@ spec:
|
||||
enableServiceLinks: false
|
||||
containers:
|
||||
- name: evcc
|
||||
image: evcc/evcc:0.310.1
|
||||
image: evcc/evcc:0.316.2@sha256:070c8a8912c5107e8424cef60bf8c3d3cac0eb32aa28d1b021d226545f0aac7b
|
||||
# evcc auto-discovers /etc/evcc.yaml on its default search path;
|
||||
# the file is delivered (with the MQTT password) via the
|
||||
# sops-encrypted evcc-config Secret mounted below.
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
# MQTT by client certificate (register account evcc-client,
|
||||
# REN-953) on the mTLS listener. These override the mqtt block of
|
||||
# evcc.yaml (viper AutomaticEnv, checked against 0.316.2); the
|
||||
# file's user/password are ignored there, as the broker takes the
|
||||
# username from the certificate CN. evcc wants the PEM text, not
|
||||
# a path.
|
||||
- name: EVCC_MQTT_BROKER
|
||||
value: tls://mosquitto.home-assistant.svc.cluster.local:8884
|
||||
- name: EVCC_MQTT_CACERT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mqtt-evcc-client-tls
|
||||
key: ca.crt
|
||||
- name: EVCC_MQTT_CLIENTCERT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mqtt-evcc-client-tls
|
||||
key: tls.crt
|
||||
- name: EVCC_MQTT_CLIENTKEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mqtt-evcc-client-tls
|
||||
key: tls.key
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 7070
|
||||
|
||||
@@ -30,6 +30,9 @@ metadata:
|
||||
# Web Service 9 = Music Assistant
|
||||
# Web Service 10 = TRMNL
|
||||
# Web Service 11 = Paperless (gate-level only, see its entry)
|
||||
# Web Service 12 = Element Web
|
||||
# Web Service 13 = Matrix (Synapse client API)
|
||||
# Web Service 14 = Matrix Authentication Service
|
||||
#
|
||||
# CORRECTION (REN-21, 2026-09-25) to the Web Service 11 comment further down,
|
||||
# which claims that an in-cluster probe of the paperless ClusterIP "needs a
|
||||
@@ -228,6 +231,46 @@ data:
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
|
||||
# Matrix (REN-663): Element Web, the Synapse client API and MAS.
|
||||
- name: Web Service 12
|
||||
group: services
|
||||
url: https://element.renner.uno/config.json
|
||||
interval: 60s
|
||||
ui:
|
||||
hide-hostname: true
|
||||
hide-url: true
|
||||
hide-port: true
|
||||
hide-conditions: true
|
||||
hide-errors: true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: Web Service 13
|
||||
group: services
|
||||
url: https://matrix.renner.uno/_matrix/client/versions
|
||||
interval: 60s
|
||||
ui:
|
||||
hide-hostname: true
|
||||
hide-url: true
|
||||
hide-port: true
|
||||
hide-conditions: true
|
||||
hide-errors: true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: Web Service 14
|
||||
group: services
|
||||
url: https://account.matrix.renner.uno/.well-known/openid-configuration
|
||||
interval: 60s
|
||||
ui:
|
||||
hide-hostname: true
|
||||
hide-url: true
|
||||
hide-port: true
|
||||
hide-conditions: true
|
||||
hide-errors: true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
# Removed 2026-05-21: Jellyfin (media.renner.uno), Wiki (wiki.renner.uno),
|
||||
# InfluxDB (influx.renner.uno) — hostnames NXDOMAIN, no Ingress; services
|
||||
# no longer deployed. Re-add when/if they come back.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -6,7 +6,18 @@ kind: Kustomization
|
||||
# and verified. See MIGRATION-gitea-woodpecker.md.
|
||||
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
|
||||
@@ -0,0 +1,39 @@
|
||||
# The live data volume of gitea-0, owned here so its size is set in git
|
||||
# (REN-583). The StatefulSet controller created this claim from the
|
||||
# volumeClaimTemplates in statefulset.yaml, but that template is immutable on
|
||||
# an existing StatefulSet, so changing the size there cannot be applied. A
|
||||
# PVC's resources.requests.storage CAN grow in place (ceph-block has
|
||||
# allowVolumeExpansion: true; ceph-csi grows the RBD image and the filesystem
|
||||
# online, no pod restart). Flux applies this over the existing claim with
|
||||
# server-side apply and the resize follows.
|
||||
#
|
||||
# - Name, namespace, accessModes, storageClassName and volumeMode must match
|
||||
# the live claim exactly: they are immutable, a mismatch fails the whole
|
||||
# gitea Kustomization (no change to the live claim).
|
||||
# - volumeName must stay: kustomize-controller has owned spec.volumeName
|
||||
# since the namespace move pre-created this claim (eb23d3ac, #878).
|
||||
# Without it, server-side apply tries to clear the field and the API server
|
||||
# rejects the whole claim as an immutable-spec change.
|
||||
# - Grow only. A smaller value is rejected by the API server.
|
||||
# - prune: disabled, so removing this file from git never deletes the claim
|
||||
# and its data. To hand ownership back, delete the file; the claim stays.
|
||||
#
|
||||
# Size history: 20Gi -> 40Gi 2026-07-22 (#336, live resize by hand);
|
||||
# 40Gi -> 60Gi 2026-10-03 (REN-583, after the volume filled again on REN-580).
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: data-gitea-0
|
||||
namespace: gitea
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
storageClassName: ceph-block
|
||||
volumeMode: Filesystem
|
||||
volumeName: pvc-e7c56db2-481d-40cb-b94a-b264b7b4b5be
|
||||
resources:
|
||||
requests:
|
||||
storage: 60Gi
|
||||
@@ -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
|
||||
# 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
|
||||
@@ -147,14 +161,10 @@ spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
storageClassName: ceph-block
|
||||
resources:
|
||||
# Expanded 20Gi -> 40Gi on 2026-07-22 (ceph-block online resize) to
|
||||
# clear a 100%-full outage (#336). The live PVC was resized directly.
|
||||
# NOTE: volumeClaimTemplates is immutable on an existing StatefulSet,
|
||||
# so bumping this value cannot be `kubectl apply`-ed in place — Flux
|
||||
# will reject the whole object. Landing this size change requires a
|
||||
# one-time `kubectl delete statefulset gitea -n gitea --cascade=orphan`
|
||||
# (pod + PVC keep running) so Flux re-creates the STS from this
|
||||
# template and re-adopts them. After the cleanup_packages GC reclaims
|
||||
# the ~10G of orphaned blobs, real usage is ~9G, so 40Gi is headroom.
|
||||
# This template only applies when the claim does not exist yet. The
|
||||
# live size of data-gitea-0 is set in pvc.yaml (REN-583), not here:
|
||||
# volumeClaimTemplates is immutable on an existing StatefulSet, so a
|
||||
# change to this value cannot be applied and Flux would reject the
|
||||
# whole object. Leave it at 40Gi.
|
||||
requests:
|
||||
storage: 40Gi
|
||||
|
||||
@@ -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
|
||||
@@ -1,15 +0,0 @@
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: home-assistant-backup-password
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: home-assistant-backup-password # keeps the old Secret's name — consumers unchanged
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/home-assistant/backup-password
|
||||
@@ -60,7 +60,7 @@ spec:
|
||||
# repo's configuration.yaml reads this key, so HA does not start
|
||||
# without it. Removing this init container takes the recorder down.
|
||||
- name: render-db-url
|
||||
image: busybox:1.36
|
||||
image: busybox:1.38@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -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
|
||||
@@ -159,7 +159,7 @@ spec:
|
||||
# substitution to POD_IP so nginx-runtime variables like $host
|
||||
# / $http_x_forwarded_for in the template stay intact.
|
||||
- name: proxy
|
||||
image: nginxinc/nginx-unprivileged:1.27-alpine
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:26b0bf6fbf07297983cb341998d79c831508787de26627dd2a112321b9c3a4af
|
||||
env:
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
@@ -187,13 +187,62 @@ 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
|
||||
# any LAN client can edit, matching the user's trust-the-network
|
||||
# model. Runs as root so its writes match HA's root-owned files.
|
||||
- name: code-server
|
||||
image: codercom/code-server:4.118.0
|
||||
image: codercom/code-server:4.140.0@sha256:1c9ad972a78450f430155144110da4c02d3627c72a598e9197e2781fb9f92e28
|
||||
args:
|
||||
- "--auth=none"
|
||||
- "--bind-addr=0.0.0.0:8080"
|
||||
@@ -253,7 +302,7 @@ spec:
|
||||
# in the PVC's remote URL; reload auth uses a dedicated long-lived token.
|
||||
# Runs as root to match HA's root-owned /config files. See config-sync.sh.
|
||||
- name: config-sync
|
||||
image: alpine/git:2.54.0
|
||||
image: alpine/git:2.54.0@sha256:a4bb51f1a3553df194ce679fc1db721d8bfba2046fa1a88fe9d4ac551ffbce25
|
||||
command: ["/bin/sh", "/scripts/sync.sh"]
|
||||
env:
|
||||
- name: SYNC_BRANCH
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: esphome
|
||||
image: ghcr.io/esphome/esphome:2026.6.2
|
||||
image: ghcr.io/esphome/esphome:2026.9.1@sha256:776a845d508909bf426d2ee7bdd81796361ebe6a7f091807ce1d6d0b48985413
|
||||
imagePullPolicy: IfNotPresent
|
||||
# Default entrypoint runs the dashboard (Device Builder) against the
|
||||
# mounted /config — no command override needed.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- backup-password-externalsecret.yaml
|
||||
- pvcs.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
@@ -13,12 +12,10 @@ resources:
|
||||
- prometheus-token-externalsecret.yaml
|
||||
- config-sync-token-externalsecret.yaml
|
||||
- mosquitto-bootstrap.yaml
|
||||
# health broker credential: OpenBao-backed + Class-A rotated (first of the
|
||||
# eight users to migrate off mosquitto-bootstrap.yaml).
|
||||
# Former `health` probe credential. The broker no longer reads it (the
|
||||
# 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
|
||||
@@ -69,6 +66,12 @@ resources:
|
||||
- ha-pg-objectstore.yaml
|
||||
- ha-pg-cluster.yaml
|
||||
- ha-pg-scheduled-backup.yaml
|
||||
# MQTT credential management (REN-802): the account controller manages the
|
||||
# production broker's Dynamic Security (REN-900). mosquitto-dynsec-test is
|
||||
# the step-1 test broker, no longer managed; removal follows separately.
|
||||
# Register: mqtt-accounts.yaml (configMapGenerator).
|
||||
- mosquitto-dynsec-test.yaml
|
||||
- mqtt-account-controller.yaml
|
||||
configMapGenerator:
|
||||
# nginx config for the reverse-proxy sidecar. Hash suffix disabled so
|
||||
# the cm name stays stable; editing proxy.conf does not auto-roll the
|
||||
@@ -104,5 +107,28 @@ configMapGenerator:
|
||||
namespace: home-assistant
|
||||
files:
|
||||
- guard.sh=zigbee2mqtt-discovery-guard.sh
|
||||
# Z2M external converters, copied into /app/data/external_converters by the
|
||||
# seed-data init container on every start. Hash suffix disabled (name stays
|
||||
# stable), so editing a converter does not auto-roll the pod — restart
|
||||
# manually with `kubectl rollout restart deploy/zigbee2mqtt -n home-assistant`.
|
||||
- name: zigbee2mqtt-external-converters
|
||||
namespace: home-assistant
|
||||
files:
|
||||
- wetterstation-ws90.js=zigbee2mqtt-ws90-converter.js
|
||||
# MQTT account register, read by mqtt-account-controller on every loop.
|
||||
# Hash suffix disabled (name stays stable); the volume is not a subPath
|
||||
# mount, so an edit reaches the running controller without a restart.
|
||||
- name: mqtt-accounts
|
||||
namespace: home-assistant
|
||||
files:
|
||||
- mqtt-accounts.yaml
|
||||
# shelly-manager api-proxy sidecar config (REN-935). Hash suffix disabled
|
||||
# (name stays stable), so editing shelly-manager-api-proxy.conf does not
|
||||
# auto-roll the pod — restart manually with
|
||||
# `kubectl rollout restart deploy/shelly-manager -n home-assistant`.
|
||||
- name: shelly-manager-api-proxy
|
||||
namespace: home-assistant
|
||||
files:
|
||||
- api.conf.template=shelly-manager-api-proxy.conf
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
|
||||
@@ -42,6 +42,9 @@ spec:
|
||||
labels:
|
||||
app.kubernetes.io/name: mdns-relay
|
||||
annotations:
|
||||
# Bump to restart the pod and re-attach net1 (DHCP via multus).
|
||||
# 2026-10-06: first attach on containernetworking/plugins v1.9.1.
|
||||
homelab.renner.uno/restarted-for: "2026-10-06 cni-plugins v1.9.1 attach check"
|
||||
k8s.v1.cni.cncf.io/networks: mdns-relay-iot
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
@@ -82,7 +85,7 @@ spec:
|
||||
# deliver, and the ClusterIP has no backend while HA restarts. The
|
||||
# trap forwards SIGTERM so the pod still terminates promptly.
|
||||
- name: socat-ipv4
|
||||
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
|
||||
image: alpine/socat:1.8.1.3@sha256:82ad20f6f6e29b91ff33b6662d24063522b1378f08fd8569cd3ab412cac13f50
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/sh
|
||||
@@ -124,7 +127,7 @@ spec:
|
||||
# depends on the kernel's bindv6only default rather than on anything
|
||||
# stated here, so the roles are made explicit instead of inherited.
|
||||
- name: socat-ipv6
|
||||
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
|
||||
image: alpine/socat:1.8.1.3@sha256:82ad20f6f6e29b91ff33b6662d24063522b1378f08fd8569cd3ab412cac13f50
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/sh
|
||||
|
||||
@@ -44,6 +44,10 @@ kind: Job
|
||||
metadata:
|
||||
name: mosquitto-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:
|
||||
@@ -55,7 +59,7 @@ spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.21
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -112,6 +116,10 @@ kind: Job
|
||||
metadata:
|
||||
name: mosquitto-admin-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:
|
||||
@@ -123,7 +131,7 @@ spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.21
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -169,73 +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
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-bootstrap
|
||||
spec:
|
||||
serviceAccountName: mosquitto-bootstrap
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.21
|
||||
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
|
||||
@@ -245,6 +186,10 @@ kind: Job
|
||||
metadata:
|
||||
name: mosquitto-sml2mqtt-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:
|
||||
@@ -256,7 +201,7 @@ spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.21
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -313,6 +258,10 @@ kind: Job
|
||||
metadata:
|
||||
name: mosquitto-open3e-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:
|
||||
@@ -324,7 +273,7 @@ spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.21
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -372,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
|
||||
@@ -383,6 +332,10 @@ kind: Job
|
||||
metadata:
|
||||
name: mosquitto-zigbee2mqtt-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:
|
||||
@@ -394,7 +347,7 @@ spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.21
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -10,3 +10,38 @@ spec:
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- mqtt.renner.uno
|
||||
---
|
||||
# Server certificate of the mutual-TLS listener :8884 (REN-802). Issued by
|
||||
# mqtt-ca because certificate clients and mqtt-account-controller trust only
|
||||
# that CA. The Let's Encrypt certificate above stays on :8883 for password
|
||||
# clients. In-cluster names only; an outside cert client (edge-1) is REN-802
|
||||
# step 3.
|
||||
#
|
||||
# Long-lived on purpose: mosquitto reads its certificate only at start, and a
|
||||
# broker restart is an outage, so nothing restarts it for a renewal. With 2
|
||||
# years and renewal after 1, the broker serves a renewed certificate after the
|
||||
# next restart of any kind (image bump, node reboot) within that year; the old
|
||||
# one stays valid until then.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: mosquitto-mtls
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
secretName: mosquitto-mtls
|
||||
duration: 17520h
|
||||
renewBefore: 8760h
|
||||
dnsNames:
|
||||
- mosquitto
|
||||
- mosquitto.home-assistant.svc
|
||||
- mosquitto.home-assistant.svc.cluster.local
|
||||
usages:
|
||||
- digital signature
|
||||
- server auth
|
||||
privateKey:
|
||||
algorithm: ECDSA
|
||||
size: 256
|
||||
issuerRef:
|
||||
group: cert-manager.io
|
||||
kind: ClusterIssuer
|
||||
name: mqtt-ca
|
||||
|
||||
@@ -64,6 +64,66 @@ data:
|
||||
certfile /mosquitto/tls/tls.crt
|
||||
keyfile /mosquitto/tls/tls.key
|
||||
|
||||
# Mutual TLS for certificate clients (REN-802). Only certificates from
|
||||
# mqtt-ca are accepted, and the certificate's CN is the username. Not on
|
||||
# the LoadBalancer: in-cluster only (Service `mosquitto`).
|
||||
listener 8884
|
||||
max_connections 200
|
||||
cafile /mosquitto/mtls/ca.crt
|
||||
certfile /mosquitto/mtls/tls.crt
|
||||
keyfile /mosquitto/mtls/tls.key
|
||||
require_certificate true
|
||||
use_identity_as_username true
|
||||
|
||||
allow_anonymous false
|
||||
|
||||
# Two account sources side by side (REN-802 step 2, tested on 2.0.22 in
|
||||
# REN-900, document `coexistence`):
|
||||
# Dynamic Security: accounts from the register mqtt-accounts.yaml,
|
||||
# managed live by mqtt-account-controller over $CONTROL.
|
||||
# password_file/acl_file: the legacy users, unchanged until each client
|
||||
# moves to the register (step 3).
|
||||
# Dynamic Security is asked first. For a username it does not know, and
|
||||
# for a request none of its roles match, it defers to the files. A
|
||||
# register entry with the same name as a legacy user therefore replaces
|
||||
# that user's password, while its acl_file block still applies to
|
||||
# whatever the entry's roles do not cover. Delete the legacy user when
|
||||
# its client moves.
|
||||
#
|
||||
# The acl_file check of 2.0 allows every SUBSCRIBE and checks only on
|
||||
# delivery, so the Dynamic Security default must deny publishClientReceive.
|
||||
# Otherwise every Dynamic Security client could read every topic.
|
||||
plugin /usr/lib/mosquitto_dynamic_security.so
|
||||
plugin_opt_config_file /mosquitto/auth/dynamic-security.json
|
||||
password_file /mosquitto/auth/passwd
|
||||
acl_file /mosquitto/auth/acl
|
||||
# Dynamic Security start file, used until mqtt-account-controller has
|
||||
# written the Secret mqtt-dynsec-seed. No secrets: only the controller's
|
||||
# certificate client and a role that reaches the Dynamic Security API and
|
||||
# nothing else.
|
||||
dynamic-security.json: |
|
||||
{
|
||||
"defaultACLAccess": {
|
||||
"publishClientSend": false,
|
||||
"publishClientReceive": false,
|
||||
"subscribe": false,
|
||||
"unsubscribe": true
|
||||
},
|
||||
"clients": [
|
||||
{
|
||||
"username": "mqtt-account-controller",
|
||||
"roles": [{"rolename": "mqtt-account-controller-admin"}]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"roles": [
|
||||
{
|
||||
"rolename": "mqtt-account-controller-admin",
|
||||
"acls": [
|
||||
{"acltype": "publishClientReceive", "topic": "$CONTROL/dynamic-security/v1/#", "priority": 0, "allow": true},
|
||||
{"acltype": "publishClientSend", "topic": "$CONTROL/dynamic-security/v1/#", "priority": 0, "allow": true},
|
||||
{"acltype": "subscribePattern", "topic": "$CONTROL/dynamic-security/v1/#", "priority": 0, "allow": true}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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-health-credentials,mosquitto-open3e-int-credentials
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
@@ -54,7 +46,7 @@ spec:
|
||||
fsGroup: 1883
|
||||
containers:
|
||||
- name: mosquitto
|
||||
# Pinned tag, auto-bumped within the 2.x line by Flux image-automation
|
||||
# Pinned tag, auto-bumped within the 2.0 line by Flux image-automation
|
||||
# (infrastructure/image-automation/mosquitto.yaml). The marker tells
|
||||
# Flux where to write; patch/minor updates land as git commits and
|
||||
# roll the broker. HA re-publishes its own discovery afterwards, but
|
||||
@@ -74,24 +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 health "$HEALTH_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.
|
||||
@@ -112,10 +94,6 @@ spec:
|
||||
user homeassistant
|
||||
topic readwrite #
|
||||
|
||||
user shellies
|
||||
topic readwrite shellies/#
|
||||
topic write homeassistant/#
|
||||
|
||||
user admin
|
||||
topic readwrite #
|
||||
|
||||
@@ -125,32 +103,15 @@ 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/#
|
||||
topic read energy/#
|
||||
|
||||
# `health` is the broker's own probe credential (see the
|
||||
# liveness/readiness probes below). Scoped to health/# and
|
||||
# nothing else: the probes publish a random value retained and
|
||||
# read it back to prove the broker actually stores+delivers
|
||||
# (not just PUBACKs) — so the user needs read AND write, but
|
||||
# only under health/#. A leak can only touch the throwaway
|
||||
# probe subtree that nothing else subscribes to.
|
||||
user health
|
||||
topic readwrite health/#
|
||||
|
||||
# `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
|
||||
@@ -165,8 +126,21 @@ spec:
|
||||
topic write homeassistant/#
|
||||
topic read homeassistant/status
|
||||
EOF
|
||||
chown 1883:1883 /mosquitto/auth/passwd /mosquitto/auth/acl
|
||||
chmod 0600 /mosquitto/auth/passwd /mosquitto/auth/acl
|
||||
# Dynamic Security start file (REN-802): the controller's seed
|
||||
# if it exists, else the static base file. The seed carries
|
||||
# every register account and its hash, so the broker is in the
|
||||
# desired state before the first client connects. The plugin
|
||||
# rewrites this file on every $CONTROL change, so the broker
|
||||
# user owns it and the directory.
|
||||
if [ -s /mosquitto/seed/dynamic-security.json ]; then
|
||||
cp /mosquitto/seed/dynamic-security.json /mosquitto/auth/dynamic-security.json
|
||||
echo "dynsec: loaded seed from Secret mqtt-dynsec-seed"
|
||||
else
|
||||
cp /mosquitto/config/dynamic-security.json /mosquitto/auth/dynamic-security.json
|
||||
echo "dynsec: no seed yet, loaded base file"
|
||||
fi
|
||||
chown 1883:1883 /mosquitto/auth /mosquitto/auth/passwd /mosquitto/auth/acl /mosquitto/auth/dynamic-security.json
|
||||
chmod 0600 /mosquitto/auth/passwd /mosquitto/auth/acl /mosquitto/auth/dynamic-security.json
|
||||
exec mosquitto -c /mosquitto/config/mosquitto.conf
|
||||
env:
|
||||
- name: HA_PASSWORD
|
||||
@@ -174,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:
|
||||
@@ -194,29 +163,11 @@ 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:
|
||||
name: mosquitto-evcc-credentials
|
||||
key: password
|
||||
# Probe-only credential. Now OpenBao-backed and Class-A rotated (see
|
||||
# mosquitto-health-credentials-externalsecret.yaml); its bootstrap Job
|
||||
# is gone.
|
||||
# Present here for the same reason as the others — the startup
|
||||
# shim hashes it into the passwd file — and reused by the exec
|
||||
# probes below, which run inside this container.
|
||||
- name: HEALTH_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-health-credentials
|
||||
key: password
|
||||
# Zigbee2MQTT bridge credential (mosquitto-zigbee2mqtt-credentials-
|
||||
# bootstrap). Hashed into the passwd file here; Z2M reads the same
|
||||
# Secret via its init container to authenticate to the broker.
|
||||
@@ -232,6 +183,9 @@ spec:
|
||||
- name: mqtts
|
||||
containerPort: 8883
|
||||
protocol: TCP
|
||||
- name: mqtts-cert
|
||||
containerPort: 8884
|
||||
protocol: TCP
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /mosquitto/config
|
||||
@@ -241,6 +195,15 @@ spec:
|
||||
- name: tls
|
||||
mountPath: /mosquitto/tls
|
||||
readOnly: true
|
||||
- name: mtls
|
||||
mountPath: /mosquitto/mtls
|
||||
readOnly: true
|
||||
- name: seed
|
||||
mountPath: /mosquitto/seed
|
||||
readOnly: true
|
||||
- name: probe-tls
|
||||
mountPath: /mosquitto/probe-tls
|
||||
readOnly: true
|
||||
# Real MQTT round-trip probes, replacing the old tcpSocket checks.
|
||||
# A bare TCP accept proved only that a listener existed. Even a
|
||||
# QoS-1 PUBACK proves only that the broker acknowledged the publish
|
||||
@@ -259,14 +222,14 @@ spec:
|
||||
# Liveness and readiness use SEPARATE topics (probe/live vs
|
||||
# probe/ready) so their rotating retained values never collide.
|
||||
#
|
||||
# Uses the dedicated least-privilege `health` user (ACL: readwrite
|
||||
# health/# only — see the passwd/acl build above and the
|
||||
# mosquitto-health-credentials ExternalSecret). Its password is in
|
||||
# this container's env (HEALTH_PASSWORD) because the startup shim
|
||||
# hashes every user into the passwd file; the exec probes run
|
||||
# inside this container and read it directly. A leak of this
|
||||
# credential can only touch the health/# probe subtree, which
|
||||
# nothing else subscribes to.
|
||||
# Logs in as the register account `mosquitto-probe` (mqtt-accounts.yaml,
|
||||
# REN-953): a client certificate from mqtt-ca on the mTLS listener
|
||||
# :8884, ACL readwrite health/# only. No password exists for it.
|
||||
# The Secret mount is not a subPath, so a cert-manager renewal
|
||||
# reaches the next probe without a restart. --insecure skips only
|
||||
# the hostname check, because the server certificate names the
|
||||
# Service and the probe dials 127.0.0.1; the chain is still checked
|
||||
# against mqtt-ca.
|
||||
#
|
||||
# mosquitto is a single-replica SPOF (HA, Shelly, sml2mqtt, open3e,
|
||||
# evcc). Liveness is deliberately slow to fire — failureThreshold 6
|
||||
@@ -286,8 +249,9 @@ spec:
|
||||
- -c
|
||||
- |
|
||||
V="probe-$(head -c8 /dev/urandom | od -An -tx1 | tr -d ' ')"
|
||||
mosquitto_pub -h 127.0.0.1 -p 1883 -u health -P "$HEALTH_PASSWORD" -q 1 -r -t health/probe/ready -m "$V" &&
|
||||
[ "$(mosquitto_sub -h 127.0.0.1 -p 1883 -u health -P "$HEALTH_PASSWORD" -t health/probe/ready -C 1 -W 5)" = "$V" ]
|
||||
T="-h 127.0.0.1 -p 8884 --insecure --cafile /mosquitto/probe-tls/ca.crt --cert /mosquitto/probe-tls/tls.crt --key /mosquitto/probe-tls/tls.key"
|
||||
mosquitto_pub $T -q 1 -r -t health/probe/ready -m "$V" &&
|
||||
[ "$(mosquitto_sub $T -t health/probe/ready -C 1 -W 5)" = "$V" ]
|
||||
periodSeconds: 15
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
@@ -298,8 +262,9 @@ spec:
|
||||
- -c
|
||||
- |
|
||||
V="probe-$(head -c8 /dev/urandom | od -An -tx1 | tr -d ' ')"
|
||||
mosquitto_pub -h 127.0.0.1 -p 1883 -u health -P "$HEALTH_PASSWORD" -q 1 -r -t health/probe/live -m "$V" &&
|
||||
[ "$(mosquitto_sub -h 127.0.0.1 -p 1883 -u health -P "$HEALTH_PASSWORD" -t health/probe/live -C 1 -W 5)" = "$V" ]
|
||||
T="-h 127.0.0.1 -p 8884 --insecure --cafile /mosquitto/probe-tls/ca.crt --cert /mosquitto/probe-tls/tls.crt --key /mosquitto/probe-tls/tls.key"
|
||||
mosquitto_pub $T -q 1 -r -t health/probe/live -m "$V" &&
|
||||
[ "$(mosquitto_sub $T -t health/probe/live -C 1 -W 5)" = "$V" ]
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 6
|
||||
@@ -320,3 +285,23 @@ spec:
|
||||
- name: tls
|
||||
secret:
|
||||
secretName: mosquitto-tls
|
||||
# mqtt-ca server certificate and CA of the :8884 mTLS listener, read
|
||||
# only at start (renewal timing: mosquitto-certificate.yaml).
|
||||
- name: mtls
|
||||
secret:
|
||||
secretName: mosquitto-mtls
|
||||
# Written by mqtt-account-controller. Optional so the broker still
|
||||
# starts with the base file if the Secret is missing. Not in the
|
||||
# reloader annotation: a register change is applied live over
|
||||
# $CONTROL and must never restart the broker.
|
||||
- name: seed
|
||||
secret:
|
||||
secretName: mqtt-dynsec-seed
|
||||
optional: true
|
||||
# Probe certificate of the register account mosquitto-probe, created
|
||||
# by mqtt-account-controller. Not optional: a pod without it fails its
|
||||
# probes, so it waits for the Secret instead.
|
||||
- name: probe-tls
|
||||
secret:
|
||||
secretName: mqtt-mosquitto-probe-tls
|
||||
defaultMode: 0400
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# Test broker for mqtt-account-controller (REN-802 step 1, REN-888).
|
||||
#
|
||||
# A second, throwaway mosquitto with the Dynamic Security plugin and a mutual-
|
||||
# TLS listener, so the controller can be proven (cert login, reconcile, seed
|
||||
# after restart, revocation) without touching the production broker. Nothing
|
||||
# in production connects here.
|
||||
#
|
||||
# Deliberately NOT labelled app.kubernetes.io/name: mosquitto: that label is
|
||||
# the selector of the production Services and of mosquitto-world-ingress, so
|
||||
# reusing it would put this pod behind mqtt.renner.uno and the IoT VLAN.
|
||||
# ClusterIP only; reachable from inside the home-assistant namespace.
|
||||
#
|
||||
# Removal: delete this file and its kustomization.yaml entry once production
|
||||
# runs Dynamic Security (REN-802 step 2).
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: mosquitto-dynsec-test-tls
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
secretName: mosquitto-dynsec-test-tls
|
||||
dnsNames:
|
||||
- mosquitto-dynsec-test
|
||||
- mosquitto-dynsec-test.home-assistant.svc
|
||||
- mosquitto-dynsec-test.home-assistant.svc.cluster.local
|
||||
usages:
|
||||
- digital signature
|
||||
- server auth
|
||||
privateKey:
|
||||
algorithm: ECDSA
|
||||
size: 256
|
||||
issuerRef:
|
||||
group: cert-manager.io
|
||||
kind: ClusterIssuer
|
||||
name: mqtt-ca
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: mosquitto-dynsec-test-config
|
||||
namespace: home-assistant
|
||||
data:
|
||||
mosquitto.conf: |
|
||||
persistence false
|
||||
log_dest stdout
|
||||
log_type error
|
||||
log_type warning
|
||||
log_type notice
|
||||
log_type information
|
||||
|
||||
# Dynamic Security owns authentication and ACLs. The plugin writes its
|
||||
# state back to this file; it is an emptyDir, refilled by the
|
||||
# initContainer on every start.
|
||||
plugin /usr/lib/mosquitto_dynamic_security.so
|
||||
plugin_opt_config_file /mosquitto/dynsec/dynamic-security.json
|
||||
|
||||
# Mutual TLS only. The client certificate's CN is the username; the
|
||||
# Dynamic Security client of that name supplies the roles.
|
||||
listener 8883
|
||||
cafile /mosquitto/tls/ca.crt
|
||||
certfile /mosquitto/tls/tls.crt
|
||||
keyfile /mosquitto/tls/tls.key
|
||||
require_certificate true
|
||||
use_identity_as_username true
|
||||
|
||||
allow_anonymous false
|
||||
# Start file used until the controller has written the Secret
|
||||
# mqtt-dynsec-seed. No secrets: only the controller's certificate client
|
||||
# and a role that reaches the Dynamic Security API and nothing else. Same
|
||||
# shape as the controller's seed renderer (internal/dynsec/config.go).
|
||||
dynamic-security.json: |
|
||||
{
|
||||
"defaultACLAccess": {
|
||||
"publishClientSend": false,
|
||||
"publishClientReceive": true,
|
||||
"subscribe": false,
|
||||
"unsubscribe": true
|
||||
},
|
||||
"clients": [
|
||||
{
|
||||
"username": "mqtt-account-controller",
|
||||
"roles": [{"rolename": "mqtt-account-controller-admin"}]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"roles": [
|
||||
{
|
||||
"rolename": "mqtt-account-controller-admin",
|
||||
"acls": [
|
||||
{"acltype": "publishClientReceive", "topic": "$CONTROL/dynamic-security/v1/#", "priority": 0, "allow": true},
|
||||
{"acltype": "publishClientSend", "topic": "$CONTROL/dynamic-security/v1/#", "priority": 0, "allow": true},
|
||||
{"acltype": "subscribePattern", "topic": "$CONTROL/dynamic-security/v1/#", "priority": 0, "allow": true}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mosquitto-dynsec-test
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-dynsec-test
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: mosquitto-dynsec-test
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-dynsec-test
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
terminationGracePeriodSeconds: 10
|
||||
# Runs as the mosquitto UID from the start instead of starting as root
|
||||
# and dropping, so the dynsec file the initContainer writes is the
|
||||
# broker's own.
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1883
|
||||
runAsGroup: 1883
|
||||
fsGroup: 1883
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
# Every start: the controller's seed if it exists, else the static
|
||||
# base file. The seed carries every account and password hash, so the
|
||||
# broker is in the desired state before the first client connects.
|
||||
- name: dynsec-seed
|
||||
image: eclipse-mosquitto:2.0.22 # {"$imagepolicy": "flux-system:mosquitto"}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
if [ -s /seed/dynamic-security.json ]; then
|
||||
cp /seed/dynamic-security.json /mosquitto/dynsec/dynamic-security.json
|
||||
echo "dynsec: loaded seed from Secret mqtt-dynsec-seed"
|
||||
else
|
||||
cp /mosquitto/config/dynamic-security.json /mosquitto/dynsec/dynamic-security.json
|
||||
echo "dynsec: no seed yet, loaded base file"
|
||||
fi
|
||||
chmod 0600 /mosquitto/dynsec/dynamic-security.json
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /mosquitto/config
|
||||
readOnly: true
|
||||
- name: seed
|
||||
mountPath: /seed
|
||||
readOnly: true
|
||||
- name: dynsec
|
||||
mountPath: /mosquitto/dynsec
|
||||
containers:
|
||||
- name: mosquitto
|
||||
# Same tag and image policy as the production broker, so the test
|
||||
# runs the version that production will run.
|
||||
image: eclipse-mosquitto:2.0.22 # {"$imagepolicy": "flux-system:mosquitto"}
|
||||
command: ["mosquitto", "-c", "/mosquitto/config/mosquitto.conf"]
|
||||
ports:
|
||||
- name: mqtts
|
||||
containerPort: 8883
|
||||
protocol: TCP
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /mosquitto/config
|
||||
readOnly: true
|
||||
- name: tls
|
||||
mountPath: /mosquitto/tls
|
||||
readOnly: true
|
||||
- name: dynsec
|
||||
mountPath: /mosquitto/dynsec
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: mqtts
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: mqtts
|
||||
periodSeconds: 30
|
||||
failureThreshold: 6
|
||||
resources:
|
||||
requests:
|
||||
cpu: 5m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: mosquitto-dynsec-test-config
|
||||
- name: tls
|
||||
secret:
|
||||
secretName: mosquitto-dynsec-test-tls
|
||||
# Written by mqtt-account-controller. Optional: absent on the very
|
||||
# first start, when the base file is used instead.
|
||||
- name: seed
|
||||
secret:
|
||||
secretName: mqtt-dynsec-seed
|
||||
optional: true
|
||||
- name: dynsec
|
||||
emptyDir:
|
||||
sizeLimit: 4Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mosquitto-dynsec-test
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-dynsec-test
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: mosquitto-dynsec-test
|
||||
ports:
|
||||
- name: mqtts
|
||||
port: 8883
|
||||
targetPort: mqtts
|
||||
protocol: TCP
|
||||
@@ -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
|
||||
@@ -12,3 +12,7 @@ spec:
|
||||
- name: mqtt
|
||||
port: 1883
|
||||
targetPort: mqtt
|
||||
# Mutual TLS for certificate clients and mqtt-account-controller (REN-802).
|
||||
- name: mqtts-cert
|
||||
port: 8884
|
||||
targetPort: mqtts-cert
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
# mqtt-account-controller (REN-802, code REN-837, deploy REN-888).
|
||||
# Source: konairius/mqtt-account-controller (maintainer Hephaestus). Reconciles
|
||||
# the broker's Dynamic Security clients/roles/ACLs, the cert-manager
|
||||
# Certificates of `auth: cert` accounts and device passwords to the register
|
||||
# mqtt-accounts.yaml, every INTERVAL (2m).
|
||||
#
|
||||
# Manages the production broker `mosquitto` over its mTLS listener :8884
|
||||
# (REN-802 step 2, REN-900). The legacy users in that broker's password_file
|
||||
# are not Dynamic Security clients, so the controller never sees or touches
|
||||
# them.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: mqtt-account-controller
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
---
|
||||
# Find the Certificates it manages (label app.kubernetes.io/managed-by=
|
||||
# mqtt-account-controller) in every namespace. List only: no get, watch or
|
||||
# write cluster-wide.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: mqtt-account-controller-list-certificates
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
rules:
|
||||
- apiGroups: ["cert-manager.io"]
|
||||
resources: ["certificates"]
|
||||
verbs: ["list"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: mqtt-account-controller-list-certificates
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: mqtt-account-controller-list-certificates
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: mqtt-account-controller
|
||||
namespace: home-assistant
|
||||
---
|
||||
# Per deliver.namespace: manage the account Certificates there (server-side
|
||||
# apply = patch, which creates through `create`). One Role + RoleBinding per
|
||||
# namespace that appears as `deliver.namespace` in the register: home-assistant,
|
||||
# evcc and paperclip.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: mqtt-account-controller-certificates
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
rules:
|
||||
- apiGroups: ["cert-manager.io"]
|
||||
resources: ["certificates"]
|
||||
verbs: ["get", "create", "patch", "delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: mqtt-account-controller-certificates
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: mqtt-account-controller-certificates
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: mqtt-account-controller
|
||||
namespace: home-assistant
|
||||
---
|
||||
# Secrets in home-assistant, by name only. This namespace holds every broker
|
||||
# and HA credential, so an unnamed `delete secrets` grant would let the
|
||||
# controller wipe all of them.
|
||||
# mqtt-account-hashes, mqtt-dynsec-seed: its own state (server-side apply
|
||||
# creates them, hence create + patch; resourceNames hold for an apply
|
||||
# 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:
|
||||
name: mqtt-account-controller-secrets-home-assistant
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames: ["mqtt-account-hashes", "mqtt-dynsec-seed", "mqtt-ha-cert-sync"]
|
||||
verbs: ["get", "create", "patch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames:
|
||||
- mqtt-test-cert-tls
|
||||
- mqtt-mosquitto-probe-tls
|
||||
- mqtt-zigbee2mqtt-bridge-tls
|
||||
- mqtt-home-assistant-tls
|
||||
verbs: ["delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: mqtt-account-controller-secrets-home-assistant
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: mqtt-account-controller-secrets-home-assistant
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: mqtt-account-controller
|
||||
namespace: home-assistant
|
||||
---
|
||||
# deliver.namespace evcc (account evcc-client): its Certificate, and its
|
||||
# Secret by name for revocation.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: mqtt-account-controller-certificates
|
||||
namespace: evcc
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
rules:
|
||||
- apiGroups: ["cert-manager.io"]
|
||||
resources: ["certificates"]
|
||||
verbs: ["get", "create", "patch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames:
|
||||
- mqtt-evcc-client-tls
|
||||
verbs: ["delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: mqtt-account-controller-certificates
|
||||
namespace: evcc
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: mqtt-account-controller-certificates
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: mqtt-account-controller
|
||||
namespace: home-assistant
|
||||
---
|
||||
# deliver.namespace paperclip (account agents-read): its Certificate, and its
|
||||
# Secret by name for revocation.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: mqtt-account-controller-certificates
|
||||
namespace: paperclip
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
rules:
|
||||
- apiGroups: ["cert-manager.io"]
|
||||
resources: ["certificates"]
|
||||
verbs: ["get", "create", "patch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames:
|
||||
- mqtt-agents-read-tls
|
||||
verbs: ["delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: mqtt-account-controller-certificates
|
||||
namespace: paperclip
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: mqtt-account-controller-certificates
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: mqtt-account-controller
|
||||
namespace: home-assistant
|
||||
---
|
||||
# The controller's own MQTT identity: CN = Dynamic Security admin client
|
||||
# (ADMIN_USER). Not labelled managed-by, so the controller never treats it
|
||||
# as an account certificate and never revokes it. Re-read on every TLS
|
||||
# handshake, so a renewal needs no restart.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: mqtt-account-controller
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
secretName: mqtt-account-controller-tls
|
||||
commonName: mqtt-account-controller
|
||||
usages:
|
||||
- digital signature
|
||||
- client auth
|
||||
privateKey:
|
||||
algorithm: ECDSA
|
||||
size: 256
|
||||
rotationPolicy: Always
|
||||
issuerRef:
|
||||
group: cert-manager.io
|
||||
kind: ClusterIssuer
|
||||
name: mqtt-ca
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mqtt-account-controller
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
annotations:
|
||||
# The controller reads the shelly-manager API key once at start; the
|
||||
# quarterly rotation of shelly-manager-auth must restart it too.
|
||||
secret.reloader.stakater.com/reload: shelly-manager-auth
|
||||
spec:
|
||||
replicas: 1
|
||||
# One writer at a time: two controllers would race on rotations and the
|
||||
# seed Secret.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
spec:
|
||||
serviceAccountName: mqtt-account-controller
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- 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-cb96b1a@sha256:9d9b9e83a9c0f9f567e57edacab11cf4c58e8c0e49e38e8d7b51421cd1242911
|
||||
env:
|
||||
- name: BROKER_URL
|
||||
value: ssl://mosquitto.home-assistant.svc.cluster.local:8884
|
||||
# Password push to Shellies (accounts with deliver.shelly-manager).
|
||||
# The API-key-only Service (api-proxy sidecar, :8081), like the
|
||||
# agents; same namespace, so the default-deny allows it.
|
||||
- name: SHELLY_MANAGER_URL
|
||||
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
|
||||
value: ":8081"
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8081
|
||||
protocol: TCP
|
||||
# /healthz is 200 while a reconcile succeeded within 3 x INTERVAL.
|
||||
# Readiness only: a restart does not fix an unreachable broker.
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
periodSeconds: 30
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: register
|
||||
mountPath: /etc/mqtt-accounts
|
||||
readOnly: true
|
||||
- name: tls
|
||||
mountPath: /etc/mqtt-tls
|
||||
readOnly: true
|
||||
- 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
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
volumes:
|
||||
# Not a subPath mount, so a register change reaches the running pod
|
||||
# (kubelet sync, ~1 min) and the next loop reads it without a restart.
|
||||
- name: register
|
||||
configMap:
|
||||
name: mqtt-accounts
|
||||
- name: tls
|
||||
secret:
|
||||
secretName: mqtt-account-controller-tls
|
||||
# Only the api-key item, not the shelly-manager admin password.
|
||||
- name: shelly-manager-key
|
||||
secret:
|
||||
secretName: shelly-manager-auth
|
||||
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
|
||||
@@ -0,0 +1,224 @@
|
||||
# MQTT account register (REN-802). The only definition of who may use the
|
||||
# broker: mqtt-account-controller reconciles certificates, device passwords
|
||||
# and Dynamic Security clients/roles/ACLs to this list. Format: README of
|
||||
# konairius/mqtt-account-controller.
|
||||
#
|
||||
# Delivered to the controller as the ConfigMap `mqtt-accounts` (see the
|
||||
# configMapGenerator in kustomization.yaml). An empty list is refused, so it
|
||||
# never revokes everyone.
|
||||
#
|
||||
# The controller manages the production broker `mosquitto` (REN-900). Its
|
||||
# legacy users (password_file in mosquitto-deployment.yaml) are not listed
|
||||
# here; each moves here with its client (REN-802 step 3). `test-cert` is the
|
||||
# certificate test account from REN-888.
|
||||
#
|
||||
# A cert account needs its Secret `mqtt-<name>-tls` added to the
|
||||
# `mqtt-account-controller-secrets-<namespace>` Role in
|
||||
# mqtt-account-controller.yaml, or revoking it fails with 403.
|
||||
- name: test-cert
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite test/cert/#"]
|
||||
# The broker's own liveness/readiness probe (mosquitto-deployment.yaml).
|
||||
# Not named `health`: a Dynamic Security client of that name would shadow the
|
||||
# legacy password user while the old broker pod still probes with it.
|
||||
- name: mosquitto-probe
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite health/#"]
|
||||
# Zigbee2MQTT and its two sidecars (discovery-guard, interview-watch), one
|
||||
# certificate for the pod. Same ACL as the legacy user `zigbee2mqtt`; not
|
||||
# named like it, so this entry cannot shadow the password login of a running
|
||||
# Z2M (REN-953).
|
||||
- name: zigbee2mqtt-bridge
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite zigbee2mqtt/#", "write homeassistant/#", "read homeassistant/status"]
|
||||
# evcc (evcc namespace). Same ACL as the legacy user `evcc`, different name
|
||||
# so it cannot shadow the running password login (REN-953). evcc takes the
|
||||
# certificate PEMs by env (EVCC_MQTT_*), see apps/evcc/deployment.yaml.
|
||||
- name: evcc-client
|
||||
auth: cert
|
||||
deliver: {namespace: evcc}
|
||||
acl: ["read open3e/#", "readwrite evcc/#", "read energy/#"]
|
||||
# Home Assistant, via the ghostunnel sidecar `mqtt-tls` in its pod (HA keeps
|
||||
# client certificates only as uploaded PEMs in its config entry, so a renewal
|
||||
# could not reach it without a reconfigure). Same ACL as the legacy user
|
||||
# `homeassistant`; not named like it, so it cannot shadow that login (REN-953).
|
||||
- name: home-assistant
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite #"]
|
||||
# Read-only account for agents (REN-953). One account for all of them: the
|
||||
# agents share sandboxes, so per-agent separation would be nominal. The
|
||||
# Secret lands in `paperclip`; agents fetch it per run with their kubectl
|
||||
# identity (Role mqtt-agents-read in apps/paperclip/agent-kubectl-rbac.yaml),
|
||||
# so a renewal needs no restart. Network path: agent sandbox pods ->
|
||||
# mosquitto :8884 only.
|
||||
- name: agents-read
|
||||
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. 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}
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
# Only devices that log in as `shellies` today. A device with MQTT off never
|
||||
# reports connected, so the controller would push (and reboot it) again
|
||||
# every ROTATE_GRACE.
|
||||
- name: shelly-6
|
||||
auth: password
|
||||
deliver: {shelly-manager: 6} # Plus i4 90380c3808b8
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-29
|
||||
auth: password
|
||||
deliver: {shelly-manager: 29} # Plus i4 b8d61a8c7f24
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-3
|
||||
auth: password
|
||||
deliver: {shelly-manager: 3} # Pro 3EM ac15186d43c0
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-42
|
||||
auth: password
|
||||
deliver: {shelly-manager: 42} # 3EM-63 G3 d885ac1c5ab4
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-43
|
||||
auth: password
|
||||
deliver: {shelly-manager: 43} # 3EM-63 G3 d885ac1ab274
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-10
|
||||
auth: password
|
||||
deliver: {shelly-manager: 10} # Plug S Gen1 4382e5
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-22
|
||||
auth: password
|
||||
deliver: {shelly-manager: 22} # Plug S Gen1 87e961
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-25
|
||||
auth: password
|
||||
deliver: {shelly-manager: 25} # Plug S Gen1 1c1a8f
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-15
|
||||
auth: password
|
||||
deliver: {shelly-manager: 15} # Plug S G3 8cbfea910578
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-16
|
||||
auth: password
|
||||
deliver: {shelly-manager: 16} # Plug S G3 b08184a63ef0
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-14
|
||||
auth: password
|
||||
deliver: {shelly-manager: 14} # Plus 1PM Mini 348518e0be40
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-27
|
||||
auth: password
|
||||
deliver: {shelly-manager: 27} # Plus 1PM Mini 348518e06c04
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-13
|
||||
auth: password
|
||||
deliver: {shelly-manager: 13} # Plus 2PM 48551997e624
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-19
|
||||
auth: password
|
||||
deliver: {shelly-manager: 19} # Plus 2PM 48551999a790
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-21
|
||||
auth: password
|
||||
deliver: {shelly-manager: 21} # Plus 2PM 0cdc7efa8d00
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-34
|
||||
auth: password
|
||||
deliver: {shelly-manager: 34} # Plus 2PM 3ce90e2f2d7c
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-2
|
||||
auth: password
|
||||
deliver: {shelly-manager: 2} # Wall Display 00082266e106
|
||||
# Checked but never rotated on "device not connected" (REN-1066).
|
||||
rotate: false
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-26
|
||||
auth: password
|
||||
deliver: {shelly-manager: 26} # Power Strip G4 206ef102a6cc
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-1
|
||||
auth: password
|
||||
deliver: {shelly-manager: 1} # Pro 4PM 9454c5b9c954
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-4
|
||||
auth: password
|
||||
deliver: {shelly-manager: 4} # Pro 4PM ac15186ca578
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-32
|
||||
auth: password
|
||||
deliver: {shelly-manager: 32} # Pro 4PM 30c6f7834ec4
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-33
|
||||
auth: password
|
||||
deliver: {shelly-manager: 33} # Pro 3 fce8c0db5f28
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
# HiluX DS8: HA drives them over MQTT only, and their firmware can hang in
|
||||
# the MQTT reconnect path until a power cycle (wiki
|
||||
# shelly-hilux-ds8-mqtt-reconnect-hang-bug). One unit at a time; 12 is a
|
||||
# bathroom mesh receiver, so its wall switch works without MQTT.
|
||||
- name: shelly-12
|
||||
auth: password
|
||||
deliver: {shelly-manager: 12} # HiluX DS8 34b7da890cfc, Bad Spiegel Rechts
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-9
|
||||
auth: password
|
||||
deliver: {shelly-manager: 9} # HiluX DS8 34b7da8c03d0, Bad Badewanne
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-31
|
||||
auth: password
|
||||
deliver: {shelly-manager: 31} # HiluX DS8 34b7da888050, Bad Spiegel Links
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-44
|
||||
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.
|
||||
- name: shelly-35
|
||||
auth: password
|
||||
deliver: {shelly-manager: 35} # Plus H&T 08b61fcea690
|
||||
wake: shellies/shellyplusht-08b61fcea690/online
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-36
|
||||
auth: password
|
||||
deliver: {shelly-manager: 36} # Plus H&T 08b61fce90dc
|
||||
wake: shellies/shellyplusht-08b61fce90dc/online
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-37
|
||||
auth: password
|
||||
deliver: {shelly-manager: 37} # H&T G3 84fce63ab4d8
|
||||
wake: shellies/shellyhtg3-84fce63ab4d8/online
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-38
|
||||
auth: password
|
||||
deliver: {shelly-manager: 38} # H&T G3 ecda3bc0b6a0
|
||||
wake: shellies/shellyhtg3-ecda3bc0b6a0/online
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
@@ -0,0 +1,30 @@
|
||||
# API-key-only listener for shelly-manager (REN-931, REN-935). Rendered by the
|
||||
# nginx-unprivileged entrypoint (envsubst, filtered to POD_IP) into
|
||||
# /etc/nginx/conf.d/ at start.
|
||||
#
|
||||
# shelly-manager signs in whoever X-authentik-username names when the peer is
|
||||
# in Auth__ReverseProxy__TrustedProxies (the pod CIDR). Only the Authentik
|
||||
# outpost may reach :8080 directly. Paperclip agent pods reach this :8081
|
||||
# instead, which drops the identity header, so every request here falls through
|
||||
# to the API key (or the login cookie) and a forged header gets 401.
|
||||
#
|
||||
# Bound to ${POD_IP}, not 0.0.0.0, so it stays off the net1 macvlan (see the
|
||||
# ASPNETCORE_URLS comment in shelly-manager-deployment.yaml).
|
||||
server {
|
||||
listen ${POD_IP}:8081;
|
||||
server_tokens off;
|
||||
client_max_body_size 64m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://${POD_IP}:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
# Empty value = nginx does not pass the header at all, whatever the
|
||||
# client's case or how many copies it sent. X-Forwarded-User is the
|
||||
# app's default HeaderName, dropped too in case the setting changes.
|
||||
# Header names with underscores never pass (ignore_invalid_headers).
|
||||
proxy_set_header X-authentik-username "";
|
||||
proxy_set_header X-Forwarded-User "";
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user