Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bb7f032d7 | ||
|
|
bcb5110758 | ||
|
|
5c6d59b0b4 | ||
|
|
c92aff9e75 | ||
|
|
cfba60afd0 | ||
|
|
ecb19e5085 | ||
|
|
17d4c55485 | ||
|
|
1f18599b4c | ||
|
|
bce155b852 | ||
|
|
6d543194af | ||
|
|
96e20146a7 | ||
|
|
c0ed09754d | ||
|
|
78904a2d62 | ||
|
|
fd7e4a6acd | ||
|
|
e205f58b87 | ||
|
|
1f3afcd7e3 | ||
|
|
7a278da1df | ||
|
|
469cdbc2ee | ||
|
|
b0df454fe4 | ||
|
|
3f6f1edcb3 | ||
|
|
09c10e3fa6 | ||
|
|
ad0102d2a9 | ||
|
|
62f1a62b2b | ||
|
|
f1445a2217 | ||
|
|
5d21426a6b | ||
|
|
64462f540a | ||
|
|
1d8ba05123 | ||
|
|
f935776f2b | ||
|
|
75e8a81c56 | ||
|
|
5c412f0486 | ||
|
|
e15067a784 | ||
|
|
541f3fcfe9 | ||
|
|
74b1bb242f | ||
|
|
847664eaa3 | ||
|
|
2ef9811b13 | ||
|
|
b8125e4669 | ||
|
|
9c9e45d17d | ||
|
|
b68ef964d9 | ||
|
|
290befa208 | ||
|
|
fbf41d27cd | ||
|
|
ce86f7dc7f | ||
|
|
f7034662ea | ||
|
|
8598f1ad65 |
@@ -32,13 +32,13 @@
|
||||
# mechanism as update-audit.yml):
|
||||
# woodpecker repo cron add --repo k.renner/homelab \
|
||||
# --name alert-monitor --branch master --schedule '*/15 * * * *'
|
||||
# - manual : woodpecker pipeline create --branch master \
|
||||
# --var PIPELINE=alert-monitor k.renner/homelab
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> alert-monitor
|
||||
# where <n> is an existing pipeline to promote.
|
||||
when:
|
||||
- event: cron
|
||||
cron: alert-monitor
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "alert-monitor"'
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "alert-monitor"'
|
||||
|
||||
steps:
|
||||
- name: fetch-alerts
|
||||
@@ -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.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -31,8 +31,8 @@ when:
|
||||
- "ci/images/**"
|
||||
- "secret-rotator/**"
|
||||
- ".woodpecker/build-ci-images.yml"
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "build-ci-images"'
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-ci-images"'
|
||||
|
||||
steps:
|
||||
- name: build-ansible
|
||||
@@ -44,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: manual
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/ansible/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
@@ -68,7 +68,7 @@ steps:
|
||||
directory: ci/images/k8s
|
||||
depends_on: []
|
||||
when:
|
||||
- event: manual
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/k8s/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
@@ -92,7 +92,7 @@ steps:
|
||||
directory: ci/images/tofu
|
||||
depends_on: []
|
||||
when:
|
||||
- event: manual
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/tofu/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
@@ -123,7 +123,7 @@ steps:
|
||||
directory: secret-rotator
|
||||
depends_on: []
|
||||
when:
|
||||
- event: manual
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["secret-rotator/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
@@ -152,7 +152,7 @@ steps:
|
||||
directory: ci/images/alpine-jq
|
||||
depends_on: []
|
||||
when:
|
||||
- event: manual
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/alpine-jq/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
@@ -181,7 +181,7 @@ steps:
|
||||
directory: ci/images/cni-plugins
|
||||
depends_on: []
|
||||
when:
|
||||
- event: manual
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/cni-plugins/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
@@ -210,7 +210,7 @@ steps:
|
||||
directory: ci/images/alpine-openssl
|
||||
depends_on: []
|
||||
when:
|
||||
- event: manual
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/alpine-openssl/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
@@ -239,7 +239,7 @@ steps:
|
||||
directory: ci/images/heating-can
|
||||
depends_on: []
|
||||
when:
|
||||
- event: manual
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/heating-can/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
#
|
||||
# Triggering:
|
||||
# - cron : the cron NAMED exactly "ci-claude-refresh" (see above).
|
||||
# - manual : woodpecker pipeline create --branch master \
|
||||
# --var PIPELINE=ci-claude-refresh k.renner/homelab
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> ci-claude-refresh
|
||||
# where <n> is an existing pipeline to promote.
|
||||
# - 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: manual
|
||||
evaluate: 'PIPELINE == "ci-claude-refresh"'
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "ci-claude-refresh"'
|
||||
- event: push
|
||||
branch: master
|
||||
path:
|
||||
|
||||
@@ -17,6 +17,6 @@ when:
|
||||
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
@@ -6,8 +6,8 @@ when:
|
||||
- "ansible/roles/claude-workstation/**"
|
||||
- "ansible/plays/install-claude-workstation.yml"
|
||||
- ".woodpecker/deploy-claude-workstation.yml"
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "deploy-claude-workstation"'
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-claude-workstation"'
|
||||
|
||||
timeout: 30
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ when:
|
||||
path:
|
||||
- "opentofu/**"
|
||||
- ".woodpecker/deploy-infrastructure.yml"
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "deploy-infrastructure"'
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "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: manual
|
||||
- event: deployment
|
||||
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: manual
|
||||
evaluate: 'PIPELINE == "deploy-netbird-client"'
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-netbird-client"'
|
||||
|
||||
timeout: 30
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ when:
|
||||
- "ansible/roles/netbird-proxy-watchdog/**"
|
||||
- "ansible/plays/install-netbird-proxy.yml"
|
||||
- ".woodpecker/deploy-netbird-proxy.yml"
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "deploy-netbird-proxy"'
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-netbird-proxy"'
|
||||
|
||||
timeout: 30
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ when:
|
||||
- "ansible/roles/netbird-proxy-watchdog/**"
|
||||
- "ansible/plays/install-netbird-server.yml"
|
||||
- ".woodpecker/deploy-netbird-server.yml"
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "deploy-netbird-server"'
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-netbird-server"'
|
||||
|
||||
timeout: 30
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
when:
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "e2e-test"'
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "e2e-test"'
|
||||
|
||||
steps:
|
||||
- name: e2e
|
||||
|
||||
+5
-4
@@ -7,7 +7,8 @@
|
||||
# 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.
|
||||
# - manual : `woodpecker pipeline create --branch master --var PIPELINE=e2e k.renner/homelab`
|
||||
# - deploy : `woodpecker-cli pipeline deploy k.renner/homelab <n> e2e`, where
|
||||
# <n> is an existing pipeline to promote.
|
||||
|
||||
when:
|
||||
- event: cron
|
||||
@@ -16,8 +17,8 @@ when:
|
||||
cron: update-audit
|
||||
- event: cron
|
||||
cron: weekly-update
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "e2e"'
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "e2e"'
|
||||
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
@@ -25,7 +26,7 @@ steps:
|
||||
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
|
||||
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
|
||||
# test from rotting (it exercises the exact injection shapes).
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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 * * * *'
|
||||
# - manual : woodpecker pipeline create --branch master \
|
||||
# --var PIPELINE=issue-fix k.renner/homelab
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> issue-fix
|
||||
# where <n> is an existing pipeline to promote.
|
||||
when:
|
||||
- event: cron
|
||||
cron: issue-fix
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "issue-fix"'
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "issue-fix"'
|
||||
|
||||
steps:
|
||||
- name: fetch-diag
|
||||
@@ -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.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
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 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
|
||||
# and earns a fresh review). There is deliberately no on-demand trigger for a
|
||||
# specific PR: Woodpecker exposes injected variables to YAML substitution and
|
||||
# `when` filters, NOT to a step's shell environment, so a PR=<n> re-review would
|
||||
# silently no-op (the step would find no PR number and exit green).
|
||||
# (Note: keep literal dollar-brace out of THIS file entirely, even in comments —
|
||||
# Woodpecker runs its variable substitution over the whole YAML text, comments
|
||||
@@ -65,7 +65,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: pr-review-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -40,12 +40,28 @@ 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 ==="
|
||||
trivy image --skip-db-update --scanners vuln --timeout 15m --severity CRITICAL,HIGH --ignore-unfixed --format json -o "$$out" "$$img" || true
|
||||
# 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
|
||||
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
|
||||
@@ -54,7 +70,7 @@ steps:
|
||||
# this step is skipped — otherwise we'd fork-bomb PRs.
|
||||
when:
|
||||
- branch: master
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
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". 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:
|
||||
# - 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:
|
||||
# woodpecker repo cron add --repo k.renner/homelab \
|
||||
# --name update-audit --branch master --schedule '@weekly'
|
||||
# - manual : woodpecker pipeline create --branch master \
|
||||
# --var PIPELINE=update-audit k.renner/homelab
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> update-audit
|
||||
# where <n> is an existing pipeline to promote.
|
||||
#
|
||||
# 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: manual
|
||||
evaluate: 'PIPELINE == "update-audit"'
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "update-audit"'
|
||||
|
||||
steps:
|
||||
- name: update-audit-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
# 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
|
||||
@@ -1,27 +0,0 @@
|
||||
$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,6 +8,8 @@
|
||||
# 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]
|
||||
@@ -17,10 +19,6 @@ 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.
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
---
|
||||
# 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,104 +0,0 @@
|
||||
---
|
||||
# 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 }}"
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
- 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
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/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.
|
||||
# of the management SQLite store via the online-backup API (which, unlike
|
||||
# copying store.db, stays consistent while netbird-management writes) 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
|
||||
@@ -14,5 +15,22 @@
|
||||
set -eu
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
sqlite3 /opt/netbird/management/store.db ".backup '$tmp/store.db'"
|
||||
# Consistency is not the same as immunity to SQLITE_BUSY: the sqlite3
|
||||
# CLI defaults to a busy timeout of 0, so a single collision with the
|
||||
# management container holding the write lock aborts the dump outright
|
||||
# (2026-08-29: all three Job attempts died with "Error: database is
|
||||
# locked" within 51 s of each other). Wait a contending writer out, and
|
||||
# retry a few times on top. Worst case ~80 s per invocation, which stays
|
||||
# inside the Job's activeDeadlineSeconds: 600 budget even across its
|
||||
# three pod attempts.
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
rm -f "$tmp"/store.db*
|
||||
if sqlite3 -cmd ".timeout 20000" /opt/netbird/management/store.db ".backup '$tmp/store.db'"; then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
[ "$attempt" = 3 ] || sleep 10
|
||||
done
|
||||
[ "$ok" = 1 ] || exit 1
|
||||
tar -C "$tmp" -czf - store.db
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
# 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"
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
- 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') }}"
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
- 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') }}"
|
||||
@@ -1,6 +0,0 @@
|
||||
# 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 %}
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,30 +0,0 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,111 @@
|
||||
# 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.
|
||||
@@ -198,8 +198,7 @@ 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 @<cilium additive half> \
|
||||
--config-patch @talos/patches/registry-mirror.yaml \
|
||||
--config-patch @talos/patches/containerd-layer-retention.yaml \
|
||||
--config-patch @talos/patches/k8s-N.yaml -o install.yaml
|
||||
```
|
||||
|
||||
@@ -208,9 +207,11 @@ 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.
|
||||
|
||||
`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.
|
||||
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.
|
||||
|
||||
Validate:
|
||||
|
||||
|
||||
@@ -136,11 +136,18 @@ in `talos/patches/k8s-N.yaml`.
|
||||
|
||||
## Step 2 — talos: form the cluster + get kubeconfig
|
||||
|
||||
**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.
|
||||
`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.
|
||||
|
||||
The procedure below is what actually works, and is the same one
|
||||
`RUNBOOK-baremetal-conversion.md` uses.
|
||||
@@ -160,17 +167,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 @<cilium additive half> \
|
||||
--config-patch @talos/patches/registry-mirror.yaml \
|
||||
--config-patch @talos/patches/containerd-layer-retention.yaml \
|
||||
--config-patch @talos/patches/k8s-$N.yaml -o install-k8s-$N.yaml
|
||||
done
|
||||
```
|
||||
|
||||
Two things the generator gets wrong for this repo, both of which will bite
|
||||
silently:
|
||||
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:
|
||||
|
||||
- **`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.
|
||||
|
||||
@@ -209,8 +216,53 @@ talosctl -n k8s-1.srv.renner.uno bootstrap
|
||||
talosctl -n k8s-1.srv.renner.uno kubeconfig .
|
||||
```
|
||||
|
||||
Ends when all three nodes are `Ready`. `scripts/import-cluster-access.sh` is the
|
||||
supported way to get the kubeconfig into place afterwards.
|
||||
`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`.
|
||||
|
||||
## Step 3 — flux: bootstrap GitOps **from the GitHub mirror** (break-glass)
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
@@ -1,18 +0,0 @@
|
||||
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,15 +18,18 @@ metadata:
|
||||
#
|
||||
# Opaque-label map (maintainer reference only — NOT served publicly):
|
||||
# services group infrastructure group
|
||||
# Web Service 1 = Authentik Infrastructure 1 = PBS 2 (pbs2.renner.uno)
|
||||
# Web Service 1 = Authentik Infrastructure 1 = retired (was PBS 2)
|
||||
# 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 = Paperless
|
||||
# Web Service 7 = retired (was the NAS file UI; never Paperless, which
|
||||
# this map claimed and which nothing here checks)
|
||||
# Web Service 8 = Audiobookshelf
|
||||
# Web Service 9 = Music Assistant
|
||||
# Web Service 10 = TRMNL
|
||||
# Web Service 11 = Paperless (gate-level only, see its entry)
|
||||
data:
|
||||
config.yaml: |
|
||||
storage:
|
||||
@@ -121,18 +124,10 @@ data:
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
|
||||
- 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)"
|
||||
# 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 8
|
||||
group: services
|
||||
@@ -178,6 +173,29 @@ 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.
|
||||
@@ -191,20 +209,11 @@ data:
|
||||
# NoData). Monitor the offsite PBS from the offsite side or via a
|
||||
# check that doesn't depend on this cluster's DNS path.
|
||||
|
||||
- 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)"
|
||||
# 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 3
|
||||
group: infrastructure
|
||||
|
||||
@@ -5,6 +5,12 @@ metadata:
|
||||
namespace: apps
|
||||
labels:
|
||||
app: gatus
|
||||
annotations:
|
||||
# Gatus reads its config once at startup and never re-reads the file, so
|
||||
# without this a ConfigMap change reconciles cleanly and then does nothing:
|
||||
# git and the running checks drift apart silently, with no failed resource
|
||||
# to notice. Adding a check would not take effect either.
|
||||
configmap.reloader.stakater.com/reload: gatus-config
|
||||
spec:
|
||||
replicas: 1
|
||||
# RWO PVC (`gatus-data`, ceph-block) — RollingUpdate's default 25% surge
|
||||
@@ -22,9 +28,30 @@ spec:
|
||||
labels:
|
||||
app: gatus
|
||||
spec:
|
||||
# The upstream image is FROM scratch with no USER, so without this the
|
||||
# pod runs as root and trips PodSecurity restricted on four counts.
|
||||
#
|
||||
# The uid is numeric because a scratch image carries no /etc/passwd for
|
||||
# the kubelet to resolve a name against. fsGroup is what makes the switch
|
||||
# survivable: the only write path is the sqlite store on the RWO volume
|
||||
# (/data/gatus.db, see the storage stanza in the ConfigMap), and fsGroup
|
||||
# both chowns the existing volume and leaves the directory group-writable
|
||||
# so sqlite can create its -wal and -journal siblings next to the db.
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
fsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gatus
|
||||
image: twinproduction/gatus:v5.36.0 # {"$imagepolicy": "flux-system:gatus"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
|
||||
@@ -210,6 +210,28 @@ spec:
|
||||
- name: vscode
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
# /healthz answers 200 whenever the HTTP server is up; its body
|
||||
# reports session heartbeat state, which is not health — an idle
|
||||
# editor reports "expired" and is perfectly fine. So this asserts
|
||||
# that code-server is serving, not that anyone is using it.
|
||||
#
|
||||
# Liveness matters here more than elsewhere in this pod: code-server
|
||||
# shares the Home Assistant config volume, and a wedged editor is
|
||||
# otherwise invisible until someone tries to open it.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: vscode
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: vscode
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Shared Traefik ServersTransport for ingresses that re-encrypt to a
|
||||
# backend serving HTTPS with a self-signed / hostname-mismatched cert:
|
||||
#
|
||||
# apps/file — NAS web UI on a self-signed cert
|
||||
#
|
||||
# Replaces the nginx `backend-protocol: HTTPS` + `proxy-ssl-verify: off`
|
||||
# annotation pair. Each Ingress that wants this behaviour sets
|
||||
# traefik.ingress.kubernetes.io/service.serversscheme: https
|
||||
# traefik.ingress.kubernetes.io/service.serverstransport: insecure-backend
|
||||
#
|
||||
# (bare name — kubernetesIngress provider resolves ServersTransport in
|
||||
# the same namespace as the Ingress; the `<ns>-<name>@kubernetescrd`
|
||||
# qualified form is for IngressRoute CRDs only).
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: ServersTransport
|
||||
metadata:
|
||||
name: insecure-backend
|
||||
namespace: apps
|
||||
spec:
|
||||
insecureSkipVerify: true
|
||||
@@ -1,10 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- insecure-backend-serverstransport.yaml
|
||||
- gatus/
|
||||
- external-dns/
|
||||
- file/
|
||||
- gitea/
|
||||
- ical-proxy/
|
||||
# woodpecker/ moved to its own dedicated `woodpecker` Kustomization
|
||||
|
||||
@@ -4,7 +4,9 @@ kind: Kustomization
|
||||
# come from the kube-prometheus-stack chart. Flux applies this Kustomization
|
||||
# only after `monitoring` is Ready (chart installed → CRDs present).
|
||||
resources:
|
||||
- prometheusrule-blackbox.yaml
|
||||
- prometheusrule-ceph.yaml
|
||||
- prometheusrule-ci-openbao.yaml
|
||||
- prometheusrule-cilium.yaml
|
||||
- prometheusrule-disk-wear.yaml
|
||||
- prometheusrule-hetzner-backup.yaml
|
||||
|
||||
@@ -49,6 +49,28 @@ spec:
|
||||
# ports outside the hairpin's 80/443 — real WAN path.
|
||||
- http://proxy-1.renner.uno:9000/health
|
||||
- http://proxy-2.renner.uno:9000/health
|
||||
# Woodpecker. Ungated, root returns 200 with no redirect. Every
|
||||
# scheduled job in this repo runs here — the alert-monitor cron, the
|
||||
# nightly scans, the deploy targets — so an outage here is silent by
|
||||
# construction: the thing that would have told you is the thing that
|
||||
# is down.
|
||||
- https://ci.renner.uno
|
||||
# OpenBao. Probes the health API rather than the UI on purpose: a
|
||||
# SEALED instance still serves the UI (307 -> /ui/ -> 200) while every
|
||||
# ExternalSecret in the cluster fails, which is exactly the
|
||||
# green-while-broken shape this file warns about elsewhere.
|
||||
#
|
||||
# standbyok=true because the endpoint answers 200 only on the ACTIVE
|
||||
# node and 429 on a standby — the external route currently lands on a
|
||||
# standby, so the bare path would be permanently red. With the
|
||||
# parameter, 200 means initialized, unsealed and serving, on whichever
|
||||
# node answers; 503 sealed and 501 uninitialized both stay red.
|
||||
- https://openbao.renner.uno/v1/sys/health?standbyok=true
|
||||
# ical-proxy serves an unauthenticated /health that answers 200 with
|
||||
# {"status":"healthy"}. Unlike the gated hostnames this is real app
|
||||
# health, not reachability — the root path returns 404 by design, since
|
||||
# the service only answers on calendar paths.
|
||||
- https://ical-proxy.renner.uno/health
|
||||
# APP-HEALTH probes for the gated monitoring UIs, from the correct
|
||||
# in-cluster vantage (monitoring-vantage-point-rule): the gate is
|
||||
# only on the ingress, so the ClusterIP /-/healthy endpoints are
|
||||
@@ -61,6 +83,11 @@ spec:
|
||||
# external reachability probe above; it is not the health vantage.)
|
||||
- http://kube-prometheus-stack-alertmanager.monitoring.svc.cluster.local:9093/-/healthy
|
||||
- http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090/-/healthy
|
||||
# Loki has no external vantage at all: its ingress requires auth on
|
||||
# every path, so an outside probe can only ever report 401. In-cluster
|
||||
# /ready answers 200 unauthenticated and is in this namespace, so the
|
||||
# same intra-namespace allowance the two above rely on covers it.
|
||||
- http://loki.monitoring.svc.cluster.local:3100/ready
|
||||
# WAN-path probes of the public edge BY PROXY IP. The name-based
|
||||
# home.renner.uno probe above resolves in-cluster (CoreDNS/k8s_gateway)
|
||||
# and never leaves the LAN — the vantage-point blind spot that let the
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: blackbox
|
||||
namespace: monitoring
|
||||
labels:
|
||||
# kube-prometheus-stack's Prometheus resource selects on this label;
|
||||
# without it the rule is created but never loaded into Prometheus.
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
groups:
|
||||
- name: blackbox.rules
|
||||
rules:
|
||||
# Catch-all for every blackbox-http target that has no rule of its own.
|
||||
# Before this existed, a target could be probed, graphed and completely
|
||||
# unalerted — which is how file.renner.uno served 502 for months.
|
||||
#
|
||||
# THE EXCLUSION LIST AND THE DEDICATED RULES ARE ONE INVARIANT: an
|
||||
# instance belongs here only while a rule elsewhere covers it, and
|
||||
# adding a dedicated rule means adding its instance here. Get it wrong
|
||||
# in one direction and the alert fires twice; wrong in the other and it
|
||||
# never fires at all. Currently excluded:
|
||||
#
|
||||
# fsn1.your-objectstorage.com HetznerBackupEndpointDown (critical)
|
||||
# netbird.renner.uno prometheusrule-netbird-availability
|
||||
# proxy-1/2:9000/health same file, relay redundancy
|
||||
# ci.renner.uno WoodpeckerDown
|
||||
# openbao .../sys/health OpenBaoUnavailable
|
||||
#
|
||||
# Warning, not critical, on purpose: this rule cannot know what a given
|
||||
# target means. A target whose loss deserves a critical page gets its
|
||||
# own rule — and lands in the list above. That is what keeps the
|
||||
# exclusion list from being arbitrary.
|
||||
- alert: BlackboxProbeFailing
|
||||
expr: probe_success{job="blackbox-http", instance!~"https://fsn1\\.your-objectstorage\\.com|https://netbird\\.renner\\.uno|http://proxy-[12]\\.renner\\.uno:9000/health|https://ci\\.renner\\.uno|https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
|
||||
for: 10m
|
||||
annotations:
|
||||
summary: "Blackbox probe of {{ $labels.instance }} has been failing for 10 minutes"
|
||||
description: >-
|
||||
The endpoint is not answering as expected. For the Authentik-gated
|
||||
hostnames this is a reachability check covering DNS, TLS, Traefik
|
||||
and the gate outpost — it says nothing about the app behind the
|
||||
gate, which the internal /-/healthy probes cover separately.
|
||||
labels:
|
||||
severity: warning
|
||||
# The check that the checks are running. Every rule above reads
|
||||
# probe_success; if the series stop being produced at all, none of them
|
||||
# can fire and the silence looks exactly like health.
|
||||
- alert: BlackboxProbesAbsent
|
||||
expr: absent(probe_success{job="blackbox-http"})
|
||||
for: 15m
|
||||
annotations:
|
||||
summary: "No blackbox-http probe results are reaching Prometheus"
|
||||
description: >-
|
||||
Either the blackbox exporter is down or the Probe objects stopped
|
||||
being discovered. Every endpoint alert depends on this job, so
|
||||
they are all blind until it returns.
|
||||
labels:
|
||||
severity: critical
|
||||
@@ -0,0 +1,47 @@
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: ci-openbao
|
||||
namespace: monitoring
|
||||
labels:
|
||||
# kube-prometheus-stack's Prometheus resource selects on this label;
|
||||
# without it the rule is created but never loaded into Prometheus.
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
groups:
|
||||
- name: ci-openbao.rules
|
||||
rules:
|
||||
- alert: WoodpeckerDown
|
||||
# Probe target defined in probes-blackbox.yaml. Root, ungated, 200.
|
||||
expr: probe_success{job="blackbox-http", instance="https://ci.renner.uno"} == 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Woodpecker is not answering on ci.renner.uno"
|
||||
description: >-
|
||||
Every scheduled job in this repo runs here: the alert-monitor
|
||||
cron that files these issues, the nightly scans, and every deploy
|
||||
target. Nothing else reports this outage, because the component
|
||||
that would have reported it is the one that is down. Alertmanager
|
||||
itself is unaffected, which is why this alert exists.
|
||||
- alert: OpenBaoUnavailable
|
||||
# Probes /v1/sys/health?standbyok=true — 200 on any unsealed node,
|
||||
# 503 sealed, 501 uninitialized. The UI stays up when sealed, so a
|
||||
# root probe would not catch this.
|
||||
#
|
||||
# Matched by prefix rather than the full string: the target carries a
|
||||
# query parameter, and an exact match would break silently if the
|
||||
# instance label ever encodes it differently.
|
||||
expr: probe_success{job="blackbox-http", instance=~"https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "OpenBao is sealed, uninitialized or unreachable"
|
||||
description: >-
|
||||
Every ExternalSecret in the cluster resolves through OpenBao.
|
||||
Existing Secrets keep working, so nothing breaks at once — the
|
||||
damage shows up at the next refresh interval or the next pod that
|
||||
needs a fresh credential. Check whether it is sealed before
|
||||
assuming a network fault.
|
||||
@@ -17,11 +17,12 @@ spec:
|
||||
# drains on recovery) and Velero backups fail. Replaces the old
|
||||
# GarageDown alert now that backups live on Hetzner, not nas-1.
|
||||
#
|
||||
# CNPG has no metric-based archiving alert yet — its pod metrics
|
||||
# are not scraped (enablePodMonitor=false). Enabling CNPG
|
||||
# PodMonitors for a granular "archiving falling behind" alert is
|
||||
# a follow-up. Velero's own VeleroBackupValidationFailure /
|
||||
# VeleroBackupStale rules already cover backup-level failures.
|
||||
# The granular counterpart is CNPGWalArchiveBacklog in
|
||||
# prometheusrule-cnpg-backup.yaml, reading the instance collector
|
||||
# scraped by cnpg-podmonitors.yaml: this alert says the target is
|
||||
# unreachable, that one says archiving is falling behind whatever the
|
||||
# cause. Velero's VeleroBackupValidationFailure / VeleroBackupStale
|
||||
# cover backup-level failures.
|
||||
expr: probe_success{instance="https://fsn1.your-objectstorage.com"} == 0
|
||||
for: 10m
|
||||
labels:
|
||||
|
||||
@@ -55,7 +55,7 @@ spec:
|
||||
# stdlib only; Flux keeps the tag in lockstep via the shared image
|
||||
# policy) — no extra dependency.
|
||||
- name: reconcile-publish-ip
|
||||
image: ghcr.io/music-assistant/server:2.10.0 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.1 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
@@ -65,7 +65,7 @@ spec:
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: music-assistant
|
||||
image: ghcr.io/music-assistant/server:2.10.0 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.1 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
env:
|
||||
- name: LOG_LEVEL
|
||||
value: info
|
||||
|
||||
@@ -24,6 +24,22 @@ spec:
|
||||
- name: wyoming
|
||||
containerPort: 10400
|
||||
protocol: TCP
|
||||
# Wyoming is raw TCP with no HTTP surface and no health verb, so
|
||||
# accepting a connection is all that can be asserted from outside the
|
||||
# process. Weaker than an HTTP probe, but the previous state was none
|
||||
# at all: a dead server stayed Ready and kept being routed to.
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
|
||||
@@ -70,6 +70,27 @@ spec:
|
||||
- name: consume
|
||||
mountPath: /home/scanner
|
||||
subPath: scanner
|
||||
# FTP has no health verb, so the control port accepting a connection
|
||||
# is what can be asserted. It is enough for the failure that matters:
|
||||
# if vsftpd dies the scanner cannot deposit anything and nothing else
|
||||
# in the chain notices, because paperless only ever sees an empty
|
||||
# consume directory — which is indistinguishable from nobody having
|
||||
# scanned.
|
||||
#
|
||||
# Only the control port is probed. The passive range is negotiated
|
||||
# per transfer and is not listening at rest.
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: ftp-ctrl
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: ftp-ctrl
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 20m
|
||||
|
||||
@@ -155,21 +155,33 @@ spec:
|
||||
- name: bridge-cert
|
||||
mountPath: /shared
|
||||
readOnly: true
|
||||
# TCP probes — HTTP probes against /api/ flooded the
|
||||
# `paperless.auth` logger every 10 s with "No authentication
|
||||
# provided from private IP …". With ENABLE_HTTP_REMOTE_USER
|
||||
# the middleware logs every unauth API request, and there's
|
||||
# no public health endpoint to probe. TCP confirms gunicorn
|
||||
# is bound; that's the same liveness signal we'd get from
|
||||
# /api/ minus the noise.
|
||||
# /accounts/login/ rather than /api/ or a TCP connect.
|
||||
#
|
||||
# /api/ is unusable as a probe target: with ENABLE_HTTP_REMOTE_USER
|
||||
# the remote-user middleware logs "No authentication provided from
|
||||
# private IP …" for every unauthenticated API request, so probing it
|
||||
# floods `paperless.auth`. Measured: four requests to /api/ produce
|
||||
# four log lines, four to /accounts/login/ produce none — the
|
||||
# middleware only covers the API surface.
|
||||
#
|
||||
# A TCP connect avoids the noise but only proves gunicorn accepted a
|
||||
# socket. /accounts/login/ renders a real Django view, so URL
|
||||
# routing, settings and templating all have to work. It does not
|
||||
# prove Postgres is healthy; nothing cheap here does.
|
||||
#
|
||||
# Safe against ALLOWED_HOSTS: the kubelet sends the pod IP as Host,
|
||||
# and PAPERLESS_ALLOWED_HOSTS is "*". Narrowing that value later
|
||||
# means giving these probes an explicit Host header.
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
port: http
|
||||
initialDelaySeconds: 120
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
|
||||
@@ -34,6 +34,32 @@ spec:
|
||||
- name: wyoming
|
||||
containerPort: 10300
|
||||
protocol: TCP
|
||||
# Wyoming is raw TCP with no HTTP surface and no health verb, so
|
||||
# accepting a connection is all that can be asserted from outside the
|
||||
# process — weaker than an HTTP probe, but the previous state was
|
||||
# none at all.
|
||||
#
|
||||
# A startupProbe rather than a long liveness delay: this container
|
||||
# runs with --retrain-on-start, and retraining against the Home
|
||||
# Assistant entity list takes as long as it takes. The startup gate
|
||||
# allows up to 10 minutes (60 x 10s) and only then hands over to
|
||||
# liveness, so a slow retrain cannot be mistaken for a hang, and a
|
||||
# genuine hang after startup is still caught within a minute.
|
||||
startupProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
periodSeconds: 10
|
||||
failureThreshold: 60
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
volumeMounts:
|
||||
- name: models
|
||||
mountPath: /models
|
||||
|
||||
@@ -29,6 +29,27 @@ spec:
|
||||
- name: wyoming
|
||||
containerPort: 10300
|
||||
protocol: TCP
|
||||
# Wyoming is a raw TCP protocol with no HTTP surface and no health
|
||||
# verb, so accepting a connection is the only thing that can be
|
||||
# asserted from outside the process. That is weaker than an HTTP
|
||||
# probe but strictly better than the previous state of none at all,
|
||||
# where a dead server stayed Ready and Home Assistant kept routing
|
||||
# speech to it.
|
||||
#
|
||||
# The liveness delay is generous because the model is loaded at
|
||||
# startup from the data volume.
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
initialDelaySeconds: 90
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
|
||||
@@ -305,7 +305,7 @@ server → agent → step pod → server end to end:
|
||||
|
||||
```sh
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' https://ci.renner.uno/
|
||||
woodpecker pipeline create --branch master --var PIPELINE=e2e-test k.renner/homelab
|
||||
woodpecker-cli pipeline deploy k.renner/homelab <pipeline-number> e2e-test
|
||||
```
|
||||
|
||||
Step pods must still reach the server on `:8000` while still being unable to
|
||||
|
||||
@@ -94,6 +94,31 @@ spec:
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: woodpecker-secret
|
||||
ports:
|
||||
# WOODPECKER_HEALTHCHECK defaults to true and serves :3000, so this
|
||||
# port already existed; declaring it just lets the probe name it.
|
||||
- name: health
|
||||
containerPort: 3000
|
||||
protocol: TCP
|
||||
# /healthz answers 500 ONLY when a running workflow has passed its own
|
||||
# timeout plus a one-hour buffer (agent/state.go, State.Healthy). An
|
||||
# idle agent always answers 200.
|
||||
#
|
||||
# So this catches a wedged build holding a slot, which is otherwise
|
||||
# invisible — and it does NOT catch an agent that has lost its server
|
||||
# connection, which reports healthy while quietly taking no work. Do
|
||||
# not read a green probe here as "CI is fine".
|
||||
#
|
||||
# Liveness only: the agent fronts no Service, so readiness would gate
|
||||
# nothing. The upstream condition is already debounced by that hour,
|
||||
# which is why the thresholds here are short.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: health
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
|
||||
+41
-4
@@ -21,6 +21,13 @@ set -euo pipefail
|
||||
|
||||
TALOS_DIR="$(cd "$(dirname "$0")/talos" && pwd)"
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# Cilium's single source of truth. Talos ships `cni: none` (talos/patches/
|
||||
# cluster.yaml) and nothing else, so the CNI has to be installed here, between
|
||||
# `talosctl bootstrap` and Flux — Flux's own controllers are pods and cannot
|
||||
# reconcile the network they need in order to start. Everything the install
|
||||
# needs is read out of this file at run time so there is no second copy of a
|
||||
# version or a value to keep in step. See PROPOSAL-cilium-single-owner.md.
|
||||
CILIUM_HR="$REPO_ROOT/kubernetes/infrastructure/cilium/helmrelease.yaml"
|
||||
ACCESS_STAGING="$REPO_ROOT/kubernetes/access-staging"
|
||||
NODES=(k8s-1 k8s-2 k8s-3)
|
||||
# No dedicated workers since the k8s-edge VM was retired (2026-07-02) —
|
||||
@@ -82,9 +89,11 @@ if phase_runs talos; then
|
||||
echo -e "${YELLOW}=== Phase: Talos bootstrap ===${NC}"
|
||||
command -v talosctl >/dev/null || fail "talosctl not found"
|
||||
command -v kubectl >/dev/null || fail "kubectl not found"
|
||||
command -v helm >/dev/null || fail "helm not found (needed to install the CNI)"
|
||||
command -v yq >/dev/null || fail "yq not found (needed to read the Cilium HelmRelease)"
|
||||
[[ -f "$TALOS_DIR/patches/cluster.yaml" ]] || fail "cluster.yaml patch not found"
|
||||
[[ -f "$TALOS_DIR/patches/cilium-cni.yaml" ]] || fail "cilium-cni.yaml patch not found"
|
||||
[[ -f "$TALOS_DIR/patches/registry-mirror.yaml" ]] || fail "registry-mirror.yaml patch not found"
|
||||
[[ -f "$TALOS_DIR/patches/containerd-layer-retention.yaml" ]] || fail "containerd-layer-retention.yaml patch not found"
|
||||
[[ -f "$CILIUM_HR" ]] || fail "cilium helmrelease not found at $CILIUM_HR"
|
||||
|
||||
step 0 "Generating Talos configs..."
|
||||
TALOS_GEN_DIR=$(mktemp -d)
|
||||
@@ -123,8 +132,7 @@ if phase_runs talos; then
|
||||
if err=$(talosctl apply-config --insecure -n "$node_ip" \
|
||||
--file "$base_config" \
|
||||
--config-patch "@$TALOS_DIR/patches/cluster.yaml" \
|
||||
--config-patch "@$TALOS_DIR/patches/cilium-cni.yaml" \
|
||||
--config-patch "@$TALOS_DIR/patches/registry-mirror.yaml" \
|
||||
--config-patch "@$TALOS_DIR/patches/containerd-layer-retention.yaml" \
|
||||
--config-patch "@$TALOS_DIR/patches/${node}.yaml" 2>&1); then
|
||||
echo " applied via $node_ip ${GREEN}(OK)${NC}"
|
||||
return 0
|
||||
@@ -174,6 +182,35 @@ if phase_runs talos; then
|
||||
done
|
||||
ok
|
||||
|
||||
step 0 "Installing Cilium (CNI)..."
|
||||
# Must run before the node-Ready wait below: with `cni: none` the kubelet
|
||||
# reports NotReady until a CNI is present, so that loop would time out.
|
||||
#
|
||||
# Chart repo, version constraint and values all come out of the HelmRelease
|
||||
# so this cannot drift from what Flux will reconcile minutes later. helm
|
||||
# accepts the semver range verbatim and resolves it the same way Flux does.
|
||||
#
|
||||
# serviceMonitor is the one override, and it is a bootstrap constraint
|
||||
# rather than a value: its CRD arrives with the monitoring stack much
|
||||
# later, and the object would fail to apply. Nothing else is overridden,
|
||||
# which is what lets helm-controller adopt this release instead of
|
||||
# replacing it.
|
||||
CILIUM_VALUES=$(mktemp)
|
||||
yq 'select(.kind == "HelmRelease") | .spec.values' "$CILIUM_HR" > "$CILIUM_VALUES"
|
||||
grep -q 'policyMapMax' "$CILIUM_VALUES" \
|
||||
|| fail "extracted Cilium values look wrong (no policyMapMax) — check $CILIUM_HR"
|
||||
|
||||
helm repo add cilium "$(yq -r 'select(.kind == "HelmRepository") | .spec.url' "$CILIUM_HR")" >/dev/null
|
||||
helm repo update cilium >/dev/null
|
||||
helm upgrade --install cilium cilium/cilium \
|
||||
--version "$(yq -r 'select(.kind == "HelmRelease") | .spec.chart.spec.version' "$CILIUM_HR")" \
|
||||
--namespace kube-system \
|
||||
-f "$CILIUM_VALUES" \
|
||||
--set prometheus.serviceMonitor.enabled=false \
|
||||
--wait --timeout 10m
|
||||
rm -f "$CILIUM_VALUES"
|
||||
ok
|
||||
|
||||
step 0 "Waiting for K8s nodes..."
|
||||
expected=${#ALL_NODES[@]}
|
||||
for i in $(seq 1 60); do
|
||||
|
||||
@@ -33,6 +33,13 @@ spec:
|
||||
remediation:
|
||||
retries: 3
|
||||
values:
|
||||
# THIS IS THE ONLY PLACE CILIUM IS CONFIGURED. Talos used to carry a
|
||||
# rendered copy as a bootstrap inline manifest, so every setting existed
|
||||
# two or three times and nothing enforced agreement — see
|
||||
# PROPOSAL-cilium-single-owner.md for what that cost. Talos now provides
|
||||
# only `cni: none` and `proxy: disabled`; a cold rebuild installs Cilium
|
||||
# from these values with helm before Flux takes over.
|
||||
#
|
||||
# Rolls the agent DaemonSet when cilium-config changes. Cilium reads
|
||||
# datapath options once at agent start and the chart puts no config
|
||||
# checksum on the pod template, so without this a changed ConfigMap leaves
|
||||
@@ -50,8 +57,6 @@ spec:
|
||||
# and every identity costs one entry per allowed (direction, proto, port)
|
||||
# in every endpoint's policy map, cluster-wide. Nothing selects on these
|
||||
# labels in policy, so they buy identity churn and nothing else.
|
||||
# Keep in lockstep with kubernetes/talos/patches/cilium-cni.yaml and
|
||||
# kubernetes/talos/cilium.yaml.
|
||||
labels: '!woodpecker-ci\.org/.* !step$'
|
||||
ipv6:
|
||||
enabled: true
|
||||
@@ -76,10 +81,6 @@ spec:
|
||||
# cilium_bpf_map_pressure. Sized at map creation, so it takes an agent
|
||||
# restart.
|
||||
#
|
||||
# THREE COPIES of this key exist — here, talos/patches/cilium-cni.yaml
|
||||
# and talos/cilium.yaml. The latter two are what a cold rebuild installs
|
||||
# from. Nothing enforces agreement; grep bpf-policy-map-max before
|
||||
# changing it.
|
||||
policyMapMax: 65536
|
||||
# VLAN ids the eBPF datapath is allowed to pass. Without this it drops
|
||||
# VLAN-tagged frames, accounted as "VLAN traffic disallowed by VLAN
|
||||
@@ -140,3 +141,28 @@ spec:
|
||||
enabled: true
|
||||
externalIPs:
|
||||
enabled: true
|
||||
# BGP to the UDM. The peering itself has always been in git as CRDs in
|
||||
# post-crds/cilium-bgp.yaml — but the switch that starts the control plane
|
||||
# was not, and neither were the CIDRs below. They were applied by hand and
|
||||
# held by the `kubectl-patch` field manager on cilium-config. Server-side
|
||||
# apply left them alone, so nothing broke and nothing noticed; a cold
|
||||
# rebuild would have come up with BGP off while the CRDs said otherwise.
|
||||
#
|
||||
# This is not a free change of ownership. Enabling bgpControlPlane through
|
||||
# the chart also emits companion keys the hand-patch never set —
|
||||
# bgp-secrets-namespace, bgp-router-id-allocation-mode and -ip-pool,
|
||||
# enable-bgp-control-plane-status-report, enable-bgp-legacy-origin-attribute
|
||||
# — so Cilium has been running on its own defaults for those. Router-id
|
||||
# allocation is the one to watch: if the chart's default disagrees with
|
||||
# Cilium's, the sessions re-establish.
|
||||
bgpControlPlane:
|
||||
enabled: true
|
||||
# Masquerading exception, not a routing statement: pod traffic to these
|
||||
# ranges leaves unmasqueraded, so LAN services see real pod IPs. That only
|
||||
# works because BGP advertises the pod CIDRs, which is what gives the UDM a
|
||||
# route back. The two settings are one decision and have to move together.
|
||||
#
|
||||
# Pod CIDRs are 10.244.0.0/16 and fd10:244::/48 (talos/patches/cluster.yaml);
|
||||
# the supernets here cover the LAN as well, which is the point.
|
||||
ipv4NativeRoutingCIDR: 10.0.0.0/8
|
||||
ipv6NativeRoutingCIDR: fd00::/8
|
||||
|
||||
@@ -33,3 +33,16 @@ spec:
|
||||
crds: CreateReplace
|
||||
remediation:
|
||||
retries: 3
|
||||
values:
|
||||
# The chart ships these disabled, so the controller ran with no probe of
|
||||
# any kind: a wedged reconciler stayed Ready and every ExternalSecret would
|
||||
# simply stop refreshing, which surfaces only when a consumer needs a
|
||||
# credential that has since rotated.
|
||||
#
|
||||
# Enabling them also starts the health server — the chart derives
|
||||
# --health-probe-bind-address from these values, and without them the
|
||||
# controller exposes nothing but its metrics port.
|
||||
livenessProbe:
|
||||
enabled: true
|
||||
readinessProbe:
|
||||
enabled: true
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -75,4 +75,14 @@ cluster:
|
||||
serviceSubnets:
|
||||
- 10.96.0.0/12
|
||||
- fd10:96::/112
|
||||
# Talos ships no CNI and no kube-proxy; Cilium provides both. These two
|
||||
# keys used to live in the deleted cilium-cni.yaml alongside the rendered
|
||||
# manifest, which is why they moved here when Cilium became Flux-only —
|
||||
# without them a freshly generated config comes up on Talos's default CNI
|
||||
# with kube-proxy running, which collides with the chart's
|
||||
# kubeProxyReplacement: true. See PROPOSAL-cilium-single-owner.md.
|
||||
cni:
|
||||
name: none
|
||||
proxy:
|
||||
disabled: true
|
||||
allowSchedulingOnControlPlanes: true
|
||||
|
||||
Reference in New Issue
Block a user