Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75e713d230 |
@@ -32,17 +32,17 @@
|
||||
# mechanism as update-audit.yml):
|
||||
# woodpecker repo cron add --repo k.renner/homelab \
|
||||
# --name alert-monitor --branch master --schedule '*/15 * * * *'
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> alert-monitor
|
||||
# where <n> is an existing pipeline to promote.
|
||||
# - manual : woodpecker pipeline create --branch master \
|
||||
# --var PIPELINE=alert-monitor k.renner/homelab
|
||||
when:
|
||||
- event: cron
|
||||
cron: alert-monitor
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "alert-monitor"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "alert-monitor"'
|
||||
|
||||
steps:
|
||||
- name: fetch-alerts
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
|
||||
environment:
|
||||
SOPS_AGE_KEY:
|
||||
from_secret: sops_age_key
|
||||
@@ -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.247
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Paperclip agent runtime images (REN-284 / REN-286): the stock
|
||||
# agent-runtime-claude plus a tool set, one image per sandbox environment.
|
||||
# Built with kaniko like build-ci-images.yml and pushed with the same
|
||||
# ci_images_token (package read+write only, see the header there).
|
||||
#
|
||||
# Own workflow, not a step in build-ci-images.yml. A change to that file
|
||||
# rebuilds all of its images at once and fills the 24-CPU woodpecker-ci-quota;
|
||||
# on the REN-286 merge (pipeline 11306) this step then got no pod
|
||||
# ("exceeded quota") and the go image was never built. Here it runs only when
|
||||
# its own sources change, and it can be re-run alone with a deployment to
|
||||
# target `build-agent-runtime`, without rebuilding the other images.
|
||||
#
|
||||
# The name looks odd on purpose. The kubernetes sandbox plugin keeps the image
|
||||
# name and tag `agent-runtime-claude:v1` and swaps only the registry prefix
|
||||
# (`imageRegistry`), replacing host + org. So each variant needs its own
|
||||
# prefix with the same name under it:
|
||||
# imageRegistry gitea.renner.uno/konairius/agent-runtime-go
|
||||
# -> gitea.renner.uno/konairius/agent-runtime-go/agent-runtime-claude:v1
|
||||
# `v1` is what the plugin pulls and moves on every build; the date tag is the
|
||||
# rollback point. konairius/ because the ci_images_token can push there and
|
||||
# it is the target of the gitea_admin/* migration (#330).
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
path:
|
||||
- "ci/images/agent-runtime-*/**"
|
||||
- ".woodpecker/build-agent-runtime.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-agent-runtime"'
|
||||
|
||||
steps:
|
||||
- name: build-agent-runtime
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images
|
||||
# Context is ci/images (not the variant dir) so both Containerfiles can
|
||||
# COPY the shared agent-runtime-common/ files.
|
||||
# One pod, both images one after the other (kaniko --cleanup between
|
||||
# builds), at 1 CPU so it still fits if it overlaps another pipeline.
|
||||
backend_options:
|
||||
kubernetes:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 4Gi
|
||||
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)
|
||||
- |
|
||||
for VARIANT in go ops; do
|
||||
/kaniko/executor \
|
||||
--cleanup \
|
||||
--context . \
|
||||
--dockerfile "agent-runtime-$VARIANT/Containerfile" \
|
||||
--destination "gitea.renner.uno/konairius/agent-runtime-$VARIANT/agent-runtime-claude:$TAG" \
|
||||
--destination "gitea.renner.uno/konairius/agent-runtime-$VARIANT/agent-runtime-claude:v1" \
|
||||
|| exit 1
|
||||
done
|
||||
+22
-173
@@ -19,27 +19,20 @@
|
||||
# the admin account could (#330).
|
||||
#
|
||||
# The token is still OWNED by gitea_admin, because Gitea scopes packages to
|
||||
# a user or org namespace and most of these images live under `gitea_admin/*`.
|
||||
# The rebuild-critical ones additionally publish to `konairius/` — an org
|
||||
# created for the ghcr mirror path, with gitea_admin as a member so this same
|
||||
# token can push there. The full gitea_admin/* -> org migration is a
|
||||
# 38-reference change tracked separately in #330 rather than bundled here.
|
||||
# Note this pipeline runs no LLM agent, so the exfil path that motivated the
|
||||
# other cutovers does not apply to it.
|
||||
#
|
||||
# Push builds run on master only. Without the branch filter a feature branch
|
||||
# that touches this file started all steps at once: it pushed unreviewed
|
||||
# images and used the whole woodpecker-ci-quota, so the PR checks could not
|
||||
# get a pod (REN-286).
|
||||
# a user or org namespace and these images live under `gitea_admin/*` — a
|
||||
# different account cannot publish there. Moving them to a dedicated org is
|
||||
# the remaining cleanup; it is a 38-reference migration across pipelines and
|
||||
# Flux-deployed CronJobs, so it is tracked separately in #330 rather than
|
||||
# bundled here. Note this pipeline runs no LLM agent, so the exfil path that
|
||||
# motivated the other cutovers does not apply to it.
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
path:
|
||||
- "ci/images/**"
|
||||
- "secret-rotator/**"
|
||||
- ".woodpecker/build-ci-images.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-ci-images"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "build-ci-images"'
|
||||
|
||||
steps:
|
||||
- name: build-ansible
|
||||
@@ -51,7 +44,7 @@ steps:
|
||||
# secret-rotator commit needlessly rebuilt all four ~1.3G ci-* images and
|
||||
# orphaned ~5G of registry layers per run — a main driver of #336.
|
||||
when:
|
||||
- event: deployment
|
||||
- event: manual
|
||||
- event: push
|
||||
path: ["ci/images/ansible/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
@@ -75,7 +68,7 @@ steps:
|
||||
directory: ci/images/k8s
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: manual
|
||||
- event: push
|
||||
path: ["ci/images/k8s/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
@@ -94,36 +87,12 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/ci-k8s:$TAG \
|
||||
--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
|
||||
directory: ci/images/vmtest
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/vmtest/**", ".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-vmtest:$TAG \
|
||||
--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
|
||||
directory: ci/images/tofu
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: manual
|
||||
- event: push
|
||||
path: ["ci/images/tofu/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
@@ -154,7 +123,7 @@ steps:
|
||||
directory: secret-rotator
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: manual
|
||||
- event: push
|
||||
path: ["secret-rotator/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
@@ -178,154 +147,87 @@ steps:
|
||||
# not for who uses it — its first consumer is the zigbee2mqtt seed-data init
|
||||
# container, but nothing about it is zigbee-specific. Exists so tools come from
|
||||
# the image rather than an `apk add` on every container start.
|
||||
#
|
||||
# Published THREE times, and the third one is not a typo. Its consumers — the
|
||||
# zigbee2mqtt seed-data init container and the trmnl renderer — start while
|
||||
# the cluster is still being rebuilt, and at that moment gitea.renner.uno does
|
||||
# not exist, because Gitea runs inside the cluster being built. A cold-rebuild
|
||||
# rehearsal caught both in ImagePullBackOff.
|
||||
#
|
||||
# So there is a copy on ghcr.io. But the manifests must NOT point at it:
|
||||
# normal operation pulls from the local registry, and an external reference
|
||||
# would make every steady-state pull leave the network. The recovery source
|
||||
# and the everyday source are different things, exactly as they are for Flux's
|
||||
# git source.
|
||||
#
|
||||
# Talos resolves that with machine.registries.mirrors, which takes an ordered
|
||||
# endpoint list — gitea first, ghcr second (bootstrap.sh). Containerd keeps
|
||||
# the repository path when it falls through, so the two registries have to
|
||||
# agree on it: `gitea_admin/alpine-jq` cannot be served by ghcr, where the
|
||||
# namespace is fixed to the account name. Hence the second Gitea destination
|
||||
# under konairius/, which the manifests reference and which matches the ghcr
|
||||
# path exactly.
|
||||
#
|
||||
# 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
|
||||
directory: ci/images/alpine-jq
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: manual
|
||||
- event: push
|
||||
path: ["ci/images/alpine-jq/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
GHCR_USER: konairius
|
||||
GHCR_TOKEN:
|
||||
from_secret: ghcr_mirror_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
||||
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
|
||||
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
|
||||
- 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/alpine-jq:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-jq:latest \
|
||||
--destination gitea.renner.uno/konairius/alpine-jq:$TAG \
|
||||
--destination gitea.renner.uno/konairius/alpine-jq:latest \
|
||||
--destination ghcr.io/konairius/alpine-jq:$TAG \
|
||||
--destination ghcr.io/konairius/alpine-jq:latest
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-jq:latest
|
||||
|
||||
# Runtime image, not CI tooling: upstream containernetworking/plugins binaries
|
||||
# baked in, consumed by the multus cni-plugins-installer DaemonSet. Replaces a
|
||||
# node-side fetch from GitHub that had NO checksum verification and sat on the
|
||||
# critical path of every node rebuild. The download now happens here, once,
|
||||
# where the sha256 is checked — see the Containerfile for the full rationale.
|
||||
#
|
||||
# This one ALSO publishes to ghcr.io. A cold-rebuild rehearsal proved why: the
|
||||
# DaemonSet could not pull from gitea.renner.uno, because Gitea runs inside the
|
||||
# very cluster being rebuilt. Without the plugins there is no macvlan, so
|
||||
# Multus cannot attach ceph-san, so Rook's network canary never starts, so the
|
||||
# CephCluster sits at "Configuring Ceph Mons" — and OpenBao waits forever for a
|
||||
# PVC that will never bind. Moving the download here fixed the integrity gap
|
||||
# but quietly swapped github.com on the critical path for something strictly
|
||||
# worse: a registry that by definition does not exist during a recovery. The
|
||||
# GHCR copy is public, so a bare node can pull it with no credential and no
|
||||
# Gitea.
|
||||
#
|
||||
# The test that proved this also proved the rule is not specific to this
|
||||
# image. alpine-jq and alpine-openssl mirror for the same reason — see the
|
||||
# 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
|
||||
directory: ci/images/cni-plugins
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: manual
|
||||
- event: push
|
||||
path: ["ci/images/cni-plugins/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
GHCR_USER: konairius
|
||||
GHCR_TOKEN:
|
||||
from_secret: ghcr_mirror_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
||||
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
|
||||
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
|
||||
- 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/cni-plugins:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/cni-plugins:latest \
|
||||
--destination gitea.renner.uno/konairius/cni-plugins:$TAG \
|
||||
--destination gitea.renner.uno/konairius/cni-plugins:latest \
|
||||
--destination ghcr.io/konairius/cni-plugins:$TAG \
|
||||
--destination ghcr.io/konairius/cni-plugins:latest
|
||||
--destination gitea.renner.uno/gitea_admin/cni-plugins:latest
|
||||
|
||||
# Runtime image, not CI tooling: alpine + the openssl CLI, for shell scripts
|
||||
# needing s_client/x509. Named for what it is; first consumer is the paperless
|
||||
# fetch-bridge-cert init container. Replaces an `apk add openssl` that ran on
|
||||
# every paperless pod start. The openssl CLI is genuinely absent from the alpine
|
||||
# base, so this one needs an image rather than a busybox substitute.
|
||||
#
|
||||
# Mirrored to ghcr.io for the reason given on build-alpine-jq: it is an init
|
||||
# container, so paperless does not start without it, and during a rebuild
|
||||
# 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
|
||||
directory: ci/images/alpine-openssl
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: manual
|
||||
- event: push
|
||||
path: ["ci/images/alpine-openssl/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
GHCR_USER: konairius
|
||||
GHCR_TOKEN:
|
||||
from_secret: ghcr_mirror_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
||||
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
|
||||
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
|
||||
- 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/alpine-openssl:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest \
|
||||
--destination gitea.renner.uno/konairius/alpine-openssl:$TAG \
|
||||
--destination gitea.renner.uno/konairius/alpine-openssl:latest \
|
||||
--destination ghcr.io/konairius/alpine-openssl:$TAG \
|
||||
--destination ghcr.io/konairius/alpine-openssl:latest
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest
|
||||
|
||||
# Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image
|
||||
# run as two services on edge-1. Replaces pip-into-venv on the host, where an
|
||||
@@ -337,7 +239,7 @@ steps:
|
||||
directory: ci/images/heating-can
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: manual
|
||||
- event: push
|
||||
path: ["ci/images/heating-can/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
@@ -355,56 +257,3 @@ steps:
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/heating-can:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/heating-can:latest
|
||||
|
||||
# netbird-service-sync: the controller that reconciles NetBird reverse-proxy
|
||||
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
|
||||
# Runtime artifact like heating-can; same registry so the audit tracks it.
|
||||
- name: build-netbird-service-sync
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/netbird-service-sync
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/netbird-service-sync/**", ".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/netbird-service-sync:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/netbird-service-sync:latest
|
||||
|
||||
# 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
|
||||
directory: ci/images/bird
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/bird/**", ".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/bird:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/bird:latest
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
# and ci-claude:latest. The Containerfile makes this layer
|
||||
# byte-reproducible, so rebuilding an unchanged version dedups
|
||||
# in the registry instead of orphaning a fresh ~260 MB copy.
|
||||
# 3. bump-pr — (only when any pin is off the latest version) rewrite every
|
||||
# consumer pin to ci-claude:<version> and open a PR. A human
|
||||
# 3. bump-pr — (only when the version actually changed) rewrite the 5
|
||||
# consumer pins to ci-claude:<version> and open a PR. A human
|
||||
# reviews and merges it; nothing auto-merges.
|
||||
#
|
||||
# TAG SCHEME — deliberately version tags (ci-claude:<claude-version>), NOT the dated
|
||||
@@ -54,8 +54,8 @@
|
||||
#
|
||||
# Triggering:
|
||||
# - cron : the cron NAMED exactly "ci-claude-refresh" (see above).
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> ci-claude-refresh
|
||||
# where <n> is an existing pipeline to promote.
|
||||
# - manual : woodpecker pipeline create --branch master \
|
||||
# --var PIPELINE=ci-claude-refresh k.renner/homelab
|
||||
# - push : a change to the Containerfile (or this file) ON master forces a
|
||||
# rebuild of the current latest version so Containerfile fixes ship
|
||||
# promptly. Restricted to master ON PURPOSE — a feature branch must
|
||||
@@ -65,8 +65,8 @@
|
||||
when:
|
||||
- event: cron
|
||||
cron: ci-claude-refresh
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "ci-claude-refresh"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "ci-claude-refresh"'
|
||||
- event: push
|
||||
branch: master
|
||||
path:
|
||||
@@ -107,22 +107,31 @@ steps:
|
||||
[0-9]*.[0-9]*.[0-9]*) : ;;
|
||||
*) echo "npm returned an implausible version '$$NEW' — aborting."; exit 1 ;;
|
||||
esac
|
||||
# Distinct tags the consumers currently reference. Divergent pins are
|
||||
# normal (a pipeline bumped on its own); the bump rewrites all of them.
|
||||
CUR=$$(grep -hoE 'ci-claude:[0-9][0-9A-Za-z._-]*' $$CONSUMERS | sed 's/^ci-claude://' | sort -u | tr '\n' ' ' | sed 's/ $$//')
|
||||
[ -n "$$CUR" ] || { echo "No ci-claude pin found in the consumers — aborting."; exit 1; }
|
||||
echo "latest=$$NEW current=[$$CUR] event=$$CI_PIPELINE_EVENT"
|
||||
# The tag every consumer currently references. They are kept identical,
|
||||
# so this must resolve to EXACTLY ONE non-empty tag; anything else
|
||||
# (no match, or a partial/disagreeing set) aborts rather than risk a
|
||||
# corrupting sed later. Counting with `grep -c .` (non-empty lines)
|
||||
# instead of `wc -l` is deliberate: an empty result yields 0 here but
|
||||
# 1 under wc -l, which would slip past the guard.
|
||||
CUR=$$(grep -hoE 'ci-claude:[0-9A-Za-z._-]+' $$CONSUMERS | sed 's/^ci-claude://' | sort -u)
|
||||
NTAGS=$$(printf '%s' "$$CUR" | grep -c . || true)
|
||||
if [ -z "$$CUR" ] || [ "$$NTAGS" -ne 1 ]; then
|
||||
echo "Expected exactly one ci-claude tag across the consumers, got [$$CUR]."
|
||||
echo "Refusing to guess — fix the pins by hand."; exit 1
|
||||
fi
|
||||
echo "latest=$$NEW current=$$CUR event=$$CI_PIPELINE_EVENT"
|
||||
printf '%s' "$$NEW" > "$$CI_WORKSPACE/.ci-refresh/version"
|
||||
printf '%s' "$$CUR" > "$$CI_WORKSPACE/.ci-refresh/current"
|
||||
# Build when any pin is off latest, or on a push (Containerfile edit must
|
||||
# ship even if the version is unchanged). Bump when any pin is off latest.
|
||||
# Build when the version changed, or on a push (Containerfile edit must
|
||||
# ship even if the version is unchanged). Bump the pins only when the
|
||||
# version the consumers reference is not the latest.
|
||||
if [ "$$CUR" != "$$NEW" ] || [ "$$CI_PIPELINE_EVENT" = "push" ]; then
|
||||
touch "$$CI_WORKSPACE/.ci-refresh/do-build"; echo "will build $$NEW"
|
||||
else
|
||||
echo "already on $$NEW — nothing to build"
|
||||
fi
|
||||
if [ "$$CUR" != "$$NEW" ]; then
|
||||
touch "$$CI_WORKSPACE/.ci-refresh/do-bump"; echo "will open bump PR [$$CUR] -> $$NEW"
|
||||
touch "$$CI_WORKSPACE/.ci-refresh/do-bump"; echo "will open bump PR $$CUR -> $$NEW"
|
||||
else
|
||||
echo "consumers already pin $$NEW — no PR"
|
||||
fi
|
||||
@@ -166,9 +175,6 @@ steps:
|
||||
- |
|
||||
[ -f "$$CI_WORKSPACE/.ci-refresh/do-bump" ] || { echo "no version change — no PR"; exit 0; }
|
||||
set -eu
|
||||
# Secret values can carry a trailing newline; it breaks the push URL and the auth header.
|
||||
PUSH_TOKEN=$$(printf '%s' "$$PUSH_TOKEN" | tr -d '\r\n')
|
||||
[ -n "$$PUSH_TOKEN" ] || { echo "PUSH_TOKEN is empty — aborting"; exit 1; }
|
||||
NEW=$$(cat "$$CI_WORKSPACE/.ci-refresh/version")
|
||||
CUR=$$(cat "$$CI_WORKSPACE/.ci-refresh/current")
|
||||
# Both were validated non-empty by the decide step; re-assert before a
|
||||
@@ -184,11 +190,9 @@ steps:
|
||||
# is not a maintained list.
|
||||
CONSUMERS=$$(grep -rlE 'ci-claude:[0-9]' .woodpecker)
|
||||
[ -n "$$CONSUMERS" ] || { echo "No ci-claude consumers found — refusing to continue."; exit 1; }
|
||||
# NEW goes into a sed replacement: allow only a plain x.y.z version.
|
||||
printf '%s' "$$NEW" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$$' || { echo "refusing non-x.y.z version '$$NEW'"; exit 1; }
|
||||
# Every version-pinned tag becomes NEW, whatever it was; consumers only.
|
||||
# Exact-string swap of the current tag for the new one, consumers only.
|
||||
for f in $$CONSUMERS; do
|
||||
sed -i "s#ci-claude:[0-9][0-9A-Za-z._-]*#ci-claude:$$NEW#g" "$$f"
|
||||
sed -i "s#ci-claude:$$CUR#ci-claude:$$NEW#g" "$$f"
|
||||
done
|
||||
# Author is claude-bot (the PAT's account); the commit subject makes
|
||||
# the automated-bump nature clear.
|
||||
|
||||
@@ -17,44 +17,30 @@
|
||||
# here is a static check that either passes or fails on the checkout alone, so it
|
||||
# can gate PRs without becoming a bottleneck or a flake source.
|
||||
when:
|
||||
# Path-filtered on purpose, unlike ci-scripts-test.yml. That pipeline is two
|
||||
# steps on already-cached images, so running it everywhere is free; this one
|
||||
# pulls four images and builds every kustomization in the tree, which is real
|
||||
# Path-filtered on purpose, unlike ci-scripts-test.yml. That pipeline is one
|
||||
# step on one already-cached image, so running it everywhere is free; this one
|
||||
# pulls three images and builds every kustomization in the tree, which is real
|
||||
# cost to put on a docs-only PR. The filter covers everything the steps actually read:
|
||||
# kubernetes/** for the manifests, scripts and promtool suites, .woodpecker/**
|
||||
# for the lint (and so an edit to this file re-runs its own checks), and the
|
||||
# promtool driver script by name.
|
||||
#
|
||||
# That last entry is NOT covered by kubernetes/**: promtool-rules.sh lives
|
||||
# under scripts/, so without it a PR that only edits the script would never run
|
||||
# the check it drives. It is listed as one file rather than scripts/ci/**
|
||||
# because the rest of that directory is CI-agent helpers with nothing to do
|
||||
# with this pipeline, and pulling four images for them is the cost this filter
|
||||
# exists to avoid. Their own tests run in ci-scripts-test.yml on every PR.
|
||||
# kubernetes/** for the manifests and scripts, .woodpecker/** for the lint (and
|
||||
# so an edit to this file re-runs its own checks).
|
||||
- event: pull_request
|
||||
path:
|
||||
- "kubernetes/**"
|
||||
- ".woodpecker/**"
|
||||
- "scripts/ci/promtool-rules.sh"
|
||||
- "scripts/ci/yaml-citations.sh"
|
||||
|
||||
# THE THIRD DISCOVERY CHECK WENT TO A SCRIPT. kustomize-build and shellcheck
|
||||
# both hand-roll the same shape — `find`, a floor guard against empty discovery,
|
||||
# then a file-fed `while read`. Two copies was deliberate: the two traps being
|
||||
# commented around (BusyBox find lacking `-printf`, and a piped loop running in
|
||||
# a subshell where `fail` cannot propagate) are different, and both are worth
|
||||
# spelling out where they bite. An earlier revision of this comment said a third
|
||||
# instance would be the point to stop copying and factor it into `scripts/ci/`,
|
||||
# where it would gain unit tests — and that is what `promtool` below does. Its
|
||||
# discovery, floors and guards live in scripts/ci/promtool-rules.sh and are
|
||||
# tested by scripts/ci/test-promtool-rules.sh on every PR via
|
||||
# ci-scripts-test.yml. A FOURTH inline copy should go the same way; do not add
|
||||
# one here.
|
||||
# ADDING A THIRD DISCOVERY CHECK: kustomize-build and shellcheck both hand-roll
|
||||
# the same shape — `find`, a floor guard against empty discovery, then a
|
||||
# file-fed `while read`. Two copies is deliberate: the two traps being commented
|
||||
# around (BusyBox find lacking `-printf`, and a piped loop running in a subshell
|
||||
# where `fail` cannot propagate) are different, and both are worth spelling out
|
||||
# where they bite. A third instance is the point to stop copying and factor it
|
||||
# into a shared script under `scripts/ci/` — which is already covered by
|
||||
# ci-scripts-test.yml, so it would gain unit tests in the process.
|
||||
#
|
||||
# Every step carries `depends_on: []` so all six run in PARALLEL and every
|
||||
# Every step carries `depends_on: []` so all four run in PARALLEL and every
|
||||
# failure shows in one run. Woodpecker's default is sequential, which would stop
|
||||
# at the first failure and make a contributor fix-and-repush to discover the
|
||||
# next — and these six are independent, cheap, offline checks with no ordering
|
||||
# next — and these four are independent, cheap, offline checks with no ordering
|
||||
# between them. Keep it on every step: `depends_on` is per-step, so dropping it
|
||||
# from one silently re-serialises that step behind the others.
|
||||
steps:
|
||||
@@ -74,7 +60,7 @@ steps:
|
||||
# run that never touched them. Tracked in #461.
|
||||
- name: kustomize-build
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
@@ -172,7 +158,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.17.0-alpine
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
@@ -260,7 +246,7 @@ steps:
|
||||
# this step fails permanently on its own documentation.
|
||||
- name: no-digest-pins
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
@@ -272,72 +258,3 @@ steps:
|
||||
exit 1
|
||||
fi
|
||||
echo "ok no digest pins under openbao/rotation/"
|
||||
|
||||
# Machine-check the alert rules: parse every PrometheusRule, run the committed
|
||||
# promtool unit suites, then damage the rules on purpose and require the suites
|
||||
# to notice. Everything it does lives in scripts/ci/promtool-rules.sh; read
|
||||
# that file and kubernetes/apps/monitoring-rules/tests/README.md for the why.
|
||||
#
|
||||
# The gap it closes: the other four steps prove a PrometheusRule assembles into
|
||||
# valid YAML and nothing more — not that an expression parses, not that a label
|
||||
# join matches anything, not that a `for` window does what its comment claims.
|
||||
# On REN-129 the CNPG surge rule was written `for: 2m`, which fires on six of
|
||||
# seven measured nights and would have mailed the owner twice a night at 04:16
|
||||
# and 04:22 local for a backlog that clears itself. Nothing here would have
|
||||
# caught it, and the arithmetic that did catch it was hand-written from scratch
|
||||
# twice, in two runs, and thrown away both times because there was nowhere in
|
||||
# the repo to commit it. REN-152 retired that rule; its `for` margin is now
|
||||
# guarded by mutations.txt:backlog-for-12m.
|
||||
#
|
||||
# Offline, like the rest of this pipeline: no cluster, no registry, no secrets,
|
||||
# no network. promtool reads files and exits.
|
||||
#
|
||||
# IMAGE — prom/prometheus, not a registry-local ci-* image, and pinned to an
|
||||
# exact upstream version rather than a dated tag. The dated-tag convention
|
||||
# guarded by no-digest-pins above is about OUR images, whose digests are
|
||||
# garbage-collected out from under a pin; this is upstream and immutable.
|
||||
#
|
||||
# The version must be 3.x, and that is not a preference. The cluster runs
|
||||
# whatever kube-prometheus-stack >= 72.0.0 ships, which is Prometheus 3, and
|
||||
# alert `for` evaluation is version-sensitive — a 2.x promtool would be
|
||||
# checking a different evaluator than production, so the margins the suites
|
||||
# assert would be claims about the wrong program. promtool-rules.sh asserts the
|
||||
# major version it was handed and fails on 2.x, so a careless bump here is a
|
||||
# red step rather than a quietly wrong check. Keep this at the latest stable
|
||||
# 3.x, per the standing rule that any software uses the latest stable version.
|
||||
#
|
||||
# This image is NOT covered by image-automation (same reason as
|
||||
# woodpecker-lint above: a Woodpecker pipeline file has no apiVersion/kind, so
|
||||
# an $imagepolicy marker here is silently ignored — fluxcd/flux2#2826). The
|
||||
# bump is manual.
|
||||
#
|
||||
# `prom/prometheus` is busybox-based and ships /bin/sh, so it can run a
|
||||
# `commands:` block — unlike the plain woodpecker-cli tag, which cannot. Its
|
||||
# find/grep/awk/sed are BusyBox, which is why promtool-rules.sh avoids
|
||||
# `find -printf` and `grep -r --include`; it was verified against BusyBox
|
||||
# 1.35.0 as well as GNU. The image also declares `USER nobody`, so the script
|
||||
# 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
|
||||
commands:
|
||||
- sh scripts/ci/promtool-rules.sh
|
||||
|
||||
# Comments cite other YAML blocks by anchor name, never by line number
|
||||
# (REN-183; the exact syntax is in CONTRIBUTING.md, not repeated here because
|
||||
# an example written in this comment would itself be checked). Line numbers drift on any edit
|
||||
# above them and nothing noticed: three drift fixes were merged by hand
|
||||
# before this step existed. This fails on a new line-number citation and on
|
||||
# a citation whose file or anchor marker does not exist.
|
||||
# Existence only — it never judges whether the anchored block still says
|
||||
# what the citing comment claims, so ordinary edits cannot turn it red.
|
||||
#
|
||||
# It scans every YAML file in the checkout, but this pipeline only runs on
|
||||
# the paths in `when:` above, so an edit confined to ansible/ or esphome/ is
|
||||
# checked on the next PR that does touch kubernetes/ or .woodpecker/.
|
||||
# Self-test: scripts/ci/test-yaml-citations.sh, in ci-scripts-test.yml.
|
||||
- name: yaml-citations
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
commands:
|
||||
- sh scripts/ci/yaml-citations.sh
|
||||
|
||||
@@ -8,45 +8,15 @@
|
||||
# against adversarial PR input. Running it here fails the offending PR's checks
|
||||
# before it can merge.
|
||||
#
|
||||
# scripts/ci/test-promtool-rules.sh is the same idea for the alert-rule gate.
|
||||
# What it tests is not whether promtool evaluates PromQL — ci-manifests-test.yml
|
||||
# does that with the real binary against the real rules — but whether
|
||||
# promtool-rules.sh REFUSES TO REPORT SUCCESS when it has checked nothing: zero
|
||||
# manifests discovered, zero suites discovered, a `rule_files` path that does not
|
||||
# resolve (promtool warns, loads zero rules, and passes every assertion), a
|
||||
# mutation the suites no longer catch. Those are the states a green pipeline
|
||||
# cannot tell apart from real coverage, so they need a test that asserts the
|
||||
# failure. It stubs promtool, so it needs neither the binary nor the heavy image.
|
||||
#
|
||||
# Both are unfiltered by path on purpose: this pipeline is two steps on one
|
||||
# already-cached image, so running it everywhere is free, and a guard whose test
|
||||
# only runs when someone remembers to touch the right directory is not a guard.
|
||||
#
|
||||
# Only in homelab (scripts/ci/ lives here); the file is not deployed to the
|
||||
# mirrored repos, so it does not run on their PRs. All tests here are offline (no
|
||||
# token, no network, no cluster) — the ci-claude image just provides sh + jq, and
|
||||
# it is already pulled by pr-review.yml on the same event, so the runner has it
|
||||
# cached.
|
||||
# mirrored repos, so it does not run on their PRs. The test is offline (no token,
|
||||
# no network, no cluster) — the ci-claude image just provides sh + jq, and it is
|
||||
# already pulled by pr-review.yml on the same event, so the runner has it cached.
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
# `depends_on: []` on every step so they run in parallel and one failure does not hide
|
||||
# the other. Same reasoning as the note above `steps:` in ci-manifests-test.yml.
|
||||
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.247
|
||||
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
|
||||
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
|
||||
commands:
|
||||
- sh scripts/ci/test-yaml-citations.sh
|
||||
|
||||
@@ -6,8 +6,8 @@ when:
|
||||
- "ansible/roles/claude-workstation/**"
|
||||
- "ansible/plays/install-claude-workstation.yml"
|
||||
- ".woodpecker/deploy-claude-workstation.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-claude-workstation"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "deploy-claude-workstation"'
|
||||
|
||||
timeout: 30
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ when:
|
||||
path:
|
||||
- "opentofu/**"
|
||||
- ".woodpecker/deploy-infrastructure.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-infrastructure"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "deploy-infrastructure"'
|
||||
|
||||
steps:
|
||||
- name: validate-secrets
|
||||
@@ -84,7 +84,7 @@ steps:
|
||||
image: gitea.renner.uno/gitea_admin/ci-tofu:2026-04-29
|
||||
directory: opentofu
|
||||
when:
|
||||
- event: deployment
|
||||
- event: manual
|
||||
evaluate: 'TOFU_APPLY == "true"'
|
||||
environment:
|
||||
TF_IN_AUTOMATION: "true"
|
||||
|
||||
@@ -7,8 +7,8 @@ when:
|
||||
- "ansible/plays/install-netbird-client.yml"
|
||||
- "ansible/inventory/group_vars/claude_workstation/**"
|
||||
- ".woodpecker/deploy-netbird-client.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-netbird-client"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "deploy-netbird-client"'
|
||||
|
||||
timeout: 30
|
||||
|
||||
|
||||
@@ -7,24 +7,10 @@ when:
|
||||
# install-netbird-proxy.yml also applies the netbird-proxy-watchdog role,
|
||||
# so changes to it must redeploy this host too.
|
||||
- "ansible/roles/netbird-proxy-watchdog/**"
|
||||
# install-netbird-proxy.yml also applies the edge bouncer.
|
||||
- "ansible/roles/crowdsec-edge-bouncer/**"
|
||||
- "ansible/plays/install-netbird-proxy.yml"
|
||||
# The variables that steer those roles, not just the roles themselves.
|
||||
# Without these two, a change that only touches inventory never deploys
|
||||
# and nothing says so: arming the bouncer lives in host_vars, and its
|
||||
# LAPI key lives in group_vars, so both would sit in git looking applied
|
||||
# while the hosts kept running the previous configuration.
|
||||
#
|
||||
# pve_hosts.yml is deliberately NOT listed. It is the single inventory
|
||||
# file for the whole fleet, so listing it would redeploy the proxies for
|
||||
# any unrelated host edit. The cost is that a change to [netbird_proxy]
|
||||
# group membership needs a manual deployment run.
|
||||
- "ansible/inventory/host_vars/proxy-*.yml"
|
||||
- "ansible/inventory/group_vars/netbird_proxy/**"
|
||||
- ".woodpecker/deploy-netbird-proxy.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-netbird-proxy"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "deploy-netbird-proxy"'
|
||||
|
||||
timeout: 30
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@ when:
|
||||
branch: master
|
||||
path:
|
||||
- "ansible/roles/netbird-server/**"
|
||||
# install-netbird-server.yml also applies the netbird-proxy-watchdog role,
|
||||
# so changes to it must redeploy this host too.
|
||||
- "ansible/roles/netbird-proxy-watchdog/**"
|
||||
- "ansible/plays/install-netbird-server.yml"
|
||||
- ".woodpecker/deploy-netbird-server.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-netbird-server"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "deploy-netbird-server"'
|
||||
|
||||
timeout: 30
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
when:
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "e2e-test"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "e2e-test"'
|
||||
|
||||
steps:
|
||||
- name: e2e
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
|
||||
environment:
|
||||
SOPS_AGE_KEY:
|
||||
from_secret: sops_age_key
|
||||
|
||||
+5
-6
@@ -7,8 +7,7 @@
|
||||
# was harmless at daily/weekly cadence — but alert-monitor.yml
|
||||
# fires every 15 minutes, so piggybacking must be opt-in now.
|
||||
# A new cron that SHOULD also run e2e gets its name added here.
|
||||
# - deploy : `woodpecker-cli pipeline deploy k.renner/homelab <n> e2e`, where
|
||||
# <n> is an existing pipeline to promote.
|
||||
# - manual : `woodpecker pipeline create --branch master --var PIPELINE=e2e k.renner/homelab`
|
||||
|
||||
when:
|
||||
- event: cron
|
||||
@@ -17,8 +16,8 @@ when:
|
||||
cron: update-audit
|
||||
- event: cron
|
||||
cron: weekly-update
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "e2e"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "e2e"'
|
||||
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
@@ -26,12 +25,12 @@ 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.247
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
- name: e2e-test
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
|
||||
environment:
|
||||
SOPS_AGE_KEY:
|
||||
from_secret: sops_age_key
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
when:
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "install-garage-synology"'
|
||||
|
||||
steps:
|
||||
- name: run-playbook
|
||||
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
|
||||
environment:
|
||||
ANSIBLE_FORCE_COLOR: "true"
|
||||
ANSIBLE_HOST_KEY_CHECKING: "false"
|
||||
SSH_PRIVATE_KEY:
|
||||
from_secret: ssh_private_key
|
||||
VAULT_PASS:
|
||||
from_secret: vault_pass
|
||||
PROXMOX_TOKEN_ID:
|
||||
from_secret: proxmox_token_id
|
||||
PROXMOX_TOKEN_SECRET:
|
||||
from_secret: proxmox_token_secret
|
||||
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
|
||||
- ansible-playbook -i inventory/ plays/install-garage-synology.yml -v
|
||||
@@ -34,13 +34,13 @@
|
||||
# - cron : a cron NAMED exactly "issue-fix":
|
||||
# woodpecker repo cron add --repo k.renner/homelab \
|
||||
# --name issue-fix --branch master --schedule '41 * * * *'
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> issue-fix
|
||||
# where <n> is an existing pipeline to promote.
|
||||
# - manual : woodpecker pipeline create --branch master \
|
||||
# --var PIPELINE=issue-fix k.renner/homelab
|
||||
when:
|
||||
- event: cron
|
||||
cron: issue-fix
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "issue-fix"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "issue-fix"'
|
||||
|
||||
steps:
|
||||
- name: fetch-diag
|
||||
@@ -49,7 +49,7 @@ steps:
|
||||
# (the issue-pick logic needs jq, which only the ci-claude image has);
|
||||
# minting a 1h read-only token per run is cheap. Degrades to
|
||||
# snapshot-only if the ci-diagnostics ServiceAccount isn't reconciled.
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
|
||||
environment:
|
||||
SOPS_AGE_KEY:
|
||||
from_secret: sops_age_key
|
||||
@@ -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.247
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -53,9 +53,9 @@
|
||||
#
|
||||
# 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
|
||||
# and earns a fresh review). There is deliberately no manual PIPELINE trigger:
|
||||
# Woodpecker exposes a --var only to YAML variable substitution and `when`
|
||||
# filters, NOT to a step's shell environment, so a --var 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
|
||||
@@ -65,7 +65,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: pr-review-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
# Trivy scan of the IaC tree and every image referenced from ansible/, with an
|
||||
# agent pass that opens a PR for CRITICAL/HIGH findings.
|
||||
#
|
||||
# Triggers:
|
||||
# - cron : the named `daily-security-scan` cron, matched by NAME.
|
||||
# Crons cannot carry variables — `woodpecker-cli repo cron add`
|
||||
# has no --var — so a variable gate can never match a cron.
|
||||
# - deployment : woodpecker-cli pipeline deploy k.renner/homelab <n> security-scan
|
||||
# The target rides in pipeline metadata as
|
||||
# CI_PIPELINE_DEPLOY_TARGET, which the workflow-root filter
|
||||
# does see, unlike manual-run variables on Woodpecker 3.18
|
||||
# (#582).
|
||||
when:
|
||||
- event: cron
|
||||
cron: daily-security-scan
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "security-scan"'
|
||||
evaluate: 'PIPELINE == "security-scan"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "security-scan"'
|
||||
|
||||
steps:
|
||||
- name: iac-scan
|
||||
@@ -40,28 +28,12 @@ steps:
|
||||
cat /tmp/images.txt
|
||||
echo "---"
|
||||
i=0
|
||||
failed=0
|
||||
failed_names=""
|
||||
while IFS= read -r img; do
|
||||
i=$$((i+1))
|
||||
out="trivy-image-$$i.json"
|
||||
echo "=== [$$i] $$img -> $$out ==="
|
||||
# Keep going on a bad image so one unpullable reference cannot cost
|
||||
# the scan of every image after it, but record that it happened.
|
||||
if ! trivy image --skip-db-update --scanners vuln --timeout 15m --severity CRITICAL,HIGH --ignore-unfixed --format json -o "$$out" "$$img"; then
|
||||
failed=$$((failed+1))
|
||||
failed_names="$$failed_names $$img"
|
||||
fi
|
||||
trivy image --skip-db-update --scanners vuln --timeout 15m --severity CRITICAL,HIGH --ignore-unfixed --format json -o "$$out" "$$img" || true
|
||||
done < /tmp/images.txt
|
||||
echo "--- scanned $$((i - failed)) of $$i images ---"
|
||||
if [ "$$failed" -gt 0 ]; then
|
||||
echo "NOT SCANNED:$$failed_names"
|
||||
exit 1
|
||||
fi
|
||||
# Non-zero above turns this step red without failing the workflow, so the
|
||||
# fix agent still runs on what was scanned. Coverage is reported, not
|
||||
# enforced: an incomplete scan is visible in the step status instead of
|
||||
# being indistinguishable from a clean one.
|
||||
failure: ignore
|
||||
|
||||
- name: security-fix-agent
|
||||
@@ -70,7 +42,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.247
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
# report; the pipeline posts it to a single persistent Gitea issue.
|
||||
#
|
||||
# Triggering:
|
||||
# - cron : a cron NAMED exactly "update-audit". Crons carry no variables —
|
||||
# `repo cron add` takes name, branch, schedule and enabled — so the
|
||||
# name filter is the only thing that can select a workflow for a
|
||||
# scheduled run:
|
||||
# - cron : a cron NAMED exactly "update-audit". Unlike the other pipelines
|
||||
# here (which gate on an injected `PIPELINE` var set at cron-create
|
||||
# time in the UI), this uses Woodpecker's documented `cron:` name
|
||||
# filter so the schedule can be created entirely from the CLI:
|
||||
# woodpecker repo cron add --repo k.renner/homelab \
|
||||
# --name update-audit --branch master --schedule '@weekly'
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> update-audit
|
||||
# where <n> is an existing pipeline to promote.
|
||||
# - manual : woodpecker pipeline create --branch master \
|
||||
# --var PIPELINE=update-audit k.renner/homelab
|
||||
#
|
||||
# NOTE: e2e.yml runs on an explicit list of cron names that includes this one,
|
||||
# so a weekly e2e run piggybacks on the audit (it was a bare `event: cron`
|
||||
@@ -25,12 +25,12 @@
|
||||
when:
|
||||
- event: cron
|
||||
cron: update-audit
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "update-audit"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "update-audit"'
|
||||
|
||||
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.247
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
# Nightly wiki maintenance for the Notes vault (k.renner/notes), replacing the
|
||||
# workstation's wiki-maintenance and wiki-consolidate timers: distill the raw/
|
||||
# backlog into wiki/ (wiki-distill agent), audit wiki/ with the vault's own
|
||||
# script (structural checks, no model), then consolidate (wiki-consolidate
|
||||
# agent: verifies a cohort of notes against sources, repos and the live
|
||||
# cluster and edits them). The pipeline commits and pushes as Claude Bot; the
|
||||
# push webhook then refreshes the wiki service.
|
||||
#
|
||||
# The agents address the vault as ~/Notes and the repos as ~/source/<repo>, so
|
||||
# each agent step links those into the workspace instead of rewriting them.
|
||||
when:
|
||||
- event: cron
|
||||
cron: wiki-maintenance
|
||||
# Hand-started runs: `woodpecker pipeline create k.renner/homelab --branch master`.
|
||||
# The typesafe_api_key secret is scoped to cron and manual, not deployment.
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- name: checkout-notes
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
|
||||
pull: true
|
||||
environment:
|
||||
WIKI_BOT_TOKEN:
|
||||
from_secret: wiki_bot_token
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
# Token only on the wire, never in .git/config. Secrets set from files carry
|
||||
# a trailing newline (run 10220: "could not read Username"); strip CR/LF.
|
||||
TOKEN=$$(printf '%s' "$$WIKI_BOT_TOKEN" | tr -d '\r\n')
|
||||
git -c http.extraheader="AUTHORIZATION: token $$TOKEN" \
|
||||
clone --depth 50 --branch main https://gitea.renner.uno/k.renner/notes.git notes
|
||||
echo "raw backlog: $$(ls notes/raw/*.md | grep -v _index.md | wc -l) file(s)"
|
||||
# Full history for the consolidate agent's `git log` checks; the
|
||||
# workspace clone is shallow and tree-filtered.
|
||||
mkdir -p sources
|
||||
git clone --quiet https://gitea.renner.uno/k.renner/homelab.git sources/homelab
|
||||
git -c http.extraheader="AUTHORIZATION: token $$TOKEN" \
|
||||
clone --quiet https://gitea.renner.uno/k.renner/homeassistant.git sources/homeassistant
|
||||
|
||||
- name: fetch-diag
|
||||
# Read-only live-cluster access for the consolidate agent, same mechanism
|
||||
# as issue-fix.yml: ./.ci-diag/kubectl plus a 1h ci-diagnostics token.
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
environment:
|
||||
SOPS_AGE_KEY:
|
||||
from_secret: sops_age_key
|
||||
S3_ACCESS_KEY_ID:
|
||||
from_secret: hetzner_s3_access_key
|
||||
S3_SECRET_ACCESS_KEY:
|
||||
from_secret: hetzner_s3_secret_key
|
||||
commands:
|
||||
- bash scripts/ci/fetch-kubeconfig.sh
|
||||
- bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- name: distill
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
|
||||
pull: true
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
TYPESAFE_API_KEY:
|
||||
from_secret: typesafe_api_key
|
||||
WIKI_INDEX_BASIC_AUTH:
|
||||
from_secret: wiki_index_basic_auth
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
# An empty queue must not start the agent: its startup context alone
|
||||
# costs more than the whole audit.
|
||||
BACKLOG=$$(ls notes/raw/*.md | grep -v _index.md | wc -l)
|
||||
if [ "$$BACKLOG" -eq 0 ]; then
|
||||
echo "distill: raw/ is empty, skipping the agent"
|
||||
exit 0
|
||||
fi
|
||||
ln -sfn "$$PWD/notes" "$$HOME/Notes"
|
||||
mkdir -p "$$HOME/.config/wiki-index"
|
||||
printf '%s' "$$WIKI_INDEX_BASIC_AUTH" | tr -d '\r\n' > "$$HOME/.config/wiki-index/basic-auth"
|
||||
chmod 600 "$$HOME/.config/wiki-index/basic-auth"
|
||||
"$$HOME/Notes/bin/wiki_rank.py" --probe-index
|
||||
MODEL=$$(sh scripts/ci/pick-model.sh "distill raw source notes into atomic wiki notes: read sources, check the wiki for existing coverage, write or merge notes; careful writing, moderate reasoning")
|
||||
if [ -n "$$MODEL" ]; then echo "distill: selected model $$MODEL"; else echo "distill: using the CLI default model"; fi
|
||||
cd notes
|
||||
cat ../ci/agents/wiki-maintenance/distill.md | claude $${MODEL:+--model $$MODEL} \
|
||||
--agent wiki-distill \
|
||||
--permission-mode acceptEdits \
|
||||
--allowedTools "Read,Edit,Write,Glob,Grep,Bash(/root/Notes/bin/wiki_rank.py:*),Bash(/root/Notes/bin/wiki_ask.py:*),Bash(mv /root/Notes/raw/:*),Bash(mkdir -p /root/Notes/:*),Bash(~/Notes/bin/wiki_rank.py:*),Bash(~/Notes/bin/wiki_ask.py:*),Bash(mv ~/Notes/raw/:*),Bash(mkdir -p ~/Notes/:*)" \
|
||||
--settings ../ci/agents/wiki-maintenance/settings.json \
|
||||
-p
|
||||
|
||||
- name: audit
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
|
||||
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
|
||||
pull: true
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
TYPESAFE_API_KEY:
|
||||
from_secret: typesafe_api_key
|
||||
WIKI_INDEX_BASIC_AUTH:
|
||||
from_secret: wiki_index_basic_auth
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
ln -sfn "$$PWD/notes" "$$HOME/Notes"
|
||||
ln -sfn "$$PWD/sources" "$$HOME/source"
|
||||
mkdir -p "$$HOME/.config/wiki-index"
|
||||
printf '%s' "$$WIKI_INDEX_BASIC_AUTH" | tr -d '\r\n' > "$$HOME/.config/wiki-index/basic-auth"
|
||||
chmod 600 "$$HOME/.config/wiki-index/basic-auth"
|
||||
if [ -f .ci-diag/kubeconfig ]; then
|
||||
ln -sf "$$PWD/.ci-diag/kubectl" /usr/local/bin/kubectl
|
||||
export KUBECONFIG="$$PWD/.ci-diag/kubeconfig"
|
||||
else
|
||||
echo "consolidate: no ci-diag kubeconfig, live cluster checks unavailable"
|
||||
fi
|
||||
cd notes
|
||||
# JSON output: the agent's report and the run's cost come out of one
|
||||
# record at the end instead of a 45-minute silent log.
|
||||
# One source for the Bash allowlist: the settings file (run 10608: the
|
||||
# agent writes ~/Notes, the rules carried only /root/Notes).
|
||||
BASH_RULES=$$(jq -r '[.permissions.allow[] | select(startswith("Bash("))] | join(",")' ../ci/agents/wiki-maintenance/consolidate-settings.json)
|
||||
rc=0
|
||||
cat ../ci/agents/wiki-maintenance/consolidate.md | claude \
|
||||
--agent wiki-consolidate \
|
||||
--permission-mode acceptEdits \
|
||||
--allowedTools "Read,Edit,Write,Glob,Grep,Agent,$$BASH_RULES" \
|
||||
--settings ../ci/agents/wiki-maintenance/consolidate-settings.json \
|
||||
--output-format json \
|
||||
-p > /tmp/consolidate.json || rc=$$?
|
||||
# A killed or crashed claude leaves no valid JSON; report rc, not a jq error.
|
||||
if [ -s /tmp/consolidate.json ] && jq -e . /tmp/consolidate.json > /dev/null; then
|
||||
jq -r '.result // empty' /tmp/consolidate.json
|
||||
jq -c '{duration_ms, num_turns, total_cost_usd, is_error}' /tmp/consolidate.json
|
||||
else
|
||||
echo "consolidate: no result record (exit $$rc)"; head -c 2000 /tmp/consolidate.json || true
|
||||
fi
|
||||
echo "consolidate: git log --oneline of the run:"
|
||||
git log --oneline origin/main..HEAD
|
||||
exit $$rc
|
||||
|
||||
- name: push-notes
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
|
||||
pull: true
|
||||
environment:
|
||||
WIKI_BOT_TOKEN:
|
||||
from_secret: wiki_bot_token
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
cd notes
|
||||
git config user.email "claude-bot@renner.uno"
|
||||
git config user.name "Claude Bot"
|
||||
# Only the vault content is committed; anything an agent leaves elsewhere
|
||||
# in the checkout (run 10362: scratch scripts in the root) stays behind.
|
||||
# The consolidate agent commits its own snapshots, so HEAD may already be
|
||||
# ahead of origin/main with a clean tree.
|
||||
git add -A -- wiki raw output .wiki-consolidation-state.json
|
||||
git diff --cached --quiet || git commit -q -m "wiki maintenance $$(date -u +%F): distill + audit + consolidate (CI run $$CI_PIPELINE_NUMBER)"
|
||||
if [ "$$(git rev-list --count origin/main..HEAD)" -eq 0 ]; then
|
||||
echo "wiki-maintenance: no changes"
|
||||
exit 0
|
||||
fi
|
||||
# The workstation pushes main on its own (notes-push.path), so main moves
|
||||
# while the agents run (run 10241: non-fast-forward). The run's work goes
|
||||
# to a branch and a PR that this step merges through Gitea; a conflict
|
||||
# leaves the PR open for a human and fails the run.
|
||||
BRANCH="wiki-maintenance/$$(date -u +%F)-$$CI_PIPELINE_NUMBER"
|
||||
git checkout -q -b "$$BRANCH"
|
||||
TOKEN=$$(printf '%s' "$$WIKI_BOT_TOKEN" | tr -d '\r\n')
|
||||
git -c http.extraheader="AUTHORIZATION: token $$TOKEN" push origin "HEAD:$$BRANCH"
|
||||
API="https://gitea.renner.uno/api/v1/repos/k.renner/notes"
|
||||
jq -n --arg title "wiki maintenance $$(date -u +%F) (CI run $$CI_PIPELINE_NUMBER)" \
|
||||
--arg body "Nightly distill + audit + consolidate. Merged by the pipeline; open only if the merge failed." \
|
||||
--arg head "$$BRANCH" '{title: $$title, body: $$body, head: $$head, base: "main"}' > /tmp/pr.json
|
||||
code=$$(curl -s -o /tmp/pr-resp.json -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token $$TOKEN" -H "Content-Type: application/json" --data @/tmp/pr.json "$$API/pulls")
|
||||
echo "POST pull -> HTTP $$code"
|
||||
[ "$$code" -ge 200 ] && [ "$$code" -lt 300 ] || { cat /tmp/pr-resp.json; exit 1; }
|
||||
NUM=$$(jq -r '.number' /tmp/pr-resp.json)
|
||||
# Merge without delete_branch_after_merge: run 10254 got HTTP 500 from that
|
||||
# call although the merge itself had landed. Verify the PR state instead of
|
||||
# trusting the status code, then drop the branch as a separate, best-effort step.
|
||||
code=$$(curl -s -o /tmp/merge-resp.json -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token $$TOKEN" -H "Content-Type: application/json" \
|
||||
--data '{"Do":"merge"}' "$$API/pulls/$$NUM/merge")
|
||||
echo "POST merge #$$NUM -> HTTP $$code"
|
||||
MERGED=$$(curl -s -H "Authorization: token $$TOKEN" "$$API/pulls/$$NUM" | jq -r '.merged')
|
||||
echo "PR #$$NUM merged: $$MERGED"
|
||||
[ "$$MERGED" = "true" ] || { cat /tmp/merge-resp.json; echo "PR #$$NUM left open"; exit 1; }
|
||||
code=$$(curl -s -o /tmp/del-resp.json -w '%{http_code}' -X DELETE -H "Authorization: token $$TOKEN" \
|
||||
"$$API/branches/$$(printf '%s' "$$BRANCH" | sed 's|/|%2F|g')")
|
||||
echo "DELETE branch $$BRANCH -> HTTP $$code (best effort)"
|
||||
@@ -1,7 +0,0 @@
|
||||
# Contributing
|
||||
|
||||
## Citing other YAML from a comment
|
||||
|
||||
- Mark the block you cite with a comment line `# anchor: <name>` (kebab-case, unique in that file). If the block is inside a `|` block scalar, put the marker just above the key, never inside the scalar.
|
||||
- Cite it as `path.yaml#<name>`, with the path relative to the citing file's directory or to the repo root. Never cite `path.yaml:<line>`, because line numbers go stale on any edit above them.
|
||||
- `scripts/ci/yaml-citations.sh` enforces this in `ci-manifests-test.yml`: it fails on line-number citations and on citations whose file or anchor does not exist.
|
||||
@@ -1,70 +0,0 @@
|
||||
# homelab
|
||||
|
||||
GitOps source for the Renner homelab cluster. Flux applies `master` to
|
||||
production, so every merge is a production change. Changes go in by pull
|
||||
request only; see [CONTRIBUTING.md](CONTRIBUTING.md) for YAML conventions.
|
||||
|
||||
## Change rules that protect Home Assistant
|
||||
|
||||
Owner decision (REN-240, REN-243). Home Assistant runs the house, so it
|
||||
gets two rules on top of normal review.
|
||||
|
||||
### 1. Disruptive work runs only 02:00–05:00 Europe/Berlin
|
||||
|
||||
Planned work that can restart Home Assistant or disrupt the cluster is
|
||||
merged and applied only between **02:00 and 05:00 Europe/Berlin**. That
|
||||
covers:
|
||||
|
||||
- node drain, node reboot, Talos upgrade
|
||||
- CNI (Cilium, Multus) and cluster-wide network policy
|
||||
- storage (Rook/Ceph, CSI, snapshot controller, CNPG operator)
|
||||
- Flux core (`kubernetes/clusters/*/flux-system/`, Flux controllers)
|
||||
- Authentik
|
||||
- OpenBao, including secret rotation that restarts consumers
|
||||
- anything in `kubernetes/apps/home-assistant/`
|
||||
|
||||
A PR in this list states in its description that it waits for the window.
|
||||
Flux applies on merge, so "wait for the window" means **merge** in the
|
||||
window, not just apply. If unsure whether a change is disruptive, treat it
|
||||
as disruptive.
|
||||
|
||||
Not covered: Home Assistant image bumps by Flux image automation. They stay
|
||||
fully automatic by owner decision. If one breaks Home Assistant, roll it
|
||||
back with [RUNBOOK-home-assistant-image-rollback.md](kubernetes/RUNBOOK-home-assistant-image-rollback.md).
|
||||
An incident rollback is not planned work; the owner decides its timing.
|
||||
|
||||
### 2. HA-impact review before merge
|
||||
|
||||
Every PR that touches `kubernetes/apps/home-assistant/`, or makes a
|
||||
cluster-wide disruptive change from the list above, needs an **HA-impact
|
||||
review** before merge: a named reviewer states in the PR what Home
|
||||
Assistant will notice (restart, lost integration, lost history, none) and
|
||||
approves.
|
||||
|
||||
- Reviewer: Hestia, the Home Assistant maintainer.
|
||||
- Until Hestia exists: the owner does the HA-impact review.
|
||||
|
||||
This is in addition to the normal review and merge by Argus. Argus does
|
||||
not merge such a PR without the HA-impact approval.
|
||||
|
||||
## Agent runtime images
|
||||
|
||||
Pod images for the Paperclip Kubernetes sandbox (REN-284). Each is the stock
|
||||
`ghcr.io/paperclipai/agent-runtime-claude` (pinned by digest) plus a tool
|
||||
set. Base user (1000, non-root), entrypoint and cmd are unchanged; the
|
||||
images run with a read-only root FS. Source in `ci/images/agent-runtime-*`,
|
||||
built by `.woodpecker/build-agent-runtime.yml` (re-run alone: deployment to
|
||||
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`.
|
||||
|
||||
| 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-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
|
||||
org, hence the nested path. Every build also pushes a date tag
|
||||
(`:YYYY-MM-DD`) as the rollback point. `konairius` is public: no pull secret.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
# Bouncer key for crowdsec-edge-bouncer, scoped to the proxy hosts.
|
||||
#
|
||||
# A single encrypted VALUE rather than a whole-file vault like its siblings in
|
||||
# group_vars/all and group_vars/edge_host. Deliberate: adding this variable to
|
||||
# an existing whole-file vault means decrypting that file first, which exposes
|
||||
# every other secret in it for the sake of appending one line. encrypt_string
|
||||
# keeps the blast radius at this one value.
|
||||
#
|
||||
# Same value as OpenBao homelab/infrastructure/crowdsec-edge-bouncer-key, which
|
||||
# the crowdsec-edge-bouncer-key ExternalSecret feeds to BOUNCER_KEY_edge on the
|
||||
# LAPI. Both sides must move together — and unlike crowdsec-bouncer-key, this
|
||||
# one is NOT in the quarterly rotator, because nothing on these hosts re-reads
|
||||
# OpenBao on a schedule. Rotating it means updating OpenBao and re-running
|
||||
# plays/install-netbird-proxy.yml.
|
||||
vault_crowdsec_edge_bouncer_key: !vault |
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
32653764333536316163303838333839616562663862666637626165303837633866643033393239
|
||||
3363376538656236643161386432326437633232643564630a633635306365323537346139626364
|
||||
36613033343938363366306239363638383466396337623333623136313234656366333366623433
|
||||
6437656632373364340a346566343866366433306533353431383064356362346536383136323663
|
||||
39333864643534326433653233303137373162663066633132623931316135323733333863306330
|
||||
6366623463666435636362376238313639666263323163303733
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
# PBS Client configuration for nas-1
|
||||
pbs_backup_paths:
|
||||
- /volume1/Documents
|
||||
- /volume1/homes
|
||||
- /volume1/Homeassistant Backup
|
||||
- /volume1/Games
|
||||
- /volume1/Media
|
||||
- /volume1/Music
|
||||
- /volume1/video
|
||||
- /volume2/Backup
|
||||
- /volume1/Movies
|
||||
|
||||
# Add more paths as needed:
|
||||
# - /volume1/photos
|
||||
# - /volume1/music
|
||||
|
||||
# PBS Server connection
|
||||
pbs_server_host: "pbs2.renner.uno"
|
||||
pbs_server_port: 8007
|
||||
pbs_datastore: "pool"
|
||||
pbs_backup_id: "nas-1"
|
||||
|
||||
# PBS Authentication - credentials in vault.yml
|
||||
pbs_username: "root@pam!nas-1"
|
||||
|
||||
# Garage S3 (K8s backup target)
|
||||
garage_image: "dxflrs/garage:v2.3.0"
|
||||
garage_zone: "dsm"
|
||||
garage_capacity: "2T"
|
||||
|
||||
# SSH connection - use password auth (key not supported by DSM)
|
||||
ansible_ssh_pass: "{{ nas_ssh_password }}"
|
||||
ansible_become_pass: "{{ nas_ssh_password }}"
|
||||
|
||||
# Python 3.9+ from Synology package
|
||||
ansible_python_interpreter: /usr/local/bin/python3.9
|
||||
@@ -0,0 +1,27 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
34663462636437633437613863366266306334666635643839313737343739643864613136613938
|
||||
3138393966306561656366396465383539303137333036300a376237376636356230343834386362
|
||||
36636535396330333732383233323263613132333166343739396137636538653435336662353138
|
||||
6565643866623235610a316266383933303830323936303735623338623165386339383733373435
|
||||
62366461363833616438373237383132366438326364363462333265366533323861376630643365
|
||||
39343036343838333639363432643738666630363063323636646661313539643465366532363438
|
||||
33303837623234613434633563666562346536346435353461373536323737376233386163326537
|
||||
61383963343737656438356136613537623538333163343466343739346135333439616432663763
|
||||
36663962633037376561326434303035333938646436663832303432346365353439666435613337
|
||||
34363636326530333233313231343566306432376532396666643761633136316333376337666135
|
||||
65363338623035663438323338663863366362653131336434323865326530326534623631336263
|
||||
36633063383031386161363131333636393364623436613331353733313235383062656131313164
|
||||
63393665633361313961363637663237616364643236383835393736643937393564656238376362
|
||||
31393465383361326264376133393233616264663636383337363962356532613438663637616536
|
||||
33653137656335323763313461323633616635346232303062323062333766383931313965346361
|
||||
66656461313239633366636463633465636463663937393932636337643861383937663662306466
|
||||
32353166643832303234643264633062616330393963333034623432613730366339306637613239
|
||||
34626161663666316339323762373639353335653663353635366664643063393432623039306237
|
||||
38363161343930363763393364663364353735343236313665313461626165636135303839363164
|
||||
37336638396564326166376233353730346563656266323535666265323062646232323938353732
|
||||
32636235333839623166633761376334316434613235333730353439343262393537363461333238
|
||||
32653435623362666662336532643832356163326332636436346132643237643030343761663332
|
||||
36663530356539303636663438646430363335353135323932613863666264363533326137353164
|
||||
31663032346236616263343637396439663334396166656364623637666535386530383535663435
|
||||
66303037303265616634373763616437333835333136336563323339666563663931396133633131
|
||||
3963653266633535303630653335666166313336653831623362
|
||||
@@ -8,8 +8,6 @@
|
||||
# pve_cluster (jupiter/neptune/saturn) run Talos on bare metal; no SSH at all
|
||||
# authentik_db / authentik_app authentik runs in-cluster (CNPG)
|
||||
# grafana runs in-cluster
|
||||
# synology_nas (nas-1) powered off; Garage and the PBS client
|
||||
# it hosted are gone with it
|
||||
# Their hosts no longer resolve, except the three that resolve to Talos nodes.
|
||||
|
||||
[netbird_server]
|
||||
@@ -19,6 +17,10 @@ netbird.renner.uno ansible_user=root
|
||||
proxy-1.renner.uno ansible_user=root
|
||||
proxy-2.renner.uno ansible_user=root
|
||||
|
||||
|
||||
[synology_nas]
|
||||
nas-1.renner.uno ansible_user=ladmin ansible_python_interpreter=/usr/bin/python3
|
||||
|
||||
# Every bare-metal edge thin client. Gets the host-generic roles only:
|
||||
# flash-wear handling, the log shipper, the MQTT bridge, the VLAN
|
||||
# sub-interface. Nothing here assumes any peripheral is attached.
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
---
|
||||
# Synology-specific playbook using raw module (Python 3.8 workaround)
|
||||
# Deploys Garage (https://garagehq.deuxfleurs.fr) as a single-node S3 target
|
||||
# for K8s backups (Velero + CNPG Barman Cloud Plugin).
|
||||
#
|
||||
# Required secrets in host_vars/nas-1.renner.uno/vault.yml:
|
||||
# garage_rpc_secret: "<openssl rand -hex 32>"
|
||||
# garage_admin_token: "<openssl rand -base64 32>"
|
||||
# garage_metrics_token: "<openssl rand -base64 32>"
|
||||
#
|
||||
# Post-install bootstrap (run once, record the returned secret keys):
|
||||
# docker exec garaged /garage status
|
||||
# docker exec garaged /garage bucket create k8s-velero
|
||||
# docker exec garaged /garage bucket create k8s-cnpg
|
||||
# docker exec garaged /garage bucket create k8s-secrets
|
||||
# docker exec garaged /garage bucket create k8s-migration
|
||||
# docker exec garaged /garage key create velero-key
|
||||
# docker exec garaged /garage key create cnpg-key
|
||||
# docker exec garaged /garage key create secrets-key
|
||||
# docker exec garaged /garage key create migration-key
|
||||
# docker exec garaged /garage bucket allow --read --write --owner k8s-velero --key velero-key
|
||||
# docker exec garaged /garage bucket allow --read --write --owner k8s-cnpg --key cnpg-key
|
||||
# docker exec garaged /garage bucket allow --read --write --owner k8s-secrets --key secrets-key
|
||||
# docker exec garaged /garage bucket allow --read --write --owner k8s-migration --key migration-key
|
||||
- name: Install Garage S3 on Synology NAS
|
||||
hosts: synology_nas
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
garage_data_dir: "/volume1/docker/garage"
|
||||
|
||||
tasks:
|
||||
- name: Create Garage directory tree
|
||||
ansible.builtin.raw: |
|
||||
mkdir -p {{ garage_data_dir }}/config \
|
||||
{{ garage_data_dir }}/meta \
|
||||
{{ garage_data_dir }}/data
|
||||
changed_when: true
|
||||
|
||||
# Secret writes use the same compare-then-write pattern as garage.toml so
|
||||
# rotation is idempotent AND a real change can drive force-recreate below.
|
||||
# Garage reads these files once at startup; without container restart, a
|
||||
# rotated token leaves the running process holding the previous value.
|
||||
- name: Write rpc_secret
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ garage_data_dir }}/config/rpc_secret.new << 'SECRET_EOF'
|
||||
{{ garage_rpc_secret }}
|
||||
SECRET_EOF
|
||||
chmod 600 {{ garage_data_dir }}/config/rpc_secret.new
|
||||
if [ ! -f {{ garage_data_dir }}/config/rpc_secret ] \
|
||||
|| ! cmp -s {{ garage_data_dir }}/config/rpc_secret.new {{ garage_data_dir }}/config/rpc_secret; then
|
||||
mv {{ garage_data_dir }}/config/rpc_secret.new {{ garage_data_dir }}/config/rpc_secret
|
||||
echo RPC_SECRET_CHANGED
|
||||
else
|
||||
rm {{ garage_data_dir }}/config/rpc_secret.new
|
||||
fi
|
||||
register: rpc_secret_write
|
||||
changed_when: "'RPC_SECRET_CHANGED' in rpc_secret_write.stdout"
|
||||
no_log: true
|
||||
|
||||
- name: Write admin_token
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ garage_data_dir }}/config/admin_token.new << 'SECRET_EOF'
|
||||
{{ garage_admin_token }}
|
||||
SECRET_EOF
|
||||
chmod 600 {{ garage_data_dir }}/config/admin_token.new
|
||||
if [ ! -f {{ garage_data_dir }}/config/admin_token ] \
|
||||
|| ! cmp -s {{ garage_data_dir }}/config/admin_token.new {{ garage_data_dir }}/config/admin_token; then
|
||||
mv {{ garage_data_dir }}/config/admin_token.new {{ garage_data_dir }}/config/admin_token
|
||||
echo ADMIN_TOKEN_CHANGED
|
||||
else
|
||||
rm {{ garage_data_dir }}/config/admin_token.new
|
||||
fi
|
||||
register: admin_token_write
|
||||
changed_when: "'ADMIN_TOKEN_CHANGED' in admin_token_write.stdout"
|
||||
no_log: true
|
||||
|
||||
- name: Write metrics_token
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ garage_data_dir }}/config/metrics_token.new << 'SECRET_EOF'
|
||||
{{ garage_metrics_token }}
|
||||
SECRET_EOF
|
||||
chmod 600 {{ garage_data_dir }}/config/metrics_token.new
|
||||
if [ ! -f {{ garage_data_dir }}/config/metrics_token ] \
|
||||
|| ! cmp -s {{ garage_data_dir }}/config/metrics_token.new {{ garage_data_dir }}/config/metrics_token; then
|
||||
mv {{ garage_data_dir }}/config/metrics_token.new {{ garage_data_dir }}/config/metrics_token
|
||||
echo METRICS_TOKEN_CHANGED
|
||||
else
|
||||
rm {{ garage_data_dir }}/config/metrics_token.new
|
||||
fi
|
||||
register: metrics_token_write
|
||||
changed_when: "'METRICS_TOKEN_CHANGED' in metrics_token_write.stdout"
|
||||
no_log: true
|
||||
|
||||
- name: Write garage.toml
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ garage_data_dir }}/config/garage.toml.new << 'TOML_EOF'
|
||||
metadata_dir = "/var/lib/garage/meta"
|
||||
data_dir = "/var/lib/garage/data"
|
||||
|
||||
# SQLite chosen over LMDB: single-node deployment has no peers to
|
||||
# rebuild metadata from on unclean-shutdown corruption. SQLite is
|
||||
# slower but portable and has no LMDB corruption window.
|
||||
db_engine = "sqlite"
|
||||
|
||||
# 6-hour auto-snapshot of the metadata DB as a backstop against
|
||||
# corruption; last two snapshots retained.
|
||||
metadata_auto_snapshot_interval = "6h"
|
||||
|
||||
# Single-zone, single-node: only coherent replication factor.
|
||||
replication_factor = 1
|
||||
|
||||
rpc_bind_addr = "[::]:3901"
|
||||
rpc_secret_file = "/etc/garage/rpc_secret"
|
||||
|
||||
compression_level = 2
|
||||
|
||||
# 64 MB blocks (default 1 MB; previously 20 MB). On the 5 × 7 TB
|
||||
# Btrfs RAID 5 volume the per-block fixed cost (SQLite lookup,
|
||||
# dir traversal, hash verify, zstd decompress, nginx buffering)
|
||||
# is the dominant overhead per object; larger blocks amortize
|
||||
# it. block_size is a maximum, so 16 MiB CNPG WAL segments
|
||||
# still upload as one block each. Only applies to NEW uploads;
|
||||
# existing objects keep their original chunking until re-uploaded.
|
||||
block_size = "64M"
|
||||
|
||||
[s3_api]
|
||||
api_bind_addr = "[::]:3900"
|
||||
s3_region = "garage"
|
||||
root_domain = ".s3.{{ inventory_hostname }}"
|
||||
|
||||
[s3_web]
|
||||
bind_addr = "[::]:3902"
|
||||
root_domain = ".web.{{ inventory_hostname }}"
|
||||
|
||||
[admin]
|
||||
api_bind_addr = "[::]:3903"
|
||||
admin_token_file = "/etc/garage/admin_token"
|
||||
metrics_token_file = "/etc/garage/metrics_token"
|
||||
TOML_EOF
|
||||
if [ ! -f {{ garage_data_dir }}/config/garage.toml ] \
|
||||
|| ! cmp -s {{ garage_data_dir }}/config/garage.toml.new {{ garage_data_dir }}/config/garage.toml; then
|
||||
mv {{ garage_data_dir }}/config/garage.toml.new {{ garage_data_dir }}/config/garage.toml
|
||||
echo GARAGE_TOML_CHANGED
|
||||
else
|
||||
rm {{ garage_data_dir }}/config/garage.toml.new
|
||||
fi
|
||||
register: garage_toml_write
|
||||
changed_when: "'GARAGE_TOML_CHANGED' in garage_toml_write.stdout"
|
||||
|
||||
- name: Write docker-compose.yml
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ garage_data_dir }}/docker-compose.yml << 'COMPOSE_EOF'
|
||||
services:
|
||||
garage:
|
||||
image: {{ garage_image }}
|
||||
container_name: garaged
|
||||
restart: unless-stopped
|
||||
# HTTP services (3900/3902/3903) bind to loopback; DSM
|
||||
# reverse proxy fronts and TLS-terminates them. RPC 3901
|
||||
# is exposed directly — the Noise handshake is raw TCP,
|
||||
# so DSM RP cannot front it. rpc_secret gates auth.
|
||||
ports:
|
||||
- "3901:3901"
|
||||
- "127.0.0.1:3900:3900"
|
||||
- "127.0.0.1:3902:3902"
|
||||
- "127.0.0.1:3903:3903"
|
||||
volumes:
|
||||
- {{ garage_data_dir }}/config/garage.toml:/etc/garage.toml:ro
|
||||
- {{ garage_data_dir }}/config/rpc_secret:/etc/garage/rpc_secret:ro
|
||||
- {{ garage_data_dir }}/config/admin_token:/etc/garage/admin_token:ro
|
||||
- {{ garage_data_dir }}/config/metrics_token:/etc/garage/metrics_token:ro
|
||||
- {{ garage_data_dir }}/meta:/var/lib/garage/meta
|
||||
- {{ garage_data_dir }}/data:/var/lib/garage/data
|
||||
COMPOSE_EOF
|
||||
changed_when: true
|
||||
|
||||
- name: Pull image and start Garage container
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
cd {{ garage_data_dir }} && docker compose pull && docker compose up -d
|
||||
become: true
|
||||
changed_when: true
|
||||
|
||||
# Garage does not reload config on SIGHUP and compose does not notice
|
||||
# when a bind-mounted host file changes. Force-recreate the container
|
||||
# when garage.toml OR any of the three secret files changed so the new
|
||||
# config / rotated tokens are picked up.
|
||||
- name: Force-recreate garaged when garage.toml or secrets changed
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
cd {{ garage_data_dir }} && docker compose up -d --force-recreate garage
|
||||
become: true
|
||||
when: garage_toml_write.changed
|
||||
or rpc_secret_write.changed
|
||||
or admin_token_write.changed
|
||||
or metrics_token_write.changed
|
||||
changed_when: true
|
||||
|
||||
- name: Wait for Garage to accept CLI calls
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec garaged /garage status >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
exit 1
|
||||
become: true
|
||||
changed_when: false
|
||||
|
||||
- name: Check whether cluster layout is already applied
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
docker exec garaged /garage status
|
||||
become: true
|
||||
register: garage_status
|
||||
changed_when: false
|
||||
|
||||
- name: Apply single-node layout (idempotent — only on first run)
|
||||
when: "'NO ROLE ASSIGNED' in garage_status.stdout"
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
NODE_ID=$(docker exec garaged /garage node id | head -n 1 | cut -d@ -f1)
|
||||
docker exec garaged /garage layout assign "$NODE_ID" \
|
||||
-z {{ garage_zone }} \
|
||||
-c {{ garage_capacity }} \
|
||||
-t {{ inventory_hostname_short }}
|
||||
docker exec garaged /garage layout apply --version 1
|
||||
become: true
|
||||
changed_when: true
|
||||
|
||||
- name: Report Garage status
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
docker exec garaged /garage status
|
||||
become: true
|
||||
register: garage_status_final
|
||||
changed_when: false
|
||||
|
||||
- name: Show Garage status
|
||||
ansible.builtin.debug:
|
||||
var: garage_status_final.stdout_lines
|
||||
|
||||
# Bootstrap buckets + keys. Idempotent: bucket_info / key_info return
|
||||
# non-zero when missing, so we only create on first run. Access-key
|
||||
# secrets are captured at create time and persisted to chmod-600 files
|
||||
# under {{ garage_data_dir }}/config/keys/<key>.creds on nas-1. These
|
||||
# files are the only post-install record of the secrets — Garage does
|
||||
# not expose them again.
|
||||
- name: Bootstrap Garage buckets, keys, and grants
|
||||
ansible.builtin.shell: |
|
||||
set -e
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
KEYS_DIR={{ garage_data_dir }}/config/keys
|
||||
mkdir -p "$KEYS_DIR"
|
||||
chmod 700 "$KEYS_DIR"
|
||||
|
||||
bootstrap_pair() {
|
||||
local BUCKET="$1"
|
||||
local KEY="$2"
|
||||
local CREDS_FILE="$KEYS_DIR/$KEY.creds"
|
||||
|
||||
if ! docker exec garaged /garage bucket info "$BUCKET" >/dev/null 2>&1; then
|
||||
docker exec garaged /garage bucket create "$BUCKET"
|
||||
fi
|
||||
|
||||
if ! docker exec garaged /garage key info "$KEY" >/dev/null 2>&1; then
|
||||
CREATE_OUT=$(docker exec garaged /garage key create "$KEY")
|
||||
KEY_ID=$(echo "$CREATE_OUT" | awk '/^Key ID:/ {print $3}')
|
||||
SECRET=$(echo "$CREATE_OUT" | awk '/^Secret key:/ {print $3}')
|
||||
umask 177
|
||||
printf 'KEY_ID=%s\nSECRET_KEY=%s\n' "$KEY_ID" "$SECRET" > "$CREDS_FILE"
|
||||
fi
|
||||
|
||||
docker exec garaged /garage bucket allow \
|
||||
--read --write --owner \
|
||||
"$BUCKET" --key "$KEY"
|
||||
}
|
||||
|
||||
bootstrap_pair k8s-velero velero-key
|
||||
bootstrap_pair k8s-cnpg cnpg-key
|
||||
bootstrap_pair k8s-secrets secrets-key
|
||||
bootstrap_pair k8s-migration migration-key
|
||||
bootstrap_pair tfstate tfstate-key
|
||||
become: true
|
||||
changed_when: true
|
||||
no_log: true
|
||||
|
||||
- name: Report bucket + key state (no secrets)
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
docker exec garaged /garage bucket list
|
||||
echo "---"
|
||||
docker exec garaged /garage key list
|
||||
echo "---"
|
||||
ls -l {{ garage_data_dir }}/config/keys/ 2>&1
|
||||
become: true
|
||||
register: garage_bootstrap_state
|
||||
changed_when: false
|
||||
|
||||
- name: Show bucket + key state
|
||||
ansible.builtin.debug:
|
||||
var: garage_bootstrap_state.stdout_lines
|
||||
|
||||
- name: Post-install note
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
- "Buckets: k8s-velero, k8s-cnpg, k8s-secrets, k8s-migration, tfstate"
|
||||
- "Keys: velero-key, cnpg-key, secrets-key, migration-key, tfstate-key"
|
||||
- "Secrets are written to {{ garage_data_dir }}/config/keys/<key>.creds on nas-1 (chmod 600, root-owned)."
|
||||
- "SSH to nas-1 and `sudo cat` the file to retrieve KEY_ID + SECRET_KEY for Velero / CNPG / migration / tfstate config."
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
# Deploy the NetBird reverse-proxy HA cluster members (the public edge).
|
||||
# Deploy a NetBird reverse-proxy HA cluster member (a second proxy host).
|
||||
# Usage: ansible-playbook -i inventory/ plays/install-netbird-proxy.yml
|
||||
#
|
||||
# Prerequisites:
|
||||
@@ -7,11 +7,12 @@
|
||||
# listed under [netbird_proxy] in inventory/pve_hosts.yml. Docker is
|
||||
# installed by the (Arch-aware) docker-install role.
|
||||
# - DNS A/AAAA for the host's own name (e.g. proxy-1.renner.uno) -> its IP.
|
||||
# - Uses the existing vault_netbird_proxy_token (shared by every member) — no
|
||||
# new token to generate; a shared token + same NB_PROXY_DOMAIN forms the
|
||||
# cluster.
|
||||
# - AFTER deploy + verify: add this host's A/AAAA to proxy.renner.uno so
|
||||
# traffic round-robins across the cluster.
|
||||
# - Uses the existing vault_netbird_proxy_token (shared with VPS-1) — no new
|
||||
# token to generate; a shared token + same NB_PROXY_DOMAIN forms the cluster.
|
||||
# - AFTER deploy + verify: add this host's IP to the proxy domain so traffic
|
||||
# round-robins across the cluster. Repoint proxy.renner.uno away from its
|
||||
# CNAME to netbird.renner.uno (management host) to A/AAAA records listing
|
||||
# BOTH proxy IPs (VPS-1 + this host).
|
||||
|
||||
- name: Deploy NetBird reverse-proxy cluster member
|
||||
hosts: netbird_proxy
|
||||
@@ -25,7 +26,3 @@
|
||||
- docker-install
|
||||
- netbird-proxy
|
||||
- netbird-proxy-watchdog
|
||||
# Enforces the cluster's CrowdSec decisions here, where public traffic
|
||||
# enters. Deployed in dry-run: it logs what it would block and creates no
|
||||
# nftables rules until crowdsec_bouncer_mode is flipped per host.
|
||||
- crowdsec-edge-bouncer
|
||||
|
||||
@@ -18,3 +18,4 @@
|
||||
- common
|
||||
- docker-install
|
||||
- netbird-server
|
||||
- netbird-proxy-watchdog
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
# Synology-specific playbook using raw module (Python 3.8 workaround)
|
||||
- name: Install PBS Client on Synology NAS
|
||||
hosts: synology_nas
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
pbs_client_data_dir: "/volume1/docker/pbs-client"
|
||||
|
||||
tasks:
|
||||
- name: Create PBS client directory
|
||||
ansible.builtin.raw: |
|
||||
mkdir -p {{ pbs_client_data_dir }}/state
|
||||
changed_when: true
|
||||
|
||||
- name: Create docker-compose.yml
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ pbs_client_data_dir }}/docker-compose.yml << 'COMPOSE_EOF'
|
||||
services:
|
||||
pbs-client:
|
||||
image: proxmox/proxmox-backup-client:latest
|
||||
container_name: pbs-client
|
||||
restart: unless-stopped
|
||||
hostname: {{ pbs_backup_id }}
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- {{ pbs_client_data_dir }}/state:/root/.proxmox-backup
|
||||
{% for path in pbs_backup_paths %}
|
||||
- "{{ path }}:{{ path }}:ro"
|
||||
{% endfor %}
|
||||
mem_limit: 2g
|
||||
command: ["sleep", "infinity"]
|
||||
COMPOSE_EOF
|
||||
changed_when: true
|
||||
|
||||
- name: Create .env file
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ pbs_client_data_dir }}/.env << 'ENV_EOF'
|
||||
PBS_REPOSITORY={{ pbs_username }}@{{ pbs_server_host }}:{{ pbs_server_port }}:{{ pbs_datastore }}
|
||||
PBS_PASSWORD={{ pbs_password }}
|
||||
ENV_EOF
|
||||
chmod 600 {{ pbs_client_data_dir }}/.env
|
||||
changed_when: true
|
||||
|
||||
- name: Create backup script
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ pbs_client_data_dir }}/backup.sh << 'SCRIPT_EOF'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
source {{ pbs_client_data_dir }}/.env
|
||||
|
||||
BACKUP_SOURCES=""
|
||||
{% for path in pbs_backup_paths %}
|
||||
BACKUP_SOURCES="$BACKUP_SOURCES {{ path | basename }}.pxar:{{ path }}"
|
||||
{% endfor %}
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting backup for {{ pbs_backup_id }}"
|
||||
docker exec pbs-client proxmox-backup-client backup \
|
||||
$BACKUP_SOURCES \
|
||||
--backup-id {{ pbs_backup_id }} \
|
||||
--repository "${PBS_REPOSITORY}"
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup completed, pruning..."
|
||||
docker exec pbs-client proxmox-backup-client prune \
|
||||
--repository "${PBS_REPOSITORY}" \
|
||||
--backup-id {{ pbs_backup_id }} \
|
||||
--keep-last {{ pbs_keep_last | default(3) }} \
|
||||
--keep-daily {{ pbs_keep_daily | default(7) }} \
|
||||
--keep-weekly {{ pbs_keep_weekly | default(4) }} \
|
||||
--keep-monthly {{ pbs_keep_monthly | default(6) }}
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Done"
|
||||
SCRIPT_EOF
|
||||
chmod 755 {{ pbs_client_data_dir }}/backup.sh
|
||||
changed_when: true
|
||||
|
||||
- name: Start PBS client container
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
cd {{ pbs_client_data_dir }} && docker compose pull && docker compose up -d
|
||||
become: true
|
||||
changed_when: true
|
||||
|
||||
- name: Setup cron job for daily backup at 2 AM (root crontab)
|
||||
ansible.builtin.shell: |
|
||||
# Remove existing entry if present
|
||||
crontab -l 2>/dev/null | grep -v "pbs-client/backup.sh" | crontab - 2>/dev/null || true
|
||||
# Add new entry
|
||||
(crontab -l 2>/dev/null; echo "0 2 * * * {{ pbs_client_data_dir }}/backup.sh >> /var/log/pbs-backup.log 2>&1") | crontab -
|
||||
become: true
|
||||
changed_when: true
|
||||
|
||||
- name: Verify container is running
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
docker ps --filter name=pbs-client --format "{{ '{{' }}.Status{{ '}}' }}"
|
||||
become: true
|
||||
register: container_status
|
||||
changed_when: false
|
||||
|
||||
- name: Show container status
|
||||
ansible.builtin.debug:
|
||||
msg: "PBS client container status: {{ container_status.stdout }}"
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
- name: Install PBS Client on Synology NAS
|
||||
hosts: synology_nas
|
||||
become: true
|
||||
# Variables are set in:
|
||||
# - Role defaults: ansible/roles/pbs-client/defaults/main.yml
|
||||
# - Host overrides: ansible/inventory/host_vars/<hostname>/vars.yml
|
||||
# - Secrets: ansible/inventory/host_vars/<hostname>/vault.yml
|
||||
|
||||
roles:
|
||||
- pbs-client
|
||||
@@ -43,10 +43,10 @@
|
||||
#
|
||||
# WHAT IS NOT, and must not be added:
|
||||
#
|
||||
# vault_netbird_proxy_token LOOKS symmetric — it is in proxy.env on the
|
||||
# proxies, an Ansible template — but the
|
||||
# management server does not validate it from
|
||||
# any file. It is registered
|
||||
# vault_netbird_proxy_token LOOKS symmetric — it is in proxy.env on both
|
||||
# the server and the proxies, all Ansible
|
||||
# templates — but the management server does not
|
||||
# validate it from any of them. It is registered
|
||||
# in the management DATABASE. Rotating it changes
|
||||
# only what the proxies present, so every proxy
|
||||
# is locked out: "proxy auth failed: too many
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
# CrowdSec firewall bouncer on the NetBird proxy hosts.
|
||||
#
|
||||
# Enforces the decisions the in-cluster CrowdSec already detects, at the point
|
||||
# where public traffic actually enters: these hosts terminate TLS for every
|
||||
# *.renner.uno name. A drop here never reaches the mesh, the relay or the
|
||||
# cluster.
|
||||
#
|
||||
# Detection stays in the cluster. The reverse-proxy produces no parseable
|
||||
# access log and there is no CrowdSec collection for it, while Traefik has a
|
||||
# working one — and every scenario in use needs HTTP detail (paths, status
|
||||
# codes, user agents) that does not exist at this layer.
|
||||
|
||||
# Pinned. A release tarball URL is immutable, and this checksum is of the
|
||||
# amd64 asset for exactly this tag.
|
||||
crowdsec_bouncer_version: v0.0.36
|
||||
crowdsec_bouncer_sha256: f86e4b72693549d99f40a9402abefb894108f047a3fbe6e72fada25ee17ce88b
|
||||
|
||||
# The LAPI over the public path these hosts already serve, see
|
||||
# kubernetes/infrastructure/crowdsec/lapi-ingress.yaml. The proxies cannot
|
||||
# reach it over the mesh: the reverse-proxy container runs non-root, so its
|
||||
# embedded NetBird client terminates the tunnel in userspace and the tunnel
|
||||
# belongs to that process rather than to the host netns.
|
||||
#
|
||||
# No ca_cert_path: Traefik serves a Let's Encrypt certificate for this name,
|
||||
# and the bouncer adds ca_cert_path to the system pool rather than replacing
|
||||
# it — the system pool already trusts LE.
|
||||
crowdsec_bouncer_api_url: https://crowdsec.renner.uno/
|
||||
crowdsec_bouncer_api_key: "{{ vault_crowdsec_edge_bouncer_key }}"
|
||||
|
||||
# Enforcing. The bouncer creates two nftables tables (ip crowdsec, ip6
|
||||
# crowdsec6) holding the decisions it pulled, touches no existing chain and
|
||||
# sets no chain policy — SSH and :443 stay open unless the source is banned.
|
||||
#
|
||||
# `dry-run` is the other value: it pulls and logs what it would block without
|
||||
# creating a single rule. That is what a new host should be given before it
|
||||
# enforces, and what an existing one can be dropped back to, per host through
|
||||
# inventory/host_vars, without disarming the rest.
|
||||
#
|
||||
# Worth knowing before arming anything new: the first bouncer on a path
|
||||
# enforces the whole CAPI set at once — around 15k addresses today — so the
|
||||
# false-positive window belongs before the switch, not after it. And because
|
||||
# this chain sits on the INPUT hook while these hosts run no other firewall,
|
||||
# there is no conntrack rule accepting established flows ahead of it: a banned
|
||||
# address is dropped even when it is answering our own outbound connection.
|
||||
# That is why capi_whitelists.yaml carries the proxies and netbird.renner.uno.
|
||||
crowdsec_bouncer_mode: nftables
|
||||
|
||||
crowdsec_bouncer_update_frequency: 10s
|
||||
crowdsec_bouncer_log_level: info
|
||||
|
||||
# Bound to loopback deliberately. These hosts are public and have no metrics
|
||||
# collection path at all today — opening :60601 to the internet to gain a
|
||||
# scrape target is the wrong trade. Revisit when host metrics exist.
|
||||
crowdsec_bouncer_metrics_listen: 127.0.0.1
|
||||
crowdsec_bouncer_metrics_port: 60601
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
# A restart drops both nftables tables and rebuilds them from the next
|
||||
# successful LAPI pull, so there is a short window with no blocklist. That is
|
||||
# the documented behaviour of a clean shutdown, not a defect — and it is why
|
||||
# retry_initial_connect matters.
|
||||
- name: Restart crowdsec firewall bouncer
|
||||
ansible.builtin.systemd:
|
||||
name: crowdsec-firewall-bouncer.service
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
# See defaults/main.yml for why this exists and why it starts inert.
|
||||
#
|
||||
# The static release tarball, not a distribution package: the AUR package
|
||||
# `crowdsec-firewall-bouncer-nftables` depends on `crowdsec` and would pull the
|
||||
# whole Security Engine onto hosts that only need the bouncer, the group is
|
||||
# mixed (proxy-1 Arch, VPS-1 Debian), and the AUR would be a new pattern this
|
||||
# repo does not otherwise have. The tarball is the same artefact the AUR
|
||||
# package downloads.
|
||||
|
||||
- name: Ensure nftables is present (the enforcing backend)
|
||||
ansible.builtin.package:
|
||||
name: nftables
|
||||
state: present
|
||||
|
||||
- name: Ensure the bouncer config directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/crowdsec/bouncers
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
# Fetched on the CONTROLLER and pushed, not pulled by the host.
|
||||
#
|
||||
# proxy-1 is IPv6-only, and neither github.com nor objects.githubusercontent.com
|
||||
# publishes an AAAA record — a get_url on the host fails with "Network is
|
||||
# unreachable" and no amount of retrying fixes it. Mirroring into
|
||||
# gitea.renner.uno is no help either: its public records are the internal LB
|
||||
# VIP (10.2.99.86 / fd99::86), so it is unreachable from outside the LAN. And
|
||||
# upstream ships no container image for this bouncer, which is expected —
|
||||
# it manipulates host netfilter.
|
||||
#
|
||||
# The CI runner has v4, so it does the fetching. The checksum is verified
|
||||
# there, before anything is copied to a host.
|
||||
- name: Fetch the pinned bouncer release on the controller
|
||||
ansible.builtin.get_url:
|
||||
url: >-
|
||||
https://github.com/crowdsecurity/cs-firewall-bouncer/releases/download/{{
|
||||
crowdsec_bouncer_version }}/crowdsec-firewall-bouncer-linux-amd64.tgz
|
||||
dest: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
|
||||
checksum: sha256:{{ crowdsec_bouncer_sha256 }}
|
||||
mode: "0644"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
run_once: true
|
||||
|
||||
# Only the download had to move to the controller — the extraction stays on
|
||||
# the host. The ci-ansible image ships a tar that ansible rejects ("detected as
|
||||
# tar type None. GNU tar required"), while both host distributions have GNU
|
||||
# tar. Shipping the archive rather than the unpacked binary also keeps the
|
||||
# artefact that the checksum was verified against intact all the way to the
|
||||
# host.
|
||||
- name: Copy the bouncer archive to the host
|
||||
ansible.builtin.copy:
|
||||
src: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
|
||||
dest: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
|
||||
mode: "0644"
|
||||
|
||||
# Only the binary. The tarball also carries install.sh / upgrade.sh /
|
||||
# uninstall.sh, which manage systemd and config themselves — that is this
|
||||
# role's job, and letting them run would leave two owners for the same files.
|
||||
- name: Unpack the bouncer binary
|
||||
ansible.builtin.unarchive:
|
||||
src: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
|
||||
dest: /opt
|
||||
remote_src: true
|
||||
include:
|
||||
- crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}/crowdsec-firewall-bouncer
|
||||
creates: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}/crowdsec-firewall-bouncer
|
||||
|
||||
- name: Install the bouncer binary
|
||||
ansible.builtin.copy:
|
||||
src: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}/crowdsec-firewall-bouncer
|
||||
dest: /usr/local/bin/crowdsec-firewall-bouncer
|
||||
remote_src: true
|
||||
mode: "0755"
|
||||
notify: Restart crowdsec firewall bouncer
|
||||
|
||||
# 0600: this file carries the LAPI key.
|
||||
- name: Deploy the bouncer configuration
|
||||
ansible.builtin.template:
|
||||
src: crowdsec-firewall-bouncer.yaml.j2
|
||||
dest: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
|
||||
mode: "0600"
|
||||
notify: Restart crowdsec firewall bouncer
|
||||
|
||||
- name: Deploy the bouncer systemd unit
|
||||
ansible.builtin.template:
|
||||
src: crowdsec-firewall-bouncer.service.j2
|
||||
dest: /etc/systemd/system/crowdsec-firewall-bouncer.service
|
||||
mode: "0644"
|
||||
notify: Restart crowdsec firewall bouncer
|
||||
|
||||
- name: Enable and start the bouncer
|
||||
ansible.builtin.systemd:
|
||||
name: crowdsec-firewall-bouncer.service
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
- name: Report how to read it
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
crowdsec-firewall-bouncer deployed in mode "{{ crowdsec_bouncer_mode }}".
|
||||
|
||||
What it would block: journalctl -u crowdsec-firewall-bouncer -f
|
||||
What it does block: nft list table ip crowdsec ; nft list table ip6 crowdsec6
|
||||
Metrics: curl -s {{ crowdsec_bouncer_metrics_listen }}:{{ crowdsec_bouncer_metrics_port }}/metrics
|
||||
|
||||
Rollback: systemctl stop crowdsec-firewall-bouncer. A clean stop deletes
|
||||
both nftables tables by itself. Only after a kill -9 do they need
|
||||
removing by hand: nft delete table ip crowdsec ; nft delete table ip6 crowdsec6
|
||||
@@ -1,24 +0,0 @@
|
||||
# Managed by Ansible (crowdsec-edge-bouncer role) — do not edit.
|
||||
#
|
||||
# Upstream's shipped unit orders itself After=crowdsec.service. There is no
|
||||
# local Security Engine on these hosts — the LAPI is in the cluster — so that
|
||||
# dependency is dropped; nss-lookup instead, because api_url is a public name
|
||||
# that has to resolve before the first connect.
|
||||
[Unit]
|
||||
Description=CrowdSec firewall bouncer (edge enforcement for the NetBird proxy)
|
||||
After=network-online.target nss-lookup.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
# -t validates the config and exits non-zero on a bad one, so a broken config
|
||||
# fails the start instead of taking the running rules down with it.
|
||||
ExecStartPre=/usr/local/bin/crowdsec-firewall-bouncer -c /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml -t
|
||||
ExecStart=/usr/local/bin/crowdsec-firewall-bouncer -c /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
LimitNOFILE=65536
|
||||
KillMode=mixed
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,55 +0,0 @@
|
||||
# Managed by Ansible (crowdsec-edge-bouncer role) — do not edit.
|
||||
mode: {{ crowdsec_bouncer_mode }}
|
||||
update_frequency: {{ crowdsec_bouncer_update_frequency }}
|
||||
|
||||
# stdout, so journald owns the log and there is no second rotation policy to
|
||||
# keep in sync. The shipped example writes to /var/log; that only matters for
|
||||
# hosts without journald.
|
||||
log_mode: stdout
|
||||
log_level: {{ crowdsec_bouncer_log_level }}
|
||||
|
||||
api_url: {{ crowdsec_bouncer_api_url }}
|
||||
api_key: {{ crowdsec_bouncer_api_key }}
|
||||
|
||||
# Without this the process EXITS when the LAPI is unreachable at startup —
|
||||
# there is no retry on the first connect. And because a clean shutdown deletes
|
||||
# the nftables tables, the systemd restart loop would leave the host with no
|
||||
# blocklist at all until the first successful connect. Present in the code,
|
||||
# absent from the shipped example config.
|
||||
retry_initial_connect: true
|
||||
|
||||
disable_ipv4: false
|
||||
disable_ipv6: false
|
||||
deny_action: DROP
|
||||
|
||||
supported_decisions_types:
|
||||
- ban
|
||||
|
||||
# Separate tables per family; the bouncer never touches an existing table or
|
||||
# chain, and never sets a chain policy. Priority -10 puts it ahead of anything
|
||||
# at the default filter priority.
|
||||
nftables:
|
||||
ipv4:
|
||||
enabled: true
|
||||
set-only: false
|
||||
table: crowdsec
|
||||
chain: crowdsec-chain
|
||||
priority: -10
|
||||
ipv6:
|
||||
enabled: true
|
||||
set-only: false
|
||||
table: crowdsec6
|
||||
chain: crowdsec6-chain
|
||||
priority: -10
|
||||
|
||||
# input is the traffic this exists for: the reverse-proxy is host-networked and
|
||||
# owns :443, so its packets traverse INPUT. forward covers the bridge-networked
|
||||
# http-redirect sidecar, whose packets never do.
|
||||
nftables_hooks:
|
||||
- input
|
||||
- forward
|
||||
|
||||
prometheus:
|
||||
enabled: true
|
||||
listen_addr: {{ crowdsec_bouncer_metrics_listen }}
|
||||
listen_port: {{ crowdsec_bouncer_metrics_port }}
|
||||
@@ -26,16 +26,8 @@ edge_open3e_poll_seconds: 30
|
||||
# entries when added), so the multi-frame read is cheap at a 30 s poll.
|
||||
# Records faults/info events only (NormalOperation, LagDeviceDtcReported,
|
||||
# LegionellaProtectionTemperatureReached) — NOT setpoint or program changes.
|
||||
# onebase.*: the 29 paired ZigBee slots on the One Base gateway (ECU 0x6c3) --
|
||||
# 8 radiator valves, 9 climate sensors, 9 underfloor actuators, 3 floor
|
||||
# thermostats. Per-device room temperature, active setpoint, humidity, valve
|
||||
# opening/demand, battery and RSSI; the local replacement for the ViCare cloud
|
||||
# room sensors. Needs the onebase overlay (files/open3e-onebase.py) -- the stock
|
||||
# codec declares 57 bytes and a paired slot answers with 68.
|
||||
# Only CurrentValues are polled. The room and device registries (1884.., 2084..)
|
||||
# are static configuration and are read on demand, not every 30 s.
|
||||
edge_open3e_datapoints: >-
|
||||
vitocal.259,vitocal.260,vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.286,vitocal.288,vitocal.318,vitocal.321,vitocal.324,vitocal.335,vitocal.336,vitocal.396,vitocal.402,vitocal.403,vitocal.476,vitocal.477,vitocal.543,vitocal.548,vitocal.565,vitocal.1007,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.1416,vitocal.1417,vitocal.2320,vitocal.2346,vitocal.2350,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2486,vitocal.2487,vitocal.2488,vitocal.2543,vitocal.2560,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.266,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1802,vx3.1810,vx3.1828,vx3.1830,vx3.1577,vx3.1578,vx3.1579,vx3.1580,vx3.1581,vx3.1582,vx3.1834,vx3.1836,vx3.2214,vx3.2240,vx3.3085,vx3.3086,vx3.3087,vx3.3088,vx3.3089,vx3.3090,vcmu.320,vcmu.322,vcmu.325,vcmu.355,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571,vitocal.2791,onebase.2086,onebase.2089,onebase.2092,onebase.2095,onebase.2098,onebase.2101,onebase.2104,onebase.2107,onebase.2110,onebase.2113,onebase.2116,onebase.2119,onebase.2122,onebase.2125,onebase.2128,onebase.2131,onebase.2134,onebase.2137,onebase.2140,onebase.2143,onebase.2262,onebase.2265,onebase.2268,onebase.2271,onebase.2274,onebase.2277,onebase.2280,onebase.2283,onebase.2286
|
||||
vitocal.259,vitocal.260,vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.286,vitocal.288,vitocal.318,vitocal.321,vitocal.324,vitocal.335,vitocal.336,vitocal.396,vitocal.402,vitocal.403,vitocal.476,vitocal.477,vitocal.543,vitocal.548,vitocal.565,vitocal.1007,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.1416,vitocal.1417,vitocal.2320,vitocal.2346,vitocal.2350,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2486,vitocal.2487,vitocal.2488,vitocal.2543,vitocal.2560,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.266,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1802,vx3.1810,vx3.1828,vx3.1830,vx3.1577,vx3.1578,vx3.1579,vx3.1580,vx3.1581,vx3.1582,vx3.1834,vx3.1836,vx3.2214,vx3.2240,vx3.3085,vx3.3086,vx3.3087,vx3.3088,vx3.3089,vx3.3090,vcmu.320,vcmu.322,vcmu.325,vcmu.355,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571,vitocal.2791
|
||||
# ECU addresses; -dev selects codec set, tx selects the UDS address.
|
||||
# vcmu (refrigerant-circuit controller) has no dedicated codec set —
|
||||
# omitting dpList makes open3e fall back to the generic datapoint list,
|
||||
@@ -46,9 +38,6 @@ edge_open3e_devices:
|
||||
# files/open3e-vx3plus.py) — die Stock-vx3-Liste filtert sie sonst weg.
|
||||
vx3: { tx: "0x6a1", dpList: "/config/vx3plus.py" }
|
||||
vcmu: { tx: "0x68c" }
|
||||
# One Base ZigBee gateway. Overlay re-declares the paired ZigBee slots at
|
||||
# their real 68-byte length (see files/open3e-onebase.py).
|
||||
onebase: { tx: "0x6c3", dpList: "/config/onebase.py" }
|
||||
|
||||
edge_e3oncan_gap_seconds: 10
|
||||
edge_e3oncan_mqtt_format: "{device}_{didNumber:04d}_{didName}"
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Managed by ansible (roles/heating-can). Datapoint overlay for the One Base
|
||||
# ZigBee gateway on ECU 0x6c3 (BACKENDGATEWAY).
|
||||
#
|
||||
# Upstream declares ZigBee<N>DeviceCurrentValues as 57 bytes. A PAIRED device
|
||||
# slot on this gateway answers with 68: the 57 known bytes plus the parent
|
||||
# device serial and three trailing bytes. udsoncan reads only the declared
|
||||
# length and then treats the next two bytes -- the first half of that serial --
|
||||
# as a foreign data identifier, so every paired slot fails to decode with
|
||||
# "Server returned values for data identifier 0x.... that was not requested".
|
||||
# The bogus identifier differs per device because the serial does.
|
||||
#
|
||||
# This overlay re-declares the 29 paired slots at 68 bytes, reusing upstream's
|
||||
# sub-type list so the per-device-type O3ESwitch (climate sensor / TRV / floor
|
||||
# thermostat / actuator, incl. DeviceTemperatureSetpoint) is inherited rather
|
||||
# than copied.
|
||||
#
|
||||
# EMPTY slots answer with 57 and decode against the stock codec, so they are
|
||||
# left alone and are not polled. A slot that is later unpaired therefore starts
|
||||
# reporting a decode error on its topic instead of a value.
|
||||
#
|
||||
# Verified on ECU 0x6c3, 2026-09-17: 29 paired slots, all 68 bytes.
|
||||
from open3e.Open3Ecodecs import O3EComplexType, RawCodec
|
||||
from open3e.Open3Edatapoints import dataIdentifiers as _master
|
||||
|
||||
# ZigBee<N>DeviceCurrentValues of the paired slots: devices 1-20 run 2086..2143
|
||||
# in steps of 3, devices 21-29 continue at 2262..2286.
|
||||
_PAIRED_CURRENT_VALUES = [2086 + 3 * n for n in range(20)] + [
|
||||
2262 + 3 * n for n in range(9)
|
||||
]
|
||||
|
||||
|
||||
def _with_parent_serial(did):
|
||||
stock = _master["dids"][did]
|
||||
return O3EComplexType(
|
||||
68,
|
||||
stock.id,
|
||||
list(stock.subTypes)
|
||||
+ [
|
||||
RawCodec(8, "ParentDeviceSerialnumber"),
|
||||
RawCodec(2, "Unknown_65_66"),
|
||||
RawCodec(1, "Unknown_67"),
|
||||
],
|
||||
desc=stock.desc,
|
||||
info=stock.info,
|
||||
acc=stock.acc,
|
||||
)
|
||||
|
||||
|
||||
dataIdentifiers = {
|
||||
"dids": {
|
||||
**_master["dids"],
|
||||
**{did: _with_parent_serial(did) for did in _PAIRED_CURRENT_VALUES},
|
||||
}
|
||||
}
|
||||
@@ -129,13 +129,6 @@
|
||||
mode: "0644"
|
||||
notify: Restart heating-can
|
||||
|
||||
- name: Deploy open3e One Base datapoint overlay (68-byte ZigBee slots)
|
||||
ansible.builtin.copy:
|
||||
src: open3e-onebase.py
|
||||
dest: "{{ heating_can_base_dir }}/onebase.py"
|
||||
mode: "0644"
|
||||
notify: Restart heating-can
|
||||
|
||||
# --- Compose project ---------------------------------------------------------
|
||||
- name: Deploy the broker credential env file
|
||||
ansible.builtin.template:
|
||||
|
||||
@@ -17,7 +17,6 @@ services:
|
||||
volumes:
|
||||
- ./devices.json:/config/devices.json:ro
|
||||
- ./vx3plus.py:/config/vx3plus.py:ro
|
||||
- ./onebase.py:/config/onebase.py:ro
|
||||
env_file:
|
||||
# Broker credential, 0600 -- see heating-can.env.j2.
|
||||
- heating-can.env
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
# Self-healing watchdog for the NetBird reverse proxy on the proxy hosts.
|
||||
# Self-healing watchdog for the NetBird reverse proxy (VPS-1 and proxy-1).
|
||||
#
|
||||
# Why it exists: the proxy's embedded NetBird client pins each proxied
|
||||
# domain's route to a specific routing-peer identity. The netbird-ingress
|
||||
@@ -20,7 +20,9 @@ netbird_watchdog_probe_hosts:
|
||||
- home.renner.uno
|
||||
- immich.renner.uno
|
||||
|
||||
# Address the probe connects to. The proxy binds :443 on the host directly.
|
||||
# Address the probe connects to. 127.0.0.1 reaches the local proxy on both
|
||||
# members: VPS-1 routes it via Traefik's HostSNI(*) TCP passthrough, proxy-1
|
||||
# binds :443 directly.
|
||||
netbird_watchdog_probe_address: 127.0.0.1
|
||||
|
||||
netbird_watchdog_container: netbird-proxy
|
||||
@@ -30,13 +32,8 @@ netbird_watchdog_fail_threshold: 3
|
||||
|
||||
# Minimum seconds between watchdog-issued restarts, so a genuinely broken
|
||||
# backend can't put the proxy into a restart loop. Shared by both the URL-probe
|
||||
# watchdog and the flood watchdog below. One minute: the route-manager wedge
|
||||
# recurs on every routing-peer loss, and rolling node reboots lose one peer
|
||||
# every few minutes; a suppressed restart there leaves the wedge standing
|
||||
# until the URL probe's three rounds fire (3.5 min of 000 on every domain,
|
||||
# measured 2026-09-07). A minute still caps a loop at one restart per
|
||||
# probe interval.
|
||||
netbird_watchdog_cooldown_seconds: 60
|
||||
# watchdog and the flood watchdog below.
|
||||
netbird_watchdog_cooldown_seconds: 600
|
||||
|
||||
# Log fingerprint of the reverse-proxy route-manager wedge, matched as a plain
|
||||
# substring in the proxy's docker logs by the flood watchdog. On this build the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
# Deploys the reverse-proxy watchdog (see defaults/main.yml for why).
|
||||
|
||||
- name: Ensure curl is installed (probe dependency)
|
||||
- name: Ensure curl is installed (probe dependency; proxy-1 is Arch, VPS-1 Debian)
|
||||
ansible.builtin.package:
|
||||
name: curl
|
||||
state: present
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
# NetBird reverse-proxy HA cluster member (proxy-only host, e.g. proxy-1).
|
||||
# Every member uses the same NB_PROXY_DOMAIN + token. NetBird load-balances/
|
||||
# fails the cluster over via DNS round-robin on the proxy domain (see
|
||||
# plays/install-netbird-proxy.yml).
|
||||
# Joins the same proxy cluster as the VPS-1 proxy: identical NB_PROXY_DOMAIN
|
||||
# + the same token. NetBird load-balances/fails the cluster over via DNS
|
||||
# round-robin on the proxy domain (see plays/install-netbird-proxy.yml).
|
||||
|
||||
# MUST match the netbird-server role's netbird_version — the reverse-proxy
|
||||
# release line has to track the management server it enrolls against.
|
||||
@@ -11,13 +11,11 @@ netbird_version: "0.73.2"
|
||||
# Management server this proxy enrolls against (public API, TLS).
|
||||
netbird_server_host: netbird.renner.uno
|
||||
|
||||
# Proxy domain — MUST be identical across every cluster member, and must NOT
|
||||
# be a subdomain of the management domain (netbird.renner.uno) per NetBird
|
||||
# docs (TLS/routing conflicts).
|
||||
# Proxy domain — MUST be identical across every cluster member.
|
||||
netbird_proxy_domain: "proxy.renner.uno"
|
||||
|
||||
# A shared NB_PROXY_TOKEN across members with the same NB_PROXY_DOMAIN is a
|
||||
# valid HA cluster, so one token serves every member.
|
||||
# Reuse VPS-1's proxy token — a shared NB_PROXY_TOKEN across members with the
|
||||
# same NB_PROXY_DOMAIN is a valid HA cluster, so no separate token is needed.
|
||||
netbird_proxy_token: "{{ vault_netbird_proxy_token }}"
|
||||
|
||||
# Relay auth secret — MUST be identical across every relay instance and the
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# NetBird reverse-proxy — HA cluster member on a proxy-only host.
|
||||
# Just the reverse-proxy container, bound directly to :443. No Traefik: the
|
||||
# proxy owns the port and terminates TLS + answers its own tls-alpn-01 ACME
|
||||
# challenges directly.
|
||||
# Just the reverse-proxy container, bound directly to :443. No Traefik:
|
||||
# unlike VPS-1 (where the proxy shares :443 with management/signal/relay via
|
||||
# SNI routing), this host runs nothing else, so the proxy owns the port and
|
||||
# terminates TLS + answers its own tls-alpn-01 ACME challenges directly.
|
||||
services:
|
||||
proxy:
|
||||
image: netbirdio/reverse-proxy:{{ netbird_version }}
|
||||
|
||||
@@ -23,3 +23,9 @@ netbird_datastore_key: "{{ vault_netbird_datastore_key }}"
|
||||
|
||||
# Relay secret for WebSocket relay authentication
|
||||
netbird_relay_secret: "{{ vault_netbird_relay_secret }}"
|
||||
|
||||
# Reverse proxy configuration
|
||||
# IMPORTANT: Proxy domain must NOT be a subdomain of the management domain
|
||||
# (netbird.renner.uno) per NetBird docs - causes TLS/routing conflicts
|
||||
netbird_proxy_domain: "proxy.renner.uno"
|
||||
netbird_proxy_token: "{{ vault_netbird_proxy_token }}"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
# Forced-command target for the netbird-backup SSH key (see the
|
||||
# "Authorize the cluster backup key" task): takes a CONSISTENT snapshot
|
||||
# of the management SQLite store via the online-backup API (safe against
|
||||
# a live writer, unlike copying store.db) and streams a gzipped tar to
|
||||
# stdout. The key's authorized_keys entry is restricted to exactly this
|
||||
# script — the in-cluster puller cannot run anything else.
|
||||
#
|
||||
# Deliberately store.db ONLY: /opt/netbird/config is fully regenerable
|
||||
# from the netbird-server role's templates + vault, and including it
|
||||
# would put the plaintext TURN secret (turnserver.conf) into every
|
||||
# nightly tarball. Restore = re-run the role (rebuilds config), then
|
||||
# untar this dump into /opt/netbird/management/.
|
||||
set -eu
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
sqlite3 /opt/netbird/management/store.db ".backup '$tmp/store.db'"
|
||||
tar -C "$tmp" -czf - store.db
|
||||
@@ -16,3 +16,7 @@
|
||||
- name: Restart netbird coturn
|
||||
ansible.builtin.shell: |
|
||||
cd /opt/netbird && docker compose up -d --force-recreate coturn
|
||||
|
||||
- name: Restart netbird proxy
|
||||
ansible.builtin.shell: |
|
||||
cd /opt/netbird && docker compose up -d --force-recreate proxy
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
- /opt/netbird/management
|
||||
- /opt/netbird/signal
|
||||
- /opt/netbird/letsencrypt
|
||||
- /opt/netbird/proxy/certs
|
||||
|
||||
- name: Deploy docker-compose.yml
|
||||
ansible.builtin.template:
|
||||
@@ -58,100 +59,47 @@
|
||||
mode: "0644"
|
||||
notify: Restart netbird coturn
|
||||
|
||||
# The public edge is the proxy hosts (plays/install-netbird-proxy.yml); this
|
||||
# host runs no reverse proxy. The tasks below remove the one it used to run,
|
||||
# its watchdogs first so they cannot restart it mid-removal, and can go once
|
||||
# the role has run against every host that ever carried it.
|
||||
- name: Check for reverse-proxy watchdog units
|
||||
ansible.builtin.stat:
|
||||
path: "/etc/systemd/system/{{ item }}"
|
||||
loop:
|
||||
- netbird-proxy-watchdog.timer
|
||||
- netbird-proxy-flood-watchdog.service
|
||||
register: _proxy_watchdog_units
|
||||
|
||||
- name: Stop and disable reverse-proxy watchdogs
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item.item }}"
|
||||
state: stopped
|
||||
enabled: false
|
||||
loop: "{{ _proxy_watchdog_units.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
when: item.stat.exists
|
||||
|
||||
- name: Remove reverse-proxy watchdog units and scripts
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- /etc/systemd/system/netbird-proxy-watchdog.timer
|
||||
- /etc/systemd/system/netbird-proxy-watchdog.service
|
||||
- /etc/systemd/system/netbird-proxy-flood-watchdog.service
|
||||
- /usr/local/bin/netbird-proxy-watchdog
|
||||
- /usr/local/bin/netbird-proxy-flood-watchdog
|
||||
- /run/netbird-proxy-watchdog
|
||||
register: _proxy_watchdog_files
|
||||
|
||||
- name: Reload systemd after removing the watchdog units
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
when: _proxy_watchdog_files.changed
|
||||
- name: Deploy proxy.env
|
||||
ansible.builtin.template:
|
||||
src: proxy.env.j2
|
||||
dest: /opt/netbird/proxy.env
|
||||
mode: "0644"
|
||||
notify: Restart netbird proxy
|
||||
|
||||
- name: Pull NetBird container images
|
||||
ansible.builtin.shell: |
|
||||
cd /opt/netbird && docker compose pull
|
||||
changed_when: false
|
||||
|
||||
# --remove-orphans removes containers of services no longer in the compose
|
||||
# file (the reverse proxy).
|
||||
- name: Start NetBird containers
|
||||
ansible.builtin.shell: |
|
||||
cd /opt/netbird && docker compose up -d --remove-orphans
|
||||
cd /opt/netbird && docker compose up -d
|
||||
register: docker_compose_result
|
||||
changed_when: >-
|
||||
'Started' in docker_compose_result.stdout or
|
||||
'Creating' in docker_compose_result.stdout or
|
||||
'Removed' in docker_compose_result.stdout or
|
||||
'Removed' in docker_compose_result.stderr
|
||||
changed_when: "'Started' in docker_compose_result.stdout or 'Creating' in docker_compose_result.stdout"
|
||||
|
||||
- name: Remove reverse-proxy volumes
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
for v in netbird_netbird_proxy_certs netbird_netbird_proxy_data netbird_netbird_proxy_state; do
|
||||
if [ -n "$(docker volume ls -q --filter "name=^${v}$")" ]; then
|
||||
docker volume rm "$v"
|
||||
echo "removed $v"
|
||||
fi
|
||||
done
|
||||
register: _proxy_volumes
|
||||
changed_when: "'removed' in _proxy_volumes.stdout"
|
||||
# Off-VPS backup of the management store (SPOF assessment 2026-07-25:
|
||||
# this SQLite DB — peers, setup keys, groups, routes — had zero backup
|
||||
# coverage; losing the VPS meant re-enrolling the entire mesh). The VPS
|
||||
# cannot reach any PBS (both PBS names resolve to the public proxy,
|
||||
# ports closed), so the CLUSTER pulls: a CronJob
|
||||
# (kubernetes/infrastructure/netbird-backup) ssh's in with a dedicated
|
||||
# key that is locked to the dump script below, and lands the tarball on
|
||||
# a Ceph PVC that Velero ships offsite. The dump is store.db ONLY —
|
||||
# /opt/netbird/config is regenerable from this role's templates + vault
|
||||
# and would otherwise leak the TURN secret into every tarball. Restore:
|
||||
# re-run this role (rebuilds config), then untar the dump into
|
||||
# /opt/netbird/management/.
|
||||
- name: Install the backup dump script
|
||||
ansible.builtin.copy:
|
||||
src: netbird-backup-dump
|
||||
dest: /usr/local/bin/netbird-backup-dump
|
||||
mode: "0755"
|
||||
|
||||
- name: Remove reverse-proxy config and state
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- /opt/netbird/proxy.env
|
||||
- /opt/netbird/proxy
|
||||
- /opt/netbird/proxy-state
|
||||
|
||||
# The management store has no cluster-side backup. Pulling one put a root
|
||||
# key on this host that the cluster held, and this server's own state is
|
||||
# its own business to protect rather than the cluster's. Dropping the two
|
||||
# tasks that placed them would only stop ansible managing the key and the
|
||||
# script — the pair below revokes them instead, and can go once the role
|
||||
# has run against every host that ever carried them.
|
||||
- name: Remove the backup dump script
|
||||
ansible.builtin.file:
|
||||
path: /usr/local/bin/netbird-backup-dump
|
||||
state: absent
|
||||
|
||||
- name: Revoke the cluster backup key
|
||||
- name: Authorize the cluster backup key (forced command, no shell)
|
||||
ansible.posix.authorized_key:
|
||||
user: root
|
||||
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFeW/L9bHMTSa5/Qb0ClppCf4Z0hL39JwzI6tBmKd2FG netbird-backup@homelab-cluster"
|
||||
state: absent
|
||||
key_options: 'restrict,command="/usr/local/bin/netbird-backup-dump"'
|
||||
|
||||
- name: Wait for management container to be ready
|
||||
ansible.builtin.pause:
|
||||
|
||||
@@ -187,9 +187,10 @@ services:
|
||||
- "traefik.http.routers.netbird-management.service=netbird-management"
|
||||
- "traefik.http.services.netbird-management.loadbalancer.server.port=33073"
|
||||
- "traefik.http.services.netbird-management.loadbalancer.server.scheme=h2c"
|
||||
# gRPC reverse-proxy service — the proxy hosts (proxy-1, proxy-2)
|
||||
# connect here via NB_PROXY_MANAGEMENT_ADDRESS=https://host:443 and get
|
||||
# a 404 without it. Same backend (h2c 33073).
|
||||
# gRPC reverse-proxy service — remote reverse-proxy instances (e.g.
|
||||
# proxy-1) connect here via NB_PROXY_MANAGEMENT_ADDRESS=https://host:443.
|
||||
# The local proxy uses management:33073 directly, so it never needed
|
||||
# this route; remote ones get a 404 without it. Same backend (h2c 33073).
|
||||
- "traefik.http.routers.netbird-proxyservice.rule=Host(`{{ netbird_server_host }}`) && PathPrefix(`/management.ProxyService/`)"
|
||||
- "traefik.http.routers.netbird-proxyservice.entrypoints=websecure"
|
||||
- "traefik.http.routers.netbird-proxyservice.tls=true"
|
||||
@@ -220,6 +221,37 @@ services:
|
||||
max-size: "500m"
|
||||
max-file: "2"
|
||||
|
||||
# Reverse Proxy Service (per docs.netbird.io/selfhosted/migration/enable-reverse-proxy)
|
||||
proxy:
|
||||
image: netbirdio/reverse-proxy:{{ netbird_version }}
|
||||
container_name: netbird-proxy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- netbird
|
||||
depends_on:
|
||||
- management
|
||||
env_file:
|
||||
- ./proxy.env
|
||||
volumes:
|
||||
- netbird_proxy_certs:/certs
|
||||
- /opt/netbird/proxy-state:/var/lib/netbird
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.tcp.routers.proxy-passthrough.entrypoints=websecure"
|
||||
- "traefik.tcp.routers.proxy-passthrough.rule=HostSNI(`*`)"
|
||||
- "traefik.tcp.routers.proxy-passthrough.tls.passthrough=true"
|
||||
- "traefik.tcp.routers.proxy-passthrough.service=proxy-tls"
|
||||
- "traefik.tcp.routers.proxy-passthrough.priority=1"
|
||||
- "traefik.tcp.services.proxy-tls.loadbalancer.server.port=8443"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "500m"
|
||||
max-file: "2"
|
||||
|
||||
networks:
|
||||
netbird:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
netbird_proxy_certs:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
NB_PROXY_DOMAIN={{ netbird_proxy_domain }}
|
||||
NB_PROXY_TOKEN={{ netbird_proxy_token }}
|
||||
NB_PROXY_MANAGEMENT_ADDRESS=http://management:33073
|
||||
NB_PROXY_ALLOW_INSECURE=true
|
||||
NB_PROXY_ADDRESS=:8443
|
||||
NB_PROXY_ACME_CERTIFICATES=true
|
||||
NB_PROXY_ACME_CHALLENGE_TYPE=tls-alpn-01
|
||||
NB_PROXY_CERTIFICATE_DIRECTORY=/certs
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
# PBS Client configuration
|
||||
pbs_client_version: "latest"
|
||||
pbs_client_data_dir: "/opt/pbs-client"
|
||||
|
||||
# PBS Server connection (override in host_vars)
|
||||
pbs_server_host: "pbs.obg.renner.uno"
|
||||
pbs_server_port: 8007
|
||||
pbs_datastore: "backups"
|
||||
|
||||
# Authentication (override in vault!)
|
||||
pbs_username: "backup@pbs"
|
||||
pbs_password: ""
|
||||
# pbs_fingerprint: "" # Optional - omit for Let's Encrypt certs
|
||||
|
||||
# Backup configuration
|
||||
pbs_backup_id: "nas-1"
|
||||
pbs_backup_paths:
|
||||
- /volume1/docker
|
||||
- /volume1/homes
|
||||
|
||||
# Schedule (cron format)
|
||||
pbs_backup_schedule: "0 2 * * *" # 2 AM daily
|
||||
|
||||
# Retention policy (prune settings)
|
||||
pbs_keep_last: 3
|
||||
pbs_keep_daily: 7
|
||||
pbs_keep_weekly: 4
|
||||
pbs_keep_monthly: 6
|
||||
|
||||
# Container resource limits
|
||||
pbs_memory_limit: "2g"
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
- name: Restart PBS client
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ pbs_client_data_dir }}"
|
||||
state: restarted
|
||||
docker_cli: "{{ docker_cli | default('/usr/local/bin/docker') }}"
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
- name: Create PBS client directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
loop:
|
||||
- "{{ pbs_client_data_dir }}"
|
||||
- "{{ pbs_client_data_dir }}/state"
|
||||
|
||||
- name: Copy PBS client docker-compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ pbs_client_data_dir }}/docker-compose.yml"
|
||||
mode: '0644'
|
||||
notify: Restart PBS client
|
||||
|
||||
- name: Start PBS client via Docker Compose
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ pbs_client_data_dir }}"
|
||||
state: present
|
||||
pull: always
|
||||
docker_cli: "{{ docker_cli | default('/usr/local/bin/docker') }}"
|
||||
@@ -0,0 +1,6 @@
|
||||
# PBS Client Environment Configuration
|
||||
PBS_REPOSITORY={{ pbs_username }}@{{ pbs_server_host }}:{{ pbs_server_port }}:{{ pbs_datastore }}
|
||||
PBS_PASSWORD={{ pbs_password }}
|
||||
{% if pbs_fingerprint | default('') != '' %}
|
||||
PBS_FINGERPRINT={{ pbs_fingerprint }}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# PBS Backup Script for {{ pbs_backup_id }}
|
||||
# Managed by Ansible - do not edit manually
|
||||
|
||||
set -e
|
||||
|
||||
# Load environment
|
||||
source {{ pbs_client_data_dir }}/.env
|
||||
|
||||
# Build backup command with all paths
|
||||
BACKUP_SOURCES=""
|
||||
{% for path in pbs_backup_paths %}
|
||||
BACKUP_SOURCES="$BACKUP_SOURCES {{ path | basename }}.pxar:{{ path }}"
|
||||
{% endfor %}
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting backup for {{ pbs_backup_id }}"
|
||||
|
||||
# Run backup via Docker
|
||||
docker exec pbs-client proxmox-backup-client backup \
|
||||
$BACKUP_SOURCES \
|
||||
--backup-id {{ pbs_backup_id }} \
|
||||
--repository "${PBS_REPOSITORY}"
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup completed"
|
||||
|
||||
# Prune old backups
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Pruning old backups"
|
||||
docker exec pbs-client proxmox-backup-client prune \
|
||||
--repository "${PBS_REPOSITORY}" \
|
||||
--backup-id {{ pbs_backup_id }} \
|
||||
--keep-last {{ pbs_keep_last }} \
|
||||
--keep-daily {{ pbs_keep_daily }} \
|
||||
--keep-weekly {{ pbs_keep_weekly }} \
|
||||
--keep-monthly {{ pbs_keep_monthly }}
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Prune completed"
|
||||
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
pbs-client:
|
||||
image: aterfax/pbs-client:{{ pbs_client_version }}
|
||||
container_name: pbs-client
|
||||
restart: unless-stopped
|
||||
hostname: {{ pbs_backup_id }}
|
||||
environment:
|
||||
- PBS_ENDPOINT={{ pbs_server_host }}:{{ pbs_server_port }}
|
||||
- PBS_DATASTORE={{ pbs_datastore }}
|
||||
- PBS_API_KEY_NAME={{ pbs_username }}
|
||||
- PBS_API_KEY_SECRET={{ pbs_password }}
|
||||
- PBS_PASSWORD={{ pbs_password }}
|
||||
- PBS_ENCRYPTION_PASSWORD={{ pbs_encryption_password | default(pbs_password) }}
|
||||
- PBS_REPOSITORY={{ pbs_username }}@{{ pbs_server_host }}:{{ pbs_server_port }}:{{ pbs_datastore }}
|
||||
- PBS_BACKUP_ID={{ pbs_backup_id }}
|
||||
- CRON_SCHEDULE={{ pbs_backup_schedule }}
|
||||
- PBS_BACKUP_CMD_APPEND_EXTRA_OPTS=--crypt-mode=none
|
||||
- PRUNE_KEEP_LAST={{ pbs_keep_last | default(3) }}
|
||||
- PRUNE_KEEP_DAILY={{ pbs_keep_daily | default(7) }}
|
||||
- PRUNE_KEEP_WEEKLY={{ pbs_keep_weekly | default(4) }}
|
||||
- PRUNE_KEEP_MONTHLY={{ pbs_keep_monthly | default(6) }}
|
||||
- CRON_BACKUP_ONLY=1
|
||||
volumes:
|
||||
- {{ pbs_client_data_dir }}/state:/root/.config/proxmox-backup
|
||||
{% for path in pbs_backup_paths %}
|
||||
- "{{ path }}:/backups/{{ path | basename | replace(' ', '_') }}:ro"
|
||||
{% endfor %}
|
||||
tmpfs:
|
||||
- /tmp
|
||||
mem_limit: {{ pbs_memory_limit }}
|
||||
@@ -1,120 +0,0 @@
|
||||
{
|
||||
"//": "Permission scope for the wiki-consolidate agent in CI: read the vault and the",
|
||||
"//2": "source repos cloned under /root/source, read the live cluster through the",
|
||||
"//3": "ci-diagnostics kubeconfig, edit wiki/, output/ and the rotation state only.",
|
||||
"//4": "Deny rules win; git and kubectl are read-only subcommands, no network tools.",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(//root/Notes/**)",
|
||||
"Read(//root/source/**)",
|
||||
"Edit(//root/Notes/wiki/**)",
|
||||
"Edit(//root/Notes/output/**)",
|
||||
"Edit(//root/Notes/.wiki-consolidation-state.json)",
|
||||
"Bash(/root/Notes/bin/wiki-snapshot.sh:*)",
|
||||
"Bash(/root/Notes/bin/wiki_rank.py:*)",
|
||||
"Bash(/root/Notes/bin/wiki_ask.py:*)",
|
||||
"Bash(~/Notes/bin/wiki-snapshot.sh:*)",
|
||||
"Bash(~/Notes/bin/wiki_rank.py:*)",
|
||||
"Bash(~/Notes/bin/wiki_ask.py:*)",
|
||||
"Bash(~/Notes/bin/wiki-delete.sh:*)",
|
||||
"Bash(git -C /root/Notes log:*)",
|
||||
"Bash(git -C /root/Notes show:*)",
|
||||
"Bash(git -C /root/Notes diff:*)",
|
||||
"Bash(git -C /root/Notes status:*)",
|
||||
"Bash(git -C /root/Notes rev-parse:*)",
|
||||
"Bash(git -C /root/Notes ls-files:*)",
|
||||
"Bash(git -C ~/Notes log:*)",
|
||||
"Bash(git -C ~/Notes show:*)",
|
||||
"Bash(git -C ~/Notes diff:*)",
|
||||
"Bash(git -C ~/Notes status:*)",
|
||||
"Bash(git -C ~/Notes rev-parse:*)",
|
||||
"Bash(git -C ~/Notes ls-files:*)",
|
||||
"Bash(kubectl get:*)",
|
||||
"Bash(kubectl describe:*)",
|
||||
"Bash(kubectl version:*)",
|
||||
"Bash(kubectl api-resources:*)",
|
||||
"Bash(kubectl explain:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git rev-parse:*)",
|
||||
"Bash(git ls-files:*)",
|
||||
"Bash(git -C /root/source/homelab log:*)",
|
||||
"Bash(git -C /root/source/homelab show:*)",
|
||||
"Bash(git -C /root/source/homelab diff:*)",
|
||||
"Bash(git -C /root/source/homelab status:*)",
|
||||
"Bash(git -C /root/source/homelab rev-parse:*)",
|
||||
"Bash(git -C /root/source/homelab ls-files:*)",
|
||||
"Bash(git -C /root/source/homeassistant log:*)",
|
||||
"Bash(git -C /root/source/homeassistant show:*)",
|
||||
"Bash(git -C /root/source/homeassistant diff:*)",
|
||||
"Bash(git -C /root/source/homeassistant status:*)",
|
||||
"Bash(git -C /root/source/homeassistant rev-parse:*)",
|
||||
"Bash(git -C /root/source/homeassistant ls-files:*)",
|
||||
"Bash(git -C ~/source/homelab log:*)",
|
||||
"Bash(git -C ~/source/homelab show:*)",
|
||||
"Bash(git -C ~/source/homelab diff:*)",
|
||||
"Bash(git -C ~/source/homelab status:*)",
|
||||
"Bash(git -C ~/source/homelab rev-parse:*)",
|
||||
"Bash(git -C ~/source/homelab ls-files:*)",
|
||||
"Bash(git -C ~/source/homeassistant log:*)",
|
||||
"Bash(git -C ~/source/homeassistant show:*)",
|
||||
"Bash(git -C ~/source/homeassistant diff:*)",
|
||||
"Bash(git -C ~/source/homeassistant status:*)",
|
||||
"Bash(git -C ~/source/homeassistant rev-parse:*)",
|
||||
"Bash(git -C ~/source/homeassistant ls-files:*)",
|
||||
"Bash(/root/Notes/bin/wiki-delete.sh:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(date:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(stat:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(tail:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Read(//proc/**)",
|
||||
"Read(//etc/**)",
|
||||
"Read(//root/.config/**)",
|
||||
"Read(//root/.kube/**)",
|
||||
"Read(//woodpecker/**/.ci-diag/**)",
|
||||
"Read(//root/Notes/.git/**)",
|
||||
"Read(//root/source/*/.git/**)",
|
||||
"Read(//root/Notes/_archive/**)",
|
||||
"Edit(//root/Notes/raw/**)",
|
||||
"Edit(//root/Notes/_archive/**)",
|
||||
"Edit(//root/source/**)",
|
||||
"Bash(kubectl apply:*)",
|
||||
"Bash(kubectl delete:*)",
|
||||
"Bash(kubectl patch:*)",
|
||||
"Bash(kubectl edit:*)",
|
||||
"Bash(kubectl scale:*)",
|
||||
"Bash(kubectl rollout:*)",
|
||||
"Bash(kubectl exec:*)",
|
||||
"Bash(kubectl cp:*)",
|
||||
"Bash(kubectl drain:*)",
|
||||
"Bash(kubectl cordon:*)",
|
||||
"Bash(kubectl label:*)",
|
||||
"Bash(kubectl annotate:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git reset:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git clean:*)",
|
||||
"Bash(git rebase:*)",
|
||||
"Bash(git config:*)",
|
||||
"Bash(git -C /root/Notes config:*)",
|
||||
"Bash(git -C ~/Notes config:*)",
|
||||
"Bash(git -C /root/source/homelab config:*)",
|
||||
"Bash(git -C /root/source/homeassistant config:*)",
|
||||
"Bash(git -C ~/source/homelab config:*)",
|
||||
"Bash(git -C ~/source/homeassistant config:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(rm -rf:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(wget:*)",
|
||||
"Bash(ssh:*)",
|
||||
"Bash(tea:*)",
|
||||
"Bash(woodpecker:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
You are running as the scheduled wiki-maintenance job in CI. The Notes vault is checked out at ~/Notes (the repository k.renner/notes); the homelab and homeassistant repositories are checked out under ~/source/. `kubectl` reads the live cluster with a read-only identity.
|
||||
|
||||
Run the wiki-consolidate agent now for today's scheduled consolidation pass. Follow its instructions exactly: snapshot the vault to git first, abort if that fails, select today's rotating cohort of 20 from the state file, verify claims against both their sources and the live systems, act on the verdicts, write the dated report to ~/Notes/output/, update the state file, and snapshot again at the end. Do not push; the pipeline pushes the run's commits.
|
||||
|
||||
Report the counts and the coverage progress when you are done.
|
||||
@@ -1,7 +0,0 @@
|
||||
You are running as the scheduled wiki-maintenance job in CI. The Notes vault is checked out at ~/Notes (the repository k.renner/notes). Do exactly this, nothing else:
|
||||
|
||||
Glob ~/Notes/raw/*.md (top level only, never ~/Notes/raw/_processed/). Distill every file you find into ~/Notes/wiki/ following your process, including the ranker check for every concept. Move each fully processed source to ~/Notes/raw/_processed/. If raw/ has no top-level files besides _index.md, say so and stop.
|
||||
|
||||
Do not commit or push; the pipeline does that after you finish. Do not touch anything outside ~/Notes/wiki/ and ~/Notes/raw/.
|
||||
|
||||
End with one line per file: which wiki notes it produced or merged into, or "nothing distillable".
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"//": "Permission scope for the wiki-maintenance agent (wiki-distill) in CI.",
|
||||
"//2": "The step's environment holds a Gitea push token, a TypeSafe key and the wiki-index",
|
||||
"//3": "client credential, so the Read tool is kept away from /proc (env dumps), /root outside",
|
||||
"//4": "the vault symlink target, /etc and the checkout's .git/. The vault itself is the work.",
|
||||
"//5": "Mirrors ci/agents/issue-fix/settings.json; absolute globs because the vault is reached",
|
||||
"//6": "through the ~/Notes symlink the pipeline creates.",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(//root/Notes/**)",
|
||||
"Edit(//root/Notes/wiki/**)",
|
||||
"Edit(//root/Notes/output/**)",
|
||||
"Edit(//root/Notes/raw/**)",
|
||||
"Bash(/root/Notes/bin/wiki_rank.py:*)",
|
||||
"Bash(/root/Notes/bin/wiki_ask.py:*)",
|
||||
"Bash(mv /root/Notes/raw/:*)",
|
||||
"Bash(mkdir -p /root/Notes/:*)",
|
||||
"Bash(~/Notes/bin/wiki_rank.py:*)",
|
||||
"Bash(~/Notes/bin/wiki_ask.py:*)",
|
||||
"Bash(mv ~/Notes/raw/:*)",
|
||||
"Bash(mkdir -p ~/Notes/:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(date:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(tail:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Read(//proc/**)",
|
||||
"Read(//etc/**)",
|
||||
"Read(//root/.config/**)",
|
||||
"Read(//root/Notes/.git/**)",
|
||||
"Read(//root/Notes/_archive/**)",
|
||||
"Edit(//root/Notes/_archive/**)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(git:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(wget:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
index 0214a509eb5e7b777a69e4e34637941c275d27b1..403be02df66e25eee29e7d5bef14627011e02b30 100644
|
||||
--- a/dist/acp-agent.js
|
||||
+++ b/dist/acp-agent.js
|
||||
@@ -2922,9 +2922,19 @@ export class ClaudeAcpAgent {
|
||||
amount: message.total_cost_usd,
|
||||
currency: "USD",
|
||||
},
|
||||
- ...(message.origin && {
|
||||
- _meta: { "_claude/origin": message.origin },
|
||||
- }),
|
||||
+ _meta: {
|
||||
+ ...(message.origin && { "_claude/origin": message.origin }),
|
||||
+ // ACP's core Usage shape exposes context occupancy and
|
||||
+ // cost, but not the billable input/output split. Keep the
|
||||
+ // SDK's bounded aggregate in extension metadata so ACPX can
|
||||
+ // normalize it without persisting a raw provider message.
|
||||
+ usage: {
|
||||
+ input_tokens: message.usage.input_tokens,
|
||||
+ output_tokens: message.usage.output_tokens,
|
||||
+ cache_read_input_tokens: message.usage.cache_read_input_tokens,
|
||||
+ cache_creation_input_tokens: message.usage.cache_creation_input_tokens,
|
||||
+ },
|
||||
+ },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5311,6 +5321,10 @@ export class ClaudeAcpAgent {
|
||||
settingSources: ["user", "project", "local"],
|
||||
...(thinking !== undefined && { thinking }),
|
||||
...userProvidedOptions,
|
||||
+ // Paperclip Runner owns the complete session context. Its isolated
|
||||
+ // user root contains only assigned skills; project/local settings
|
||||
+ // would reintroduce host prompts, plugins, skills, and MCP servers.
|
||||
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && { settingSources: ["user"] }),
|
||||
...(settings && { settings }),
|
||||
env,
|
||||
// Override certain fields that must be controlled by ACP
|
||||
@@ -5318,7 +5332,9 @@ export class ClaudeAcpAgent {
|
||||
includePartialMessages: true,
|
||||
forwardSubagentText,
|
||||
mcpServers: {
|
||||
- ...(userProvidedOptions?.mcpServers || {}),
|
||||
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1"
|
||||
+ ? {}
|
||||
+ : (userProvidedOptions?.mcpServers || {})),
|
||||
...mcpServers,
|
||||
...(fileChangeAuditSupport
|
||||
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
|
||||
@@ -5329,6 +5345,16 @@ export class ClaudeAcpAgent {
|
||||
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
||||
permissionMode: initialPermissionMode,
|
||||
canUseTool: this.canUseTool(sessionId),
|
||||
+ // The runner pins this actual MCP endpoint before provider launch.
|
||||
+ // Exempt only task delivery/control tools at the SDK dispatch boundary,
|
||||
+ // not arbitrary provider permission metadata or MCP name prefixes.
|
||||
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && {
|
||||
+ allowedTools: mcpServers.paperclip?.type === "http"
|
||||
+ && mcpServers.paperclip.url === process.env.PAPERCLIP_ACPX_TASK_TOOL_BRIDGE_URL
|
||||
+ ? ["paperclip_finish", "paperclip_block", "read_current_wake_comments", "request_human_input"]
|
||||
+ .map((tool) => `mcp__paperclip__${tool}`)
|
||||
+ : [],
|
||||
+ }),
|
||||
// Forward MCP elicitation requests onto ACP elicitation. Only attached
|
||||
// when the client advertised support, so non-supporting clients keep the
|
||||
// SDK's default (auto-decline) behavior. (AskUserQuestion is handled in
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.73.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.280"
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
# Paperclip agent runtime + Go toolchain (REN-284 / REN-286). Runs as the pod
|
||||
# image of the `k8s-go` sandbox environment.
|
||||
#
|
||||
# Every tool comes from its upstream image, pinned by tag AND digest, so the
|
||||
# dockerfile manager in Renovate bumps version and digest together. A curl +
|
||||
# sha256 pin (as in ci/images/k8s) would break on every Renovate bump.
|
||||
#
|
||||
# Both binaries are static; they run on the Ubuntu 22.04 base unchanged.
|
||||
FROM golang:1.27.1-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS go
|
||||
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS golangci-lint
|
||||
|
||||
# upstream publishes no `v1` tag, only git-<sha> tags. Renovate keeps the
|
||||
# digest current but cannot order commit tags; a newer base is a manual bump.
|
||||
FROM ghcr.io/paperclipai/agent-runtime-claude:git-38d8f371722b315d2fb3bbaa512518742e33ce2f@sha256:33ad285f9476b8637d6d940c08d577797030125675bdf4f497a8ed1f79261c8b
|
||||
|
||||
COPY --from=go /usr/local/go /usr/local/go
|
||||
COPY --from=golangci-lint /usr/bin/golangci-lint /usr/local/bin/golangci-lint
|
||||
# go and gofmt also in /usr/local/bin: a process started with the default PATH
|
||||
# (not the image ENV) still finds them.
|
||||
USER root
|
||||
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
|
||||
|
||||
# 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
|
||||
# (isolated context, task-tool allow-list, usage metadata). Not bumped by
|
||||
# Renovate on purpose: bump only together with Paperclip, and replace the patch.
|
||||
# Paperclip also overrides the SDK (package.json pnpm overrides:
|
||||
# claude-agent-sdk 0.3.280); a plain `npm install -g` ignores that and pulls an
|
||||
# older SDK. So install from a package.json with the same override into a
|
||||
# fixed prefix, and link the binary into /usr/local/bin.
|
||||
COPY agent-runtime-common/claude-agent-acp.package.json /opt/claude-agent-acp/package.json
|
||||
COPY agent-runtime-common/claude-agent-acp-0.73.0.patch /tmp/claude-agent-acp.patch
|
||||
USER root
|
||||
RUN cd /opt/claude-agent-acp \
|
||||
&& npm install --omit=dev --no-audit --no-fund \
|
||||
&& git -C node_modules/@agentclientprotocol/claude-agent-acp apply /tmp/claude-agent-acp.patch \
|
||||
&& npm ls @anthropic-ai/claude-agent-sdk | grep -q 'claude-agent-sdk@0.3.280' \
|
||||
&& ln -s /opt/claude-agent-acp/node_modules/.bin/claude-agent-acp /usr/local/bin/claude-agent-acp \
|
||||
&& rm /tmp/claude-agent-acp.patch \
|
||||
&& npm cache clean --force
|
||||
USER 1000:1000
|
||||
|
||||
# The sandbox pod has a read-only root FS. Writable mounts: $HOME
|
||||
# (/home/paperclip, 1Gi), $HOME/.cache (1Gi), /tmp (2Gi), /workspace.
|
||||
# - GOPATH (module cache) goes to /tmp, the biggest one.
|
||||
# - GOCACHE and the golangci-lint cache keep their default under $HOME/.cache.
|
||||
# - GOTOOLCHAIN=local: a go.mod asking for a newer Go fails clearly instead of
|
||||
# downloading a toolchain through the egress allow-list.
|
||||
ENV PATH=/usr/local/go/bin:$PATH \
|
||||
GOPATH=/tmp/go \
|
||||
GOTOOLCHAIN=local
|
||||
|
||||
# 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 \
|
||||
&& 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'
|
||||
@@ -1,51 +0,0 @@
|
||||
# Paperclip agent runtime + cluster tools (REN-284 / REN-286). Runs as the pod
|
||||
# image of the `k8s-ops` sandbox environment.
|
||||
#
|
||||
# Every tool comes from its upstream image, pinned by tag AND digest, so the
|
||||
# dockerfile manager in Renovate bumps version and digest together. A curl +
|
||||
# sha256 pin (as in ci/images/k8s) would break on every Renovate bump.
|
||||
#
|
||||
# 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/kustomize/kustomize:v5.8.1@sha256:899fcd3bc898160e62bcaf82932b0cb29ba38d16272353db2e7acbba82129429 AS kustomize
|
||||
FROM alpine/helm:4.3.0@sha256:a6cf54599ccb99d90cf0712b30f03fdb3cab062e6b94e0418cc4db7e8a1464b2 AS helm
|
||||
|
||||
# upstream publishes no `v1` tag, only git-<sha> tags. Renovate keeps the
|
||||
# digest current but cannot order commit tags; a newer base is a manual bump.
|
||||
FROM ghcr.io/paperclipai/agent-runtime-claude:git-38d8f371722b315d2fb3bbaa512518742e33ce2f@sha256:33ad285f9476b8637d6d940c08d577797030125675bdf4f497a8ed1f79261c8b
|
||||
|
||||
COPY --from=kubectl /bin/kubectl /usr/local/bin/kubectl
|
||||
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
|
||||
|
||||
# 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
|
||||
# (isolated context, task-tool allow-list, usage metadata). Not bumped by
|
||||
# Renovate on purpose: bump only together with Paperclip, and replace the patch.
|
||||
# Paperclip also overrides the SDK (package.json pnpm overrides:
|
||||
# claude-agent-sdk 0.3.280); a plain `npm install -g` ignores that and pulls an
|
||||
# older SDK. So install from a package.json with the same override into a
|
||||
# fixed prefix, and link the binary into /usr/local/bin.
|
||||
COPY agent-runtime-common/claude-agent-acp.package.json /opt/claude-agent-acp/package.json
|
||||
COPY agent-runtime-common/claude-agent-acp-0.73.0.patch /tmp/claude-agent-acp.patch
|
||||
USER root
|
||||
RUN cd /opt/claude-agent-acp \
|
||||
&& npm install --omit=dev --no-audit --no-fund \
|
||||
&& git -C node_modules/@agentclientprotocol/claude-agent-acp apply /tmp/claude-agent-acp.patch \
|
||||
&& npm ls @anthropic-ai/claude-agent-sdk | grep -q 'claude-agent-sdk@0.3.280' \
|
||||
&& ln -s /opt/claude-agent-acp/node_modules/.bin/claude-agent-acp /usr/local/bin/claude-agent-acp \
|
||||
&& rm /tmp/claude-agent-acp.patch \
|
||||
&& npm cache clean --force
|
||||
USER 1000:1000
|
||||
|
||||
# Read-only root FS: kubectl (~/.kube/cache) and helm (~/.cache, ~/.config)
|
||||
# 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 \
|
||||
&& 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'
|
||||
@@ -1,22 +0,0 @@
|
||||
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
||||
|
||||
# bird: BGP daemon for the pod-network mesh over pod0. Distro package, no build
|
||||
# step — the image is the binary plus its runtime.
|
||||
#
|
||||
# 3.22 rather than the 3.21 the other images here pin: bird2 first appears in
|
||||
# 3.22/community.
|
||||
#
|
||||
# Config is read from /etc/bird/bird.conf, not the package default
|
||||
# /etc/bird.conf, so the mount cannot shadow what the package ships.
|
||||
#
|
||||
# kubectl renders the init container's per-node config: go-template-file for
|
||||
# the templating, a field selector for excluding the node itself, in-cluster
|
||||
# config for the auth. From the distro, so there is no version literal to drift
|
||||
# and no download to trust. It is only ever used to read core/v1 Nodes.
|
||||
RUN apk add --no-cache bird2 kubectl
|
||||
|
||||
# Fails the build rather than the first pod if a binary or its path is wrong.
|
||||
RUN /usr/sbin/bird --version \
|
||||
&& kubectl version --client > /dev/null
|
||||
|
||||
ENTRYPOINT ["/usr/sbin/bird", "-f", "-c", "/etc/bird/bird.conf"]
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
|
||||
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2
|
||||
|
||||
# Default to the latest Claude Code. Pinning a fixed version is the wrong
|
||||
# default here: old releases eventually stop working against the API, so a
|
||||
@@ -13,9 +13,7 @@ RUN apk add --no-cache \
|
||||
git \
|
||||
jq \
|
||||
openssh-client \
|
||||
openssl \
|
||||
python3 \
|
||||
py3-yaml
|
||||
openssl
|
||||
|
||||
# Install Claude Code, then make the resulting layer byte-stable so that a
|
||||
# rebuild of the SAME version DEDUPS in the Gitea registry instead of orphaning
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
|
||||
ARG TALOS_VERSION=v1.13.10
|
||||
ARG TALOS_VERSION=v1.12.6
|
||||
ARG VELERO_VERSION=v1.16.0
|
||||
ARG KUBECTL_VERSION=v1.36.3
|
||||
ARG FLUX_VERSION=2.9.5
|
||||
ARG KUBECTL_VERSION=v1.36.2
|
||||
ARG FLUX_VERSION=2.9.2
|
||||
|
||||
RUN apk add --no-cache \
|
||||
aws-cli \
|
||||
@@ -18,7 +18,7 @@ RUN apk add --no-cache \
|
||||
|
||||
RUN curl -sfL -o /usr/local/bin/talosctl \
|
||||
"https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" \
|
||||
&& echo "28e9ccd9aebd690fa03d8cf2582221d87ea38b2a1704f411d75a03589c58f2aa /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& echo "4c41a3b10b075292e64b182283d558e9d8e935f43239de1ed4e4b14359593efb /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& chmod +x /usr/local/bin/talosctl
|
||||
|
||||
RUN curl -sfL -o /usr/local/bin/kubectl \
|
||||
@@ -31,7 +31,7 @@ RUN curl -sfL -o /usr/local/bin/kubectl \
|
||||
|
||||
RUN curl -sfL -o /tmp/flux.tar.gz \
|
||||
"https://github.com/fluxcd/flux2/releases/download/v${FLUX_VERSION}/flux_${FLUX_VERSION}_linux_amd64.tar.gz" \
|
||||
&& echo "b853df82adfd7736f580692f9f734473d571606307139f8fd20c2a80dd1ff473 /tmp/flux.tar.gz" | sha256sum -c - \
|
||||
&& echo "4092b367fd060097976fb7261601f79420ecf7266328417dfd8dfee27de0e6a3 /tmp/flux.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/flux.tar.gz -C /usr/local/bin flux \
|
||||
&& chmod +x /usr/local/bin/flux \
|
||||
&& rm -f /tmp/flux.tar.gz
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# netbird-service-sync: reconciles NetBird reverse-proxy services (public
|
||||
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
|
||||
# public label. Standard library only, so the image is the interpreter plus one
|
||||
# file; runs unprivileged as its own user.
|
||||
FROM python:3.13-slim
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY controller.py /opt/controller.py
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
USER controller
|
||||
ENTRYPOINT ["python3", "/opt/controller.py"]
|
||||
@@ -1,275 +0,0 @@
|
||||
#!/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).
|
||||
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
|
||||
Traefik untouched, the proxy terminates nothing, and no domain resource is
|
||||
involved -- the routing peers stay ordinary IP routers. The target sends a
|
||||
PROXY protocol v2 header so Traefik (and CrowdSec behind its access log) sees
|
||||
the WAN client address instead of the routing peer's pod IP.
|
||||
|
||||
Ownership is derived from NetBird itself, not from local state: a service is
|
||||
managed iff its mode is "tls" and its target is this controller's subnet
|
||||
resource. Services that do not match (the hand-made HTTP/domain ones) are never
|
||||
touched; a desired host held by such a service is reported and skipped until
|
||||
that service is removed by hand.
|
||||
|
||||
Credentials: the NetBird PAT is read from OpenBao on every pass through the
|
||||
Kubernetes auth role, exactly like the setup-key rotator; it never lands in a
|
||||
Kubernetes Secret or in a log line.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
LOG = logging.getLogger("netbird-service-sync")
|
||||
|
||||
NB = os.environ.get("NB_MANAGEMENT_URL", "https://netbird.renner.uno").rstrip("/")
|
||||
BAO_ADDR = os.environ.get("BAO_ADDR", "http://openbao.openbao.svc.cluster.local:8200").rstrip("/")
|
||||
BAO_ROLE = os.environ.get("BAO_ROLE", "netbird-service-sync")
|
||||
BAO_PAT_PATH = os.environ.get("BAO_PAT_PATH", "homelab/data/infrastructure/secret-rotators-bootstrap/netbird")
|
||||
BAO_PAT_KEY = os.environ.get("BAO_PAT_KEY", "pat")
|
||||
SERVICE_CIDR = os.environ.get("SERVICE_CIDR", "10.96.0.0/12")
|
||||
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")
|
||||
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "443"))
|
||||
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
|
||||
HTTP_TIMEOUT = 20
|
||||
|
||||
K8S_API = "https://kubernetes.default.svc"
|
||||
K8S_TOKEN_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
K8S_CA_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def http(method, url, body=None, headers=None, cafile=None):
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Accept", "application/json")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
for k, v in (headers or {}).items():
|
||||
req.add_header(k, v)
|
||||
ctx = None
|
||||
if cafile:
|
||||
import ssl
|
||||
ctx = ssl.create_default_context(cafile=cafile)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT, context=ctx) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else None
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode(errors="replace")[:300]
|
||||
raise ApiError(f"{method} {url} -> {e.code}: {detail}") from None
|
||||
|
||||
|
||||
# --- Kubernetes -------------------------------------------------------------
|
||||
|
||||
def k8s(path):
|
||||
with open(K8S_TOKEN_FILE) as f:
|
||||
token = f.read().strip()
|
||||
return http("GET", K8S_API + path, headers={"Authorization": f"Bearer {token}"}, cafile=K8S_CA_FILE)
|
||||
|
||||
|
||||
def desired_hosts():
|
||||
sel = urllib.parse.quote(f"{PUBLIC_LABEL}=true")
|
||||
ingresses = k8s(f"/apis/networking.k8s.io/v1/ingresses?labelSelector={sel}")
|
||||
hosts = {}
|
||||
for ing in ingresses.get("items", []):
|
||||
ref = f'{ing["metadata"]["namespace"]}/{ing["metadata"]["name"]}'
|
||||
for rule in ing.get("spec", {}).get("rules", []):
|
||||
host = rule.get("host")
|
||||
if not host or host.startswith("*"):
|
||||
continue
|
||||
hosts.setdefault(host, ref)
|
||||
return hosts
|
||||
|
||||
|
||||
def traefik_cluster_ip():
|
||||
svc = k8s(f"/api/v1/namespaces/{TRAEFIK_NS}/services/{TRAEFIK_SVC}")
|
||||
for ip in svc["spec"].get("clusterIPs", []):
|
||||
if ":" not in ip:
|
||||
return ip
|
||||
raise ApiError(f"{TRAEFIK_NS}/{TRAEFIK_SVC} has no IPv4 ClusterIP")
|
||||
|
||||
|
||||
# --- OpenBao ----------------------------------------------------------------
|
||||
|
||||
def netbird_pat():
|
||||
with open(K8S_TOKEN_FILE) as f:
|
||||
jwt = f.read().strip()
|
||||
login = http("POST", f"{BAO_ADDR}/v1/auth/kubernetes/login", {"role": BAO_ROLE, "jwt": jwt})
|
||||
bao_token = login["auth"]["client_token"]
|
||||
kv = http("GET", f"{BAO_ADDR}/v1/{BAO_PAT_PATH}", headers={"X-Vault-Token": bao_token})
|
||||
pat = kv["data"]["data"].get(BAO_PAT_KEY)
|
||||
if not pat:
|
||||
raise ApiError(f"OpenBao {BAO_PAT_PATH} has no key {BAO_PAT_KEY}")
|
||||
return pat
|
||||
|
||||
|
||||
# --- NetBird management -----------------------------------------------------
|
||||
|
||||
class NetBird:
|
||||
def __init__(self, pat):
|
||||
self.h = {"Authorization": f"Token {pat}"}
|
||||
|
||||
def get(self, path):
|
||||
return http("GET", f"{NB}/api/{path}", headers=self.h)
|
||||
|
||||
def list(self, path):
|
||||
# An empty collection comes back as an empty body / null, not [].
|
||||
return self.get(path) or []
|
||||
|
||||
def post(self, path, body):
|
||||
return http("POST", f"{NB}/api/{path}", body, headers=self.h)
|
||||
|
||||
def put(self, path, body):
|
||||
return http("PUT", f"{NB}/api/{path}", body, headers=self.h)
|
||||
|
||||
def delete(self, path):
|
||||
return http("DELETE", f"{NB}/api/{path}", headers=self.h)
|
||||
|
||||
|
||||
def group_id(nb, name):
|
||||
for g in nb.list("groups"):
|
||||
if g["name"] == name:
|
||||
return g["id"]
|
||||
raise ApiError(f"NetBird group {name!r} not found")
|
||||
|
||||
|
||||
def ensure_network(nb):
|
||||
for n in nb.list("networks"):
|
||||
if n["name"] == NETWORK_NAME:
|
||||
return n["id"]
|
||||
n = nb.post("networks", {"name": NETWORK_NAME,
|
||||
"description": "managed by netbird-service-sync; public ingress via TLS passthrough"})
|
||||
LOG.info("created network %s (%s)", NETWORK_NAME, n["id"])
|
||||
return n["id"]
|
||||
|
||||
|
||||
def ensure_resource(nb, net):
|
||||
for r in nb.list(f"networks/{net}/resources"):
|
||||
if r.get("address") == SERVICE_CIDR:
|
||||
return r["id"]
|
||||
r = nb.post(f"networks/{net}/resources", {"name": "cluster service CIDR", "address": SERVICE_CIDR,
|
||||
"description": "managed by netbird-service-sync",
|
||||
"enabled": True, "groups": []})
|
||||
LOG.info("created subnet resource %s (%s)", SERVICE_CIDR, r["id"])
|
||||
return r["id"]
|
||||
|
||||
|
||||
def ensure_router(nb, net, router_group):
|
||||
for r in nb.list(f"networks/{net}/routers"):
|
||||
if router_group in (r.get("peer_groups") or []):
|
||||
return r["id"]
|
||||
r = nb.post(f"networks/{net}/routers", {"peer_groups": [router_group], "metric": 9999,
|
||||
"masquerade": True, "enabled": True})
|
||||
LOG.info("created router on group %s (%s)", ROUTER_GROUP, r["id"])
|
||||
return r["id"]
|
||||
|
||||
|
||||
def ensure_policy(nb, resource, source_group):
|
||||
name = f"{NETWORK_NAME} access"
|
||||
for p in nb.list("policies"):
|
||||
if p["name"] == name:
|
||||
return p["id"]
|
||||
p = nb.post("policies", {"name": name, "enabled": True, "rules": [{
|
||||
"name": name, "enabled": True, "action": "accept", "protocol": "tcp",
|
||||
"ports": [str(LISTEN_PORT)], "bidirectional": False, "sources": [source_group],
|
||||
"destinationResource": {"id": resource, "type": "subnet"}}]})
|
||||
LOG.info("created policy %s (%s)", name, p["id"])
|
||||
return p["id"]
|
||||
|
||||
|
||||
def service_body(host, resource, target_ip):
|
||||
# proxy_protocol: PROXY v2 header carries the WAN client address to
|
||||
# Traefik (proxyProtocol.trustedIPs on websecure); TCP/TLS modes only.
|
||||
return {"name": host, "domain": host, "mode": "tls", "listen_port": LISTEN_PORT, "enabled": True,
|
||||
"targets": [{"target_type": "subnet", "target_id": resource, "protocol": "tcp",
|
||||
"host": target_ip, "port": 443, "enabled": True,
|
||||
"options": {"proxy_protocol": True}}]}
|
||||
|
||||
|
||||
def is_managed(svc, resource):
|
||||
return svc.get("mode") == "tls" and any(t.get("target_id") == resource for t in svc.get("targets") or [])
|
||||
|
||||
|
||||
def target_matches(svc, target_ip):
|
||||
if svc.get("listen_port") != LISTEN_PORT or not svc.get("enabled"):
|
||||
return False
|
||||
ts = svc.get("targets") or []
|
||||
return len(ts) == 1 and ts[0].get("host") == target_ip and ts[0].get("port") == 443 \
|
||||
and ts[0].get("protocol") == "tcp" and ts[0].get("enabled") \
|
||||
and (ts[0].get("options") or {}).get("proxy_protocol") is True
|
||||
|
||||
|
||||
def reconcile():
|
||||
hosts = desired_hosts()
|
||||
target_ip = traefik_cluster_ip()
|
||||
nb = NetBird(netbird_pat())
|
||||
|
||||
router_group = group_id(nb, ROUTER_GROUP)
|
||||
source_group = group_id(nb, SOURCE_GROUP)
|
||||
net = ensure_network(nb)
|
||||
resource = ensure_resource(nb, net)
|
||||
ensure_router(nb, net, router_group)
|
||||
ensure_policy(nb, resource, source_group)
|
||||
|
||||
by_domain = {s["domain"]: s for s in nb.list("reverse-proxies/services")}
|
||||
created = updated = deleted = held = 0
|
||||
|
||||
for host, ref in sorted(hosts.items()):
|
||||
svc = by_domain.get(host)
|
||||
if svc is None:
|
||||
nb.post("reverse-proxies/services", service_body(host, resource, target_ip))
|
||||
LOG.info("created %s (from %s) -> %s:443", host, ref, target_ip)
|
||||
created += 1
|
||||
elif not is_managed(svc, resource):
|
||||
LOG.warning("%s is held by unmanaged service %s (mode %s); remove it to migrate",
|
||||
host, svc["id"], svc.get("mode"))
|
||||
held += 1
|
||||
elif not target_matches(svc, target_ip):
|
||||
nb.put(f"reverse-proxies/services/{svc['id']}", service_body(host, resource, target_ip))
|
||||
LOG.info("updated %s -> %s:443", host, target_ip)
|
||||
updated += 1
|
||||
|
||||
for domain, svc in by_domain.items():
|
||||
if is_managed(svc, resource) and domain not in hosts:
|
||||
nb.delete(f"reverse-proxies/services/{svc['id']}")
|
||||
LOG.info("deleted %s (no public Ingress carries it any more)", domain)
|
||||
deleted += 1
|
||||
|
||||
LOG.info("reconciled: %d hosts desired, %d created, %d updated, %d deleted, %d held by unmanaged services",
|
||||
len(hosts), created, updated, deleted, held)
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), stream=sys.stdout,
|
||||
format="%(asctime)s %(levelname)s %(message)s")
|
||||
if os.environ.get("ONCE") == "1":
|
||||
reconcile()
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
reconcile()
|
||||
except Exception as e: # keep running; the next pass retries
|
||||
LOG.error("reconcile failed: %s", e)
|
||||
time.sleep(INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,84 +0,0 @@
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
|
||||
# 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
|
||||
# wants ~14.5G for four VMs — a single run left under 1G headroom, and two
|
||||
# overlapping runs OOM-killed the session. The nodes have ~94G with 70G free.
|
||||
#
|
||||
# Separate from ci-k8s rather than built on it: that image pins talosctl v1.12.6,
|
||||
# and this test creates a cluster at the version talos/patches/cluster.yaml
|
||||
# installs. A talosctl older than the Talos it provisions is exactly the kind of
|
||||
# skew the test exists to catch, so it must not be baked in here.
|
||||
#
|
||||
# KEEP TALOS_VERSION IN STEP with the installer image tag in
|
||||
# kubernetes/talos/patches/cluster.yaml. The phase reads the version from that
|
||||
# file at run time and passes it to the provisioner; this pin only decides which
|
||||
# client talks to it.
|
||||
ARG TALOS_VERSION=v1.13.10
|
||||
ARG KUBECTL_VERSION=v1.36.2
|
||||
ARG FLUX_VERSION=2.9.2
|
||||
ARG KOPIA_VERSION=0.23.1
|
||||
|
||||
# qemu-system-x86_64 and qemu-img are the provisioner's actual backend, and ovmf
|
||||
# supplies the UEFI firmware those VMs boot from — without it the provisioner
|
||||
# stops with "the required flash image was not found in any of the expected
|
||||
# paths", having already downloaded the ISO and generated the PKI. Note the
|
||||
# firmware comes from ovmf specifically: qemu-system-x86_64 ships an edk2 CODE
|
||||
# image but no VARS, which is the half the provisioner needs.
|
||||
#
|
||||
# iptables is shelled out to by the CNI plugins that build the VM network; the
|
||||
# provisioner fails with "error accessing iptables" without it. The rest is
|
||||
# what the phase shells out to: helm for the Cilium install, yq to read the
|
||||
# HelmRelease and the installer image, jq for OpenBao's seal status.
|
||||
#
|
||||
# Versions come from the digest-pinned base rather than being pinned one by one,
|
||||
# the same way ci-ansible does it.
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
helm \
|
||||
iptables \
|
||||
jq \
|
||||
openssl \
|
||||
ovmf \
|
||||
qemu-img \
|
||||
qemu-system-x86_64 \
|
||||
yq
|
||||
|
||||
RUN curl -sfL -o /usr/local/bin/talosctl \
|
||||
"https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" \
|
||||
&& echo "28e9ccd9aebd690fa03d8cf2582221d87ea38b2a1704f411d75a03589c58f2aa /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& chmod +x /usr/local/bin/talosctl
|
||||
|
||||
RUN curl -sfL -o /usr/local/bin/kubectl \
|
||||
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \
|
||||
&& curl -sfL -o /tmp/kubectl.sha256 \
|
||||
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" \
|
||||
&& echo "$(cat /tmp/kubectl.sha256) /usr/local/bin/kubectl" | sha256sum -c - \
|
||||
&& rm -f /tmp/kubectl.sha256 \
|
||||
&& chmod +x /usr/local/bin/kubectl
|
||||
|
||||
RUN curl -sfL -o /tmp/flux.tar.gz \
|
||||
"https://github.com/fluxcd/flux2/releases/download/v${FLUX_VERSION}/flux_${FLUX_VERSION}_linux_amd64.tar.gz" \
|
||||
&& echo "4092b367fd060097976fb7261601f79420ecf7266328417dfd8dfee27de0e6a3 /tmp/flux.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/flux.tar.gz -C /usr/local/bin flux \
|
||||
&& chmod +x /usr/local/bin/flux \
|
||||
&& rm -f /tmp/flux.tar.gz
|
||||
|
||||
# kopia reads the Velero backup repository directly, which is what the OpenBao
|
||||
# restore does when there is no Velero yet to do it: the credentials Velero
|
||||
# needs live in OpenBao, so OpenBao cannot be restored by Velero. Its absence
|
||||
# was found by a rebuild rehearsal reaching the restore for the first time and
|
||||
# stopping at "kopia not found".
|
||||
#
|
||||
# Same checksum-verified download as the tools above; the tarball unpacks into
|
||||
# a versioned directory, so the binary is pulled out by name.
|
||||
RUN curl -sfL -o /tmp/kopia.tar.gz \
|
||||
"https://github.com/kopia/kopia/releases/download/v${KOPIA_VERSION}/kopia-${KOPIA_VERSION}-linux-x64.tar.gz" \
|
||||
&& echo "416d0f84a3dbb321a8b2d8f0997b1a0a6e915babe79ee76fa6e4d2bd1e1c5178 /tmp/kopia.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/kopia.tar.gz -C /tmp \
|
||||
&& mv "/tmp/kopia-${KOPIA_VERSION}-linux-x64/kopia" /usr/local/bin/kopia \
|
||||
&& chmod +x /usr/local/bin/kopia \
|
||||
&& rm -rf /tmp/kopia.tar.gz "/tmp/kopia-${KOPIA_VERSION}-linux-x64"
|
||||
@@ -1,976 +0,0 @@
|
||||
# Paperclip 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 Paperclip (server-local agent runs, option B) into the homelab cluster behind an Authentik gate, with CNPG Postgres and OpenBao-sourced secrets.
|
||||
|
||||
**Architecture:** One Flux `Kustomization` (`paperclip`) applies `kubernetes/apps/paperclip/`: a single-replica Deployment on a ceph-block PVC, CNPG `pclp-pg` (PG17) with barman backups to Hetzner, and a Traefik Ingress that backends only the `paperclip-gate` Authentik outpost. Namespace, baseline CNP, ClusterSecretStore allowlist and image automation live in the existing `infrastructure` tree.
|
||||
|
||||
**Tech Stack:** Flux v2 (kustomize-controller, image-reflector v1.2.5), CNPG + barman-cloud plugin, External Secrets → OpenBao, Cilium CNP, Traefik, Authentik blueprints.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-25-paperclip-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Worktree: `/home/krenner/source/homelab-worktrees/paperclip`, branch `feat/paperclip`. Never edit the main checkout.
|
||||
- Commits as claude-bot: `git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit …`, message ends with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Push: `git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push …`. PR via `tea pr create --login claude-bot`. Never self-merge.
|
||||
- One Bash command per call: no `&&`, `;`, `|` chaining, no `2>/dev/null`, no `python -c`.
|
||||
- Namespace `paperclip`; hostname `paperclip.renner.uno`; LAN-only (no `dns.renner.uno/public` label).
|
||||
- Image `ghcr.io/paperclipai/paperclip`, tag `nightly`, digest-pinned, `digestReflectionPolicy: Always`.
|
||||
- Postgres: CNPG `pclp-pg`, `ghcr.io/cloudnative-pg/postgresql:17`, 2 instances, `ceph-block`.
|
||||
- PVC `paperclip-data`, `ceph-block`, 20Gi, mounted at `/paperclip`.
|
||||
- OpenBao key `apps/paperclip` with fields `BETTER_AUTH_SECRET`, `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`, `PAPERCLIP_SECRETS_MASTER_KEY` (64-char hex), `CLAUDE_CODE_OAUTH_TOKEN`.
|
||||
- Env: `PAPERCLIP_DEPLOYMENT_MODE=authenticated`, `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`, `PAPERCLIP_PUBLIC_URL=https://paperclip.renner.uno`, `HOST=0.0.0.0`, `PORT=3100`.
|
||||
- Manifest comments: one terse factual line; rationale goes in commit messages/PR.
|
||||
- No `kubectl apply`/`exec`/`rollout restart` against the live cluster; Flux applies after merge. Server-side `--dry-run=server` is allowed.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Pod replaced mid agent run** (image bump, secret rotation via Reloader, node drain) — `Recreate` kills in-flight runs; expected: Paperclip marks them failed/recoverable on restart, not wedged. Covered in Task 9 Step 6.
|
||||
2. **Shared OAuth token expired or missing** — expected: agent runs fail with a visible auth error in Paperclip, server stays healthy. Covered in Task 9 Step 7.
|
||||
3. **Direct access bypassing the gate** from another namespace pod — expected: connection refused/dropped by CNP. Covered in Task 9 Step 4.
|
||||
4. **Unauthenticated LAN request** to `paperclip.renner.uno` — expected: redirect to Authentik, never Paperclip's UI. Covered in Task 9 Step 3.
|
||||
5. **Nightly image with a breaking DB migration** — expected: a base backup exists before the first bump lands. Covered in Task 9 Step 5.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Upstream facts that decide two manifest values
|
||||
|
||||
No repo changes; produces two decisions consumed by Task 6.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `SIGNUP_DISABLED` (`"true"` or `"false"`), `CONFIG_PARTIAL_OK` (yes/no), `ENTRYPOINT_NEEDS_ROOT` (yes/no).
|
||||
|
||||
- [ ] **Step 1: Sign-up vs instance claim.** Read upstream `server/src/auth/better-auth.ts`, `server/src/routes/auth.ts`, and the claim flow for `authenticated`+`private` (search `claim` under `server/src`):
|
||||
|
||||
Run: `gh api repos/paperclipai/paperclip/contents/server/src/routes/auth.ts -H "Accept: application/vnd.github.raw"`
|
||||
|
||||
Decide: if the first-admin claim creates the account through a path that ignores `disableSignUp` (e.g. invite/claim token handler calling the adapter directly), `SIGNUP_DISABLED="true"`. Otherwise `SIGNUP_DISABLED="false"` — the Authentik gate is the access control, and a follow-up PR flips it to `"true"` after the admin is claimed.
|
||||
|
||||
- [ ] **Step 2: Partial config.json.** Read `server/src/config.ts` and the config-file loader it calls (search `PAPERCLIP_CONFIG` / `readConfigFile`).
|
||||
|
||||
Run: `gh api repos/paperclipai/paperclip/contents/server/src/config.ts -H "Accept: application/vnd.github.raw"`
|
||||
|
||||
Decide `CONFIG_PARTIAL_OK=yes` if a file containing only `{"telemetry":{"enabled":false}}` parses (every other section optional/defaulted). If not, stop and report to the user — do not invent config fields.
|
||||
|
||||
- [ ] **Step 3: Entrypoint privilege.**
|
||||
|
||||
Run: `gh api repos/paperclipai/paperclip/contents/docker-entrypoint.sh -H "Accept: application/vnd.github.raw"`
|
||||
|
||||
`ENTRYPOINT_NEEDS_ROOT=yes` if it calls `usermod`/`groupmod`/`chown`/`gosu`. Record the result for Task 6.
|
||||
|
||||
- [ ] **Step 4: Anonymous health response.**
|
||||
|
||||
Run: `gh api repos/paperclipai/paperclip/contents/server/src/routes/health.ts -H "Accept: application/vnd.github.raw"`
|
||||
|
||||
Confirm `GET /api/health` returns HTTP 200 to an anonymous caller when healthy (probes use it). If it returns 401 anonymously, use a `tcpSocket` probe on 3100 in Task 6 instead.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Namespace, baseline CNP, ClusterSecretStore allowlist
|
||||
|
||||
**Files:**
|
||||
- Modify: `kubernetes/infrastructure/namespaces/namespaces.yaml` (append)
|
||||
- Create: `kubernetes/infrastructure/network-policies/paperclip-ns.yaml`
|
||||
- Modify: `kubernetes/infrastructure/network-policies/kustomization.yaml`
|
||||
- Modify: `kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml` (sorted list)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: namespace `paperclip`; ESO may reference `openbao` from it.
|
||||
|
||||
- [ ] **Step 1: Append the namespace** to the end of `namespaces.yaml`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: paperclip
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `paperclip-ns.yaml`** (copy of `paperless-ns.yaml`'s `default-deny-ingress`, ns renamed, paperless-only CNPs dropped):
|
||||
|
||||
```yaml
|
||||
# Traefik is not admitted: the only entry is the paperclip-gate outpost (apps/paperclip/network-policy.yaml).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny-ingress
|
||||
namespace: paperclip
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": database
|
||||
- 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": paperclip
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": authentik
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": rook-ceph
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- world
|
||||
- toEndpoints:
|
||||
- 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 }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register it** — in `network-policies/kustomization.yaml` add ` - paperclip-ns.yaml` directly after ` - paperless-ns.yaml`.
|
||||
|
||||
- [ ] **Step 4: Allowlist the namespace** — in `openbao-clustersecretstore.yaml` insert ` - paperclip` between ` - monitoring` and ` - paperless` (list is sorted).
|
||||
|
||||
- [ ] **Step 5: Render check.**
|
||||
|
||||
Run: `kubectl kustomize kubernetes/infrastructure/network-policies`
|
||||
Expected: output contains `namespace: paperclip`, exit 0.
|
||||
|
||||
Run: `kubectl apply --dry-run=server -f kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml`
|
||||
Expected: `clustersecretstore.external-secrets.io/openbao configured (server dry run)`.
|
||||
|
||||
- [ ] **Step 6: Commit** `feat(paperclip): namespace, baseline CNP and OpenBao allowlist`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: OpenBao secret `apps/paperclip`
|
||||
|
||||
Out-of-repo; must exist before merge or the ExternalSecret never becomes Ready.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: OpenBao KV `apps/paperclip` with the four fields in Global Constraints.
|
||||
|
||||
- [ ] **Step 1:** Ask wiki-synth for the claude-bot OpenBao write procedure (note `claude-bot-openbao-write-only-identity`): login method, KV mount, exact `bao kv put`/`patch` syntax for an `apps/<name>` key. Use exactly what it returns.
|
||||
|
||||
- [ ] **Step 2: Write the generated fields** as claude-bot (write-only identity — never read values back). Values generated inline by the command:
|
||||
|
||||
`BETTER_AUTH_SECRET` = `openssl rand -hex 32`, `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` = `openssl rand -hex 32`, `PAPERCLIP_SECRETS_MASTER_KEY` = `openssl rand -hex 32` (64 hex chars = 32 bytes, accepted format).
|
||||
|
||||
Generate each with a separate `openssl rand -hex 32` call and pass it directly into the `bao kv put` from Step 1; do not echo or log them.
|
||||
|
||||
- [ ] **Step 3: Ask the user** to add the shared token themselves (never handle it): "Run `claude setup-token`, then `bao kv patch <mount>/apps/paperclip CLAUDE_CODE_OAUTH_TOKEN=<token>`." Wait for confirmation.
|
||||
|
||||
- [ ] **Step 4: Verify field names only** (no values): `bao kv metadata get <mount>/apps/paperclip` shows a current version ≥2. If claude-bot cannot read metadata, ask the user to confirm the four keys exist.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Image automation + initial digest
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/infrastructure/image-automation/paperclip.yaml`
|
||||
- Modify: `kubernetes/infrastructure/image-automation/kustomization.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: ImagePolicy `flux-system:paperclip`; `INITIAL_DIGEST` (`sha256:…`) for Task 6.
|
||||
|
||||
- [ ] **Step 1: Create `paperclip.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: ghcr.io/paperclipai/paperclip
|
||||
interval: 1h
|
||||
---
|
||||
# Upstream publishes no semver tags; track the nightly tag by digest.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: paperclip
|
||||
digestReflectionPolicy: Always
|
||||
filterTags:
|
||||
pattern: '^nightly$'
|
||||
policy:
|
||||
alphabetical:
|
||||
order: asc
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register** — add ` - paperclip.yaml` after ` - paperless-ngx.yaml` in `image-automation/kustomization.yaml`.
|
||||
|
||||
- [ ] **Step 3: Server dry-run.**
|
||||
|
||||
Run: `kubectl apply --dry-run=server -f kubernetes/infrastructure/image-automation/paperclip.yaml`
|
||||
Expected: both objects `created (server dry run)`; no `unknown field "digestReflectionPolicy"` error.
|
||||
|
||||
- [ ] **Step 4: Resolve the current nightly digest.**
|
||||
|
||||
The workstation has `docker` only (no skopeo/crane/buildx, checked 2026-09-25); `docker manifest inspect` lists per-platform digests but not the index digest, so pull once:
|
||||
|
||||
Run: `docker pull ghcr.io/paperclipai/paperclip:nightly`
|
||||
Run: `docker image inspect --format '{{index .RepoDigests 0}}' ghcr.io/paperclipai/paperclip:nightly`
|
||||
Expected: `ghcr.io/paperclipai/paperclip@sha256:<64 hex>`. Record the `sha256:…` part as `INITIAL_DIGEST`.
|
||||
Run: `docker image rm ghcr.io/paperclipai/paperclip:nightly`
|
||||
|
||||
- [ ] **Step 5: Commit** `feat(paperclip): Flux image automation tracks the nightly digest`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Postgres (CNPG) and secrets in the app directory
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/apps/paperclip/cluster.yaml`, `objectstore.yaml`, `scheduled-backup.yaml`, `hetzner-creds-externalsecret.yaml`, `externalsecret.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: Secret `pclp-pg-app` (CNPG, key `uri`), Service `pclp-pg-rw`, Secret `paperclip-secrets`.
|
||||
|
||||
- [ ] **Step 1: `cluster.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
metadata:
|
||||
name: pclp-pg
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # data guard
|
||||
spec:
|
||||
instances: 2
|
||||
smartShutdownTimeout: 10
|
||||
stopDelay: 60
|
||||
# PG17: upstream's external Postgres target (docker/docker-compose.yml).
|
||||
imageName: ghcr.io/cloudnative-pg/postgresql:17
|
||||
storage:
|
||||
storageClass: ceph-block
|
||||
size: 10Gi
|
||||
plugins:
|
||||
- name: barman-cloud.cloudnative-pg.io
|
||||
isWALArchiver: true
|
||||
parameters:
|
||||
barmanObjectName: pclp-pg-store
|
||||
bootstrap:
|
||||
initdb:
|
||||
database: paperclip
|
||||
owner: paperclip
|
||||
postgresql:
|
||||
parameters:
|
||||
shared_buffers: "256MB"
|
||||
max_connections: "200"
|
||||
checkpoint_timeout: "15min"
|
||||
max_wal_size: "2GB"
|
||||
wal_compression: "zstd"
|
||||
archive_timeout: "30min"
|
||||
synchronous:
|
||||
method: any
|
||||
number: 1
|
||||
dataDurability: preferred
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `objectstore.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: barmancloud.cnpg.io/v1
|
||||
kind: ObjectStore
|
||||
metadata:
|
||||
name: pclp-pg-store
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # backup target
|
||||
spec:
|
||||
configuration:
|
||||
destinationPath: s3://renner-uno-k8s-cnpg/pclp-pg
|
||||
endpointURL: https://fsn1.your-objectstorage.com
|
||||
s3Credentials:
|
||||
accessKeyId:
|
||||
name: hetzner-s3-creds
|
||||
key: ACCESS_KEY_ID
|
||||
secretAccessKey:
|
||||
name: hetzner-s3-creds
|
||||
key: ACCESS_SECRET_KEY
|
||||
region:
|
||||
name: hetzner-s3-creds
|
||||
key: REGION
|
||||
wal:
|
||||
compression: gzip
|
||||
data:
|
||||
compression: gzip
|
||||
retentionPolicy: "30d"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `scheduled-backup.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: ScheduledBackup
|
||||
metadata:
|
||||
name: pclp-pg-daily
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # backup schedule
|
||||
spec:
|
||||
schedule: "0 45 2 * * *"
|
||||
immediate: true
|
||||
backupOwnerReference: self
|
||||
cluster:
|
||||
name: pclp-pg
|
||||
method: plugin
|
||||
pluginConfiguration:
|
||||
name: barman-cloud.cloudnative-pg.io
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `hetzner-creds-externalsecret.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: hetzner-s3-creds
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # barman S3 creds
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: hetzner-s3-creds
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: infrastructure/hetzner-s3
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `externalsecret.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: paperclip-secrets
|
||||
namespace: paperclip
|
||||
spec:
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: paperclip-secrets
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/paperclip
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit** `feat(paperclip): CNPG pclp-pg with barman backups and OpenBao secrets`. (Rendered together with Task 6's kustomization.)
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Server workload, storage, ingress, network policy
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/apps/paperclip/pvc.yaml`, `paperclip.yaml`, `gate-service.yaml`, `ingress.yaml`, `network-policy.yaml`, `kustomization.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pclp-pg-app` (`uri`), `paperclip-secrets`, ImagePolicy `flux-system:paperclip`, `INITIAL_DIGEST` (Task 4), `SIGNUP_DISABLED` / `ENTRYPOINT_NEEDS_ROOT` / probe choice (Task 1).
|
||||
- Produces: pods labelled `app.kubernetes.io/name: paperclip` listening on 3100; Service `paperclip:3100`; ExternalName `ak-outpost-paperclip-gate:9000`.
|
||||
|
||||
- [ ] **Step 1: `pvc.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: paperclip-data
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # data guard
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: ceph-block
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `paperclip.yaml`** — replace `<INITIAL_DIGEST>` with the Task 4 value and `<SIGNUP_DISABLED>` with the Task 1 value. If Task 1 found `ENTRYPOINT_NEEDS_ROOT=no`, replace the container `securityContext` with `runAsNonRoot: true`, `runAsUser: 1000`, `runAsGroup: 1000`, `allowPrivilegeEscalation: false`, `capabilities: {drop: ["ALL"]}`. If Task 1 chose `tcpSocket`, replace each `httpGet` block with `tcpSocket: {port: http}`.
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: paperclip
|
||||
labels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
annotations:
|
||||
secret.reloader.stakater.com/reload: paperclip-secrets
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
# Telemetry is only configurable in config.json.
|
||||
- name: disable-telemetry
|
||||
image: ghcr.io/paperclipai/paperclip:nightly@<INITIAL_DIGEST> # {"$imagepolicy": "flux-system:paperclip"}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
f=/paperclip/instances/default/config.json
|
||||
mkdir -p "$(dirname "$f")"
|
||||
if [ -s "$f" ]; then
|
||||
jq '.telemetry.enabled = false' "$f" > "$f.tmp"
|
||||
else
|
||||
printf '%s\n' '{"telemetry":{"enabled":false}}' > "$f.tmp"
|
||||
fi
|
||||
mv "$f.tmp" "$f"
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /paperclip
|
||||
containers:
|
||||
- name: paperclip
|
||||
image: ghcr.io/paperclipai/paperclip:nightly@<INITIAL_DIGEST> # {"$imagepolicy": "flux-system:paperclip"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3100
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pclp-pg-app
|
||||
key: uri
|
||||
- name: HOST
|
||||
value: "0.0.0.0"
|
||||
- name: PORT
|
||||
value: "3100"
|
||||
- name: PAPERCLIP_DEPLOYMENT_MODE
|
||||
value: authenticated
|
||||
- name: PAPERCLIP_DEPLOYMENT_EXPOSURE
|
||||
value: private
|
||||
- name: PAPERCLIP_PUBLIC_URL
|
||||
value: https://paperclip.renner.uno
|
||||
- name: PAPERCLIP_AUTH_DISABLE_SIGN_UP
|
||||
value: "<SIGNUP_DISABLED>"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: paperclip-secrets
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /paperclip
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 60
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 4Gi
|
||||
securityContext:
|
||||
runAsNonRoot: false
|
||||
allowPrivilegeEscalation: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: paperclip-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: paperclip
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: paperclip
|
||||
ports:
|
||||
- name: http
|
||||
port: 3100
|
||||
targetPort: http
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `gate-service.yaml`:**
|
||||
|
||||
```yaml
|
||||
# ExternalName mirror so the Ingress can backend the paperclip-gate outpost in the authentik namespace.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ak-outpost-paperclip-gate
|
||||
namespace: paperclip
|
||||
spec:
|
||||
type: ExternalName
|
||||
externalName: ak-outpost-paperclip-gate.authentik.svc.cluster.local
|
||||
ports:
|
||||
- port: 9000
|
||||
targetPort: 9000
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `ingress.yaml`:**
|
||||
|
||||
```yaml
|
||||
# LAN-only (no dns.renner.uno/public label); backends the gate outpost, never the Service.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- paperclip.renner.uno
|
||||
secretName: paperclip-tls
|
||||
rules:
|
||||
- host: paperclip.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ak-outpost-paperclip-gate
|
||||
port:
|
||||
number: 9000
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `network-policy.yaml`** (Traefik pod-side port is 8443, verified via `kubectl get pods -n ingress -l app.kubernetes.io/name=traefik`):
|
||||
|
||||
```yaml
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: paperclip-gate-ingress
|
||||
namespace: paperclip
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": authentik
|
||||
goauthentik.io/outpost-name: paperclip-gate
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3100"
|
||||
protocol: TCP
|
||||
---
|
||||
# Covers the server and every agent it spawns (same pod).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: paperclip-egress
|
||||
namespace: paperclip
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
cnpg.io/cluster: pclp-pg
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "5432"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
# Gitea, Woodpecker, HA, Loki via their *.renner.uno names.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8443"
|
||||
protocol: TCP
|
||||
- toEntities:
|
||||
- kube-apiserver
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "6443"
|
||||
protocol: TCP
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
- [ ] **Step 6: `kustomization.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- externalsecret.yaml
|
||||
- hetzner-creds-externalsecret.yaml
|
||||
- cluster.yaml
|
||||
- objectstore.yaml
|
||||
- scheduled-backup.yaml
|
||||
- pvc.yaml
|
||||
- paperclip.yaml
|
||||
- gate-service.yaml
|
||||
- ingress.yaml
|
||||
- network-policy.yaml
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Render check.**
|
||||
|
||||
Run: `kubectl kustomize kubernetes/apps/paperclip`
|
||||
Expected: exit 0; output contains `kind: Cluster`, `kind: Deployment`, `claimName: paperclip-data`, and no literal `<INITIAL_DIGEST>` or `<SIGNUP_DISABLED>`.
|
||||
|
||||
Run: `grep -rn "<INITIAL_DIGEST>\|<SIGNUP_DISABLED>" kubernetes/apps/paperclip`
|
||||
Expected: no output, exit 1.
|
||||
|
||||
- [ ] **Step 8: Commit** `feat(paperclip): server Deployment, PVC, gated Ingress and CNPs`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Authentik gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `kubernetes/apps/authentik/blueprints-configmap.yaml` (insert before the line ` # navidrome-gate: the Navidrome web UI.`)
|
||||
- Create: `kubernetes/apps/authentik/paperclip-gate-egress.yaml`
|
||||
- Modify: `kubernetes/apps/authentik/kustomization.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: outpost pods labelled `goauthentik.io/outpost-name: paperclip-gate`, Service `ak-outpost-paperclip-gate.authentik:9000`.
|
||||
|
||||
- [ ] **Step 1: Insert blueprint entries** (same indentation as the neighbouring `wiki-gate` block):
|
||||
|
||||
```yaml
|
||||
# paperclip-gate: Paperclip UI; the CNP admits only this outpost. Paperclip keeps its own login behind it.
|
||||
- id: paperclip-users-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: Paperclip Users
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
users:
|
||||
- !Find [authentik_core.user, [username, k.renner]]
|
||||
|
||||
- id: paperclip-gate-provider
|
||||
model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
name: paperclip-gate
|
||||
state: present
|
||||
attrs:
|
||||
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
mode: proxy
|
||||
internal_host: http://paperclip.paperclip.svc.cluster.local:3100
|
||||
internal_host_ssl_validation: false
|
||||
external_host: https://paperclip.renner.uno
|
||||
cookie_domain: renner.uno
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: paperclip-gate
|
||||
state: present
|
||||
attrs:
|
||||
name: Paperclip
|
||||
slug: paperclip-gate
|
||||
provider: !KeyOf paperclip-gate-provider
|
||||
meta_launch_url: https://paperclip.renner.uno
|
||||
meta_description: "Paperclip agent orchestration"
|
||||
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, paperclip-gate]]
|
||||
group: !KeyOf paperclip-users-group
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: paperclip-gate
|
||||
state: present
|
||||
attrs:
|
||||
type: proxy
|
||||
service_connection: !KeyOf k8s-conn
|
||||
providers:
|
||||
- !KeyOf paperclip-gate-provider
|
||||
config:
|
||||
log_level: info
|
||||
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
|
||||
authentik_host_insecure: false
|
||||
authentik_host_browser: "https://auth.renner.uno/"
|
||||
refresh_interval: "minutes=5"
|
||||
container_image: null
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
kubernetes_service_type: ClusterIP
|
||||
kubernetes_disabled_components: ["ingress"]
|
||||
kubernetes_image_pull_secrets: []
|
||||
kubernetes_ingress_annotations: {}
|
||||
kubernetes_ingress_class_name: null
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `paperclip-gate-egress.yaml`:**
|
||||
|
||||
```yaml
|
||||
# Lets the paperclip-gate outpost reach the Paperclip server; scoped to this outpost's pods.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: paperclip-gate-egress
|
||||
namespace: authentik
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: paperclip-gate
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
app.kubernetes.io/name: paperclip
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3100"
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register** — add ` - paperclip-gate-egress.yaml` after ` - paperless-gate-egress.yaml` in `apps/authentik/kustomization.yaml`.
|
||||
|
||||
- [ ] **Step 4: Render + dry-run.**
|
||||
|
||||
Run: `kubectl kustomize kubernetes/apps/authentik`
|
||||
Expected: exit 0 (valid YAML incl. the blueprint ConfigMap).
|
||||
|
||||
Run: `kubectl apply --dry-run=server -f kubernetes/apps/authentik/paperclip-gate-egress.yaml`
|
||||
Expected: `ciliumnetworkpolicy.cilium.io/paperclip-gate-egress created (server dry run)`.
|
||||
|
||||
- [ ] **Step 5: Commit** `feat(authentik): paperclip-gate outpost and Paperclip Users group`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Flux Kustomization
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/clusters/homelab/paperclip.yaml`
|
||||
|
||||
- [ ] **Step 1:**
|
||||
|
||||
```yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 1m
|
||||
timeout: 20m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/paperclip
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
- name: infrastructure-ceph-cluster
|
||||
- name: infrastructure-cnpg-barman
|
||||
healthCheckExprs:
|
||||
- apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
failed: "has(status.phase) && status.phase.startsWith('Failed')"
|
||||
current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Server dry-run.**
|
||||
|
||||
Run: `kubectl apply --dry-run=server -f kubernetes/clusters/homelab/paperclip.yaml`
|
||||
Expected: `kustomization.kustomize.toolkit.fluxcd.io/paperclip created (server dry run)`.
|
||||
|
||||
- [ ] **Step 3: Commit** `feat(paperclip): Flux Kustomization`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: PR, merge by user, live verification
|
||||
|
||||
- [ ] **Step 1: Push and open the PR** (Task 3 must be complete).
|
||||
|
||||
Run: `git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push -u origin feat/paperclip`
|
||||
Run: `tea pr create --login claude-bot --base master --head feat/paperclip --title "feat(paperclip): deploy Paperclip (server-local runs, trial)" --description "<summary of spec decisions + link to spec + bootstrap steps>\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)"`
|
||||
Then wait for the user to merge. Do not merge.
|
||||
|
||||
- [ ] **Step 2: Reconciliation.** After merge:
|
||||
|
||||
Run: `flux get kustomizations paperclip`
|
||||
Expected: `Ready True`. If False, read `kubectl describe kustomization paperclip -n flux-system` and the failing object's events; diagnose, don't poke.
|
||||
|
||||
Run: `kubectl get cluster -n paperclip pclp-pg`
|
||||
Expected: `Cluster in healthy state`, 2 ready instances.
|
||||
|
||||
Run: `kubectl get pods -n paperclip -l app.kubernetes.io/name=paperclip`
|
||||
Expected: `1/1 Running`.
|
||||
|
||||
- [ ] **Step 3: Gate enforced (Review Focus 4).**
|
||||
|
||||
Run: `curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' https://paperclip.renner.uno/`
|
||||
Expected: `302` redirecting to `https://paperclip.renner.uno/outpost.goauthentik.io/…` or `auth.renner.uno`, never `200`.
|
||||
|
||||
- [ ] **Step 4: Gate not bypassable (Review Focus 3).**
|
||||
|
||||
`default` has no CNPs (verified 2026-09-25), so its egress is open and only paperclip's ingress policy can block the probe.
|
||||
|
||||
Run: `kubectl get ciliumnetworkpolicies -n default`
|
||||
Expected: `No resources found` (else pick another CNP-free namespace).
|
||||
|
||||
Run: `kubectl run cnp-probe -n default --rm -i --restart=Never --image=curlimages/curl:8.10.1 -- curl -sS -m 5 http://paperclip.paperclip.svc.cluster.local:3100/api/health`
|
||||
Expected: timeout (exit 28), no HTTP response.
|
||||
|
||||
- [ ] **Step 5: First backup (Review Focus 5).**
|
||||
|
||||
Run: `kubectl get backup -n paperclip`
|
||||
Expected: one `pclp-pg-daily-…` with phase `completed`.
|
||||
|
||||
- [ ] **Step 6: Image policy.**
|
||||
|
||||
Run: `kubectl get imagepolicy -n flux-system paperclip -o jsonpath='{.status.latestRef}'`
|
||||
Expected: `nightly` tag with a `sha256:` digest.
|
||||
|
||||
- [ ] **Step 7: Hand-over to the user (browser steps, theirs):**
|
||||
1. Open `https://paperclip.renner.uno`, pass Authentik, claim the instance as admin.
|
||||
2. Create a test agent with the `claude-local` adapter and a trivial task ("reply with OK"); it must complete with the shared token (Review Focus 2: if it fails, report the run's error text — an expired token must show as an auth error, not a hang).
|
||||
3. If `SIGNUP_DISABLED` was `"false"`, open the follow-up PR flipping `PAPERCLIP_AUTH_DISABLE_SIGN_UP` to `"true"` once the admin is claimed.
|
||||
4. Review Focus 1 is observed on the first nightly bump: check the interrupted-run status in the UI.
|
||||
|
||||
- [ ] **Step 8: Wiki.** Route the verified Paperclip facts (execution topology, env vars, image tags, digest-reflection usage) through wiki-ingest → wiki-distill once the deployment has proven out.
|
||||
@@ -1,206 +0,0 @@
|
||||
# Paperclip in the homelab cluster — design
|
||||
|
||||
Date: 2026-09-25
|
||||
Status: draft, awaiting review
|
||||
|
||||
## Goal
|
||||
|
||||
Run [Paperclip](https://github.com/paperclipai/paperclip) (MIT, agent-orchestration
|
||||
server + UI) in the homelab cluster for real use: its agents do actual work against
|
||||
homelab systems. This spec covers the **platform** only. Bot identities and their
|
||||
credentials are out of scope (see "Deferred").
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| 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). |
|
||||
| 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. |
|
||||
|
||||
### Known trade-off of B (accepted for the trial)
|
||||
|
||||
Upstream `runChildProcess()` spawns every local adapter with
|
||||
`{...sanitizeInheritedPaperclipEnv(process.env), ...opts.env}`; the sanitizer strips only
|
||||
`PAPERCLIP_*` keys. Every agent therefore sees `DATABASE_URL`, `BETTER_AUTH_SECRET`,
|
||||
the shared filesystem (including every other agent's workspace and the
|
||||
`ai-local-logins` credentials) and every agent-scoped secret it can read from disk. Under B, no bot may hold
|
||||
a credential we would not tolerate any other bot using. Per-bot isolation is C's job.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
LAN client ─▶ Traefik ─▶ ExternalName svc ─▶ ak-outpost-paperclip-gate (authentik ns)
|
||||
│ forward-auth OK
|
||||
▼
|
||||
paperclip Deployment (ns paperclip, 1 replica)
|
||||
├─ server :3100 (+ agent CLIs as child processes)
|
||||
├─ PVC /paperclip (ceph-block)
|
||||
└─▶ pclp-pg-rw (CNPG, PG17, 2 instances) ─▶ barman ▶ Hetzner S3
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
All modeled on `kubernetes/apps/paperless/` unless stated.
|
||||
|
||||
### Namespace and baseline policy
|
||||
- `Namespace paperclip` in `kubernetes/infrastructure/namespaces/namespaces.yaml`.
|
||||
- Baseline default-deny CNP `kubernetes/infrastructure/network-policies/paperclip-ns.yaml`
|
||||
(copy of `paperless-ns.yaml`, renamed).
|
||||
- Add `paperclip` to the `openbao` ClusterSecretStore namespace allowlist.
|
||||
|
||||
### Flux
|
||||
- `kubernetes/clusters/homelab/paperclip.yaml`: Flux `Kustomization`,
|
||||
`path: ./kubernetes/apps/paperclip`, `prune: true`, `wait: true`, same `dependsOn` and
|
||||
CNPG `healthCheckExprs` as `paperless.yaml`.
|
||||
- `kubernetes/apps/paperclip/kustomization.yaml`: plain resource list.
|
||||
|
||||
### Server Deployment (`paperclip.yaml`)
|
||||
- Image `ghcr.io/paperclipai/paperclip:nightly@sha256:<digest>` with the Flux
|
||||
image-automation marker for digest updates.
|
||||
- `replicas: 1`, `strategy: Recreate` (single writer on an RWO PVC).
|
||||
- Starts as root: upstream `docker-entrypoint.sh` remaps the `node` user, chowns
|
||||
`/paperclip`, then drops to uid 1000 via `gosu`. Caps limited to CHOWN,
|
||||
DAC_OVERRIDE, FOWNER, SETGID, SETUID; `fsGroup: 1000`.
|
||||
- `serviceAccountName: paperclip-prometheus-read` with
|
||||
`automountServiceAccountToken: true`. All agents share the pod filesystem, so all
|
||||
of them can read that token — it is mounted anyway because the Role behind it
|
||||
grants exactly `get` on `services/proxy` for
|
||||
`kube-prometheus-stack-prometheus:9090` and
|
||||
`kube-prometheus-stack-alertmanager:9093` in `monitoring`, and nothing else
|
||||
(`kubernetes/apps/paperclip/prometheus-read-rbac.yaml`, REN-36 then REN-95).
|
||||
It was `false` until then.
|
||||
Three things about that grant are worth knowing before you use or debug it:
|
||||
- **It scopes the HTTP method, not the endpoint.** The apiserver maps GET→`get`,
|
||||
POST→`create`, PUT→`update` on a service-proxy request, so `get` alone refuses
|
||||
`POST /-/reload` and `POST /-/quit` in the authorizer. It does *not* narrow
|
||||
which GET endpoints are reachable: the whole read surface of both services is,
|
||||
including `/api/v1/status/config` (scrape credentials there render as
|
||||
`<secret>`) and the web UI.
|
||||
- **Force GET in the caller.** Prometheus accepts both GET and POST on
|
||||
`/api/v1/query`; under this Role only the GET form works, so a client library
|
||||
that switches to POST for long queries will 403 for a reason that has nothing
|
||||
to do with the query.
|
||||
- **If reads start 403ing, check the Service name against `resourceNames`
|
||||
first.** `resourceNames` is matched verbatim, and
|
||||
`kubernetes/apps/monitoring/helmrelease-prometheus.yaml` floats the chart at
|
||||
`>=72.0.0` — a release rename breaks this path silently,
|
||||
with everything still looking correct in git. The port-qualified form is the
|
||||
only one granted: `svc`, `svc:9090` and `http:svc:9090` are three different
|
||||
strings to the authorizer.
|
||||
- Port 3100; `Service paperclip` ClusterIP :3100.
|
||||
- Probes: startup and readiness on `GET /api/health` with `Host: localhost` (the
|
||||
private-hostname guard rejects the pod IP); liveness is a TCP check, since
|
||||
`/api/health` runs `SELECT 1` and a DB outage must not restart the server.
|
||||
- Env:
|
||||
- `DATABASE_URL` assembled from CNPG `pclp-pg-app` Secret (`uri` key).
|
||||
- `HOST=0.0.0.0`, `PORT=3100`
|
||||
- `PAPERCLIP_DEPLOYMENT_MODE=authenticated`
|
||||
- `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`
|
||||
- `PAPERCLIP_PUBLIC_URL=https://paperclip.renner.uno`
|
||||
- `PAPERCLIP_AUTH_DISABLE_SIGN_UP=false` until the first admin is claimed: the
|
||||
claim (`/bootstrap/claim`) requires an already signed-in Better Auth account.
|
||||
A follow-up flips it to `true`.
|
||||
- `PAPERCLIP_TELEMETRY_DISABLED=1` (read by `resolveTelemetryConfig`).
|
||||
- `PAPERCLIP_API_URL=http://paperclip.paperclip.svc.cluster.local:3100` plus
|
||||
`PAPERCLIP_ALLOWED_HOSTNAMES=paperclip.paperclip.svc.cluster.local`: upstream
|
||||
otherwise hands agents the public URL, and the gate (`intercept_header_auth`)
|
||||
rejects their bearer tokens. The Service name also works for sandbox pods under
|
||||
option C; agent JWTs still authenticate each call.
|
||||
- `envFrom: secretRef paperclip-secrets`
|
||||
- Reloader: `secret.reloader.stakater.com/reload: paperclip-secrets`.
|
||||
|
||||
### Storage
|
||||
- `PVC paperclip-data`, `ceph-block`, 20Gi, mounted at `/paperclip` (`PAPERCLIP_HOME`
|
||||
and `HOME`): instance config, workspaces, repo checkouts, per-agent `claude-runtime`
|
||||
dirs.
|
||||
|
||||
### Postgres
|
||||
- CNPG `Cluster pclp-pg`: 2 instances, `imageName: ghcr.io/cloudnative-pg/postgresql:17`,
|
||||
`ceph-block` 10Gi, `bootstrap.initdb {database: paperclip, owner: paperclip}`,
|
||||
barman-cloud plugin as WAL archiver.
|
||||
- `ObjectStore pclp-pg-store` → `s3://renner-uno-k8s-cnpg/pclp-pg`, creds via the
|
||||
existing `hetzner-s3-creds` ExternalSecret pattern.
|
||||
- `ScheduledBackup` nightly, `method: plugin`, `backupOwnerReference: self`.
|
||||
- `kustomize.toolkit.fluxcd.io/prune: disabled` on Cluster, ObjectStore, ScheduledBackup.
|
||||
- PG17 deviates from the cluster-wide PG16 convention because upstream's external
|
||||
Postgres target is `postgres:17` (`docker/docker-compose.yml`) and its embedded default
|
||||
is PG18.
|
||||
|
||||
### Secrets
|
||||
One ExternalSecret `paperclip-secrets` ← OpenBao `apps/paperclip` (whole-object
|
||||
extract):
|
||||
- `BETTER_AUTH_SECRET`
|
||||
- `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`
|
||||
- `PAPERCLIP_SECRETS_MASTER_KEY` (64-char hex). Held in OpenBao, not on the PVC, so
|
||||
encrypted agent secrets in Postgres survive PVC loss.
|
||||
|
||||
### Ingress and SSO
|
||||
- Hostname `paperclip.renner.uno`, Traefik `Ingress`, `cert-manager.io/cluster-issuer:
|
||||
letsencrypt-cluster`, **no** `dns.renner.uno/public` label (LAN-only).
|
||||
- Authentik gate, per the paperless pattern:
|
||||
- blueprint entries in `kubernetes/apps/authentik/blueprints-configmap.yaml`:
|
||||
proxy provider `paperclip-gate` (`internal_host:
|
||||
http://paperclip.paperclip.svc.cluster.local:3100`, `external_host:
|
||||
https://paperclip.renner.uno`), application, group "Paperclip Users", policy binding;
|
||||
- outpost `ak-outpost-paperclip-gate`;
|
||||
- `kubernetes/apps/authentik/paperclip-gate-egress.yaml` (no `auth.renner.uno`
|
||||
outpost Ingress: like wiki/shelly gates, the outpost serves its own callback on
|
||||
`paperclip.renner.uno`);
|
||||
- `ExternalName` Service in `paperclip` ns pointing at the outpost; the app Ingress
|
||||
targets it on :9000.
|
||||
- Paperclip's own email/password login stays on (no header/OIDC login upstream).
|
||||
|
||||
### Network policy (`kubernetes/apps/paperclip/network-policy.yaml`)
|
||||
Covers the server **and every agent** (same pod under B).
|
||||
- Ingress to the app pod: only from the `paperclip-gate` outpost (by label) on 3100.
|
||||
- Egress from the app pod:
|
||||
- `pclp-pg` pods :5432
|
||||
- kube-dns :53
|
||||
- `ingress` namespace (Traefik) :8443 (pod-side websecure port) — reaches Gitea, Woodpecker, HA, Loki by their
|
||||
`*.renner.uno` names
|
||||
- `kube-apiserver` entity :6443
|
||||
- `world` TCP :443 (Anthropic API, GitHub, npm)
|
||||
- No SSH egress; git over HTTPS only. The namespace baseline
|
||||
(`paperclip-ns.yaml`) also restricts `world` to TCP 443 — Cilium unions allows
|
||||
across CNPs, so an unrestricted baseline would override this list.
|
||||
- Known gap (house baseline): pods in `monitoring`/`database` and other pods in
|
||||
`paperclip` can reach :3100 without the gate; Paperclip's own login still applies.
|
||||
|
||||
### Image automation
|
||||
`kubernetes/infrastructure/image-automation/paperclip.yaml`: `ImageRepository`
|
||||
(`ghcr.io/paperclipai/paperclip`) + `ImagePolicy` filtering tag `^nightly$` with
|
||||
`digestReflectionPolicy: Always` (image-reflector-controller v1.2.5 in cluster; field
|
||||
available since v0.35.0). First use of digest reflection in this repo.
|
||||
|
||||
## Guardrails
|
||||
- No bot receives cluster-admin or any binding in `flux-system`, `authentik`, `openbao`.
|
||||
- Git work goes through PRs; bots get no merge rights on protected branches.
|
||||
- Nothing is granted under B that we would not tolerate any agent misusing.
|
||||
|
||||
## Bootstrap (one-time)
|
||||
1. Place `apps/paperclip` values in OpenBao (the three generated secrets).
|
||||
2. Merge; Flux deploys with sign-up enabled.
|
||||
3. Sign up through the gate and claim the instance as admin; then set
|
||||
`PAPERCLIP_AUTH_DISABLE_SIGN_UP=true`.
|
||||
4. In onboarding "Connect a model", choose Claude subscription and run the shown login
|
||||
**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.
|
||||
|
||||
## Verification
|
||||
- Flux Kustomization `paperclip` Ready; CNPG `pclp-pg` healthy; first ScheduledBackup
|
||||
completes.
|
||||
- `curl https://paperclip.renner.uno/api/health` through the gate returns healthy after
|
||||
Authentik login; unauthenticated request redirects to `auth.renner.uno`.
|
||||
- Direct pod access from another namespace is denied (CNP).
|
||||
- A test agent (claude-local) completes a trivial task using the shared Claude connection.
|
||||
- Flux ImagePolicy reports a `nightly@sha256:` digest.
|
||||
|
||||
## Deferred
|
||||
- **Option C** (sandboxed runs) — required before any per-bot credential isolation.
|
||||
- **AIR-Manager** role and all bot identities/credentials.
|
||||
- Public exposure.
|
||||
@@ -50,18 +50,15 @@ pass insert homelab/openbao/root-token
|
||||
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste key
|
||||
```
|
||||
|
||||
**Unseal is automatic since 2026-09-08** (static seal, PR #825, issue #805):
|
||||
the seal key lives in the `openbao-seal-key` Secret (created by `bootstrap.sh`
|
||||
from `pass show homelab/openbao/seal-key`) and every restarted replica unseals
|
||||
itself. The shamir key above became the **recovery key** in the migration; it
|
||||
is kept in `pass` under its old name, `homelab/openbao/unseal-key`. The server
|
||||
runs three raft peers (see `infrastructure/openbao/helmrelease.yaml`); a sealed
|
||||
one is neither a voter nor a standby, so a replica that stays sealed after a
|
||||
restart — wrong or missing seal-key Secret — costs redundancy, two cost
|
||||
quorum. The trade-off taken with the static seal: the key that unlocks the
|
||||
store sits in the same etcd as the Secrets ESO already syncs there in
|
||||
plaintext. Symptom of sealed: ESO stores go NotReady, synced Secrets keep
|
||||
their last value (they are real k8s Secrets — pods keep running).
|
||||
**Manual unseal is required after every pod restart** (node drain, upgrade,
|
||||
crash), **per replica** — the server runs three raft peers (see
|
||||
`infrastructure/openbao/helmrelease.yaml`), and a sealed one is neither a
|
||||
voter nor a standby, so it stays out of the cluster until unsealed:
|
||||
`kubectl -n openbao exec -it openbao-<N> -- bao operator unseal`, same key for
|
||||
all three. Three peers mean one sealed replica is survivable; two are not.
|
||||
Accepted for now; auto-unseal needs a second trust root (transit/KMS) and is
|
||||
explicitly out of scope. Symptom of sealed: ESO stores go NotReady, synced
|
||||
Secrets keep their last value (they are real k8s Secrets — pods keep running).
|
||||
|
||||
## Phase 2 — configure + ClusterSecretStore (follow-up PR)
|
||||
|
||||
@@ -237,19 +234,16 @@ one full reconcile + one OpenBao pod restart/unseal cycle:
|
||||
- **Backups:** ✅ nightly raft-snapshot CronJob (03:00, keeps 7 on a
|
||||
dedicated PVC; see infrastructure/openbao/snapshot-cronjob.yaml) + velero
|
||||
`openbao-daily` Schedule (06:15) ships that PVC offsite, 30 d retention.
|
||||
A raft snapshot restore needs the matching seal key — which lives in
|
||||
A raft snapshot restore needs the matching unseal key — which lives in
|
||||
`pass`, outside the cluster (GPG key backed up externally 2026-07-14).
|
||||
A failed snapshot Job is the sealed-after-restart alarm. Still owed:
|
||||
one tested restore before phase 4.
|
||||
- **Cluster rebuild order:** seal-key Secret from `pass` → Flux brings up
|
||||
openbao (empty) → init → restore raft snapshot → replicas unseal themselves
|
||||
→ ClusterSecretStore Ready → ESO materializes Secrets → apps start
|
||||
(`bootstrap.sh flux` does all of it; RUNBOOK-cold-rebuild.md Step 4 by
|
||||
hand). Until then, secret-consuming apps simply stay Pending on missing
|
||||
Secrets — same as any missing dependency.
|
||||
- **Break-glass:** `pass` holds seal key, recovery key + root token (GPG,
|
||||
offline-capable). With those, everything is recoverable from a raft
|
||||
snapshot alone.
|
||||
- **Cluster rebuild order:** Flux brings up openbao (sealed, empty) → restore
|
||||
raft snapshot → unseal (key from `pass`) → ClusterSecretStore Ready → ESO
|
||||
materializes Secrets → apps start. Until unseal, secret-consuming apps
|
||||
simply stay Pending on missing Secrets — same as any missing dependency.
|
||||
- **Break-glass:** `pass` holds unseal key + root token (GPG, offline-capable).
|
||||
With those two, everything is recoverable from a raft snapshot alone.
|
||||
- **Ansible Vault password:** `ansible/vault-password.sh` reads
|
||||
`ANSIBLE_VAULT_PASSWORD` from the environment with no offline backup as of
|
||||
2026-07-14. It gates decryption of several Ansible-Vault files (PBS
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# Traffic classes on the k8s nodes
|
||||
|
||||
Issue #576. Five classes, each on its own VLAN.
|
||||
|
||||
## Where each class runs
|
||||
|
||||
| class | link | mechanism |
|
||||
|---|---|---|
|
||||
| Ceph replication | `ceph-san`, VLAN 49 | the whole link is handed to the OSD pod (Multus `host-device`) |
|
||||
| pod↔pod | `pod0`, VLAN 48 | pod-sourced traffic is routed from table 47; bird puts the peers' pod CIDRs there via `pod0` |
|
||||
| world↔pod | `ingress0`, VLAN 47 | BGP session sourced there (next hop of every LoadBalancer VIP); pod egress takes table 47's default |
|
||||
| node↔node | `node0`, VLAN 46 | the node InternalIP and etcd live there; so do apiserver↔kubelet, cilium-health and host→remote-pod traffic |
|
||||
| world↔node | `mgmt0`, VLAN 40 | API VIP for kubectl, talosctl from outside, node egress (DNS, NTP, image pulls) |
|
||||
|
||||
Hard constraint, set by the owner: inter-node and pod traffic never traverses
|
||||
the UDM. Everything but world↔node stays on the 10G switch. VLAN 46 and 48 are
|
||||
statically addressed, without a gateway; VLAN 46 is isolated in UniFi.
|
||||
|
||||
## Pod traffic: one table
|
||||
|
||||
Two rules, one per family, send every packet with a pod source to table 47
|
||||
(`talos/patches/site-homelab.yaml`, priorities 1000/1001, below main). The
|
||||
table decides by longest prefix; no rule carves out exceptions.
|
||||
|
||||
bird (`kubernetes/infrastructure/bird/`) owns table 47 and fills it with:
|
||||
|
||||
- the node's attached networks — `mgmt0`, `ingress0`, `pod0`, `node0` —
|
||||
taken from the interfaces;
|
||||
- the peers' pod CIDRs, learned over an iBGP mesh on `pod0`, next hop the
|
||||
peer's `pod0` address;
|
||||
- the node's own pod CIDR via `cilium_host`, originated rather than read back
|
||||
from the kernel (reading it back made BIRD depend on the shape of Cilium's
|
||||
route, and KRT rejects the IPv4 one);
|
||||
- the egress default via the UDM on `ingress0`, from the node annotations
|
||||
`net.renner.uno/egress-gw-v4/v6`.
|
||||
|
||||
`node0` has to be in the table: a pod reaching a node address — the
|
||||
`kubernetes` ClusterIP, which socket LB rewrites to the apiserver's advertise
|
||||
address, or a hostNetwork pod — would otherwise take the default to the UDM.
|
||||
|
||||
The routing decision runs before Cilium's masquerade hook, so the rule sees the
|
||||
pod address and the masquerade then rewrites it to the egress link's.
|
||||
|
||||
Node-originated traffic does not match the rules and uses main, where Cilium
|
||||
routes each peer's pod CIDR via the peer's InternalIP — on `node0`.
|
||||
|
||||
No address is written into this repo twice. Each node publishes its `pod0`
|
||||
addresses as `nodeAnnotations` beside the `VLANConfig` that assigns them; the
|
||||
pod CIDRs come from the Node objects. bird's init container renders both from
|
||||
the API and ends with `bird -p`, so BIRD's own parser gates startup.
|
||||
|
||||
## Node↔node
|
||||
|
||||
`kubelet.nodeIP.validSubnets` and `cluster.etcd.advertisedSubnets` point at
|
||||
VLAN 46. Talos picks one InternalIP per family, IPv4 first — the service CIDR
|
||||
is IPv4, and the apiserver's advertise address must match it.
|
||||
|
||||
The API VIP stays on `mgmt0` for kubectl. No in-cluster client uses it:
|
||||
kubelet, scheduler, controller-manager and Cilium go through KubePrism
|
||||
(`localhost:7445`), which balances over every address of every control-plane
|
||||
member; pods use the ClusterIP.
|
||||
|
||||
Talos never updates an existing etcd member's peer URLs
|
||||
(siderolabs/talos#12720). Changing `advertisedSubnets` means, per member:
|
||||
stage the config, `talosctl etcd forfeit-leadership`, `talosctl etcd leave`,
|
||||
reboot. `leave` removes only `/var/lib/etcd`; the node rejoins as a learner
|
||||
and is promoted. EPHEMERAL does not need a wipe.
|
||||
|
||||
## Moving node IPs: the mixed state
|
||||
|
||||
While some nodes have their InternalIP on the new network and some on the old,
|
||||
host→remote-pod traffic leaves with a node address that is not an InternalIP:
|
||||
the kernel picks the source from the route to the peer's InternalIP. Cilium
|
||||
classifies that as `world`, and every ingress policy that admits only
|
||||
`host`/`remote-node` (rook-ceph, monitoring, …) drops it. Kernel Ceph clients
|
||||
lose the OSDs on the other side; RBD mounts hang.
|
||||
|
||||
Bridge it for the duration with a temporary `CiliumClusterwideNetworkPolicy`
|
||||
admitting the node subnets of both networks, both families, with
|
||||
`enableDefaultDeny: false` so it restricts nothing else, and delete it once
|
||||
every node has moved. Keep all OSD nodes schedulable while one is drained:
|
||||
draining two takes Ceph below its data availability.
|
||||
|
||||
## Failure behaviour
|
||||
|
||||
If bird stops, table 47 empties, the rules find nothing and fall through to
|
||||
main: pod traffic returns to the node networks, unseparated rather than
|
||||
misrouted. The same holds during a cold rebuild until Gitea serves bird's
|
||||
image. The default route lives in table 47, not in the Talos config, so a
|
||||
default cannot outlive the specific routes and send pod traffic to the UDM.
|
||||
|
||||
## Sources
|
||||
|
||||
Cilium v1.20 `pkg/datapath/linux/node.go`; BIRD 2.17 user manual (kernel
|
||||
protocol, `strict bind`, `direct`); Talos v1.13 `RoutingRuleConfig`,
|
||||
`machine.nodeAnnotations`, configuration patching (lists are appended,
|
||||
`$patch: delete`), etcd `LeaveCluster`; KubePrism docs;
|
||||
siderolabs/talos#12720. PRs #831, #833, #835, #836, #837, #842, #852, #854,
|
||||
#855, #857, #858, #918, #919, #922; closed unmerged: #830, #838, #839.
|
||||
@@ -1,111 +0,0 @@
|
||||
# Cilium: one owner
|
||||
|
||||
## The rule
|
||||
|
||||
Cilium is owned by **Flux**, through `infrastructure/cilium/helmrelease.yaml`.
|
||||
Talos provides the conditions for a CNI and nothing else. Never both.
|
||||
|
||||
Talos-only would also be a valid answer — it is the simpler one, and it keeps a
|
||||
cold rebuild self-forming. It is rejected here only because it costs Flux's
|
||||
reconciliation, image automation and value ergonomics for the one component
|
||||
most likely to need a careful, reviewed change.
|
||||
|
||||
## Where we are
|
||||
|
||||
Both own it, which is the state this document exists to end.
|
||||
|
||||
- 22 Cilium objects are listed in the `talos-bootstrap-manifests-inventory`
|
||||
ConfigMap: both DaemonSets, the operator Deployment, both ConfigMaps, the
|
||||
ServiceAccounts, the ClusterRoles/Bindings and the `cilium-secrets`
|
||||
namespace. That is the whole installation, not a fragment.
|
||||
- The same objects carry `meta.helm.sh/release-name: cilium`.
|
||||
- `cilium-config` has three field managers: `helm-controller`, `talos` and
|
||||
`kubectl-patch`.
|
||||
|
||||
The consequences are not theoretical. The seed sat at Cilium 1.19.2 for twelve
|
||||
days while the chart was on 1.20.1, and `upgrade-k8s --dry-run` shows that a
|
||||
Kubernetes upgrade in that state downgrades the CNI and switches off socket
|
||||
load balancing. Separately, `enable-bgp-control-plane` and the two
|
||||
native-routing CIDRs exist in no file at all — applied by hand, so a cold
|
||||
rebuild would have come up with BGP off while the peering CRDs in git made it
|
||||
look configured.
|
||||
|
||||
Nothing fails when these drift. That is the whole problem.
|
||||
|
||||
## Target state
|
||||
|
||||
**Talos** keeps exactly this, in `talos/patches/cluster.yaml`, and gains nothing
|
||||
else:
|
||||
|
||||
```yaml
|
||||
cluster:
|
||||
network:
|
||||
cni:
|
||||
name: none
|
||||
proxy:
|
||||
disabled: true
|
||||
```
|
||||
|
||||
No `inlineManifests` entry for cilium. `talos/patches/cilium-cni.yaml` is
|
||||
deleted.
|
||||
|
||||
**Flux** owns everything else. `helmrelease.yaml` is the single source for
|
||||
every Cilium setting, including the three that were hand-patched.
|
||||
|
||||
**A cold rebuild** installs Cilium once, by hand, between `talosctl bootstrap`
|
||||
and the Flux phase, from the same HelmRelease values. This is the cost of the
|
||||
decision and it is deliberate: the cluster no longer forms itself from the
|
||||
machine config alone. Given `bootstrap.sh talos` is already dead and Step 2 of
|
||||
RUNBOOK-cold-rebuild.md is already a manual procedure, the marginal cost is one
|
||||
documented command.
|
||||
|
||||
## Transition
|
||||
|
||||
The order matters, because Talos prunes what its inventory used to list and no
|
||||
longer does — removing the manifest naively deletes the CNI.
|
||||
|
||||
1. **Track the untracked settings first.** `bgpControlPlane.enabled`,
|
||||
`ipv4NativeRoutingCIDR`, `ipv6NativeRoutingCIDR` into the HelmRelease. Note
|
||||
this is not a pure change of ownership: enabling bgpControlPlane through the
|
||||
chart also emits five companion keys the hand-patch never set, so Cilium has
|
||||
been running on its own defaults for router-id allocation. Verify the BGP
|
||||
sessions after Flux applies it.
|
||||
|
||||
2. **Remove cilium from `cluster.inlineManifests`.** This has no immediate
|
||||
effect. Evidence: Talos was upgraded to 1.13.9 four days ago and Cilium
|
||||
stayed at 1.20.1 — an OS upgrade does not reconcile bootstrap manifests.
|
||||
Only `upgrade-k8s` does.
|
||||
|
||||
3. **Prove the prune before running it.** `upgrade-k8s --dry-run
|
||||
--manifests-no-prune` and confirm the 22 objects are not listed for
|
||||
deletion. `NoPrune` is honoured by the dry run as well as the apply
|
||||
(`pkg/cluster/kubernetes/talos_managed.go`), so this is a real test and not
|
||||
a reading of the flag description.
|
||||
|
||||
4. **Run `upgrade-k8s --manifests-no-prune`.** The inventory is rewritten
|
||||
without Cilium and the objects survive. Helm is then sole owner. This is the
|
||||
same run that takes Kubernetes 1.35.2 → 1.36.3.
|
||||
|
||||
5. **Verify** the inventory lists no Cilium, the agents did not restart, and
|
||||
the BGP sessions are still established.
|
||||
|
||||
6. **Delete `talos/patches/cilium-cni.yaml`** and add the install step to
|
||||
RUNBOOK-cold-rebuild.md.
|
||||
|
||||
## Not part of this, and not a defect either
|
||||
|
||||
The cluster runs `routingMode: tunnel` (the chart default, set nowhere) while
|
||||
also advertising pod CIDRs over BGP. That is not one layer too many — the two
|
||||
carry different directions:
|
||||
|
||||
- **VXLAN** carries pod-to-pod between nodes, directly, node to node.
|
||||
- **BGP** gives the UDM routes into the pod CIDRs so traffic from the LAN can
|
||||
reach pods. That path goes through the router by definition.
|
||||
|
||||
Dropping the encapsulation in favour of native routing would not simplify
|
||||
anything; it would push inter-node pod traffic onto the underlay path and
|
||||
through the UDM, replacing a direct node-to-node hop with a router hop.
|
||||
|
||||
`routingMode` being unset is still worth knowing — the current behaviour is a
|
||||
default nobody chose rather than a decision anyone recorded — but the default
|
||||
is the right one here, and this document is not the place to revisit it.
|
||||
@@ -0,0 +1,194 @@
|
||||
# Proposal — separate the four traffic classes onto their own VLANs
|
||||
|
||||
Status: draft. The question this started with is **answered** — from the Cilium
|
||||
source and flag reference, not from a test — and the answer changes the shape
|
||||
of the proposal rather than confirming it.
|
||||
|
||||
Supersedes the framing in issue #576, which described this as "give node
|
||||
management its own NIC, and Ceph public its own VLAN". That was two of four
|
||||
classes and one of them turned out already solved.
|
||||
|
||||
## The four classes
|
||||
|
||||
| class | what it is | where it goes today |
|
||||
|---|---|---|
|
||||
| **Ceph replication** | OSD-to-OSD heartbeat, replication, recovery | VLAN 49, its own X710 port — **separated** |
|
||||
| **cluster-internal** | pod-to-pod east-west, including Ceph client I/O | pod network, VXLAN over `mgmt0` |
|
||||
| **cluster-external** | ingress to LoadBalancer VIPs, egress to LAN and internet | `mgmt0` |
|
||||
| **management** | Talos API, etcd, kube-apiserver, kubelet | `mgmt0` |
|
||||
|
||||
Three of four share one interface. That is not a design decision — until the
|
||||
bare-metal conversion there was one NIC to work with.
|
||||
|
||||
Ceph client I/O living on the pod network is correct rather than a leftover:
|
||||
that traffic *is* cluster-internal. Ceph is done.
|
||||
|
||||
## Principle
|
||||
|
||||
**Each class gets its own VLAN. Which physical interface a VLAN lands on is a
|
||||
separate, changeable decision.**
|
||||
|
||||
Defining classes by VLAN means the separation survives recabling, port failures
|
||||
and capacity changes, and it does not require one port per class. Defining them
|
||||
by interface would freeze a capacity decision into the architecture.
|
||||
|
||||
## The finding that shapes everything else
|
||||
|
||||
The original open question was whether `nodePort.directRoutingDevice` moves the
|
||||
next-hop of Cilium's direct routes, or only selects an interface. It is
|
||||
answerable from the source, and the answer is: **only the interface.**
|
||||
|
||||
`installDirectRoute` takes the next-hop as a parameter:
|
||||
|
||||
```go
|
||||
func installDirectRoute(log *slog.Logger, prefix netip.Prefix, nodeIP net.IP,
|
||||
skipUnreachable bool) (routeSpec *netlink.Route, err error)
|
||||
```
|
||||
|
||||
and `nodeUpdate` supplies it as `newNode.GetNodeIP(false)`. `GetNodeIP` returns,
|
||||
in priority order, `NodeInternalIP` → `NodeExternalIP` → anything else. The
|
||||
same `newIP4` is what tunnel mode uses as the VXLAN endpoint.
|
||||
|
||||
`NodeInternalIP` comes from the Kubernetes Node object. `--ipv4-node` looks like
|
||||
an override but is not one: `initFromConfig()` calls `SetNodeInternalIP` and
|
||||
`initFromK8s()` runs **after** it and overwrites the value. In a Kubernetes
|
||||
cluster there is no path by which Cilium's node IP differs from the kubelet's
|
||||
`InternalIP`.
|
||||
|
||||
`--direct-routing-device` is documented as "Device name used to connect nodes in
|
||||
direct routing mode" — a device, not an address.
|
||||
|
||||
**So cluster-internal traffic is welded to the node IP, and the node IP is the
|
||||
kubelet InternalIP.** Two consequences:
|
||||
|
||||
- The class cannot be moved off whatever VLAN the node IP lives on.
|
||||
- **Routing mode is irrelevant to this problem.** Tunnel mode sources the VXLAN
|
||||
from the node IP; native mode uses it as the direct-route next-hop. Native
|
||||
routing buys nothing here, so the disruptive, non-rolling step this proposal
|
||||
previously contained is simply gone.
|
||||
|
||||
## Consequence: draw the line the other way
|
||||
|
||||
If cluster-internal cannot leave the node IP, then separation has to come from
|
||||
moving *management services* off it — and each one has to be checked
|
||||
individually, because they do not share a mechanism.
|
||||
|
||||
| management service | can it leave the node IP? | lever |
|
||||
|---|---|---|
|
||||
| etcd | **yes** | `cluster.etcd.advertisedSubnets` (+ `listenSubnets`, which defaults to it) |
|
||||
| kube-apiserver endpoint | **yes** | already a VIP — `Layer2VIPConfig` with a `link:` field |
|
||||
| Talos API | **n/a** | `apid` answers on every node address (measured); a client and firewall choice, not a bind setting |
|
||||
| kubelet | **no** | it *is* the node IP, by definition |
|
||||
|
||||
So four VLANs are reachable, but the boundary does not fall where the class
|
||||
names suggest: **kubelet stays with cluster-internal.** That is the honest
|
||||
result, and it is worth stating plainly rather than filing kubelet under
|
||||
management and hoping.
|
||||
|
||||
The remaining classes keep the levers established earlier:
|
||||
|
||||
- **cluster-external ingress** — `CiliumBGPNodeConfigOverride.spec.bgpInstances[].peers[].localAddress`.
|
||||
For eBGP the session source is the next-hop, so pinning the session pins
|
||||
where return traffic arrives. The session to the UDM has been running for
|
||||
months.
|
||||
- **cluster-external egress** — eBPF masquerading follows the output device, so
|
||||
this is a default-route decision plus `enable-masquerade-to-route-source`
|
||||
(today `false`).
|
||||
- **Ceph replication** — Multus macvlan on VLAN 49, done.
|
||||
|
||||
## The general escape hatch
|
||||
|
||||
Ceph is separated because its OSDs got a second interface and stopped using the
|
||||
pod network for replication. That is the *only* mechanism that fully detaches a
|
||||
workload from the node IP, and it generalises: anything that must not ride the
|
||||
node IP gets its own `NetworkAttachmentDefinition`.
|
||||
|
||||
It is also expensive per workload — an attachment, an IPAM pool, a pod spec
|
||||
change. Worth it for Ceph replication. Not worth it for east-west traffic in
|
||||
general.
|
||||
|
||||
## Recommended mapping
|
||||
|
||||
Two ways to arrange this, and they differ only in which VLAN gets relabelled:
|
||||
|
||||
**A — move the node IP to a new cluster VLAN.** VLAN 40 keeps etcd, the API VIP
|
||||
and Talos API. Cleanest match to the class names, but it changes every node's
|
||||
identity, which is the one address the whole cluster is administered through.
|
||||
|
||||
**B (recommended) — leave the node IP on VLAN 40 and move management off it.**
|
||||
VLAN 40 becomes the cluster VLAN; a new VLAN carries etcd and the API VIP.
|
||||
Identical end state, and no node ever changes its InternalIP.
|
||||
|
||||
Under B:
|
||||
|
||||
| VLAN | carries |
|
||||
|---|---|
|
||||
| 49 | Ceph replication |
|
||||
| 40 (relabelled *cluster*) | pod east-west, kubelet |
|
||||
| new *management* | etcd, kube-apiserver VIP, Talos API by convention |
|
||||
| new *external* | LoadBalancer ingress, egress default route |
|
||||
|
||||
The property the user asked for — management carries only management traffic —
|
||||
holds under B. The label moves; the property is what matters.
|
||||
|
||||
## Migration shape
|
||||
|
||||
Every step is rolling and reversible. The routing-mode change is gone.
|
||||
|
||||
1. **New VLANs** on the switch, trunked to the X710. Nothing uses them.
|
||||
2. **Addresses** on the new VLANs via Talos, one node at a time, `--mode=try`.
|
||||
Still unused; this only proves the L2 works.
|
||||
3. **etcd** to the management VLAN via `advertisedSubnets`. One node at a time,
|
||||
waiting for a healthy quorum between each.
|
||||
4. **API VIP** to the management VLAN via `Layer2VIPConfig.link`. DNS for
|
||||
`k8s.renner.uno` follows.
|
||||
5. **cluster-external**: BGP `localAddress`, default route, masquerade flag.
|
||||
Reversible by reverting the override; failure is visible immediately in
|
||||
`cilium-dbg shell -- bgp/peers`.
|
||||
6. Verify VLAN 40 by what is *absent* from it, not by what still works.
|
||||
|
||||
Step 3 is the one that deserves care — etcd is the only step where a mistake
|
||||
costs quorum rather than connectivity.
|
||||
|
||||
## What this does not buy
|
||||
|
||||
Management currently shares a 10 Gb link and is not contended. Nothing observed
|
||||
today is slow because of this. The case for the split is that traffic classes
|
||||
with different failure modes and security postures should not be
|
||||
indistinguishable — not that something is broken.
|
||||
|
||||
## Sources and measurements
|
||||
|
||||
Source, Cilium v1.20.1 (the running version):
|
||||
|
||||
- `pkg/datapath/linux/node.go` — `installDirectRoute(…, nodeIP net.IP, …)`,
|
||||
fed from `newIP4 = newNode.GetNodeIP(false)`; `createDirectRouteSpec` rejects
|
||||
a next-hop that is not directly reachable.
|
||||
- `pkg/node/types/node.go` — `GetNodeIP` prefers `NodeInternalIP`.
|
||||
- `pkg/node/sync/local_node_sync.go` — `initFromConfig()` applies
|
||||
`--ipv4-node`, `initFromK8s()` runs afterwards and overwrites it from the
|
||||
Kubernetes Node.
|
||||
- `cilium-agent` flag reference — `--direct-routing-device`: "Device name used
|
||||
to connect nodes in direct routing mode".
|
||||
|
||||
Talos:
|
||||
|
||||
- Multihoming guide — `cluster.etcd.advertisedSubnets`; `listenSubnets`
|
||||
defaults to it. kube-apiserver and Talos API listen addresses are not
|
||||
covered there.
|
||||
- `kubernetes/talos/patches/k8s-{1,2,3}.yaml` — `Layer2VIPConfig` for
|
||||
`10.2.40.60` with `link: mgmt0`.
|
||||
|
||||
Measured on the cluster:
|
||||
|
||||
- `apid` answered on a node address that is not the InternalIP, so the Talos
|
||||
API is not bound to one address.
|
||||
- k8s-1 currently holds one routable v4 address plus the VIP on `mgmt0`; its
|
||||
InternalIP had changed since the last session, which is why nothing here
|
||||
names an address as fixed.
|
||||
- Cilium runs `routing-mode: tunnel` with `enable-masquerade-to-route-source`
|
||||
unset and no direct-routing device configured.
|
||||
|
||||
Not verified: whether the kube-apiserver's `--advertise-address` follows the
|
||||
node IP independently of the VIP. It does not affect the recommendation, since
|
||||
clients reach the API through the VIP either way.
|
||||
@@ -1,315 +0,0 @@
|
||||
# Wiki: local web interface and agent API, as a modular .NET solution
|
||||
|
||||
## Goal
|
||||
|
||||
A web interface for the Notes wiki that runs entirely in the cluster, and an
|
||||
HTTP API through which agents ask the wiki typed questions and deposit facts.
|
||||
Built as one C# solution with a MudBlazor front end, and built so that every
|
||||
external dependency is replaceable: where the notes live, which model embeds
|
||||
them, what judges a typed question, what runs an agent, how the vector index
|
||||
is stored. The first adapters are what the homelab uses today. The point of
|
||||
the design is that the next one is a project and a config line, not a
|
||||
rewrite.
|
||||
|
||||
## Where we are
|
||||
|
||||
- `wiki-index` (Python, ns `ai`) syncs `wiki/` from `k.renner/notes` by blob
|
||||
SHA, embeds through `wiki-embed` (bge-m3 on llama-server), answers
|
||||
`POST /rank`. It keeps slug, title and vector, not the text. LAN-only behind
|
||||
Traefik BasicAuth; the Gitea webhook hits the ClusterIP.
|
||||
- `~/Notes/bin/wiki_rank.py` and `wiki_ask.py` rank through the index, build a
|
||||
document context from a local vault checkout, send it with typed questions
|
||||
to TypeSafe, gate on `covered`. The wiki-synth agent composes answers from
|
||||
those judgements.
|
||||
- New knowledge enters as a file in `raw/` (facts with provenance, never
|
||||
hypotheses), distilled nightly in CI.
|
||||
- The house C# project, shelly-manager, sets the conventions: net10.0, an
|
||||
`.slnx` with `src/` and `test/`, xUnit, MudBlazor 9, Blazor Web App with
|
||||
interactive server rendering, minimal APIs, an Authentik proxy-header
|
||||
authentication handler for humans and an API-key handler for machines, a
|
||||
multi-stage Dockerfile, `dotnet test` on every push and a kaniko release
|
||||
pipeline to the Gitea registry. Human-facing apps sit behind an Authentik
|
||||
outpost (`<app>-gate`) with a CNP that admits only that outpost.
|
||||
|
||||
## Architecture: ports in the core, adapters around it
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Wiki.Host (ASP.NET Core + Blazor/Mud) │ composition root,
|
||||
│ /api/v1, /rank, /webhook, UI pages │ config, auth
|
||||
└───────────────┬──────────────────────────┘
|
||||
│ uses
|
||||
┌───────────────▼──────────────────────────┐
|
||||
│ Wiki.Application (use cases) │ Sync, Rank, Ask,
|
||||
│ │ DepositFact, Queue-
|
||||
└───────────────┬──────────────────────────┘ Question, Evaluate
|
||||
│ depends only on
|
||||
┌───────────────▼──────────────────────────┐
|
||||
│ Wiki.Core (domain + ports) │ Note, RawSource,
|
||||
│ no package references │ Report, LinkGraph,
|
||||
└──────────────────────────────────────────┘ the interfaces
|
||||
implemented by ▲ ▲ ▲ ▲ ▲
|
||||
Adapters.Gitea Adapters.LlamaCpp Adapters.TypeSafe Adapters.FileIndex Adapters.Anthropic
|
||||
(later: Database, Ollama/Onnx, local Jev, pgvector/Qdrant, OpenAI-compatible answer models)
|
||||
```
|
||||
|
||||
Rules that keep it modular, enforced by project references and a test:
|
||||
|
||||
- `Wiki.Core` references nothing. It holds the domain types and the port
|
||||
interfaces. Adapters reference Core only; they never reference each other
|
||||
or the host. `Wiki.Host` is the only project that knows which adapters
|
||||
exist.
|
||||
- Every port has a **contract test suite** in `test/Wiki.Contracts` that
|
||||
runs against every adapter of that port. A new adapter is done when the
|
||||
suite is green. That is what makes "swap the storage" a safe operation.
|
||||
- Adapters are selected by configuration and registered by name (keyed
|
||||
services), so several can be active at once. The UI's lab page and the
|
||||
evaluator use that to compare, the API uses the one marked active.
|
||||
- Nothing in Core or Application knows about HTTP, Gitea, TypeSafe, files or
|
||||
Kubernetes.
|
||||
|
||||
### Ports
|
||||
|
||||
| port | what it abstracts | first adapter | examples of the next ones |
|
||||
|---|---|---|---|
|
||||
| `INoteStore` | read notes, raw sources and reports at a head; create a new document (a fact becomes a raw note in the application layer, the store only moves text) | Gitea repository API (`k.renner/notes`, blob SHA) | git checkout on a volume; a database (CNPG Postgres) with the vault imported; an Obsidian sync folder |
|
||||
| `IEmbeddingModel` | text → vector, with a model id and dimension | llama-server `/v1/embeddings` (bge-m3 via wiki-embed, the model that won the German-document comparison on accuracy per cost) | the qwen3 embedding variants from that comparison; ONNX in-process; any OpenAI-compatible endpoint |
|
||||
| `IVectorIndex` | store vectors per (model id, document), query top-k | in-memory cosine with a file snapshot per model, as today | pgvector in the database; Qdrant |
|
||||
| `IChunker` | split a document into addressable units for typed questions | paragraph splitter (headings, fences), as in `wiki_ask.py` | sentence windows; semantic chunks |
|
||||
| `IJudge` | typed questions over a state: Choice, Noul, Score, with probabilities | TypeSafe System One (`jev-latest`, cloud; the wiki records no locally hosted Jev today) | a locally hosted Jev with the same request shape, should TypeSafe offer one; a constrained-output local LLM adapter, flagged as uncalibrated |
|
||||
| `IAnswerModel` | prose from a question plus evidence: the selected paragraphs with their notes and the judgements; must cite by slug; streaming | Anthropic Messages API (Claude, model from config, Sonnet by default) | any OpenAI-compatible endpoint, including the local llama-server; a different Claude model per question |
|
||||
| `IEvaluator` | measure a retrieval or judge configuration against the labelled set (questions and cited sources in `output/`) | recall@k, hit rate, MRR, as `wiki_rank_eval.py` | judge accuracy against hand-labelled paragraphs |
|
||||
| `IIdentity` | who is calling, for attribution of facts and saved answers | Authentik proxy headers (humans), API key (machines), as in shelly-manager | OIDC bearer tokens |
|
||||
|
||||
Two ports deserve a sentence of scope each. The judge returns judgements
|
||||
with probabilities, never prose, and the UI never presents its output as an
|
||||
answer, only as ranked, quoted evidence. The answer model turns that
|
||||
evidence into words and is allowed nothing else: it sees the selected
|
||||
paragraphs and the judgements, not the whole context, and its output is
|
||||
shown next to the evidence it was given. Both are single model calls inside
|
||||
the request. The service runs no agent: no tool loop, no vault edits. The
|
||||
wiki-synth agent in a Claude session remains the path for artifacts and for
|
||||
multi-question synthesis, and it can call `/questions` itself.
|
||||
|
||||
### Solution layout
|
||||
|
||||
```
|
||||
Wiki.slnx
|
||||
src/
|
||||
Wiki.Core/ domain types, ports, frontmatter and link parsing
|
||||
Wiki.Application/ use cases; depends on Core only
|
||||
Wiki.Adapters.Gitea/ INoteStore (read at a head, create)
|
||||
Wiki.Adapters.LlamaCpp/ IEmbeddingModel (OpenAI-compatible embeddings)
|
||||
Wiki.Adapters.TypeSafe/ IJudge (System One client)
|
||||
Wiki.Adapters.FileIndex/ IVectorIndex (memory + snapshot per model)
|
||||
Wiki.Adapters.Anthropic/ IAnswerModel (Messages API, streaming)
|
||||
Wiki.Host/ ASP.NET Core: API, webhook, MudBlazor UI, auth,
|
||||
composition root, appsettings
|
||||
test/
|
||||
Wiki.Core.Tests/ parsing, link graph, chunker
|
||||
Wiki.Contracts/ one suite per port, parameterised over adapters
|
||||
Wiki.Application.Tests/ use cases with fake ports
|
||||
Wiki.Host.Tests/ API surface, auth handlers
|
||||
```
|
||||
|
||||
Own repository `k.renner/wiki`, same shape as shelly-manager: `Dockerfile`,
|
||||
`.woodpecker/test.yml` on pull requests, `.woodpecker/release.yml` building
|
||||
the image with kaniko into the Gitea registry on push to `main`, tagged
|
||||
`build.<unix-ts>` plus the commit SHA so Flux image automation can follow
|
||||
it. The homelab repo holds only the manifests. `claude-bot` needs write
|
||||
access to the new repository.
|
||||
|
||||
Two things the shelly-manager history settles in advance: the TypeSafe
|
||||
client is written from the documented field contract (endpoint, bearer key,
|
||||
`jev-latest`, Noul/Choice/Score, 255-option cap, 32k-token state budget),
|
||||
there is no prior C# client to copy; and the UI needs no login page, because
|
||||
identity comes from the outpost's headers, which avoids the Blazor-circuit
|
||||
sign-in constraint shelly-manager ran into.
|
||||
|
||||
### Configuration
|
||||
|
||||
Adapters are declared in `appsettings` and selected by name; secrets come
|
||||
through environment variables, filled by ExternalSecret, never from the file.
|
||||
|
||||
```json
|
||||
"Wiki": {
|
||||
"NoteStore": { "Active": "gitea", "gitea": { "Repository": "k.renner/notes", "Branch": "main" } },
|
||||
"Embeddings": {
|
||||
"Active": "bge-m3",
|
||||
"Models": {
|
||||
"bge-m3": { "Adapter": "LlamaCpp", "Url": "http://wiki-embed.ai.svc:8080", "Model": "bge-m3" },
|
||||
"e5-large": { "Adapter": "Ollama", "Url": "http://ollama.ai.svc:11434", "Model": "multilingual-e5-large" }
|
||||
}
|
||||
},
|
||||
"Judges": {
|
||||
"Active": "typesafe",
|
||||
"Models": {
|
||||
"typesafe": { "Adapter": "TypeSafe", "Url": "https://api.typesafe.ai/v1/systemone", "Model": "jev-latest" },
|
||||
"jev-local": { "Adapter": "TypeSafe", "Url": "http://jev.ai.svc:8080/v1/systemone", "Model": "jev" } // illustration: same adapter, other endpoint
|
||||
}
|
||||
},
|
||||
"Index": { "Active": "file", "file": { "Path": "/data/index" } },
|
||||
"AnswerModels": {
|
||||
"Active": "claude-sonnet",
|
||||
"Models": {
|
||||
"claude-sonnet": { "Adapter": "Anthropic", "Model": "claude-sonnet-5" },
|
||||
"qwen-local": { "Adapter": "OpenAI", "Url": "http://llama-server.ai.svc:8080/v1", "Model": "qwen3.6-35b-a3b" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every embedding model gets its own index namespace. Switching the active
|
||||
model is a config change after the new index has been built in the
|
||||
background; until then both exist and can be compared. Nothing is
|
||||
re-embedded in place.
|
||||
|
||||
## The web interface (MudBlazor)
|
||||
|
||||
Blazor Web App, interactive server rendering, hosted in `Wiki.Host`. Static
|
||||
assets vendored into the image, no CDN. Pages:
|
||||
|
||||
1. **Search**: a query box, results ranked by the active index with score and
|
||||
tags; for a phrased question, optionally the paragraph-level hits from the
|
||||
judge with their probabilities, shown as quoted evidence.
|
||||
2. **Note**: frontmatter, rendered body with `[[wikilinks]]` resolved,
|
||||
backlinks, sources linking to their raw file.
|
||||
3. **Browse**: by tag, by `updated`, recently changed, orphans.
|
||||
4. **Answers**: a question box. The answer streams in within seconds: rank,
|
||||
judge, compose, all in the request. Below it the evidence: the paragraphs
|
||||
the judge selected with their notes and probabilities, and the `covered`
|
||||
gate; a low gate is shown as "the wiki does not cover this" instead of an
|
||||
answer. A save button writes the answer to `output/` with the wiki-synth
|
||||
frontmatter (`question`, `sources`, `confidence`), so it joins the
|
||||
labelled set. Underneath, the saved answers, newest first, including the
|
||||
ones the wiki-synth agent wrote from a session.
|
||||
5. **Maintenance**: the raw queue with ages, the last consolidation and audit
|
||||
reports, index and sync status.
|
||||
6. **Lab**: pick any registered embedding model and judge, run the same query
|
||||
through them side by side, and run the evaluator on the labelled set to
|
||||
get recall@k per configuration. This is the page for "play around with
|
||||
different models".
|
||||
7. **Settings**: read-only view of the active adapters and their health.
|
||||
|
||||
No editing of notes in the browser. The vault stays a git repository edited
|
||||
by the consolidate agent and by hand.
|
||||
|
||||
## API v1
|
||||
|
||||
Reads are authenticated by the hostname's gate (humans) or API key
|
||||
(machines); writes always need an API key so a fact carries its author.
|
||||
|
||||
| method and path | purpose |
|
||||
|---|---|
|
||||
| `GET /api/v1/status` | index commit and age, counts, active adapters |
|
||||
| `GET /api/v1/notes?q=&tag=&limit=` | list; `q` ranks through the active index |
|
||||
| `GET /api/v1/notes/{slug}` | frontmatter, body, links, backlinks, sources |
|
||||
| `GET /api/v1/tags` | tags with counts |
|
||||
| `POST /api/v1/rank` | `{question, k, model?}` → ranked slugs; also served at `/rank` for the existing scripts |
|
||||
| `POST /api/v1/ask` | `{queries, questions, k, judge?, model?}` → context, typed judgements with the answering paragraphs verbatim, `covered`. For agents and code. |
|
||||
| `POST /api/v1/questions` | `{question, k?, judge?, model?, answerModel?, save?}` → the answer text with `[[slug]]` citations, the evidence (paragraphs, probabilities), `covered`, model and token usage; streamed as server-sent events when requested; with `save`, the `output/` path and commit |
|
||||
| `POST /api/v1/facts` | deposit a raw source: title, source, `kind: fact`, observed_at, content, tags → path and commit |
|
||||
| `GET /api/v1/raw` | the distill queue with ages |
|
||||
| `GET /api/v1/reports` | `output/` documents by type, newest first |
|
||||
| `POST /api/v1/lab/eval` | run the evaluator for a (model, judge) pair |
|
||||
| `POST /webhook` | Gitea push, HMAC as today |
|
||||
|
||||
`POST /api/v1/facts` is rejected without `source`, with a `kind` other than
|
||||
`fact`, or with content under a minimum length. The raw rule is enforced by
|
||||
shape: there is no field for a hypothesis. The file gets the vault's
|
||||
provenance frontmatter with `captured_by` set from the API key's identity.
|
||||
|
||||
`POST /api/v1/ask` is `wiki_ask.py` moved server-side, so the scripts become
|
||||
thin clients and agents no longer need a vault checkout. Its result is
|
||||
judgements and quotations, never prose.
|
||||
|
||||
`POST /api/v1/questions` is `/ask` plus one answer-model call, in process:
|
||||
rank the question, build the context, run the default typed questions
|
||||
(answered, where, covered), hand the selected paragraphs and judgements to
|
||||
the answer model, stream the prose back. Below the `covered` threshold it
|
||||
returns the gap instead of an answer. Time is the sum of the parts, about
|
||||
0.2 s rank, 1 to 4 s judge, a few seconds of generation. Cost is one model
|
||||
call over a few thousand tokens, a few cents on Sonnet, against a quarter
|
||||
dollar for a full agent run.
|
||||
|
||||
## Auth and network
|
||||
|
||||
- Humans: `wiki.renner.uno`, LAN-only (no `dns.renner.uno/public` label),
|
||||
Ingress backend `ak-outpost-wiki-gate:9000`, a `wiki-gate-egress` CNP in
|
||||
`authentik`, the proxy-header handler trusts `X-Authentik-*` only from that
|
||||
outpost.
|
||||
- Machines: `wiki-index.renner.uno` with the API-key handler; the workstation
|
||||
key stays where it is. CI reaches the ClusterIP.
|
||||
- Secrets (Gitea token, webhook secret, API keys, TypeSafe key, Anthropic
|
||||
key) reach the
|
||||
pod as environment variables from Kubernetes Secrets filled by
|
||||
ExternalSecret from the `openbao` ClusterSecretStore. The pod itself has
|
||||
no OpenBao access. The Python service fetched its secrets from OpenBao
|
||||
directly through its own Kubernetes-auth role; that role, its policy and
|
||||
the `ai` → `openbao` CNP entry are retired with it.
|
||||
- Egress from the pod: Gitea, wiki-embed, `api.typesafe.ai:443`,
|
||||
`api.anthropic.com:443`. Every
|
||||
further adapter that talks to a new endpoint gets its own CNP line, which
|
||||
is the one place adding an adapter touches the cluster.
|
||||
|
||||
## Health endpoints
|
||||
|
||||
Three endpoints with three different meanings, wired to the .NET health
|
||||
checks with tags so that the probes and the humans see the right thing:
|
||||
|
||||
| endpoint | meaning | probe |
|
||||
|---|---|---|
|
||||
| `GET /health` | the process serves HTTP. Always 200 once Kestrel listens; no dependency checks. | liveness |
|
||||
| `GET /ready` | the active note store has synced at least once and the active index is loaded; 503 while the first sync or index build runs. A failed later sync keeps the last good state and stays ready. | readiness, plus a startup probe with enough budget for the first sync (the Python service was killed mid-build when its `/health` returned 503 during the initial sync) |
|
||||
| `GET /api/v1/status` | the full picture for humans and the Settings page: index commit and age, counts, queue lengths, and one entry per registered adapter with its last check result and latency. | none |
|
||||
|
||||
Adapter checks (Gitea reachable, embeddings endpoint answering, judge
|
||||
endpoint answering, answer model answering) are reported in `/status` and
|
||||
on the Settings page and are never part of `/ready`: a judge outage must not
|
||||
take the reader down. Their failures show up as red entries, not as
|
||||
restarts.
|
||||
|
||||
## Phases
|
||||
|
||||
One PR each, verified before the next. The Python `wiki-index` keeps running
|
||||
until phase 3 proves parity.
|
||||
|
||||
0. **Skeleton.** Repository, solution, `Wiki.Core` with the ports and domain
|
||||
types, `Wiki.Contracts` with the suites, `Wiki.Host` serving `/health`,
|
||||
`/ready` and `/api/v1/status`. CI: test and release pipelines. Verify:
|
||||
image in the registry, pipeline green, the Deployment's three probes
|
||||
wired and the pod Ready.
|
||||
1. **Parity with the Python service.** Gitea, LlamaCpp and FileIndex
|
||||
adapters, the sync use case, `/rank` and `/webhook`. Verify: the 40-query
|
||||
recall eval gives the same numbers as the Python service on the same
|
||||
commit; `wiki_rank.py --probe-index` against the new service passes.
|
||||
2. **Read API and UI.** Notes, tags, raw, reports; Search, Note, Browse,
|
||||
Maintenance pages; `wiki-gate`. Verify: a note renders with resolved
|
||||
links; an unauthenticated request is redirected to Authentik.
|
||||
3. **Ask.** Chunker, TypeSafe adapter, `/api/v1/ask`, scripts as clients.
|
||||
Verify: `test_wiki_ask.py` passes against the API; a wiki-synth run with
|
||||
the definition unchanged. Then the Python service is retired and the
|
||||
`wiki-index` hostname points at the new one.
|
||||
4. **Facts.** API keys as an ExternalSecret, the deposit-fact use case, `/api/v1/facts`. Verify:
|
||||
a fact posted from the workstation shows as a `claude-bot` commit and in
|
||||
the next distill.
|
||||
5. **Questions.** Anthropic adapter, `/api/v1/questions` with streaming,
|
||||
the Answers page with save. Verify: a question answered in under ten
|
||||
seconds with citations that resolve to the shown evidence; a question the
|
||||
wiki does not cover returns the gap; a saved answer appears in `output/`
|
||||
with the wiki-synth frontmatter and is picked up by the evaluator.
|
||||
6. **Lab.** Evaluator, a second embedding model and a second answer model
|
||||
registered, the Lab page. Verify: two models compared on the labelled set
|
||||
from the UI.
|
||||
|
||||
## Not in scope
|
||||
|
||||
- Editing notes in the browser.
|
||||
- Replacing Obsidian; the vault stays a git repository and `notes-push.path`
|
||||
keeps pushing.
|
||||
- Public exposure; LAN and VPN only.
|
||||
- Running or triggering agents. The service makes single model calls inside
|
||||
a request; it never runs a tool loop or edits the vault. Distill,
|
||||
consolidate and multi-question synthesis stay Claude processes in CI or in
|
||||
a session; the service shows their inputs and outputs and serves their API
|
||||
calls.
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
Converts one MS-01 from "Proxmox host running a Talos VM" to "Talos on bare
|
||||
metal", and swaps its Ceph OSD onto a Samsung PM983 at the same time. Written
|
||||
after doing jupiter on 2026-08-25.
|
||||
|
||||
All three nodes are now on bare metal — `talosctl get systeminformation`
|
||||
reports Minisforum MS-01 hardware with distinct chassis serials for k8s-1,
|
||||
k8s-2 and k8s-3, with no hypervisor in between. Kept as the procedure for a
|
||||
rebuild, not as outstanding work.
|
||||
after doing jupiter on 2026-08-25; saturn and neptune remain.
|
||||
|
||||
Everything here that reads like an over-specific warning is something that
|
||||
actually happened. The conversion itself is straightforward — the failures were
|
||||
@@ -203,7 +198,8 @@ talosctl gen secrets --from-controlplane-config cp.yaml -o secrets.yaml
|
||||
talosctl gen config homelab https://k8s.renner.uno:6443 \
|
||||
--with-secrets secrets.yaml --output-types controlplane \
|
||||
--config-patch @talos/patches/cluster.yaml \
|
||||
--config-patch @talos/patches/containerd-layer-retention.yaml \
|
||||
--config-patch @<cilium additive half> \
|
||||
--config-patch @talos/patches/registry-mirror.yaml \
|
||||
--config-patch @talos/patches/k8s-N.yaml -o install.yaml
|
||||
```
|
||||
|
||||
@@ -212,11 +208,9 @@ Confirm `cluster.id` matches the running cluster before applying.
|
||||
Strip the generated `HostnameConfig` document — it collides with
|
||||
`machine.network.hostname` from the per-node patch. `bootstrap.sh` does the same.
|
||||
|
||||
There is no CNI patch to pass any more. Talos supplies only `cni: none` and
|
||||
`proxy: disabled`; Cilium is installed from its HelmRelease values, which is
|
||||
Step 2b of RUNBOOK-cold-rebuild.md. A node converted into an existing cluster
|
||||
gets its CNI from the Cilium DaemonSet already running there, so nothing extra
|
||||
is needed here.
|
||||
`cilium-cni.yaml` cannot be passed whole: its `$patch: delete` document fails
|
||||
against a base with no `inlineManifests` (issue #581). Use only the additive
|
||||
second document.
|
||||
|
||||
Validate:
|
||||
|
||||
@@ -432,8 +426,8 @@ is why it belongs here.
|
||||
among its devices but not `ens19`, because `ens19` is deliberately
|
||||
address-less. Cilium therefore owns `mgmt0` and sees VLAN 40 frames already
|
||||
untagged by the 8021q layer, while VLAN 30 frames only ever meet the filter
|
||||
on the shared parent `uplink`, still tagged. Any future address-less VLAN
|
||||
carrying pod traffic will need the same treatment.
|
||||
on the shared parent `enp2s0f0np0`, still tagged. Any future address-less
|
||||
VLAN carrying pod traffic will need the same treatment.
|
||||
|
||||
The symptom does not look like a VLAN problem. A tc-layer drop reaches an
|
||||
AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports `no buffer space
|
||||
|
||||
@@ -67,12 +67,10 @@ These must exist outside the cluster or the rebuild cannot complete:
|
||||
`flux` phase creates the `sops-age` Secret, and any not-yet-migrated sops
|
||||
files decrypt with it. (Post-OpenBao-phase-4 this may be retired — see
|
||||
`MIGRATION-sops-openbao.md`.)
|
||||
- [ ] **OpenBao seal key, recovery key + root token** — in the GPG `pass`
|
||||
store (`homelab/openbao/seal-key`, `homelab/openbao/unseal-key` — the
|
||||
former shamir key, now the recovery key — and `homelab/openbao/root-token`),
|
||||
and the GPG private key backed up off-machine. The seal key is the one
|
||||
the snapshots are encrypted under: without it, no OpenBao snapshot can be
|
||||
restored and no secret-consuming app can start.
|
||||
- [ ] **OpenBao unseal key + root token** — in the GPG `pass` store
|
||||
(`homelab/openbao/unseal-key`, `homelab/openbao/root-token`), and the GPG
|
||||
private key backed up off-machine. Without the unseal key, no OpenBao
|
||||
snapshot can be restored and no secret-consuming app can start.
|
||||
- [ ] **OpenBao raft snapshot** reachable — nightly PVC snapshots
|
||||
(`infrastructure/openbao/snapshot-cronjob.yaml`) plus the Velero
|
||||
`openbao-daily` offsite copy. In a total-loss rebuild the offsite Velero
|
||||
@@ -107,7 +105,7 @@ config, and the nodes are addressed by name.
|
||||
## Step 1b — boot all three machines from install media
|
||||
|
||||
```
|
||||
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.13.10/metal-amd64.iso
|
||||
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.13.9/metal-amd64.iso
|
||||
```
|
||||
|
||||
Write it to a USB stick and boot each MS-01 from it. Each comes up in
|
||||
@@ -121,11 +119,9 @@ against do not exist, and the node ends up with no address on any interface.
|
||||
Beyond that it carries:
|
||||
|
||||
- `siderolabs/intel-ucode` — no hypervisor supplies microcode here
|
||||
- `siderolabs/i915` — the Alder Lake-P Iris Xe. The Intel GPU device plugin
|
||||
(`infrastructure/intel-gpu-plugin`) turns this into the schedulable resource
|
||||
`gpu.intel.com/i915`, which `apps/llama-server` requests for LLM inference
|
||||
and which any hardware-transcoding workload would use too. Without the
|
||||
extension the plugin finds no `/dev/dri` and advertises nothing
|
||||
- `siderolabs/i915` — the Alder Lake-P Iris Xe. Nothing requests `/dev/dri`
|
||||
today; present so wanting hardware transcoding later is a config change
|
||||
rather than another rolling reinstall
|
||||
- `siderolabs/thunderbolt` — inert until `machine.kernel.modules` requests
|
||||
`thunderbolt`/`thunderbolt_net`, present for the same reason. Note its udev
|
||||
rule auto-authorises any Thunderbolt device attached once those modules load
|
||||
@@ -140,18 +136,11 @@ in `talos/patches/k8s-N.yaml`.
|
||||
|
||||
## Step 2 — talos: form the cluster + get kubeconfig
|
||||
|
||||
`kubernetes/bootstrap.sh talos` does this, including the CNI install that used
|
||||
to be a hand-typed step. It was unusable for months because it passed
|
||||
`cilium-cni.yaml` whole and the file's `$patch: delete` document failed against
|
||||
a fresh config (#581); that file no longer exists, so the failure is gone with
|
||||
it.
|
||||
|
||||
Verified as far as it can be without wiping a cluster: `talosctl gen config`
|
||||
with exactly the patches the script passes produces a config carrying
|
||||
`cni: none` and `proxy: disabled` that passes `talosctl validate --mode metal`.
|
||||
The apply-and-bootstrap half has not been exercised since the rewrite. Read the
|
||||
manual procedure below before running the script, so you know what it is doing
|
||||
and can take over at any step if it stops.
|
||||
**Do not use `kubernetes/bootstrap.sh talos`.** That phase cannot generate a
|
||||
config: it passes `talos/patches/cilium-cni.yaml` whole, and that file's
|
||||
`$patch: delete` document fails against a base with no `inlineManifests`
|
||||
(#581). Its other phases — `flux`, `wait-apps` — are still used, in Steps 3, 5
|
||||
and 6; only config generation is broken.
|
||||
|
||||
The procedure below is what actually works, and is the same one
|
||||
`RUNBOOK-baremetal-conversion.md` uses.
|
||||
@@ -171,17 +160,17 @@ for N in 1 2 3; do
|
||||
talosctl gen config homelab https://k8s.renner.uno:6443 \
|
||||
--with-secrets secrets.yaml --output-types controlplane \
|
||||
--config-patch @talos/patches/cluster.yaml \
|
||||
--config-patch @talos/patches/containerd-layer-retention.yaml \
|
||||
--config-patch @<cilium additive half> \
|
||||
--config-patch @talos/patches/registry-mirror.yaml \
|
||||
--config-patch @talos/patches/k8s-$N.yaml -o install-k8s-$N.yaml
|
||||
done
|
||||
```
|
||||
|
||||
There is no CNI patch here any more. Talos supplies `cni: none` and
|
||||
`proxy: disabled` from `cluster.yaml` and nothing else; Cilium is installed in
|
||||
Step 2b below. See PROPOSAL-cilium-single-owner.md.
|
||||
|
||||
One thing the generator gets wrong for this repo, and it bites silently:
|
||||
Two things the generator gets wrong for this repo, both of which will bite
|
||||
silently:
|
||||
|
||||
- **`cilium-cni.yaml` cannot be passed whole** — extract only its additive
|
||||
second document. See issue #581.
|
||||
- **Strip the generated `HostnameConfig`** — it collides with
|
||||
`machine.network.hostname` from the per-node patch.
|
||||
|
||||
@@ -220,53 +209,8 @@ talosctl -n k8s-1.srv.renner.uno bootstrap
|
||||
talosctl -n k8s-1.srv.renner.uno kubeconfig .
|
||||
```
|
||||
|
||||
`scripts/import-cluster-access.sh` is the supported way to get the kubeconfig
|
||||
into place afterwards.
|
||||
|
||||
The nodes will **not** reach `Ready` here. Talos is configured with `cni: none`,
|
||||
so there is no pod network until Step 2b — expect `NotReady` with
|
||||
`container runtime network not ready` and carry on.
|
||||
|
||||
## Step 2b — cilium: install the CNI
|
||||
|
||||
Nothing schedules without it, Flux included, so this comes before Step 3. Talos
|
||||
no longer carries a rendered copy of Cilium; `helmrelease.yaml` is the only
|
||||
place it is configured, and this installs from those same values so the cluster
|
||||
comes up on what git describes rather than on a second, drifting copy. See
|
||||
PROPOSAL-cilium-single-owner.md for why it works this way.
|
||||
|
||||
`bootstrap.sh talos` already did this. What follows is the same thing by hand,
|
||||
for when the script stopped partway or you are not using it.
|
||||
|
||||
Every input is read out of `helmrelease.yaml`. Nothing here is a version or a
|
||||
URL you have to keep in step with anything — copy the block as it stands:
|
||||
|
||||
```sh
|
||||
HR=kubernetes/infrastructure/cilium/helmrelease.yaml
|
||||
|
||||
helm repo add cilium "$(yq -r 'select(.kind == "HelmRepository") | .spec.url' "$HR")"
|
||||
helm repo update cilium
|
||||
|
||||
yq 'select(.kind == "HelmRelease") | .spec.values' "$HR" > /tmp/cilium-values.yaml
|
||||
|
||||
helm install cilium cilium/cilium \
|
||||
--version "$(yq -r 'select(.kind == "HelmRelease") | .spec.chart.spec.version' "$HR")" \
|
||||
--namespace kube-system \
|
||||
-f /tmp/cilium-values.yaml \
|
||||
--set prometheus.serviceMonitor.enabled=false
|
||||
```
|
||||
|
||||
`--version` takes the HelmRelease's range verbatim; helm resolves it to the same
|
||||
release Flux would pick, so the two cannot diverge. That is the point of writing
|
||||
it this way — a pinned version in a runbook is a copy nobody maintains, and the
|
||||
next rebuild installs something the HelmRelease will immediately fight.
|
||||
|
||||
The one literal is `serviceMonitor`, disabled for this install only: its CRD
|
||||
arrives with the monitoring stack much later and the object would fail to apply.
|
||||
Flux adds the real one once it owns the release. Nothing else is overridden,
|
||||
which is what lets helm-controller adopt this release rather than replace it.
|
||||
|
||||
Ends when all three nodes are `Ready`.
|
||||
Ends when all three nodes are `Ready`. `scripts/import-cluster-access.sh` is the
|
||||
supported way to get the kubeconfig into place afterwards.
|
||||
|
||||
## Step 3 — flux: bootstrap GitOps **from the GitHub mirror** (break-glass)
|
||||
|
||||
@@ -296,65 +240,42 @@ output** — a green step over a still-forming Ceph is a known trap.
|
||||
> Steady-state (non-rebuild) bootstrap is just `kubernetes/bootstrap.sh flux`
|
||||
> with `GITEA_TOKEN` set; `FLUX_SOURCE` defaults to `gitea`.
|
||||
|
||||
## Step 4 — openbao: restore the snapshot
|
||||
## Step 4 — openbao: restore the snapshot + unseal
|
||||
|
||||
`bootstrap.sh flux` does this step itself (`restore_openbao`): it creates the
|
||||
`openbao-seal-key` Secret from `pass` before Flux brings OpenBao up, initialises
|
||||
the empty server, restores the newest raft snapshot straight out of the Velero
|
||||
kopia repository, and waits until every replica has unsealed itself from the
|
||||
static seal. What follows is the same procedure by hand, for when the script
|
||||
is not an option.
|
||||
|
||||
OpenBao unseals itself from the static seal (`openbao-seal-key` Secret, key
|
||||
`key`, the 32-byte base64 key from `pass show homelab/openbao/seal-key`). The
|
||||
snapshots are encrypted under that key, so it must be the same key the backed
|
||||
up cluster used — a different one restores nothing. Until the snapshot is
|
||||
restored, the `ClusterSecretStore` stays NotReady, ESO cannot materialize
|
||||
Secrets, and every secret-consuming app stays Pending.
|
||||
After Step 3, OpenBao comes up **sealed and empty**. Until it is restored and
|
||||
unsealed, the `ClusterSecretStore` stays NotReady, ESO cannot materialize
|
||||
Secrets, and every secret-consuming app stays Pending. This step is **manual**
|
||||
(the unseal key lives in `pass`, off-cluster by design).
|
||||
|
||||
```sh
|
||||
# 1. The seal key, before the pods start (a pod without it never starts).
|
||||
kubectl create namespace openbao --dry-run=client -o yaml | kubectl apply -f -
|
||||
kubectl -n openbao create secret generic openbao-seal-key \
|
||||
--from-literal=key="$(pass show homelab/openbao/seal-key)"
|
||||
# 1. Wait for the OpenBao pods to exist (all three sealed, 0/1 Ready is expected).
|
||||
kubectl -n openbao get pods
|
||||
|
||||
# 2. Wait for openbao-0 (uninitialised: 0/1 Ready is expected), then
|
||||
# initialise it. With an auto-seal, init hands out recovery keys and the
|
||||
# server unseals itself. These keys are throwaway: the restore replaces them.
|
||||
kubectl -n openbao exec -it openbao-0 -- bao operator init \
|
||||
-recovery-shares=1 -recovery-threshold=1
|
||||
|
||||
# 3. Restore the most recent raft snapshot. Source it from the snapshot PVC
|
||||
# 2. Restore the most recent raft snapshot. Source it from the snapshot PVC
|
||||
# (infrastructure/openbao/snapshot-cronjob.yaml) or, in a total-loss rebuild,
|
||||
# from the Velero `openbao-daily` offsite copy. Root token from the init
|
||||
# output. No -force: OpenBao checks the snapshot was written under this
|
||||
# server's seal key and refuses otherwise ("different autoseal key") — that
|
||||
# refusal is the signal the Secret is wrong. -force would skip the check and
|
||||
# leave a server that can never unseal.
|
||||
# from the Velero `openbao-daily` offsite copy. Copy it into the pod, then:
|
||||
kubectl -n openbao cp <snapshot>.snap openbao-0:/tmp/restore.snap
|
||||
kubectl -n openbao exec -it openbao-0 -- \
|
||||
env BAO_TOKEN=<init root token> bao operator raft snapshot restore /tmp/restore.snap
|
||||
kubectl -n openbao exec -it openbao-0 -- bao operator raft snapshot restore /tmp/restore.snap
|
||||
|
||||
# 4. The server seals against the snapshot's keyring and unseals itself from
|
||||
# the static seal; openbao-1 and openbao-2 follow (OrderedReady). Nothing
|
||||
# to type. Afterwards the tokens and recovery key from pass apply again.
|
||||
kubectl -n openbao get pods -w
|
||||
# 3. Unseal with the key from pass (threshold 1) — EVERY replica, same key.
|
||||
# A sealed server is neither a raft voter nor a standby, so unsealing only
|
||||
# openbao-0 leaves the three-peer cluster one node short of quorum.
|
||||
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste homelab/openbao/unseal-key
|
||||
kubectl -n openbao exec -it openbao-1 -- bao operator unseal # same key
|
||||
kubectl -n openbao exec -it openbao-2 -- bao operator unseal # same key
|
||||
```
|
||||
|
||||
Verify the store recovers before moving on:
|
||||
|
||||
```sh
|
||||
kubectl -n openbao exec openbao-0 -- bao status # want: Seal Type static, Sealed false
|
||||
kubectl get clustersecretstore openbao # want: Valid / Ready
|
||||
kubectl -n <ns> get externalsecret # want: SecretSynced
|
||||
kubectl get clustersecretstore openbao # want: Valid / Ready
|
||||
kubectl -n <ns> get externalsecret # want: SecretSynced
|
||||
```
|
||||
|
||||
> A replica that stays **sealed** after a restart could not use the static
|
||||
> seal — the Secret is missing or does not match the key the data was
|
||||
> written under. Fix the Secret and restart the pod; `bao operator unseal`
|
||||
> with the recovery key (`pass show homelab/openbao/unseal-key`) does not
|
||||
> unseal an auto-sealed server and is only for recovery-mode operations.
|
||||
> Details + rationale: `MIGRATION-sops-openbao.md`.
|
||||
> **Manual unseal is required after every OpenBao pod restart**, not just
|
||||
> during rebuilds, and for *each* replica that restarts — a rolling node
|
||||
> drain seals all three in turn. Details + rationale:
|
||||
> `MIGRATION-sops-openbao.md`.
|
||||
|
||||
## Step 5 — apps: let the rest reconcile
|
||||
|
||||
@@ -418,15 +339,14 @@ it: repo → **Settings → Repository → Mirror Settings** in Gitea, or
|
||||
- **`bootstrap.sh talos` is dead** — it cannot generate a config (#581), and
|
||||
Step 2 no longer uses it. Its `flux` and `wait-apps` phases are still used in
|
||||
Steps 3, 5 and 6 and work; do not read this as the whole script being gone.
|
||||
Its `flux` phase carries the OpenBao restore (Step 4); the seal key and the
|
||||
other secrets are read from `pass` or streamed in at run time and never live
|
||||
in CI.
|
||||
- **OpenBao unseals itself, but only under the seal key the snapshot was
|
||||
written with.** Three raft replicas mean the service survives losing one; a
|
||||
sealed server is neither a voter nor a standby. A pod that stays sealed after
|
||||
a restart has the wrong `openbao-seal-key` Secret — Step 4 covers this; it is
|
||||
repeated here because it is the step most likely to be half-done under
|
||||
pressure.
|
||||
It has also never had an `openbao` phase — Step 4 is manual because the
|
||||
unseal key must not live in CI; see issue #187 on whether to wire a
|
||||
semi-automated OpenBao restore.
|
||||
- **OpenBao seals on every restart and each replica must be unsealed
|
||||
individually.** Three raft replicas mean the service survives losing one, but
|
||||
a sealed server is neither a voter nor a standby — unsealing only one leaves
|
||||
the cluster short of quorum. Step 4 covers this; it is repeated here because
|
||||
it is the step most likely to be half-done under pressure.
|
||||
- Ceph's cluster network is a Multus attachment on VLAN 49 with whereabouts
|
||||
IPAM, not host networking. Nothing in the rebuild needs special handling for
|
||||
it — the nodes hold no address on that segment — but a rebuilt cluster will
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# Runbook — roll back a Home Assistant image bump
|
||||
|
||||
Use when an automatic Home Assistant update (a
|
||||
`chore(image-automation): bump ghcr.io/home-assistant/home-assistant:<new>`
|
||||
commit on `master`) breaks Home Assistant. The owner approves the rollback
|
||||
and its timing. Atlas writes the PR; Argus merges it.
|
||||
|
||||
## Why a plain `git revert` is not enough
|
||||
|
||||
Image automation (`kubernetes/infrastructure/image-automation/`) re-scans
|
||||
the registry every hour and rewrites the tag every 30 minutes to the
|
||||
highest release matching the `home-assistant` ImagePolicy. A plain revert
|
||||
of the bump commit would be bumped forward again within about an hour.
|
||||
|
||||
So the rollback commit does two things at once: it reverts the tag **and**
|
||||
drops the `# {"$imagepolicy": ...}` marker from that image line. Without
|
||||
the marker the Setters strategy has nothing to rewrite, so the old tag
|
||||
stays pinned. It is still one commit, and reverting that commit restores
|
||||
automation.
|
||||
|
||||
## Before you roll back
|
||||
|
||||
1. Find the bump commit:
|
||||
```sh
|
||||
git log --oneline -5 origin/master -- kubernetes/apps/home-assistant/deployment.yaml
|
||||
```
|
||||
2. Check the release notes of the broken version for a **database schema**
|
||||
change. Home Assistant migrates the recorder schema (CNPG cluster
|
||||
`ha-pg`) on start and cannot downgrade it. An older version started on a
|
||||
newer schema runs without recorder history until the schema matches
|
||||
again. If the bump crossed a schema change, tell the owner before going
|
||||
on: the choice is roll back without history, or restore the recorder
|
||||
database from backup as a separate step.
|
||||
|
||||
## Roll back (one commit, one PR)
|
||||
|
||||
```sh
|
||||
git worktree add -b atlas/ha-rollback-<old> "$WT" origin/master && cd "$WT"
|
||||
git revert --no-commit <bump-sha>
|
||||
# In kubernetes/apps/home-assistant/deployment.yaml, delete the trailing
|
||||
# # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
# from the home-assistant image line. Leave every other line alone.
|
||||
git diff --cached --stat # expect: 1 file, 1 line changed
|
||||
git commit -m "fix(home-assistant): pin <old>, pause image automation (bump to <new> broke HA)"
|
||||
git push -u origin HEAD
|
||||
```
|
||||
|
||||
The PR description states: broken version, pinned version, symptom, schema
|
||||
check result, and "revert this commit to resume automatic updates".
|
||||
HA-impact review and merge follow [README.md](../README.md#2-ha-impact-review-before-merge).
|
||||
|
||||
On merge Flux applies the Deployment (strategy `Recreate`): Home Assistant
|
||||
restarts once, about one to two minutes of downtime.
|
||||
|
||||
## Verify
|
||||
|
||||
```sh
|
||||
kubectl -n home-assistant get deploy home-assistant \
|
||||
-o jsonpath='{.spec.template.spec.containers[?(@.name=="home-assistant")].image}'
|
||||
kubectl -n home-assistant rollout status deploy/home-assistant
|
||||
```
|
||||
|
||||
Read-only commands only (see REN-240 protection rule 1). One hour after
|
||||
merge, confirm no new `chore(image-automation)` commit touched
|
||||
`deployment.yaml`.
|
||||
|
||||
## Resume automatic updates
|
||||
|
||||
When a fixed Home Assistant release is out, revert the rollback commit. The
|
||||
marker comes back, and image automation bumps to the newest release on its
|
||||
next pass. That bump is itself a Home Assistant restart.
|
||||
@@ -21,9 +21,6 @@ metadata:
|
||||
namespace: audiobookshelf
|
||||
labels:
|
||||
app.kubernetes.io/name: audiobookshelf
|
||||
annotations:
|
||||
# envFrom does not restart the pod when the ConfigMap changes; Reloader does.
|
||||
configmap.reloader.stakater.com/reload: audiobookshelf-config
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
@@ -70,7 +67,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.36.0 # {"$imagepolicy": "flux-system:audiobookshelf"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
@@ -122,7 +119,7 @@ spec:
|
||||
claimName: audiobookshelf-metadata
|
||||
- name: books
|
||||
persistentVolumeClaim:
|
||||
claimName: audiobookshelf-books-bulk
|
||||
claimName: audiobookshelf-books
|
||||
- name: le-ca
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
|
||||
@@ -35,14 +35,14 @@ spec:
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: audiobookshelf-books-bulk
|
||||
name: audiobookshelf-books
|
||||
namespace: audiobookshelf
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem-bulk
|
||||
storageClassName: ceph-filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 300Gi
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: barman-cloud-creds
|
||||
namespace: authentik
|
||||
annotations:
|
||||
# Prune-disabled — a standing guard kept from the seed->authentik-KS handoff (barman creds).
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: barman-cloud-creds # keeps the old Secret's name — consumers unchanged
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: seed/authentik/barman-creds
|
||||
@@ -25,13 +25,20 @@ data:
|
||||
attrs:
|
||||
local: true
|
||||
|
||||
# Retired OIDC layer. Access is gated by the paperless-gate
|
||||
# ProxyProvider below, which forwards identity as X-authentik-* headers
|
||||
# consumed via PAPERLESS_ENABLE_HTTP_REMOTE_USER.
|
||||
# Retired OIDC layer. Paperless's URL-level access is gated by the
|
||||
# paperless-gate ProxyProvider (below); user identity is forwarded
|
||||
# from the gate to paperless via the X-authentik-* request headers
|
||||
# and consumed by paperless via PAPERLESS_ENABLE_HTTP_REMOTE_USER
|
||||
# (see kubernetes/apps/paperless/paperless.yaml). One Authentik
|
||||
# Application, one Provider — the dual-OAuth2-flow setup with a
|
||||
# separate paperless OIDC client + django-allauth callback was
|
||||
# structurally fine but added a second consent screen on first
|
||||
# login for no real defense-in-depth gain.
|
||||
#
|
||||
# ORDER MATTERS: delete the Application BEFORE the OAuth2Provider.
|
||||
# Application.provider is on_delete=PROTECT, so the provider cannot be
|
||||
# removed while an Application still references it.
|
||||
# Order matters: delete the Application BEFORE the OAuth2Provider
|
||||
# because Application.provider is on_delete=PROTECT, so the
|
||||
# OAuth2Provider can't be removed while the Application still
|
||||
# references it.
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: paperless
|
||||
@@ -42,15 +49,22 @@ data:
|
||||
name: Paperless
|
||||
state: absent
|
||||
|
||||
# Orphan ProxyProvider, unbound — leftover DB state.
|
||||
# Also clean up the very-early orphan `paperless` ProxyProvider
|
||||
# left behind from this session's first attempts. It's not bound
|
||||
# to anything any more, just leftover state in the DB.
|
||||
- model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
name: paperless
|
||||
state: absent
|
||||
|
||||
# Dedicated forward-auth gate for paperless: its own outpost
|
||||
# Deployment + Service, so every request is authenticated before it
|
||||
# reaches paperless.
|
||||
# Dedicated forward-auth gate for paperless. This is a SEPARATE
|
||||
# outpost (its own Deployment + Service `ak-outpost-paperless-
|
||||
# gate`) running in front of paperless so every request is
|
||||
# authenticated and authorized by Authentik before it reaches
|
||||
# paperless's own attack surface. The gate sits on top of, and
|
||||
# separate from, the OIDC OAuth2Provider above (which is what
|
||||
# paperless's django-allauth uses for SSO once a request makes
|
||||
# it through the gate).
|
||||
- id: paperless-gate-provider
|
||||
model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
@@ -59,13 +73,16 @@ data:
|
||||
attrs:
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
# Proxy, NOT forward_domain: the outpost terminates the request
|
||||
# itself, so the trust boundary sits here rather than at the ingress
|
||||
# controller, which only routes to the outpost Service and never
|
||||
# reaches paperless directly. The outpost populates
|
||||
# X-Authentik-Username, and the paperless NetworkPolicy admits
|
||||
# traffic only from this outpost — so nothing else in the cluster
|
||||
# can bypass the gate and inject those headers.
|
||||
# Proxy (not forward_domain) so the outpost itself terminates
|
||||
# the request and forwards to paperless — moving the trust
|
||||
# boundary off ingress-nginx and onto paperless-gate. ingress-
|
||||
# nginx now only routes paperless.renner.uno to the outpost
|
||||
# service; it never talks to paperless directly. The outpost
|
||||
# is what populates X-Authentik-Username on the upstream
|
||||
# request, and the NetworkPolicy in the paperless namespace
|
||||
# only allows traffic to paperless from the gate outpost pod
|
||||
# itself, so ingress-nginx (or any other in-cluster workload)
|
||||
# cannot bypass the gate to inject headers.
|
||||
mode: proxy
|
||||
internal_host: http://paperless.paperless.svc.cluster.local
|
||||
internal_host_ssl_validation: false
|
||||
@@ -74,12 +91,19 @@ data:
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
# The static-asset tree passes anonymously, and must. A
|
||||
# <link rel="manifest"> is fetched with `credentials: 'omit'` per
|
||||
# the W3C spec, so the session cookie is never sent and every page
|
||||
# load turns into a redirect loop through the login origin. Django's
|
||||
# STATIC tree is public by design; MEDIA under /api/documents/*
|
||||
# stays gated.
|
||||
# Pass paperless's entire static-asset tree through anonymously.
|
||||
# Two reasons:
|
||||
# 1. <link rel="manifest"> is fetched with `credentials: 'omit'`
|
||||
# (W3C spec, no crossorigin attr) so the cookie is never sent
|
||||
# — every page load and every SPA navigation turned into a
|
||||
# 302 → auth.renner.uno round-trip with an orphan session JWT.
|
||||
# Same root cause as the retired music-assistant-gate (see
|
||||
# comment further below); paperless can't retire the gate so
|
||||
# it carves out the offending paths instead.
|
||||
# 2. Every CSS/JS/icon fetch otherwise validates the cookie at
|
||||
# the outpost — pure overhead on assets that carry no
|
||||
# authentication value. Django's STATIC tree is public by
|
||||
# design; MEDIA (under /api/documents/*) stays gated.
|
||||
skip_path_regex: ^/static/.*
|
||||
|
||||
- model: authentik_core.application
|
||||
@@ -106,14 +130,18 @@ data:
|
||||
negate: false
|
||||
failure_result: false
|
||||
|
||||
# Retired: the music-assistant gate. A forward-auth gate cannot work in
|
||||
# front of a PWA — the manifest and service worker are fetched with
|
||||
# `credentials: 'omit'`, and a cached service worker bypasses fresh
|
||||
# navigations, so the session cookie never reaches the installed app.
|
||||
# Do not reintroduce one there; MA delegates auth to Home Assistant.
|
||||
#
|
||||
# ORDER MATTERS: Application before Provider before Outpost, because
|
||||
# Application.provider is on_delete=PROTECT.
|
||||
# Retired: the music-assistant-gate ProxyProvider, Application,
|
||||
# and dedicated Outpost. The gate fundamentally conflicted with
|
||||
# MA's PWA model — Chrome fetches manifest.webmanifest / sw.js /
|
||||
# icons with `credentials: 'omit'` (per W3C spec, no crossorigin
|
||||
# attribute on MA's <link rel="manifest">), and the cached
|
||||
# service worker bypasses fresh navigations on PWA cold-start so
|
||||
# the Authentik cookie never lands in the WebAPK's cookie jar.
|
||||
# MA has its own auth (delegated to Home Assistant); the gate's
|
||||
# marginal value didn't justify the recurring breakage.
|
||||
# Order matters: Application before Provider before Outpost.
|
||||
# Application.provider is on_delete=PROTECT, so the Provider
|
||||
# can't be removed while the Application still references it.
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: music-assistant-gate
|
||||
@@ -129,13 +157,18 @@ data:
|
||||
name: music-assistant-gate
|
||||
state: absent
|
||||
|
||||
# Dedicated outpost, serving the in-cluster auth endpoint only.
|
||||
#
|
||||
# The browser-facing legs of the OAuth flow must happen on
|
||||
# auth.renner.uno, because that is the origin WebAuthn credentials are
|
||||
# bound to. Hence the ProxyProvider is attached to BOTH this outpost and
|
||||
# the embedded one, and authentik_host_browser points at auth.renner.uno
|
||||
# here too — so any redirect this outpost emits names the right host.
|
||||
# Dedicated outpost: its ONLY job is to serve the in-cluster
|
||||
# /outpost.goauthentik.io/auth/nginx endpoint that nginx
|
||||
# auth_request hits for every request to paperless. The
|
||||
# browser-facing legs of the OAuth flow (/start, /callback, the
|
||||
# `/if/...` login UI, /application/o/authorize, /api, etc.) all
|
||||
# need to happen on auth.renner.uno because that's the origin
|
||||
# WebAuthn/FIDO/TOTP credentials are bound to. So we attach the
|
||||
# ProxyProvider to BOTH outposts (see embedded outpost below) and
|
||||
# set authentik_host_browser to auth.renner.uno here too — the
|
||||
# nginx auth_request happens server-to-server, but if the
|
||||
# dedicated outpost ever does emit a redirect URL, it points at
|
||||
# the right host.
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: paperless-gate
|
||||
@@ -163,7 +196,12 @@ data:
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# Leftover Application from an earlier experiment.
|
||||
# Tear down the leftover paperless-proxy Application from an
|
||||
# earlier experiment. The orphan `paperless` ProxyProvider that
|
||||
# used to back paperless.app is left in place — it's PROTECTed
|
||||
# by Application.provider as long as anything references it, and
|
||||
# leftover orphans don't break anything. Delete via the Authentik
|
||||
# UI once everything's confirmed working.
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, paperless-proxy]]
|
||||
@@ -179,11 +217,15 @@ data:
|
||||
# ------------------------------------------------------------------
|
||||
# vscode config-editor gate
|
||||
#
|
||||
# code-server runs with --auth=none, as root, mounting the whole HA
|
||||
# /config including plaintext secrets.yaml and .storage. This gate is
|
||||
# the only thing in front of it: the outpost terminates the request and
|
||||
# enforces a passkey step-up, Traefik routes only to the outpost, and
|
||||
# the home-assistant CNP admits :8080 from the gate alone.
|
||||
# code-server (the HA /config editor sidecar) runs with --auth=none,
|
||||
# as root, mounting the whole HA /config incl. plaintext secrets.yaml
|
||||
# and .storage. This gate moves it behind Authentik with a passkey
|
||||
# step-up, mirroring the paperless-gate proxy-outpost pattern above:
|
||||
# the dedicated ak-outpost-vscode-gate terminates the request,
|
||||
# enforces the session, and forwards to the code-server Service.
|
||||
# Traefik only ever routes vscode.home.renner.uno to the outpost; the
|
||||
# home-assistant CNP is tightened so ONLY the gate outpost may reach
|
||||
# :8080 (see home-assistant-ns.yaml + vscode-gate-egress.yaml).
|
||||
# ------------------------------------------------------------------
|
||||
- id: vscode-config-editors-group
|
||||
model: authentik_core.group
|
||||
@@ -223,14 +265,17 @@ data:
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
# code-server is a service-worker app, so its manifest and SW are
|
||||
# fetched with credentials:'omit' and 302-loop unless carved out.
|
||||
# The editor's actual content rides authenticated websocket/API
|
||||
# paths and stays gated.
|
||||
#
|
||||
# This skip set is version-specific: re-check it after a
|
||||
# code-server bump, because a renamed asset path reintroduces the
|
||||
# loop silently.
|
||||
# code-server is a service-worker app: <link rel="manifest"> and
|
||||
# the service worker are fetched with credentials:'omit', which
|
||||
# turned into a /authorize redirect loop and forced the RETIREMENT
|
||||
# of the music-assistant gate (see comment above). Carve its
|
||||
# static/manifest/SW tree out so those cookieless fetches pass
|
||||
# through instead of 302-looping; the editor's actual content
|
||||
# (the /config tree) is served over authenticated websocket/API
|
||||
# paths, which stay gated.
|
||||
# NOTE: the exact skip set is code-server-version-specific and
|
||||
# MUST be confirmed post-deploy against the real redirect-loop
|
||||
# behaviour (see the PR validation checklist).
|
||||
skip_path_regex: ^/(_static/|manifest\.json|.*\.webmanifest|.*serviceWorker\.js).*
|
||||
|
||||
- model: authentik_core.application
|
||||
@@ -286,11 +331,16 @@ data:
|
||||
# ------------------------------------------------------------------
|
||||
# shelly-manager gate
|
||||
#
|
||||
# shelly-manager has no authentication of its own: anything that reaches
|
||||
# :8080 can rewrite device config, push scripts and read the stored
|
||||
# per-device AdminPassword values. Same shape as the vscode gate — the
|
||||
# outpost is the only control, Traefik routes only to it, and the CNP
|
||||
# admits :8080 from this outpost alone.
|
||||
# shelly-manager has no authentication of its own: anything that can
|
||||
# reach :8080 can rewrite Shelly device config, push scripts, and read
|
||||
# the stored per-device AdminPassword values. Until now the only
|
||||
# control was reachability of shelly-manager.home.renner.uno.
|
||||
#
|
||||
# Same shape as the vscode-gate above: a dedicated outpost terminates
|
||||
# the request, enforces a passkey step-up, and forwards to the
|
||||
# shelly-manager Service. Traefik only ever routes the hostname to the
|
||||
# outpost; the home-assistant CNP allows ONLY this outpost to reach
|
||||
# :8080 (see home-assistant-ns.yaml + shelly-manager-gate-egress.yaml).
|
||||
# ------------------------------------------------------------------
|
||||
- id: shelly-managers-group
|
||||
model: authentik_core.group
|
||||
@@ -877,379 +927,6 @@ data:
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# llama-server WebUI gate
|
||||
#
|
||||
# llama-server has no authentication of its own — it serves its chat UI
|
||||
# and a full OpenAI-compatible API to anyone who can reach the port. That
|
||||
# is why it was LAN-only until now. This gate is what makes a public
|
||||
# hostname defensible: the outpost terminates every request, enforces the
|
||||
# session, and only then forwards to llama-server.
|
||||
#
|
||||
# Same reasoning as vscode-gate, and the same passkey step-up: what sits
|
||||
# behind the gate is entirely unprotected, so the gate is the whole of the
|
||||
# access control rather than a second factor on top of an app login.
|
||||
#
|
||||
# In-cluster consumers (Home Assistant, Paperless, CI) are unaffected:
|
||||
# they address llama-server.ai.svc.cluster.local directly and never pass
|
||||
# through Traefik or this outpost.
|
||||
#
|
||||
# No group and no policy binding: any user who can authenticate gets in,
|
||||
# as with Immich, Audiobookshelf, Home Assistant and Music Assistant. The
|
||||
# gates that pin membership to a named user in git — vscode, esphome,
|
||||
# zigbee2mqtt, shelly-manager, prometheus — are admin interfaces, where
|
||||
# the allowlist is the point. A chat UI is not one of those, and deciding
|
||||
# its membership here would put an access-control choice in a manifest
|
||||
# instead of leaving it where the user directory belongs.
|
||||
# ------------------------------------------------------------------
|
||||
- id: llama-gate-provider
|
||||
model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
name: llama-gate
|
||||
state: present
|
||||
attrs:
|
||||
# Both flows, mirroring the Audiobookshelf provider. Setting only
|
||||
# authorization_flow leaves authentication on Authentik's default
|
||||
# flow, which is what made the first login clumsy — the step-up ran
|
||||
# on top of a password prompt instead of replacing it. With the
|
||||
# passwordless flow named explicitly, signing in is the passkey
|
||||
# itself.
|
||||
#
|
||||
# Referenced by !Find rather than !KeyOf: both flows are defined in
|
||||
# the passkey-stepup document of this ConfigMap, and !KeyOf only
|
||||
# resolves within one document.
|
||||
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
mode: proxy
|
||||
internal_host: http://llama-server.ai.svc.cluster.local:8080
|
||||
internal_host_ssl_validation: false
|
||||
external_host: https://llm.renner.uno
|
||||
cookie_domain: renner.uno
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
# The WebUI ships `<link rel="manifest">` and registers a service
|
||||
# worker. Per the W3C spec a manifest is fetched with
|
||||
# `credentials: 'omit'`, so it carries no session cookie and the gate
|
||||
# answers it with a 302 to auth.renner.uno — which turns every page
|
||||
# load into a redirect round-trip. This is the failure that forced
|
||||
# paperless-gate's own carve-out and that retired music-assistant-gate
|
||||
# outright. Both paths serve no authenticated content, so letting them
|
||||
# through anonymously costs nothing.
|
||||
skip_path_regex: ^/(manifest\.webmanifest|sw\.js)$
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: llama-gate
|
||||
state: present
|
||||
attrs:
|
||||
name: Local LLM (Gate)
|
||||
slug: llama-gate
|
||||
provider: !KeyOf llama-gate-provider
|
||||
meta_launch_url: https://llm.renner.uno
|
||||
meta_description: "Forward-auth gate in front of the local llama-server WebUI"
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: llama-gate
|
||||
state: present
|
||||
attrs:
|
||||
type: proxy
|
||||
service_connection: !KeyOf k8s-conn
|
||||
providers:
|
||||
- !KeyOf llama-gate-provider
|
||||
config:
|
||||
log_level: info
|
||||
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
|
||||
authentik_host_insecure: false
|
||||
authentik_host_browser: "https://auth.renner.uno/"
|
||||
refresh_interval: "minutes=5"
|
||||
container_image: null
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
kubernetes_service_type: ClusterIP
|
||||
kubernetes_disabled_components: ["ingress"]
|
||||
kubernetes_image_pull_secrets: []
|
||||
kubernetes_ingress_annotations: {}
|
||||
kubernetes_ingress_class_name: null
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# wiki-gate: the wiki service (ns ai) behind the same passkey flows as
|
||||
# llama-gate. The service trusts X-Authentik-Username only from pod
|
||||
# addresses, and its CNP admits only this outpost, so the gate is the
|
||||
# whole access control for wiki.renner.uno.
|
||||
- id: wiki-gate-provider
|
||||
model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
name: wiki-gate
|
||||
state: present
|
||||
attrs:
|
||||
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
mode: proxy
|
||||
internal_host: http://wiki.ai.svc.cluster.local:8080
|
||||
internal_host_ssl_validation: false
|
||||
external_host: https://wiki.renner.uno
|
||||
cookie_domain: renner.uno
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: wiki-gate
|
||||
state: present
|
||||
attrs:
|
||||
name: Wiki
|
||||
slug: wiki-gate
|
||||
provider: !KeyOf wiki-gate-provider
|
||||
meta_launch_url: https://wiki.renner.uno
|
||||
meta_description: "Notes wiki: search, notes, answers, maintenance"
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: wiki-gate
|
||||
state: present
|
||||
attrs:
|
||||
type: proxy
|
||||
service_connection: !KeyOf k8s-conn
|
||||
providers:
|
||||
- !KeyOf wiki-gate-provider
|
||||
config:
|
||||
log_level: info
|
||||
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
|
||||
authentik_host_insecure: false
|
||||
authentik_host_browser: "https://auth.renner.uno/"
|
||||
refresh_interval: "minutes=5"
|
||||
container_image: null
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
kubernetes_service_type: ClusterIP
|
||||
kubernetes_disabled_components: ["ingress"]
|
||||
kubernetes_image_pull_secrets: []
|
||||
kubernetes_ingress_annotations: {}
|
||||
kubernetes_ingress_class_name: null
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# paperclip-gate: Paperclip UI; the CNP admits only this outpost. Paperclip keeps its own login behind it.
|
||||
- id: paperclip-users-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: Paperclip Users
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
users:
|
||||
- !Find [authentik_core.user, [username, k.renner]]
|
||||
|
||||
- id: paperclip-gate-provider
|
||||
model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
name: paperclip-gate
|
||||
state: present
|
||||
attrs:
|
||||
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
mode: proxy
|
||||
internal_host: http://paperclip.paperclip.svc.cluster.local:3100
|
||||
internal_host_ssl_validation: false
|
||||
external_host: https://paperclip.renner.uno
|
||||
cookie_domain: renner.uno
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: paperclip-gate
|
||||
state: present
|
||||
attrs:
|
||||
name: Paperclip
|
||||
slug: paperclip-gate
|
||||
provider: !KeyOf paperclip-gate-provider
|
||||
meta_launch_url: https://paperclip.renner.uno
|
||||
meta_description: "Paperclip agent orchestration"
|
||||
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, paperclip-gate]]
|
||||
group: !KeyOf paperclip-users-group
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: paperclip-gate
|
||||
state: present
|
||||
attrs:
|
||||
type: proxy
|
||||
service_connection: !KeyOf k8s-conn
|
||||
providers:
|
||||
- !KeyOf paperclip-gate-provider
|
||||
config:
|
||||
log_level: info
|
||||
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
|
||||
authentik_host_insecure: false
|
||||
authentik_host_browser: "https://auth.renner.uno/"
|
||||
refresh_interval: "minutes=5"
|
||||
container_image: null
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
kubernetes_service_type: ClusterIP
|
||||
kubernetes_disabled_components: ["ingress"]
|
||||
kubernetes_image_pull_secrets: []
|
||||
kubernetes_ingress_annotations: {}
|
||||
kubernetes_ingress_class_name: null
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# navidrome-gate: the Navidrome web UI. Navidrome auto-creates the
|
||||
# X-Authentik-Username user on first login; the first user is admin.
|
||||
# /rest and /share bypass the gate at the Traefik router.
|
||||
- id: navidrome-gate-provider
|
||||
model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
name: navidrome-gate
|
||||
state: present
|
||||
attrs:
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
mode: proxy
|
||||
internal_host: http://navidrome.navidrome.svc.cluster.local
|
||||
internal_host_ssl_validation: false
|
||||
external_host: https://navidrome.renner.uno
|
||||
cookie_domain: renner.uno
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: navidrome-gate
|
||||
state: present
|
||||
attrs:
|
||||
name: Navidrome
|
||||
slug: navidrome-gate
|
||||
provider: !KeyOf navidrome-gate-provider
|
||||
meta_launch_url: https://navidrome.renner.uno
|
||||
meta_description: "Music streaming (Subsonic)"
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: navidrome-gate
|
||||
state: present
|
||||
attrs:
|
||||
type: proxy
|
||||
service_connection: !KeyOf k8s-conn
|
||||
providers:
|
||||
- !KeyOf navidrome-gate-provider
|
||||
config:
|
||||
log_level: info
|
||||
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
|
||||
authentik_host_insecure: false
|
||||
authentik_host_browser: "https://auth.renner.uno/"
|
||||
refresh_interval: "minutes=5"
|
||||
container_image: null
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
kubernetes_service_type: ClusterIP
|
||||
kubernetes_disabled_components: ["ingress"]
|
||||
kubernetes_image_pull_secrets: []
|
||||
kubernetes_ingress_annotations: {}
|
||||
kubernetes_ingress_class_name: null
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# lidarr-gate: Lidarr runs with AuthenticationMethod=External, so the gate
|
||||
# is its only access control.
|
||||
- id: music-librarians-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: Music Librarians
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
# Membership is owned here; blueprint apply reverts UI edits.
|
||||
users:
|
||||
- !Find [authentik_core.user, [username, k.renner]]
|
||||
|
||||
- id: lidarr-gate-provider
|
||||
model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
name: lidarr-gate
|
||||
state: present
|
||||
attrs:
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
mode: proxy
|
||||
internal_host: http://lidarr.navidrome.svc.cluster.local
|
||||
internal_host_ssl_validation: false
|
||||
external_host: https://lidarr.renner.uno
|
||||
cookie_domain: renner.uno
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: lidarr-gate
|
||||
state: present
|
||||
attrs:
|
||||
name: Lidarr
|
||||
slug: lidarr-gate
|
||||
provider: !KeyOf lidarr-gate-provider
|
||||
meta_launch_url: https://lidarr.renner.uno
|
||||
meta_description: "Music library gaps: discographies, Spotify artists"
|
||||
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, lidarr-gate]]
|
||||
group: !KeyOf music-librarians-group
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
failure_result: false
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: lidarr-gate
|
||||
state: present
|
||||
attrs:
|
||||
type: proxy
|
||||
service_connection: !KeyOf k8s-conn
|
||||
providers:
|
||||
- !KeyOf lidarr-gate-provider
|
||||
config:
|
||||
log_level: info
|
||||
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
|
||||
authentik_host_insecure: false
|
||||
authentik_host_browser: "https://auth.renner.uno/"
|
||||
refresh_interval: "minutes=5"
|
||||
container_image: null
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
kubernetes_service_type: ClusterIP
|
||||
kubernetes_disabled_components: ["ingress"]
|
||||
kubernetes_image_pull_secrets: []
|
||||
kubernetes_ingress_annotations: {}
|
||||
kubernetes_ingress_class_name: null
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: authentik Embedded Outpost
|
||||
|
||||
@@ -120,14 +120,7 @@ spec:
|
||||
server:
|
||||
resources:
|
||||
requests:
|
||||
# 500m, not 200m: the HPA below targets 70% of *this* request, so at
|
||||
# 200m the scale-up threshold sat at 140m — below the pod's own
|
||||
# ordinary peak. Measured over 7d, a warm server pod p99s at ~108m
|
||||
# and peaks at ~190m, and a freshly-started one burns ~300m booting
|
||||
# Django. That made the HPA oscillate 2<->3<->4 all day and pin at
|
||||
# maxReplicas (KubeHpaMaxedOut, issue #761). At 500m the threshold
|
||||
# is 350m/pod, above the boot burst, so only real load scales out.
|
||||
cpu: 500m
|
||||
cpu: 200m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
@@ -161,11 +154,7 @@ spec:
|
||||
worker:
|
||||
resources:
|
||||
requests:
|
||||
# Same reasoning as server above, and the worker is burstier still:
|
||||
# over 7d its per-pod p99 reaches ~336m and peaks hit ~630m while
|
||||
# running blueprint applies and scheduled tasks. 200m understated
|
||||
# that badly enough that routine task batches read as saturation.
|
||||
cpu: 500m
|
||||
cpu: 200m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
|
||||
@@ -15,4 +15,4 @@ spec:
|
||||
name: hetzner-s3-creds # keeps the old Secret's name — consumers unchanged
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: infrastructure/hetzner-s3
|
||||
key: seed/authentik/hetzner-creds
|
||||
|
||||
@@ -7,7 +7,6 @@ resources:
|
||||
- helmrelease.yaml
|
||||
- paperless-gate-ingress.yaml
|
||||
- paperless-gate-egress.yaml
|
||||
- paperclip-gate-egress.yaml
|
||||
- vscode-gate-egress.yaml
|
||||
- shelly-manager-gate-egress.yaml
|
||||
- esphome-gate-egress.yaml
|
||||
@@ -16,10 +15,7 @@ resources:
|
||||
- alertmanager-gate-egress.yaml
|
||||
- prometheus-gate-egress.yaml
|
||||
- wordpress-gate-egress.yaml
|
||||
- llama-gate-egress.yaml
|
||||
- wiki-gate-egress.yaml
|
||||
- navidrome-gate-egress.yaml
|
||||
- lidarr-gate-egress.yaml
|
||||
- outpost-servicemonitors.yaml
|
||||
- ldap-outpost-cert.yaml
|
||||
- ldap-outpost-lb.yaml
|
||||
- ldap-outpost-cnp.yaml
|
||||
@@ -31,4 +27,5 @@ resources:
|
||||
- cluster.yaml
|
||||
- objectstore.yaml
|
||||
- scheduled-backup.yaml
|
||||
- barman-creds-externalsecret.yaml
|
||||
- hetzner-creds-externalsecret.yaml
|
||||
|
||||
@@ -40,7 +40,7 @@ metadata:
|
||||
# so this Service produces no public record — LDAP stays
|
||||
# LAN-internal, which matches the threat model of the cleartext
|
||||
# bind on 389.
|
||||
external-dns.kubernetes.io/hostname: ldap.renner.uno
|
||||
external-dns.alpha.kubernetes.io/hostname: ldap.renner.uno
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
externalTrafficPolicy: Local
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Lets the lidarr-gate outpost reach Lidarr; the matching ingress rule is in
|
||||
# network-policies/navidrome-ns.yaml.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: lidarr-gate-egress
|
||||
namespace: authentik
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: lidarr-gate
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": navidrome
|
||||
app.kubernetes.io/name: lidarr
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8686"
|
||||
protocol: TCP
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
# Lets the llama-gate outpost reach llama-server. The cluster baseline CNP
|
||||
# (kubernetes/infrastructure/network-policies/authentik-ns.yaml) otherwise stops
|
||||
# any authentik-namespace pod from entering the ai namespace.
|
||||
#
|
||||
# The outpost is the trust boundary: it forwards to llama-server only after
|
||||
# validating the Authentik session, and the matching ingress rule in
|
||||
# ai-ns.yaml admits nothing else — so nothing in the cluster can route around
|
||||
# the gate to reach the unauthenticated API through the public hostname.
|
||||
#
|
||||
# Scoped to this outpost's pod label rather than the whole namespace, so the
|
||||
# other gates cannot reach llama-server either.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: llama-gate-egress
|
||||
namespace: authentik
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: llama-gate
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: llama-server
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
@@ -1,20 +0,0 @@
|
||||
# Lets the navidrome-gate outpost reach Navidrome; the matching ingress rule
|
||||
# is in network-policies/navidrome-ns.yaml.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: navidrome-gate-egress
|
||||
namespace: authentik
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: navidrome-gate
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": navidrome
|
||||
app.kubernetes.io/name: navidrome
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "4533"
|
||||
protocol: TCP
|
||||
+46
-13
@@ -1,10 +1,50 @@
|
||||
---
|
||||
# Only radius needs a hand-written ServiceMonitor. The goauthentik.io operator
|
||||
# creates one per outpost and Prometheus discovers those directly, because
|
||||
# serviceMonitorSelector here is empty (see monitoring/helmrelease-prometheus.yaml).
|
||||
# Standalone copies for ldap and paperless-gate used to live here as well; they
|
||||
# duplicated the operator's and were removed.
|
||||
#
|
||||
# The goauthentik.io operator (inside authentik-worker) auto-creates a
|
||||
# ServiceMonitor per outpost, but it does not stamp the
|
||||
# `release: kube-prometheus-stack` label that this cluster's Prometheus
|
||||
# requires on serviceMonitorSelector. Those operator-owned SMs are
|
||||
# silently ignored. Declare standalone SMs here with the correct label
|
||||
# so the outposts actually get scraped.
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: authentik-outpost-ldap
|
||||
namespace: authentik
|
||||
labels:
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
namespaceSelector:
|
||||
matchNames:
|
||||
- authentik
|
||||
selector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: ldap-outpost
|
||||
goauthentik.io/outpost-type: ldap
|
||||
endpoints:
|
||||
- port: http-metrics
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
---
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: authentik-outpost-paperless-gate
|
||||
namespace: authentik
|
||||
labels:
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
namespaceSelector:
|
||||
matchNames:
|
||||
- authentik
|
||||
selector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: paperless-gate
|
||||
goauthentik.io/outpost-type: proxy
|
||||
endpoints:
|
||||
- port: http-metrics
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
---
|
||||
# Radius requires extra plumbing. The radius binary listens on :9300 just
|
||||
# like ldap/proxy (hardcoded in authentik/lib/default.yml), but authentik's
|
||||
# Python operator (`providers/radius/controllers/kubernetes.py`) omits the
|
||||
@@ -13,11 +53,6 @@
|
||||
# it, radius does not. Result: the operator-managed `ak-outpost-local-radius`
|
||||
# Service exposes only UDP 1812. Add a sibling Service here that selects the
|
||||
# same pod labels and exposes 9300/TCP, then a ServiceMonitor for it.
|
||||
#
|
||||
# The operator's own `ak-outpost-local-radius` ServiceMonitor cannot cover
|
||||
# this: it selects on `app.kubernetes.io/managed-by: goauthentik.io` together
|
||||
# with `goauthentik.io/service-type: metrics`, and no Service carries both —
|
||||
# the operator's has the first, the one below has the second.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
@@ -51,8 +86,6 @@ spec:
|
||||
matchNames:
|
||||
- authentik
|
||||
selector:
|
||||
# Pinned to the Service above. Without managed-by this would also match the
|
||||
# operator's Service and scrape the same endpoint twice.
|
||||
matchLabels:
|
||||
app.kubernetes.io/managed-by: homelab
|
||||
goauthentik.io/outpost-name: local-radius
|
||||
@@ -1,19 +0,0 @@
|
||||
# Lets the paperclip-gate outpost reach the Paperclip server; scoped to this outpost's pods.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: paperclip-gate-egress
|
||||
namespace: authentik
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: paperclip-gate
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
app.kubernetes.io/name: paperclip
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3100"
|
||||
protocol: TCP
|
||||
@@ -1,21 +0,0 @@
|
||||
# Lets the wiki-gate outpost reach the wiki service. The authentik namespace
|
||||
# baseline CNP otherwise stops any authentik pod from entering ai. Scoped to
|
||||
# this outpost's pod label; the matching ingress rule is in ai-ns.yaml.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: wiki-gate-egress
|
||||
namespace: authentik
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: wiki-gate
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: wiki
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
@@ -3,4 +3,3 @@ kind: Kustomization
|
||||
resources:
|
||||
- externalsecret.yaml
|
||||
- helmrelease.yaml
|
||||
- networkpolicy.yaml
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# The apps namespace is default-deny-ingress, so the ServiceMonitor added in
|
||||
# monitoring-rules/external-dns-servicemonitor.yaml scraped into a timeout.
|
||||
# The selector matches both releases — the Cloudflare one deployed here and
|
||||
# external-dns-unifi from infrastructure/ — since they share the chart's
|
||||
# app.kubernetes.io/name and land in the same namespace.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: external-dns-allow-metrics-scrape
|
||||
namespace: apps
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: external-dns
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "7979"
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,31 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: file
|
||||
namespace: apps
|
||||
labels:
|
||||
app.kubernetes.io/name: file
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
# Backend serves HTTPS with a self-signed cert; re-encrypt + skip
|
||||
# verify via the shared insecure-backend ServersTransport (see
|
||||
# apps/insecure-backend-serverstransport.yaml).
|
||||
traefik.ingress.kubernetes.io/service.serversscheme: https
|
||||
traefik.ingress.kubernetes.io/service.serverstransport: insecure-backend@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: file.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: file
|
||||
port:
|
||||
number: 7001
|
||||
tls:
|
||||
- secretName: file-tls
|
||||
hosts:
|
||||
- file.renner.uno
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- helmrelease.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: file
|
||||
namespace: apps
|
||||
labels:
|
||||
app.kubernetes.io/name: file
|
||||
spec:
|
||||
# Caddy reverse-proxied https://nas.renner.uno:7001. nas.renner.uno
|
||||
# is a CNAME -> nas-1.renner.uno owned by UniFi DNS, k8s-gateway
|
||||
# forwards srv lookups so this resolves cluster-side.
|
||||
type: ExternalName
|
||||
externalName: nas.renner.uno
|
||||
ports:
|
||||
- name: https
|
||||
port: 7001
|
||||
targetPort: 7001
|
||||
protocol: TCP
|
||||
@@ -18,51 +18,16 @@ metadata:
|
||||
#
|
||||
# Opaque-label map (maintainer reference only — NOT served publicly):
|
||||
# services group infrastructure group
|
||||
# Web Service 1 = Authentik Infrastructure 1 = retired (was PBS 2)
|
||||
# Web Service 1 = Authentik Infrastructure 1 = PBS 2 (pbs2.renner.uno)
|
||||
# Web Service 2 = Gitea Infrastructure 2 = retired (was Proxmox VE)
|
||||
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.40.1)
|
||||
# Web Service 4 = Woodpecker CI Infrastructure 4 = Mosquitto MQTT broker
|
||||
# Web Service 5 = Grafana
|
||||
# Web Service 6 = Home Assistant
|
||||
# Web Service 7 = retired (was the NAS file UI; never Paperless, which
|
||||
# this map claimed and which nothing here checks)
|
||||
# Web Service 7 = Paperless
|
||||
# Web Service 8 = Audiobookshelf
|
||||
# Web Service 9 = Music Assistant
|
||||
# Web Service 10 = TRMNL
|
||||
# Web Service 11 = Paperless (gate-level only, see its entry)
|
||||
#
|
||||
# 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
|
||||
# CiliumNetworkPolicy first — the monitoring probes get away without one only
|
||||
# because their targets share a namespace with the prober".
|
||||
#
|
||||
# That is wrong, and it was wrong when written. It is stated here rather than
|
||||
# fixed in place because the sentence lives inside the `config.yaml: |` block
|
||||
# scalar below: any edit to it changes ConfigMap DATA, and
|
||||
# `configmap.reloader.stakater.com/reload: gatus-config`
|
||||
# (deployment.yaml#reloader) would then roll the gatus Deployment — a
|
||||
# production restart for a comment. This header is outside `data:`, so it is
|
||||
# inert; the served config is byte-identical.
|
||||
#
|
||||
# * When written, the paperless baseline admitted the whole monitoring
|
||||
# namespace on every port, and monitoring is where blackbox-exporter runs.
|
||||
# That admit is gone (REN-28); the probe is now admitted by the dedicated
|
||||
# ../../infrastructure/network-policies/paperless-ns.yaml#paperless-allow-blackbox,
|
||||
# scoped to blackbox-exporter on the paperless pod's :8000. Cilium unions
|
||||
# the allow rules of every CNP selecting an endpoint, so nothing else is
|
||||
# required.
|
||||
# * The in-cluster paperless probe therefore exists:
|
||||
# ../monitoring-rules/probes-blackbox.yaml#paperless-app-health, target
|
||||
# http://paperless.paperless.svc.cluster.local:80/accounts/login/
|
||||
# * The claim does NOT generalise to gatus itself. Gatus runs in `apps`,
|
||||
# which is a different source namespace; a gatus-sourced in-cluster check
|
||||
# would need its own allow, exactly as
|
||||
# cnp-cross-namespace-allow-apps-gatus-to-monitored-services describes.
|
||||
#
|
||||
# The Web Service 11 entry below stays a reachability-only check on purpose.
|
||||
# The app-health half of the pair is the blackbox probe named above.
|
||||
data:
|
||||
# anchor: gatus-endpoints
|
||||
config.yaml: |
|
||||
storage:
|
||||
type: sqlite
|
||||
@@ -156,10 +121,18 @@ data:
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
|
||||
# Removed 2026-08-28: the NAS file UI (file.renner.uno). Its Ingress and
|
||||
# ExternalName Service were deleted in #696 because they pointed at
|
||||
# nas-1, which is powered off; this check outlived them and had been
|
||||
# reporting failure on the public page since.
|
||||
- name: Web Service 7
|
||||
group: services
|
||||
url: https://file.renner.uno
|
||||
interval: 60s
|
||||
ui:
|
||||
hide-hostname: true
|
||||
hide-url: true
|
||||
hide-port: true
|
||||
hide-conditions: true
|
||||
hide-errors: true
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
|
||||
- name: Web Service 8
|
||||
group: services
|
||||
@@ -205,29 +178,6 @@ data:
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
|
||||
# Paperless sits entirely behind the Authentik gate — every path,
|
||||
# including /api/, answers 302 to unauthenticated callers, so there is no
|
||||
# endpoint that proves the app rather than the gate. This check is
|
||||
# therefore reachability only: DNS, TLS, Traefik and the outpost. It
|
||||
# stays green if paperless itself is dead behind a live gate.
|
||||
#
|
||||
# Closing that blind spot needs an in-cluster probe of the paperless
|
||||
# ClusterIP, which crosses a namespace boundary and so needs a
|
||||
# CiliumNetworkPolicy first — the monitoring probes get away without one
|
||||
# only because their targets share a namespace with the prober.
|
||||
- name: Web Service 11
|
||||
group: services
|
||||
url: https://paperless.renner.uno
|
||||
interval: 60s
|
||||
ui:
|
||||
hide-hostname: true
|
||||
hide-url: true
|
||||
hide-port: true
|
||||
hide-conditions: true
|
||||
hide-errors: true
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
|
||||
# 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.
|
||||
@@ -241,11 +191,20 @@ data:
|
||||
# NoData). Monitor the offsite PBS from the offsite side or via a
|
||||
# check that doesn't depend on this cluster's DNS path.
|
||||
|
||||
# Removed 2026-08-28: PBS 2 (pbs2.renner.uno:8007) — it has no clients
|
||||
# left. Its two were the PVE cluster, retired to bare-metal Talos, and
|
||||
# nas-1, powered off and dropped from the repo. Checking the liveness of
|
||||
# a backup server that nothing backs up to reports on nothing. Re-add
|
||||
# alongside the first host that is declared to back up to it.
|
||||
- name: Infrastructure 1
|
||||
group: infrastructure
|
||||
url: https://pbs2.renner.uno:8007
|
||||
interval: 60s
|
||||
client:
|
||||
insecure-skip-verify: true
|
||||
ui:
|
||||
hide-hostname: true
|
||||
hide-url: true
|
||||
hide-port: true
|
||||
hide-conditions: true
|
||||
hide-errors: true
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
|
||||
- name: Infrastructure 3
|
||||
group: infrastructure
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user