Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
532b853929 | ||
|
|
af7103fd75 | ||
|
|
1984b01e5d | ||
|
|
e02ad7639b | ||
|
|
80f5310653 | ||
|
|
9895b7f4f4 | ||
|
|
c380c7c2b8 | ||
|
|
0f86537bb1 | ||
|
|
31459dac6d | ||
|
|
57c96a9a62 | ||
|
|
ffd24865ee | ||
|
|
439e4b4b83 | ||
|
|
622a4cc593 | ||
|
|
96ea510143 | ||
|
|
60d3acc202 | ||
|
|
cb697e2812 | ||
|
|
1e26d5cdb2 | ||
|
|
fbd3fbd119 | ||
|
|
3c05098780 | ||
|
|
5ecd17d26b | ||
|
|
f29e9f965a | ||
|
|
42a3a31460 | ||
|
|
188784d406 | ||
|
|
6f5c4f7b81 | ||
|
|
76f3d4b6b5 | ||
|
|
67ff176875 | ||
|
|
2ddb9544e6 | ||
|
|
25e70dc555 | ||
|
|
a4b18f44c3 | ||
|
|
49856fe577 | ||
|
|
576611faae | ||
|
|
cc898ec553 | ||
|
|
7d53c99062 | ||
|
|
8ce5ebd8ac | ||
|
|
9258947fef | ||
|
|
1955a11b81 | ||
|
|
da614f83fe | ||
|
|
e5c99655e5 | ||
|
|
392a010daa | ||
|
|
f04f449163 | ||
|
|
32d0f0cc7b | ||
|
|
c79ccfb3f3 | ||
|
|
0904f73721 | ||
|
|
ef2024c565 | ||
|
|
3c86f0970c | ||
|
|
4c5665aa75 | ||
|
|
38091cbe11 | ||
|
|
33c0266fc1 | ||
|
|
9bcc1cb420 | ||
|
|
b10b50fd2d | ||
|
|
f9c517bd37 | ||
|
|
49cafb2f6d | ||
|
|
6fa10a7744 | ||
|
|
b341b26673 | ||
|
|
aad24d4186 | ||
|
|
7ef4475952 | ||
|
|
aca39a5a8f | ||
|
|
d7ca8dda11 | ||
|
|
c8483b3545 | ||
|
|
bb090e6809 | ||
|
|
5aee6a54ad | ||
|
|
8a53171ad0 | ||
|
|
299194247e | ||
|
|
6b6e1e7e04 | ||
|
|
752fcd5d03 | ||
|
|
55cd083484 | ||
|
|
ada3d3d0b4 | ||
|
|
1bd3111912 | ||
|
|
0e12e90509 | ||
|
|
613e1da011 |
@@ -319,7 +319,7 @@ steps:
|
||||
# writes its stripped mirror under mktemp and never into the workspace.
|
||||
- name: promtool
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: prom/prometheus:v3.15.0@sha256:efd719c99d83b060d9daefdcf00360461adf279f45ef5391f8d111892118753e
|
||||
image: prom/prometheus:v3.15.1@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
|
||||
commands:
|
||||
- sh scripts/ci/promtool-rules.sh
|
||||
|
||||
|
||||
@@ -35,25 +35,25 @@ when:
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
- name: pr-review-mistral-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
|
||||
commands:
|
||||
- sh scripts/ci/test-pr-review-mistral.sh
|
||||
|
||||
- name: promtool-rules-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
|
||||
commands:
|
||||
- sh scripts/ci/test-promtool-rules.sh
|
||||
|
||||
- name: yaml-citations-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
|
||||
commands:
|
||||
- sh scripts/ci/test-yaml-citations.sh
|
||||
|
||||
@@ -63,7 +63,7 @@ steps:
|
||||
# bridge, and the secret guard.
|
||||
- name: hermes-skill-tests
|
||||
depends_on: []
|
||||
image: python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
|
||||
image: python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
|
||||
environment:
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
commands:
|
||||
|
||||
@@ -13,7 +13,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: validate-secrets
|
||||
image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
image: alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
environment:
|
||||
TF_VAR_proxmox_api_url:
|
||||
from_secret: proxmox_api_url
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ steps:
|
||||
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
|
||||
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
|
||||
# test from rotting (it exercises the exact injection shapes).
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: go-test
|
||||
image: golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61
|
||||
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
|
||||
commands:
|
||||
- test -z "$(gofmt -l review-bot)"
|
||||
- go -C review-bot vet ./...
|
||||
|
||||
@@ -18,12 +18,12 @@ when:
|
||||
|
||||
steps:
|
||||
- name: iac-scan
|
||||
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
|
||||
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
|
||||
commands:
|
||||
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
|
||||
|
||||
- name: image-scans
|
||||
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
|
||||
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
|
||||
commands:
|
||||
- trivy image --download-db-only
|
||||
- |
|
||||
@@ -70,7 +70,7 @@ steps:
|
||||
# this step is skipped — otherwise we'd fork-bomb PRs.
|
||||
when:
|
||||
- branch: main
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -30,7 +30,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: update-audit-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
+13
-14
@@ -72,14 +72,14 @@ none of these commands.
|
||||
|
||||
## Secrets (before first play run)
|
||||
|
||||
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault; the
|
||||
current live values come straight out of the cluster:
|
||||
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault. The
|
||||
cluster broker has no `open3e` user: edge-1 logs in only as the register
|
||||
account `edge-1` (see "MQTT bridge" below), so
|
||||
`vault_edge_open3e_mqtt_password` reaches only the loopback broker, which
|
||||
allows anonymous clients; any random value works (`openssl rand -hex 24`).
|
||||
The zwave-js value comes out of the cluster:
|
||||
|
||||
```
|
||||
# sml2mqtt broker password
|
||||
kubectl -n home-assistant get secret mosquitto-sml2mqtt-credentials -o jsonpath='{.data.password}' | base64 -d
|
||||
# open3e broker password
|
||||
kubectl -n home-assistant get secret mosquitto-open3e-credentials -o jsonpath='{.data.password}' | base64 -d
|
||||
# zwave-js session secret
|
||||
kubectl -n home-assistant get secret zwave-js-session -o jsonpath='{.data.session-secret}' | base64 -d
|
||||
```
|
||||
@@ -230,15 +230,14 @@ They connect to a **local mosquitto on loopback** (`127.0.0.1:1883`,
|
||||
broker is briefly unreachable, and flushes on reconnect — edge-1 no longer
|
||||
hard-depends on cluster/network uptime for every publish.
|
||||
|
||||
Two bridge `connection`s preserve the existing cluster identities, so **no
|
||||
cluster-side change was needed**: one authenticates as the `open3e` user
|
||||
(carries `open3e/#` out + `open3e/cmnd` back), one as `sml2mqtt` (`sml2mqtt/#`
|
||||
out). The local broker is `allow_anonymous` on loopback — the trust boundary
|
||||
is the host; nothing off-host can reach it.
|
||||
The local broker is `allow_anonymous` on loopback — the trust boundary is the
|
||||
host; nothing off-host can reach it.
|
||||
|
||||
**Bridge credential (REN-1307).** The two vault-password bridges are being
|
||||
replaced by one bridge as the register account `edge-1`
|
||||
(`kubernetes/apps/home-assistant/mqtt-accounts.yaml`). Its password is pushed
|
||||
**Bridge credential (REN-1307).** One bridge `connection` logs in as the
|
||||
register account `edge-1` (`kubernetes/apps/home-assistant/mqtt-accounts.yaml`)
|
||||
and carries `open3e/#` out and `open3e/cmnd` back. The cluster broker does not
|
||||
accept the vault-password bridges in `bridge.d/legacy.conf`, which exists only
|
||||
until the first push. Its password is pushed
|
||||
by mqtt-account-controller to `edge-mqtt-cred` (`:8443`, the controller's
|
||||
esp32 API over HTTPS). That endpoint accepts only a client certificate from
|
||||
the MQTT CA with CN `mqtt-account-controller`; edge-1 holds the public CA and
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# Alpine plus jq. Nothing application-specific — a general-purpose base for any
|
||||
# workload that needs to read JSON from a shell script.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# Alpine plus the openssl CLI. Nothing application-specific — named for what it
|
||||
# is, so anything needing `openssl s_client` / `x509` from a shell script can use
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
RUN apk add --no-cache \
|
||||
ansible \
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
# The unit tests gate the image: the final stage copies the controller out of
|
||||
# this stage, so a failing test fails the build.
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS test
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS test
|
||||
WORKDIR /src
|
||||
COPY controller.py test_controller.py ./
|
||||
RUN python3 -B -m unittest -v
|
||||
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY --from=test /src/controller.py /opt/controller.py
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM hetznercloud/cli:v1.70.1@sha256:795ac09a5ecabd48cc13a8f1c3dc45983c8f75ae1277c70d621719e07f276ccb AS hcloud
|
||||
FROM hetznercloud/cli:v1.70.2@sha256:3b3a8f66323a5754089df7939fd9d3f350127072deed13418f3d3052b4bbeeae AS hcloud
|
||||
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# bird: BGP daemon for the pod-network mesh over pod0. Distro package, no build
|
||||
# step — the image is the binary plus its runtime.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f
|
||||
FROM node:24-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
|
||||
|
||||
# Default to the latest Claude Code. Pinning a fixed version is the wrong
|
||||
# default here: old releases eventually stop working against the API, so a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Cloudflare. The unit tests (no tags, so no envtest) gate the image: the
|
||||
# final stage copies the binary out of this stage, so a failing test fails the
|
||||
# build.
|
||||
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
|
||||
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
@@ -10,7 +10,7 @@ COPY . .
|
||||
RUN go test ./...
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cloudflare-lb-sync ./cmd/cloudflare-lb-sync
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
|
||||
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
|
||||
USER 65532
|
||||
ENTRYPOINT ["/cloudflare-lb-sync"]
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
# amd64 only, matching the ARCH the installer already hardcoded and the cluster's
|
||||
# node architecture. Add a second stage if a different arch is ever needed.
|
||||
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 AS fetch
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a AS fetch
|
||||
|
||||
ARG CNI_PLUGINS_VERSION=v1.9.1
|
||||
ARG CNI_PLUGINS_SHA256=b98f74a0f8522f0a83867178729c1aa70f2158f90c45a2ca8fa791db1c76b303
|
||||
@@ -61,7 +61,7 @@ RUN set -eu; \
|
||||
[ -x "/plugins/$p" ] || { echo "FATAL: /plugins/$p missing or not executable" >&2; exit 1; }; \
|
||||
done
|
||||
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# Alpine rather than scratch: the installer needs a shell to copy the binaries
|
||||
# onto the host. busybox covers cp/mv/chown/ls, so nothing is added.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
# namespace, so can0 is only reachable with network_mode: host. That is also
|
||||
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
|
||||
# no capabilities, so both run unprivileged.
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS build
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
|
||||
|
||||
# Pins live here, in the artifact, rather than in Ansible variables.
|
||||
#
|
||||
@@ -56,7 +56,7 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
|
||||
&& rm -rf /opt/e3oncan/.git
|
||||
|
||||
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
|
||||
|
||||
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
|
||||
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM nousresearch/hermes-agent:v0.22.2@sha256:36099926da316742423544e055d4734777105ffe6341873af5459b957d0c2919
|
||||
FROM nousresearch/hermes-agent:v0.22.5@sha256:9c5561579f3ffdf333401797094ac3a743738d6c25d40f4331c7bf77b694238d
|
||||
|
||||
USER root
|
||||
|
||||
@@ -6,9 +6,9 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/siderolabs/talosctl:v1.13.10@sha256:3116f64c97ddffb53bd3c5bf87c8614d99f04df4326177dbbb7702b5087f0e4d /talosctl /usr/local/bin/talosctl
|
||||
COPY --from=alpine/kubectl:1.36.3@sha256:dae261106d5555008acede256b7102c8a6decde7209198434f5999f051460f57 /usr/local/bin/kubectl /usr/local/bin/kubectl
|
||||
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.5@sha256:704d5529535570a495249b643159c93a9aa88ffca07813c974a54b7ba6edbd86 /usr/local/bin/flux /usr/local/bin/flux
|
||||
COPY --from=ghcr.io/siderolabs/talosctl:v1.14.4@sha256:b3233234327350f20ed194f3fad83d90a64344c1940f839c7b0c0abef50c8c33 /talosctl /usr/local/bin/talosctl
|
||||
COPY --from=alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7 /usr/local/bin/kubectl /usr/local/bin/kubectl
|
||||
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.6@sha256:b1ac18156f227af9a524b842a96f2c20986c7a779ef721df3ba4e4540f449d76 /usr/local/bin/flux /usr/local/bin/flux
|
||||
COPY --from=quay.io/openbao/openbao:2.7.1@sha256:6d2b93856e3fcf7b18ad855a0b51eaba474dc8b79cf554379ea32034797d2acf /usr/bin/bao /usr/local/bin/bao
|
||||
COPY --from=gitea/tea:0.16.0@sha256:af50a2060ce4d8fd76b7ebe75a3d6db4bb0c615c5bc8f2bdfd8ea83207c4752d /usr/bin/tea /usr/local/bin/tea
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
ARG TALOS_VERSION=v1.13.12
|
||||
ARG VELERO_VERSION=v1.16.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. The unit tests
|
||||
# (no tags, so no Docker and no envtest) gate the image: the final stage copies
|
||||
# the binary out of this stage, so a failing test fails the build.
|
||||
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
|
||||
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
@@ -9,7 +9,7 @@ COPY . .
|
||||
RUN go test ./...
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/kuma-sync ./cmd/kuma-sync
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
|
||||
COPY --from=build /out/kuma-sync /kuma-sync
|
||||
USER 65532
|
||||
ENTRYPOINT ["/kuma-sync"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
|
||||
# public label. Standard library only, so the image is the interpreter plus one
|
||||
# file; runs unprivileged as its own user.
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY controller.py /opt/controller.py
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
ARG TOFU_VERSION=1.11.0
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# Everything `bootstrap.sh vmtest` needs, so the rebuild test can run as a Job in
|
||||
# the cluster instead of on a workstation. The workstation has 31G and the test
|
||||
|
||||
@@ -48,7 +48,7 @@ spec:
|
||||
# cert when doing server-side OIDC token exchange. Bundle both
|
||||
# roots into one PEM since NODE_EXTRA_CA_CERTS accepts one file.
|
||||
- name: fetch-le-staging-roots
|
||||
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
|
||||
image: curlimages/curl:8.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -70,7 +70,7 @@ spec:
|
||||
mountPath: /ca
|
||||
containers:
|
||||
- name: audiobookshelf
|
||||
image: ghcr.io/advplyr/audiobookshelf:2.37.1 # {"$imagepolicy": "flux-system:audiobookshelf"}
|
||||
image: ghcr.io/advplyr/audiobookshelf:2.37.2 # {"$imagepolicy": "flux-system:audiobookshelf"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
|
||||
@@ -1034,9 +1034,19 @@ data:
|
||||
negate: false
|
||||
failure_result: false
|
||||
|
||||
# wiki.renner.uno signs in via OIDC application `wiki`; only this group may sign in.
|
||||
# Members are managed in Authentik.
|
||||
- id: wiki-readers-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: Wiki Readers
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
|
||||
# Wiki (apps/wiki): its own OIDC login. Public client + PKCE; RS256
|
||||
# so the service verifies the ID token against the JWKS. No policy
|
||||
# binding: every Authentik user may sign in.
|
||||
# so the service verifies the ID token against the JWKS. Only the
|
||||
# Wiki Readers group passes.
|
||||
- id: wiki-provider
|
||||
model: authentik_providers_oauth2.oauth2provider
|
||||
identifiers:
|
||||
@@ -1072,6 +1082,17 @@ data:
|
||||
meta_launch_url: https://wiki.renner.uno
|
||||
meta_description: "Homelab wiki"
|
||||
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, wiki]]
|
||||
group: !KeyOf wiki-readers-group
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
failure_result: false
|
||||
|
||||
# OpenProject, decommissioned. Application first: Application.provider
|
||||
# is on_delete=PROTECT. Its policy binding cascades with it.
|
||||
- model: authentik_core.application
|
||||
|
||||
@@ -105,7 +105,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0748 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-25-1458 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -30,7 +30,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-22-1904 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-25-1504 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
|
||||
env:
|
||||
- name: CLOUDFLARE_API_TOKEN
|
||||
valueFrom:
|
||||
|
||||
@@ -28,7 +28,7 @@ spec:
|
||||
enableServiceLinks: false
|
||||
containers:
|
||||
- name: evcc
|
||||
image: evcc/evcc:0.317.1@sha256:c50cd4f83c22b39722a76a76b8d428b69bf8ab3334209ed9ed24ec0e3c48dde5
|
||||
image: evcc/evcc:0.318.1@sha256:2f8163ab22f04863afd86cd511c09607ba71debd9eebd65511f3d654922a80d1
|
||||
# evcc auto-discovers /etc/evcc.yaml on its default search path;
|
||||
# the file is delivered (with the MQTT password) via the
|
||||
# sops-encrypted evcc-config Secret mounted below.
|
||||
|
||||
@@ -20,7 +20,7 @@ metadata:
|
||||
# services group infrastructure group
|
||||
# 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 3 = Immich Infrastructure 3 = UDM DNS (10.2.47.1)
|
||||
# Web Service 4 = Woodpecker CI Infrastructure 4 = Mosquitto MQTT broker
|
||||
# Web Service 5 = Grafana
|
||||
# Web Service 6 = Home Assistant
|
||||
@@ -290,9 +290,11 @@ data:
|
||||
# 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.
|
||||
|
||||
# The UDM on the segment pod traffic leaves on (k8s-gateway-site in
|
||||
# talos/patches/site-homelab.yaml).
|
||||
- name: Infrastructure 3
|
||||
group: infrastructure
|
||||
url: 10.2.40.1
|
||||
url: 10.2.47.1
|
||||
interval: 30s
|
||||
dns:
|
||||
query-name: udm.renner.uno
|
||||
|
||||
@@ -115,7 +115,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: retention
|
||||
image: docker.io/library/python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
|
||||
image: docker.io/library/python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
|
||||
command: ["python3", "/app/package-retention.py"]
|
||||
env:
|
||||
# In-cluster Service; the public name hairpins off the ingress
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: sync
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
- /opt/hermes-skills/gitea-issues/scripts/sync_webhook.py
|
||||
|
||||
@@ -51,7 +51,7 @@ spec:
|
||||
# connects from the pod network. No env equivalent exists, so it is
|
||||
# set in config.yaml on every start (idempotent).
|
||||
- name: config
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
@@ -75,7 +75,7 @@ spec:
|
||||
mountPath: /opt/data
|
||||
# WebSocket keepalive ping interval in seconds (default 20).
|
||||
- name: config-ws-ping
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
@@ -99,7 +99,7 @@ spec:
|
||||
mountPath: /opt/data
|
||||
# Skills from git (ConfigMap hermes-skills) beside the PVC's skills/.
|
||||
- name: skills-config
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
@@ -130,7 +130,7 @@ spec:
|
||||
# - alert-monitor: hourly; the script wakes the agent only for
|
||||
# untriaged alerts.
|
||||
- name: cron-jobs
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
|
||||
# Non-fatal: a broken jobs.json or a changed cron CLI after an image
|
||||
# bump logs a warning instead of keeping Hermes from starting.
|
||||
command:
|
||||
@@ -187,7 +187,7 @@ spec:
|
||||
readOnly: true
|
||||
# Home Assistant MCP server; token resolved from HA_TOKEN at connect, writes need approval.
|
||||
- name: config-mcp-homeassistant
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
@@ -214,7 +214,7 @@ spec:
|
||||
# skills, the Home Assistant MCP and the tea login are re-applied on
|
||||
# every start. default gets the kanban toolset to hand cards to them.
|
||||
- name: experts
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
|
||||
# Non-fatal like cron-jobs: a broken expert logs a warning.
|
||||
command:
|
||||
- sh
|
||||
@@ -305,7 +305,7 @@ spec:
|
||||
# Gitea issues -> kanban (skills/gitea-issues): route script, webhook route on default,
|
||||
# completion shell hook plus its allowlist entry on every profile.
|
||||
- name: gitea-issues
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
|
||||
# Non-fatal like cron-jobs: a failure logs a warning.
|
||||
command:
|
||||
- sh
|
||||
@@ -368,7 +368,7 @@ spec:
|
||||
readOnly: true
|
||||
# Multiplexed default reads platform credentials only from .env (rationale: commit message).
|
||||
- name: default-env
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
|
||||
# Fatal on a writer failure: without these Matrix and the API server stay down.
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
@@ -428,7 +428,7 @@ spec:
|
||||
# tea login hermes-bot, written from the mounted token into an
|
||||
# emptyDir at the agent's ~/.config/tea.
|
||||
- name: tea-login
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- sh
|
||||
- -ec
|
||||
@@ -457,7 +457,7 @@ spec:
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: hermes
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
|
||||
args: ["gateway", "run"]
|
||||
env:
|
||||
- name: HERMES_DASHBOARD
|
||||
|
||||
@@ -88,7 +88,7 @@ spec:
|
||||
# deliver, and the ClusterIP has no backend while HA restarts. The
|
||||
# trap forwards SIGTERM so the pod still terminates promptly.
|
||||
- name: socat-ipv4
|
||||
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
|
||||
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/sh
|
||||
@@ -130,7 +130,7 @@ spec:
|
||||
# depends on the kernel's bindv6only default rather than on anything
|
||||
# stated here, so the roles are made explicit instead of inherited.
|
||||
- name: socat-ipv6
|
||||
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
|
||||
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/sh
|
||||
|
||||
@@ -61,7 +61,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: shelly-manager
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1792387459 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1792997278 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
@@ -177,7 +177,7 @@ spec:
|
||||
# No probes: it only serves the API-key path, and a probe failure would
|
||||
# also pull the pod out of the shelly-manager Service.
|
||||
- name: api-proxy
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b9241c6e7b8e9a862f129d8d4199ab64b10390949a78bdd5603379b32c844083
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
|
||||
env:
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
# Proposal: enrol the mosquitto broker credentials in Class-A rotation
|
||||
|
||||
**Status:** proposal, nothing implemented.
|
||||
**Scope:** the 8 MQTT broker users and the credentials behind them.
|
||||
|
||||
## Problem
|
||||
|
||||
Every self-generated secret in this cluster rotates automatically except these.
|
||||
`kubernetes/infrastructure/openbao/rotation/` holds 13 manifests defining 15
|
||||
rotator CronJobs (`home-assistant.yaml` alone defines three). Five are **Class-A**
|
||||
— authentik, crowdsec, paperless, shelly-manager and woodpecker — meaning
|
||||
"self-generated random secret", which is exactly what a broker password is.
|
||||
The mosquitto credentials are the only Class-A-shaped secrets not enrolled.
|
||||
|
||||
They are currently minted once by seven near-identical bootstrap Jobs
|
||||
(`mosquitto-bootstrap.yaml`, 508 lines — the ServiceAccount, Role and RoleBinding
|
||||
are inside that same file) which generate a password, hand-build Secret JSON with
|
||||
`jq` and POST it to the Kubernetes API with `curl`. Once created they never
|
||||
change again.
|
||||
|
||||
Three consequences:
|
||||
|
||||
1. **They never rotate.** Not on a schedule, not on compromise, not ever.
|
||||
2. **They exist only in the Secret object** — not in OpenBao, so not covered by
|
||||
the OpenBao snapshot CronJob, and unrecoverable if lost.
|
||||
3. **The Jobs cannot be modified.** A Job's `spec.template` is immutable, so
|
||||
changing even the container image makes the whole `home-assistant`
|
||||
Kustomization fail to reconcile with `field is immutable`. Any edit requires
|
||||
deleting or renaming the Jobs.
|
||||
|
||||
## Why the obvious fixes do not work
|
||||
|
||||
Recorded because each was tried and rejected on evidence, and the reasons are
|
||||
not obvious from the code.
|
||||
|
||||
| Approach | Why it fails |
|
||||
| --- | --- |
|
||||
| ExternalSecrets reading hand-seeded OpenBao paths | Depends on content copied in by hand. Git cannot recreate it; a fresh OpenBao leaves the paths empty and ESO then overwrites live broker credentials with nothing. |
|
||||
| ESO `Password` generator | **Overwrites pre-existing Secrets.** Verified: a Secret holding `KNOWN-PREEXISTING-VALUE` was replaced with a freshly generated password on first sync. Adopting it rotates the 7 Job-minted credentials at once, uncoordinated (`evcc` is already ESO-extract-backed and so unaffected). |
|
||||
| Keep the Jobs, fix only the image | Blocked by `spec.template` immutability. Renaming the Jobs works but is a hack that recurs on every future edit. |
|
||||
|
||||
The common thread: each treats the credential as something to *place*, when the
|
||||
real requirement is a credential that can be *replaced*.
|
||||
|
||||
## How rotation actually has to work here
|
||||
|
||||
The broker rebuilds its password file from scratch on **every pod start**
|
||||
(`mosquitto-deployment.yaml`): `rm -f /mosquitto/auth/passwd` followed by
|
||||
`mosquitto_passwd -c -b …` for each of the 8 users, sourced from env vars backed
|
||||
by the 8 Secrets.
|
||||
|
||||
Two things follow, and they shape the whole design:
|
||||
|
||||
- **A Secret change has no effect until the broker restarts.** There is no
|
||||
Reloader annotation on the mosquitto Deployment today, unlike the consumers of
|
||||
the existing Class-A rotators.
|
||||
- **There is no make-before-break.** A mosquitto user has exactly one password.
|
||||
The LLAT rotators can mint a second token and revoke the first afterwards; a
|
||||
broker user cannot hold old and new simultaneously. Unless a transitional
|
||||
second user is created, broker and client must cut over together.
|
||||
|
||||
### Who holds each credential
|
||||
|
||||
This is the part that determines cutover cost, and it is not visible from the
|
||||
manifests alone.
|
||||
|
||||
| Broker user | Held by | Cutover |
|
||||
| --- | --- | --- |
|
||||
| `health` | the broker's own liveness/readiness probes | in-cluster, same pod restart |
|
||||
| `zigbee2mqtt` | zigbee2mqtt Deployment (`secretKeyRef`) | in-cluster, pod restart |
|
||||
| `evcc` | evcc app; already OpenBao-backed via ESO | in-cluster, pod restart |
|
||||
| `sml2mqtt` | edge host, from `vault_edge_sml2mqtt_mqtt_password` | re-run the edge-host play |
|
||||
| `open3e` | edge host, from `vault_edge_open3e_mqtt_password` | re-run the edge-host play |
|
||||
| `homeassistant` | HA MQTT integration — a config entry created in the UI | needs an API/WebSocket update, or a manual re-entry |
|
||||
| `shellies` | Shelly device firmware | pushed per-device; `shelly-manager` is deployed and is the natural mechanism |
|
||||
| `admin` | a workstation client, held by a human | read the new value from OpenBao |
|
||||
|
||||
Note `sml2mqtt` and `open3e` currently keep **the same password in two unlinked
|
||||
places** — the Kubernetes Secret for the broker, and `ansible-vault` for the
|
||||
client — synchronised by hand. That duplication, not any client limitation, is
|
||||
what makes rotation feel impossible today.
|
||||
|
||||
## Proposed design
|
||||
|
||||
Follow the existing Class-A pattern exactly (`rotation/paperless.yaml` is the
|
||||
closest model): *generate random bytes → merge-patch into OpenBao → ESO syncs the
|
||||
Secret → restart the consumer*.
|
||||
|
||||
1. **One rotator CronJob per broker user**, running the existing `secret-rotator`
|
||||
binary in random mode, writing `apps/home-assistant/mosquitto-<user>-credentials`
|
||||
in OpenBao. Reuses the established policy/role/SA pattern; no new machinery.
|
||||
2. **ExternalSecrets** project each OpenBao entry to the existing Secret name and
|
||||
key, so `mosquitto-deployment.yaml` and the in-cluster consumers are unchanged.
|
||||
Key names stay as they are (`homeassistant-password` on one, `password` on the
|
||||
rest) — consumers reference them, so normalising is a separate change.
|
||||
3. **Add a Reloader annotation to the mosquitto Deployment** so a Secret change
|
||||
restarts the broker, instead of silently taking effect at some unrelated future
|
||||
restart.
|
||||
4. **Move the edge host off `ansible-vault` for these two values**, reading the
|
||||
same OpenBao entry instead, so there is one source of truth per credential.
|
||||
5. **Rotate one user at a time.** Each rotation then affects exactly one client,
|
||||
not all eight.
|
||||
|
||||
### The cutover window
|
||||
|
||||
With no make-before-break, each rotation has a window where broker and client
|
||||
disagree. Two options:
|
||||
|
||||
- **Accept a brief window** (recommended for most users). Order: rotator writes
|
||||
OpenBao → client picks up the new value → broker restarts. MQTT clients
|
||||
reconnect automatically; the window is seconds. Because rotation is per-user,
|
||||
only that one client is affected.
|
||||
|
||||
One failure mode to expect rather than be alarmed by: the broker rolls with
|
||||
`maxUnavailable: 0` / `maxSurge: 1`, so mid-rollout **two broker pods with
|
||||
different passwd files sit behind the LB**. A client reconnecting during that
|
||||
window can land on the old pod and fail auth until the rollout completes. It is
|
||||
self-healing and lasts seconds to a minute, but it will look alarming in logs
|
||||
during the first `health` / `zigbee2mqtt` rotation, so it is worth knowing in
|
||||
advance.
|
||||
- **Transitional dual user** (`<user>` and `<user>-next` in the passwd file and
|
||||
ACL) for any credential where even a brief outage is unacceptable. More moving
|
||||
parts; probably only worth it for `homeassistant`, which carries everything.
|
||||
|
||||
## Suggested phasing
|
||||
|
||||
Ordered so the risky work happens after the pattern is proven, and so each phase
|
||||
is independently valuable.
|
||||
|
||||
1. **`health` and `zigbee2mqtt` first.** Both are entirely in-cluster and pick up
|
||||
a new password on pod restart with no external coordination. Proves rotator →
|
||||
OpenBao → ESO → Reloader end to end with the smallest blast radius. `health`
|
||||
is the safest possible start: only the broker's own probes consume it.
|
||||
2. **`evcc`.** Already OpenBao-backed via ESO; only the rotator is missing.
|
||||
3. **`sml2mqtt` and `open3e`**, together with switching the edge host to read from
|
||||
OpenBao. This is where the duplicate-source-of-truth problem gets fixed.
|
||||
4. **`shellies`**, via shelly-manager pushing to devices. Needs investigation:
|
||||
shelly-manager has its own Class-A rotator today, but that rotates
|
||||
shelly-manager's own secret and does not touch device MQTT credentials.
|
||||
5. **`homeassistant`** last. Highest blast radius, and the config entry is UI-created,
|
||||
so it needs an API/WebSocket update path — the HA LLAT rotators
|
||||
(`rotation/home-assistant.yaml`) already talk to HA over WebSocket and are the
|
||||
obvious precedent.
|
||||
6. **`admin`** whenever convenient; a human reads the new value from OpenBao.
|
||||
|
||||
Only once a user is enrolled does its bootstrap Job become dead code. Delete the
|
||||
Jobs incrementally as their users migrate, rather than in one step — that also
|
||||
avoids the immutability problem entirely, since the Jobs are deleted, never
|
||||
patched.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Does `secret-rotator` random mode fit unchanged?** The existing rotators write
|
||||
a field into an OpenBao entry; here the entry holds a single password field, so
|
||||
it should, but this needs confirming against the binary rather than assumed.
|
||||
- **Can shelly-manager push MQTT credentials to devices**, and does it cover both
|
||||
Gen1 (`/settings/mqtt`) and Gen2+ (`Mqtt.SetConfig`) firmwares? If not, the
|
||||
`shellies` user may need a different mechanism or stay manual longest.
|
||||
- **How is the HA MQTT config entry updated programmatically?** Reusing the
|
||||
WebSocket approach from the LLAT rotator is plausible but unverified.
|
||||
- **Rotation cadence.** The existing rotators' schedules should be the reference
|
||||
rather than inventing a new one.
|
||||
- **ESO refresh latency.** Rotator-writes-OpenBao → Secret-updates →
|
||||
Reloader-restarts is delayed by up to one `refreshInterval`; the `evcc`
|
||||
ExternalSecret uses `1h`. That does not widen the outage window — broker and
|
||||
client still move together — but it does stretch how long a rotation takes to
|
||||
land, which matters for verifying one completed and for the cold-rebuild
|
||||
ordering question above. Worth deciding whether these ExternalSecrets want a
|
||||
shorter interval than 1h, or whether the rotator should trigger the sync
|
||||
directly.
|
||||
- **Bootstrap on a cold rebuild.** On a fresh cluster the rotator must create the
|
||||
OpenBao entry before ESO can sync it; check this against
|
||||
`kubernetes/RUNBOOK-cold-rebuild.md` so broker bring-up is not blocked.
|
||||
|
||||
## What this does not change
|
||||
|
||||
- Secret names, key names and every `secretKeyRef` in `mosquitto-deployment.yaml`.
|
||||
- The ACL model and the per-user topic scoping.
|
||||
- The `evcc` ExternalSecret, which already follows the target pattern.
|
||||
@@ -128,7 +128,7 @@ spec:
|
||||
memory: 32Mi
|
||||
containers:
|
||||
- name: home-assistant
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.10.2 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.10.4 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
@@ -192,7 +192,7 @@ spec:
|
||||
# substitution to POD_IP so nginx-runtime variables like $host
|
||||
# / $http_x_forwarded_for in the template stay intact.
|
||||
- name: proxy
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:378d0c32f6562f7fd9629170797f57d2b953aa09be983b3daaf4809bb039ca00
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
|
||||
env:
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
@@ -236,7 +236,7 @@ spec:
|
||||
# readiness probe on purpose: a broker outage must not take HA out of
|
||||
# its Service endpoints.
|
||||
- name: mqtt-tls
|
||||
image: ghostunnel/ghostunnel:v1.11.4@sha256:e5248233863aadd36383a11c92360f2981141881ef05e38360a6ada8630d36b1
|
||||
image: ghostunnel/ghostunnel:v1.11.5@sha256:65c4e34ec0ee0f30ea1f72b7411ec70a91c32fc50f067a8466ad2adb1e7420f2
|
||||
args:
|
||||
- client
|
||||
- --listen=127.0.0.1:11883
|
||||
@@ -275,7 +275,7 @@ spec:
|
||||
# any LAN client can edit, matching the user's trust-the-network
|
||||
# model. Runs as root so its writes match HA's root-owned files.
|
||||
- name: code-server
|
||||
image: codercom/code-server:4.141.0@sha256:72ab24b5680ee597dd647b30d82625752f40109b5f61bef90ffbf1ea1f02dbb8
|
||||
image: codercom/code-server:4.143.1@sha256:377b66bec5645510ed52549bb00e648c273edc604d230a3f90fd2b2433afcb97
|
||||
args:
|
||||
- "--auth=none"
|
||||
- "--bind-addr=0.0.0.0:8080"
|
||||
@@ -337,7 +337,7 @@ spec:
|
||||
# dedicated long-lived token.
|
||||
# Runs as root to match HA's root-owned /config files. See config-sync.sh.
|
||||
- name: config-sync
|
||||
image: alpine/git:2.54.0@sha256:03228d7ded483b0b3e55e1de7fa660d70d3e1bb432fa7021d29e192640de11aa
|
||||
image: alpine/git:2.54.0@sha256:e8ceedc73bd80992ae8d58a7d24c58b8322146c354f1d2e81a97db4452068ec0
|
||||
command: ["/bin/sh", "/scripts/sync.sh"]
|
||||
env:
|
||||
- name: SYNC_BRANCH
|
||||
|
||||
@@ -36,7 +36,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: esphome
|
||||
image: ghcr.io/esphome/esphome:2026.9.1@sha256:776a845d508909bf426d2ee7bdd81796361ebe6a7f091807ce1d6d0b48985413
|
||||
image: ghcr.io/esphome/esphome:2026.10.0@sha256:1dff19a59070cdb523b76576cdc74d200f862d43c1407809088855920cf57145
|
||||
imagePullPolicy: IfNotPresent
|
||||
# Default entrypoint runs the dashboard (Device Builder) against the
|
||||
# mounted /config — no command override needed.
|
||||
|
||||
@@ -12,26 +12,20 @@ resources:
|
||||
- config-sync-token-externalsecret.yaml
|
||||
- config-sync-gitea-externalsecret.yaml
|
||||
- matrix-externalsecret.yaml
|
||||
- mosquitto-bootstrap.yaml
|
||||
# Former `health` probe credential. The broker no longer reads it (the
|
||||
# probes use the cert account mosquitto-probe, REN-953); removed with its
|
||||
# rotator in the REN-802 cleanup step.
|
||||
- mosquitto-health-credentials-externalsecret.yaml
|
||||
- mosquitto-admin-password.yaml
|
||||
- mosquitto-config.yaml
|
||||
- mosquitto-certificate.yaml
|
||||
- mosquitto-service.yaml
|
||||
- mosquitto-iot-service.yaml
|
||||
- mosquitto-deployment.yaml
|
||||
- mosquitto-evcc-credentials-externalsecret.yaml
|
||||
# shelly-manager and the mDNS relay sit on the IoT VLAN: ../home-assistant-iot.
|
||||
- esphome-pvc.yaml
|
||||
- esphome-deployment.yaml
|
||||
- esphome-service.yaml
|
||||
- esphome-ingress.yaml
|
||||
# Zigbee2MQTT — ZHA replacement bridge. Ships scaled to 0; the cutover
|
||||
# runbook (zigbee2mqtt-migration-runbook.md) scales it up. The MQTT
|
||||
# `zigbee2mqtt` broker user is provisioned by mosquitto-bootstrap.yaml
|
||||
# (above) and wired into mosquitto-deployment.yaml (passwd + ACL).
|
||||
# runbook (zigbee2mqtt-migration-runbook.md) scales it up. It logs in to
|
||||
# the broker as register account `zigbee2mqtt-bridge` (mqtt-accounts.yaml).
|
||||
- zigbee2mqtt-coordinator-backup-externalsecret.yaml
|
||||
- zigbee2mqtt-config.yaml
|
||||
- zigbee2mqtt-pvc.yaml
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Password of the broker's `admin` workstation login (mosquitto-deployment.yaml).
|
||||
# Generated once in-cluster, nothing in OpenBao. Read it with
|
||||
# kubectl -n home-assistant get secret mosquitto-admin-password -o jsonpath='{.data.password}' | base64 -d
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
metadata:
|
||||
name: mosquitto-admin-password
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
length: 32
|
||||
digits: 8
|
||||
symbols: 0
|
||||
allowRepeat: true
|
||||
noUpper: false
|
||||
secretKeys:
|
||||
- password
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: mosquitto-admin-password
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
refreshPolicy: CreatedOnce
|
||||
target:
|
||||
name: mosquitto-admin-password
|
||||
dataFrom:
|
||||
- sourceRef:
|
||||
generatorRef:
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
name: mosquitto-admin-password
|
||||
@@ -1,394 +0,0 @@
|
||||
---
|
||||
# Generates mosquitto-credentials on first deploy. The password is created
|
||||
# in-cluster, lives only in the Secret object, and is never written to git
|
||||
# or seen by a human. The Mosquitto broker hashes it via mosquitto_passwd
|
||||
# -U at startup; HA reads the same value from this Secret (configured
|
||||
# manually in the HA MQTT integration UI).
|
||||
#
|
||||
# Idempotent: if the Secret already exists, the Job exits 0 without
|
||||
# regenerating, so subsequent Flux reconciles don't rotate the password.
|
||||
# To rotate, delete both the Secret and this Job; Flux will re-create
|
||||
# the Job and a fresh password.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: mosquitto-bootstrap
|
||||
namespace: home-assistant
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: mosquitto-bootstrap
|
||||
namespace: home-assistant
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["get", "create"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: mosquitto-bootstrap
|
||||
namespace: home-assistant
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: mosquitto-bootstrap
|
||||
namespace: home-assistant
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: mosquitto-bootstrap
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: mosquitto-credentials-bootstrap
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
# Job pod templates are immutable; force-recreate so image bumps
|
||||
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
|
||||
kustomize.toolkit.fluxcd.io/force: enabled
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-bootstrap
|
||||
spec:
|
||||
serviceAccountName: mosquitto-bootstrap
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
apk add --no-cache curl jq >/dev/null
|
||||
|
||||
NS=home-assistant
|
||||
NAME=mosquitto-credentials
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
API=https://kubernetes.default.svc
|
||||
|
||||
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
|
||||
echo "$NAME already exists; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
|
||||
|
||||
body=$(jq -nc \
|
||||
--arg name "$NAME" \
|
||||
--arg ns "$NS" \
|
||||
--arg pw "$PW_B64" \
|
||||
'{apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {name: $name, namespace: $ns},
|
||||
type: "Opaque",
|
||||
data: {"homeassistant-password": $pw}}')
|
||||
|
||||
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
--data "$body" \
|
||||
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
|
||||
|
||||
echo "generated $NAME"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
---
|
||||
# Third broker user: `admin`. Full readwrite (same as HA) but kept on
|
||||
# its own credential so a workstation client can hold a password the
|
||||
# in-cluster HA config never sees, and rotating one doesn't affect the
|
||||
# other. Retrieve with:
|
||||
# kubectl -n home-assistant get secret mosquitto-admin-credentials \
|
||||
# -o jsonpath='{.data.password}' | base64 -d
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: mosquitto-admin-credentials-bootstrap
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
# Job pod templates are immutable; force-recreate so image bumps
|
||||
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
|
||||
kustomize.toolkit.fluxcd.io/force: enabled
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-bootstrap
|
||||
spec:
|
||||
serviceAccountName: mosquitto-bootstrap
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
apk add --no-cache curl jq >/dev/null
|
||||
|
||||
NS=home-assistant
|
||||
NAME=mosquitto-admin-credentials
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
API=https://kubernetes.default.svc
|
||||
|
||||
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
|
||||
echo "$NAME already exists; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
|
||||
|
||||
body=$(jq -nc \
|
||||
--arg name "$NAME" \
|
||||
--arg ns "$NS" \
|
||||
--arg pw "$PW_B64" \
|
||||
'{apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {name: $name, namespace: $ns},
|
||||
type: "Opaque",
|
||||
data: {password: $pw}}')
|
||||
|
||||
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
--data "$body" \
|
||||
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
|
||||
|
||||
echo "generated $NAME"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
---
|
||||
# Fourth broker user: `sml2mqtt` — the power-meter bridge. Publish-only
|
||||
# under `sml2mqtt/#` (ACL in mosquitto-deployment.yaml). Own credential so
|
||||
# its lifecycle is independent of the others. The sml2mqtt pod reads this
|
||||
# password via its init container and renders it into config.yml.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: mosquitto-sml2mqtt-credentials-bootstrap
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
# Job pod templates are immutable; force-recreate so image bumps
|
||||
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
|
||||
kustomize.toolkit.fluxcd.io/force: enabled
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-bootstrap
|
||||
spec:
|
||||
serviceAccountName: mosquitto-bootstrap
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
apk add --no-cache curl jq >/dev/null
|
||||
|
||||
NS=home-assistant
|
||||
NAME=mosquitto-sml2mqtt-credentials
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
API=https://kubernetes.default.svc
|
||||
|
||||
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
|
||||
echo "$NAME already exists; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
|
||||
|
||||
body=$(jq -nc \
|
||||
--arg name "$NAME" \
|
||||
--arg ns "$NS" \
|
||||
--arg pw "$PW_B64" \
|
||||
'{apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {name: $name, namespace: $ns},
|
||||
type: "Opaque",
|
||||
data: {password: $pw}}')
|
||||
|
||||
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
--data "$body" \
|
||||
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
|
||||
|
||||
echo "generated $NAME"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
---
|
||||
# Fifth broker user: `open3e` — the Viessmann E3 CAN bridge (Vitocal 250-A
|
||||
# + Vitocharge VX3 via open3e). Scoped to `open3e/#` (ACL in
|
||||
# mosquitto-deployment.yaml). Own credential so its lifecycle is
|
||||
# independent of the others. Retrieve with:
|
||||
# kubectl -n home-assistant get secret mosquitto-open3e-credentials \
|
||||
# -o jsonpath='{.data.password}' | base64 -d
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: mosquitto-open3e-credentials-bootstrap
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
# Job pod templates are immutable; force-recreate so image bumps
|
||||
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
|
||||
kustomize.toolkit.fluxcd.io/force: enabled
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-bootstrap
|
||||
spec:
|
||||
serviceAccountName: mosquitto-bootstrap
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
apk add --no-cache curl jq >/dev/null
|
||||
|
||||
NS=home-assistant
|
||||
NAME=mosquitto-open3e-credentials
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
API=https://kubernetes.default.svc
|
||||
|
||||
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
|
||||
echo "$NAME already exists; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
|
||||
|
||||
body=$(jq -nc \
|
||||
--arg name "$NAME" \
|
||||
--arg ns "$NS" \
|
||||
--arg pw "$PW_B64" \
|
||||
'{apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {name: $name, namespace: $ns},
|
||||
type: "Opaque",
|
||||
data: {password: $pw}}')
|
||||
|
||||
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
--data "$body" \
|
||||
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
|
||||
|
||||
echo "generated $NAME"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
---
|
||||
# Broker user `zigbee2mqtt` — the Zigbee2MQTT bridge (ZHA replacement). Scoped
|
||||
# to its own `zigbee2mqtt/#` subtree plus `write homeassistant/#` (so it can
|
||||
# publish HA MQTT-discovery configs) and
|
||||
# `read homeassistant/status` (HA's birth/will topic; on `online` Z2M
|
||||
# republishes cached device states, NOT discovery configs — ACL and the full
|
||||
# reasoning in mosquitto-deployment.yaml). Own credential so its lifecycle is
|
||||
# independent of the others. The Z2M init container reads this password and
|
||||
# renders it into /app/data/secret.yaml.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: mosquitto-zigbee2mqtt-credentials-bootstrap
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
# Job pod templates are immutable; force-recreate so image bumps
|
||||
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
|
||||
kustomize.toolkit.fluxcd.io/force: enabled
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-bootstrap
|
||||
spec:
|
||||
serviceAccountName: mosquitto-bootstrap
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
apk add --no-cache curl jq >/dev/null
|
||||
|
||||
NS=home-assistant
|
||||
NAME=mosquitto-zigbee2mqtt-credentials
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
API=https://kubernetes.default.svc
|
||||
|
||||
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
|
||||
echo "$NAME already exists; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
|
||||
|
||||
body=$(jq -nc \
|
||||
--arg name "$NAME" \
|
||||
--arg ns "$NS" \
|
||||
--arg pw "$PW_B64" \
|
||||
'{apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {name: $name, namespace: $ns},
|
||||
type: "Opaque",
|
||||
data: {password: $pw}}')
|
||||
|
||||
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
--data "$body" \
|
||||
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
|
||||
|
||||
echo "generated $NAME"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
@@ -77,18 +77,15 @@ data:
|
||||
|
||||
allow_anonymous false
|
||||
|
||||
# Two account sources side by side (REN-802 step 2, tested on 2.0.22 in
|
||||
# REN-900, document `coexistence`):
|
||||
# Dynamic Security: accounts from the register mqtt-accounts.yaml,
|
||||
# managed live by mqtt-account-controller over $CONTROL.
|
||||
# password_file/acl_file: the legacy users, unchanged until each client
|
||||
# moves to the register (step 3).
|
||||
# Accounts (tested on 2.0.22 in REN-900, document `coexistence`):
|
||||
# Dynamic Security: every client account, from the register
|
||||
# mqtt-accounts.yaml, managed live by mqtt-account-controller over
|
||||
# $CONTROL. Applies to all listeners (per_listener_settings false).
|
||||
# password_file/acl_file: the owner's workstation login `admin` only
|
||||
# (mosquitto-deployment.yaml).
|
||||
# Dynamic Security is asked first. For a username it does not know, and
|
||||
# for a request none of its roles match, it defers to the files. A
|
||||
# register entry with the same name as a legacy user therefore replaces
|
||||
# that user's password, while its acl_file block still applies to
|
||||
# whatever the entry's roles do not cover. Delete the legacy user when
|
||||
# its client moves.
|
||||
# register entry named `admin` therefore replaces the file login.
|
||||
#
|
||||
# The acl_file check of 2.0 allows every SUBSCRIBE and checks only on
|
||||
# delivery, so the Dynamic Security default must deny publishClientReceive.
|
||||
|
||||
@@ -64,67 +64,15 @@ spec:
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
# `admin` is the owner's workstation login over the LoadBalancer
|
||||
# (1883/8883), full readwrite. The only password_file user: the
|
||||
# controller cannot deliver a password account to a Secret, and
|
||||
# the cert listener :8884 is in-cluster only.
|
||||
rm -f /mosquitto/auth/passwd
|
||||
mosquitto_passwd -c -b /mosquitto/auth/passwd homeassistant "$HA_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd admin "$ADMIN_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd sml2mqtt "$SML2MQTT_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd open3e "$OPEN3E_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd evcc "$EVCC_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd zigbee2mqtt "$ZIGBEE2MQTT_PASSWORD"
|
||||
# ACL: HA keeps full access (subscribes to everything). Shellies
|
||||
# are not legacy users any more: each has its own Dynamic Security
|
||||
# account from mqtt-accounts.yaml (REN-802, REN-1017).
|
||||
# `admin` is a workstation/debugging credential — full
|
||||
# readwrite, kept on its own credential so its password can
|
||||
# be rotated independently of the HA session.
|
||||
# `sml2mqtt` is the power-meter bridge — WRITE-only under its own
|
||||
# `sml2mqtt/#` subtree (it only publishes; HA reads it via the
|
||||
# full-access homeassistant user).
|
||||
# `open3e` is the Viessmann E3 CAN bridge (Vitocal/VX3) —
|
||||
# scoped to its own `open3e/#` subtree: publishes datapoints
|
||||
# and later subscribes to `open3e/cmnd` in listener mode (HA
|
||||
# reads via the full-access homeassistant user).
|
||||
# `evcc` is the (cross-namespace) EV charge controller — reads
|
||||
# `open3e/#` (VX3 grid/PV/battery/SoC datapoints), publishes its
|
||||
# own state under `evcc/#`, and reads the grid price from
|
||||
# `energy/#` (HA republishes input_number.strompreis to
|
||||
# energy/tariff/grid; kept OUTSIDE evcc/# so evcc's own retained-
|
||||
# topic management can't wipe it).
|
||||
mosquitto_passwd -c -b /mosquitto/auth/passwd admin "$ADMIN_PASSWORD"
|
||||
cat > /mosquitto/auth/acl <<'EOF'
|
||||
user homeassistant
|
||||
topic readwrite #
|
||||
|
||||
user admin
|
||||
topic readwrite #
|
||||
|
||||
user sml2mqtt
|
||||
topic write sml2mqtt/#
|
||||
|
||||
user open3e
|
||||
topic readwrite open3e/#
|
||||
|
||||
user evcc
|
||||
topic read open3e/#
|
||||
topic readwrite evcc/#
|
||||
topic read energy/#
|
||||
|
||||
# `zigbee2mqtt` is the Zigbee2MQTT bridge (the ZHA replacement).
|
||||
# Owns its own `zigbee2mqtt/#` subtree; may WRITE `homeassistant/#`
|
||||
# to publish HA MQTT-discovery configs (write-only there, to deny
|
||||
# discovery-tree recon from a compromised bridge); and READs `homeassistant/status`, HA's birth/will topic.
|
||||
#
|
||||
# That read grant does NOT buy a discovery re-publish: on
|
||||
# `online`, Z2M 2.12.1 republishes bridge/state and cached device
|
||||
# states only (lib/extension/homeassistant.ts onMQTTMessage) —
|
||||
# discover() runs solely from start(). The grant is still correct,
|
||||
# because the cached-state republish is what makes HA show current
|
||||
# values right after an HA restart. Restoring discovery after a
|
||||
# BROKER restart is handled by the discovery-guard sidecar in
|
||||
# zigbee2mqtt-deployment.yaml.
|
||||
user zigbee2mqtt
|
||||
topic readwrite zigbee2mqtt/#
|
||||
topic write homeassistant/#
|
||||
topic read homeassistant/status
|
||||
EOF
|
||||
# Dynamic Security start file (REN-802): the controller's seed
|
||||
# if it exists, else the static base file. The seed carries
|
||||
@@ -143,38 +91,11 @@ spec:
|
||||
chmod 0600 /mosquitto/auth/passwd /mosquitto/auth/acl /mosquitto/auth/dynamic-security.json
|
||||
exec mosquitto -c /mosquitto/config/mosquitto.conf
|
||||
env:
|
||||
- name: HA_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-credentials
|
||||
key: homeassistant-password
|
||||
# mosquitto-admin-password.yaml
|
||||
- name: ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-admin-credentials
|
||||
key: password
|
||||
- name: SML2MQTT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-sml2mqtt-credentials
|
||||
key: password
|
||||
- name: OPEN3E_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-open3e-credentials
|
||||
key: password
|
||||
- name: EVCC_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-evcc-credentials
|
||||
key: password
|
||||
# Zigbee2MQTT bridge credential (mosquitto-zigbee2mqtt-credentials-
|
||||
# bootstrap). Hashed into the passwd file here; Z2M reads the same
|
||||
# Secret via its init container to authenticate to the broker.
|
||||
- name: ZIGBEE2MQTT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-zigbee2mqtt-credentials
|
||||
name: mosquitto-admin-password
|
||||
key: password
|
||||
ports:
|
||||
- name: mqtt
|
||||
@@ -231,12 +152,12 @@ spec:
|
||||
# Service and the probe dials 127.0.0.1; the chain is still checked
|
||||
# against mqtt-ca.
|
||||
#
|
||||
# mosquitto is a single-replica SPOF (HA, Shelly, sml2mqtt, open3e,
|
||||
# evcc). Liveness is deliberately slow to fire — failureThreshold 6
|
||||
# x 30s = 3 min of sustained failure before a restart — so a
|
||||
# transient hiccup never bounces the broker every client depends
|
||||
# on. The startupProbe (tcpSocket) suspends both while the
|
||||
# container builds the passwd/acl files and mosquitto binds :1883.
|
||||
# mosquitto is a single-replica SPOF (HA, Shelly, Zigbee2MQTT,
|
||||
# edge-1, evcc). Liveness is deliberately slow to fire —
|
||||
# failureThreshold 6 x 30s = 3 min of sustained failure before a
|
||||
# restart — so a transient hiccup never bounces the broker every
|
||||
# client depends on. The startupProbe (tcpSocket) suspends both while
|
||||
# the container prepares /mosquitto/auth and mosquitto binds :1883.
|
||||
startupProbe:
|
||||
tcpSocket:
|
||||
port: mqtt
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: mosquitto-evcc-credentials
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: mosquitto-evcc-credentials # keeps the old Secret's name — consumers unchanged
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/home-assistant/mosquitto-evcc-credentials
|
||||
@@ -1,36 +0,0 @@
|
||||
# Projects the mosquitto `health` broker credential from OpenBao into the Secret
|
||||
# the broker already consumes. FIRST of the eight broker users to move off the
|
||||
# bootstrap Jobs and onto Class-A rotation (see
|
||||
# PROPOSAL-mosquitto-credential-rotation.md (PR #453 — not yet merged; this
|
||||
# file lands with it)); the rotator lives at
|
||||
# kubernetes/infrastructure/openbao/rotation/mosquitto-health.yaml.
|
||||
#
|
||||
# Secret name and key are UNCHANGED (`password`), so mosquitto-deployment.yaml's
|
||||
# secretKeyRef and the exec probes that read $HEALTH_PASSWORD need no edit.
|
||||
#
|
||||
# refreshInterval is 10m rather than the 1h used by mosquitto-evcc-credentials:
|
||||
# a rotation only lands once ESO re-reads, so 1h would mean up to an hour between
|
||||
# the rotator running and the broker actually using the new password, with the
|
||||
# old one still valid in the meantime. 10m keeps that window short without
|
||||
# polling OpenBao pointlessly hard.
|
||||
#
|
||||
# ⚠️ OWNERSHIP: creationPolicy is implicitly Owner, so ESO sets an ownerReference
|
||||
# on the Secret. Deleting this ExternalSecret therefore DELETES the live Secret
|
||||
# and with it the broker's probe credential — the bootstrap Job it replaces had
|
||||
# no such coupling. To detach without destroying, set creationPolicy: Orphan
|
||||
# before removing it.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: mosquitto-health-credentials
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: mosquitto-health-credentials
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/home-assistant/mosquitto-health-credentials
|
||||
@@ -5,9 +5,7 @@
|
||||
# mqtt-accounts.yaml, every INTERVAL (2m).
|
||||
#
|
||||
# Manages the production broker `mosquitto` over its mTLS listener :8884
|
||||
# (REN-802 step 2, REN-900). The legacy users in that broker's password_file
|
||||
# are not Dynamic Security clients, so the controller never sees or touches
|
||||
# them.
|
||||
# (REN-802 step 2, REN-900).
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
@@ -87,6 +85,9 @@ subjects:
|
||||
# delivered to this namespace. Add the name with the register entry.
|
||||
# mqtt-ha-cert-sync: sha256 of the client certificate last accepted by
|
||||
# Home Assistant's MQTT config entry (HA cert sync, REN-953/REN-1284).
|
||||
# mqtt-<name>-password: the login of a password account delivered to this
|
||||
# namespace, written, rewritten and deleted on revocation. One entry per
|
||||
# account.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
@@ -99,6 +100,11 @@ rules:
|
||||
resources: ["secrets"]
|
||||
resourceNames: ["mqtt-account-hashes", "mqtt-dynsec-seed", "mqtt-ha-cert-sync"]
|
||||
verbs: ["get", "create", "patch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames:
|
||||
- mqtt-admin-password
|
||||
verbs: ["get", "create", "patch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames:
|
||||
@@ -219,7 +225,7 @@ spec:
|
||||
- name: controller
|
||||
# Woodpecker builds main-<sha> on every merge to main; no sortable
|
||||
# tag, so bumps are manual (same as ical-proxy).
|
||||
image: gitea.renner.uno/konairius/mqtt-account-controller:main-cb96b1a@sha256:9d9b9e83a9c0f9f567e57edacab11cf4c58e8c0e49e38e8d7b51421cd1242911
|
||||
image: gitea.renner.uno/konairius/mqtt-account-controller:main-b1f294d@sha256:8081adfee171c36cfa83734e5e7ccc3aad3debf1c0154488de70143674cde85f
|
||||
env:
|
||||
- name: BROKER_URL
|
||||
value: ssl://mosquitto.home-assistant.svc.cluster.local:8884
|
||||
|
||||
@@ -7,51 +7,50 @@
|
||||
# configMapGenerator in kustomization.yaml). An empty list is refused, so it
|
||||
# never revokes everyone.
|
||||
#
|
||||
# The controller manages the production broker `mosquitto` (REN-900). Its
|
||||
# legacy users (password_file in mosquitto-deployment.yaml) are not listed
|
||||
# here; each moves here with its client (REN-802 step 3). `test-cert` is the
|
||||
# certificate test account from REN-888.
|
||||
# The controller manages the production broker `mosquitto` (REN-900).
|
||||
# `test-cert` is the certificate test account from REN-888.
|
||||
#
|
||||
# A cert account needs its Secret `mqtt-<name>-tls` added to the
|
||||
# A cert account needs its Secret `mqtt-<name>-tls`, a password account
|
||||
# delivered to a namespace its Secret `mqtt-<name>-password`, added to the
|
||||
# `mqtt-account-controller-secrets-<namespace>` Role in
|
||||
# mqtt-account-controller.yaml, or revoking it fails with 403.
|
||||
# mqtt-account-controller.yaml, or writing or revoking it fails with 403.
|
||||
# The owner's workstation login over the LoadBalancer (1883/8883). Password in
|
||||
# the Secret mqtt-admin-password (home-assistant); deleting the Secret makes a
|
||||
# new one.
|
||||
- name: admin
|
||||
auth: password
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite #"]
|
||||
- name: test-cert
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite test/cert/#"]
|
||||
# The broker's own liveness/readiness probe (mosquitto-deployment.yaml).
|
||||
# Not named `health`: a Dynamic Security client of that name would shadow the
|
||||
# legacy password user while the old broker pod still probes with it.
|
||||
- name: mosquitto-probe
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite health/#"]
|
||||
# Zigbee2MQTT and its two sidecars (discovery-guard, interview-watch), one
|
||||
# certificate for the pod. Same ACL as the legacy user `zigbee2mqtt`; not
|
||||
# named like it, so this entry cannot shadow the password login of a running
|
||||
# Z2M (REN-953).
|
||||
# certificate for the pod (REN-953).
|
||||
- name: zigbee2mqtt-bridge
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite zigbee2mqtt/#", "write homeassistant/#", "read homeassistant/status"]
|
||||
# evcc (evcc namespace). Same ACL as the legacy user `evcc`, different name
|
||||
# so it cannot shadow the running password login (REN-953). evcc takes the
|
||||
# certificate PEMs by env (EVCC_MQTT_*), see apps/evcc/deployment.yaml.
|
||||
# evcc (evcc namespace, REN-953). evcc takes the certificate PEMs by env
|
||||
# (EVCC_MQTT_*), see apps/evcc/deployment.yaml.
|
||||
- name: evcc-client
|
||||
auth: cert
|
||||
deliver: {namespace: evcc}
|
||||
acl: ["read open3e/#", "readwrite evcc/#", "read energy/#"]
|
||||
# Home Assistant, via the ghostunnel sidecar `mqtt-tls` in its pod (HA keeps
|
||||
# client certificates only as uploaded PEMs in its config entry, so a renewal
|
||||
# could not reach it without a reconfigure). Same ACL as the legacy user
|
||||
# `homeassistant`; not named like it, so it cannot shadow that login (REN-953).
|
||||
# could not reach it without a reconfigure, REN-953).
|
||||
- name: home-assistant
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite #"]
|
||||
# edge-1's local mosquitto bridge (REN-1307): one bridge for open3e/e3oncan
|
||||
# and sml2mqtt, replacing the legacy password users `open3e` and `sml2mqtt`
|
||||
# (different name, so it cannot shadow their logins). The controller pushes
|
||||
# edge-1's local mosquitto bridge (REN-1307): one bridge for open3e/e3oncan.
|
||||
# The controller pushes
|
||||
# the password to edge-mqtt-cred on edge-1 (ansible role mqtt-bridge) over
|
||||
# HTTPS with its own client certificate, and pins edge-1's self-generated
|
||||
# server certificate on first contact (key edge-1.pin in mqtt-account-hashes;
|
||||
|
||||
@@ -50,13 +50,13 @@ release it before Z2M connects (step 3).
|
||||
Confirm the standing pieces are in place (nothing is running yet):
|
||||
|
||||
```
|
||||
kubectl -n home-assistant get secret mosquitto-zigbee2mqtt-credentials # exists (bootstrap Job ran)
|
||||
kubectl -n home-assistant get secret mqtt-zigbee2mqtt-bridge-tls # exists (register account zigbee2mqtt-bridge)
|
||||
kubectl -n home-assistant get externalsecret zigbee2mqtt-coordinator-backup # SecretSyncedError / NotReady — EXPECTED
|
||||
kubectl -n home-assistant get deploy zigbee2mqtt # 0/0 — EXPECTED
|
||||
```
|
||||
|
||||
The `mosquitto` pod will have rolled to pick up the new `zigbee2mqtt` passwd/ACL
|
||||
entry. Verify the broker still serves HA (it will — the change is additive).
|
||||
Z2M logs in to the broker by certificate on `:8884` as the register account
|
||||
`zigbee2mqtt-bridge` (`mqtt-accounts.yaml`).
|
||||
|
||||
## Step 1 — export the Open Coordinator Backup (non-disruptive)
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ spec:
|
||||
# The library PVC is populated by the seed phase before this
|
||||
# Deployment reconciles, so no rsync init is needed here.
|
||||
- name: fetch-le-staging-roots
|
||||
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
|
||||
image: curlimages/curl:8.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -57,7 +57,7 @@ spec:
|
||||
runAsNonRoot: false
|
||||
initContainers:
|
||||
- name: wait-for-influxd
|
||||
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
|
||||
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
|
||||
env:
|
||||
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
|
||||
- { name: INFLUX_ORG, value: renner }
|
||||
@@ -80,7 +80,7 @@ spec:
|
||||
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
|
||||
containers:
|
||||
- name: create
|
||||
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
|
||||
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
|
||||
env:
|
||||
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
|
||||
- { name: INFLUX_ORG, value: renner }
|
||||
|
||||
@@ -88,7 +88,7 @@ spec:
|
||||
# state). Two-stage so we don't try to create an auth before
|
||||
# the chart's `influx setup` has finished.
|
||||
- name: wait-for-influxd
|
||||
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
|
||||
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
|
||||
env:
|
||||
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
|
||||
- { name: INFLUX_ORG, value: renner }
|
||||
@@ -111,7 +111,7 @@ spec:
|
||||
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
|
||||
containers:
|
||||
- name: create
|
||||
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
|
||||
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
|
||||
env:
|
||||
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
|
||||
- { name: INFLUX_ORG, value: renner }
|
||||
|
||||
@@ -55,7 +55,7 @@ spec:
|
||||
repository: influxdb
|
||||
# Was pinned to VM 108's 2.7.4 for the one-time `influx restore`; that
|
||||
# migration is done. No downgrade below 2.9 without `influxd downgrade`.
|
||||
tag: "2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac"
|
||||
tag: "2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2"
|
||||
# Single replica — InfluxDB OSS doesn't cluster. RWO Ceph-block + Velero
|
||||
# backups (pending) is the durability story.
|
||||
persistence:
|
||||
|
||||
@@ -30,7 +30,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-22-1858 # {"$imagepolicy": "flux-system:kuma-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-25-1459 # {"$imagepolicy": "flux-system:kuma-sync"}
|
||||
env:
|
||||
- name: KUMA_URL
|
||||
value: https://kuma.renner.uno
|
||||
|
||||
@@ -72,7 +72,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: element-web
|
||||
image: ghcr.io/element-hq/element-web:v1.12.30@sha256:3e7dbd4424de9e11c812bc740acf2725b2abe66ea0d0802023b56fd85d3fa09a
|
||||
image: ghcr.io/element-hq/element-web:v1.12.32@sha256:f8700633d4336258af7a05b53865a4f479e0d628cf2d9ca83d28b5939314d4ce
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -137,7 +137,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: mas
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0@sha256:e089f1048a1d4a9a492ed17b9fe759100f1bd619407b001f5927928d88b780c4
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1@sha256:02712e68c90f3743fedbd5e3343517481b2b4f246a9fd9e2f050dc2553033847
|
||||
# The server applies database migrations and syncs the upstream
|
||||
# providers from the config on start.
|
||||
args: ["server", "--config", "/config/config.yaml"]
|
||||
|
||||
@@ -124,7 +124,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: livekit
|
||||
image: livekit/livekit-server:v1.13.10@sha256:89ca9ce0791e18d6e03bf6e2c49ceb27e1c754eae8bd50c989386c1a5963791a
|
||||
image: livekit/livekit-server:v1.13.11@sha256:25cd902de7cacbd5f1f779f6a6ac1eae20ba8f56b0935934ce6b0ab80d1e3d20
|
||||
args:
|
||||
- --config
|
||||
- /config/livekit.yaml
|
||||
|
||||
@@ -179,7 +179,7 @@ spec:
|
||||
# first. The marker on the data PVC makes later starts skip it.
|
||||
# Debug image: the plain one has no shell for the marker check.
|
||||
- name: migrate-to-mas
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0-debug@sha256:46f7480f5ea4243ccb6fd2c447ea859e0230c8b3c0e676ee285d4ae6ca344ee3
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1-debug@sha256:75f369b206785f1480f4a9f8dd81d7f9f3c6793c40fd184358ecd5d7acae17d4
|
||||
command:
|
||||
- /busybox/sh
|
||||
- -ec
|
||||
@@ -234,7 +234,7 @@ spec:
|
||||
mountPath: /data
|
||||
containers:
|
||||
- name: synapse
|
||||
image: ghcr.io/element-hq/synapse:v1.162.0@sha256:6b84a7bbac36f080b2d2e51e0289cf1b08b349598ea44a558df38d558f2c2311
|
||||
image: ghcr.io/element-hq/synapse:v1.163.2@sha256:c3c21919a322f74d177a43bce8031adc81ef839ddd117a8dd03cfec350e76e02
|
||||
# Bypass /start.py: it expects to run as root and template a config
|
||||
# from env. Default mode is generate-missing-and-run, which creates
|
||||
# the signing key on first start.
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
#
|
||||
# The UDM is queried directly rather than through the cluster resolver:
|
||||
# k8s-gateway caches these with `serve_stale 1h immediate`, which would keep
|
||||
# a deleted record green for an hour.
|
||||
# a deleted record green for an hour. The addresses are the UDM on the segment
|
||||
# pod traffic leaves on, the same upstream k8s-gateway uses (k8s-gateway-site
|
||||
# in talos/patches/site-homelab.yaml).
|
||||
#
|
||||
# `release` is load-bearing — probeSelector matches on it.
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
@@ -25,8 +27,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
# instance is the resolver and identical across these Probes, so the
|
||||
# queried name has to come from a label.
|
||||
labels:
|
||||
@@ -49,8 +51,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: mqtt.renner.uno
|
||||
---
|
||||
@@ -71,8 +73,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: ldap.renner.uno
|
||||
---
|
||||
@@ -93,8 +95,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: syslog.renner.uno
|
||||
---
|
||||
@@ -115,8 +117,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: ftp.paperless.renner.uno
|
||||
---
|
||||
@@ -137,7 +139,7 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: syncthing.paperless.renner.uno
|
||||
|
||||
@@ -82,9 +82,9 @@ spec:
|
||||
#
|
||||
# The pod regex is the Deployment's name shape
|
||||
# (mosquitto-<replicaset hash>-<suffix>) and not `mosquitto-.*` on
|
||||
# purpose: mosquitto-bootstrap.yaml runs six Jobs named
|
||||
# mosquitto-*-credentials-bootstrap, and a bootstrap pod that is
|
||||
# briefly Ready must not hide a broker that is down.
|
||||
# purpose: other pods in the namespace start with `mosquitto-`
|
||||
# (mosquitto-dynsec-test), and a Ready one must not hide a broker
|
||||
# that is down.
|
||||
expr: |
|
||||
absent(kube_pod_status_ready{namespace="home-assistant", pod=~"mosquitto-[a-z0-9]{6,10}-[a-z0-9]{5}", condition="true"} == 1)
|
||||
for: 5m
|
||||
|
||||
@@ -137,13 +137,13 @@ tests:
|
||||
exp_alerts: []
|
||||
|
||||
# --- MosquittoDown ------------------------------------------------------
|
||||
# The broker goes not-Ready at t=10m while a credentials-bootstrap Job pod is
|
||||
# Ready. The Job pod must not mask the broker.
|
||||
# The broker goes not-Ready at t=10m while the mosquitto-dynsec-test pod is
|
||||
# Ready. That pod must not mask the broker.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-7f8b9c6d5-x2k9q",condition="true"}'
|
||||
values: '1x9 0x20'
|
||||
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-credentials-bootstrap-q7w2e",condition="true"}'
|
||||
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-dynsec-test-6d9f8b7c5-r4t8w",condition="true"}'
|
||||
values: '1x30'
|
||||
alert_rule_test:
|
||||
- eval_time: 14m
|
||||
|
||||
@@ -32,6 +32,9 @@ spec:
|
||||
deploymentMode: SingleBinary
|
||||
loki:
|
||||
auth_enabled: false
|
||||
# No usage reports to stats.grafana.org (egress is not allowed anyway).
|
||||
analytics:
|
||||
reporting_enabled: false
|
||||
commonConfig:
|
||||
replication_factor: 1
|
||||
storage:
|
||||
|
||||
@@ -17,7 +17,7 @@ spec:
|
||||
chart:
|
||||
spec:
|
||||
chart: kube-prometheus-stack
|
||||
version: "92.3.0"
|
||||
version: "92.13.1"
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: prometheus-community
|
||||
|
||||
@@ -21,10 +21,13 @@ data:
|
||||
the pod's default route — which players on the IoT VLAN cannot route to.
|
||||
The v30iot address lives on the net1 macvlan child (see
|
||||
network-attachment.yaml).
|
||||
- bind_ip must be 0.0.0.0. The Sendspin server (aiosendspin, port 8927) has
|
||||
NO all-interfaces fallback: if bind_ip is a specific IP that is not local
|
||||
it fails to bind and the server never starts, and its proxy then targets
|
||||
that dead IP instead of 127.0.0.1. Binding 0.0.0.0 avoids both.
|
||||
- bind_ip must be the same live v30iot address. aiosendspin starts its
|
||||
zeroconf on [bind_ip], or on InterfaceChoice.Default for 0.0.0.0; Default
|
||||
sends mDNS queries via the main-table default route (eth0, net1 routes
|
||||
live in the sbr table), so MA never queries the IoT VLAN for
|
||||
_sendspin._tcp and Voice PEs are only rediscovered when they announce
|
||||
on their own. The Sendspin server has no all-interfaces bind fallback, so
|
||||
bind_ip must be local at start; it is, because it is read from net1 here.
|
||||
|
||||
The v30iot IP is DHCP and node-dependent (MA is not node-pinned), so a stored
|
||||
IP is a stale assumption the moment MA reschedules or the lease changes -
|
||||
@@ -91,15 +94,15 @@ data:
|
||||
.setdefault("values", {})
|
||||
)
|
||||
old_pub, old_bind = values.get("publish_ip"), values.get("bind_ip")
|
||||
if (old_pub, old_bind) == (ip, "0.0.0.0"):
|
||||
if (old_pub, old_bind) == (ip, ip):
|
||||
print("[reconcile] publish_ip/bind_ip already correct; no change")
|
||||
return 0
|
||||
|
||||
values["publish_ip"] = ip
|
||||
values["bind_ip"] = "0.0.0.0"
|
||||
values["bind_ip"] = ip
|
||||
print(
|
||||
f"[reconcile] publish_ip {old_pub!r} -> {ip!r}, "
|
||||
f"bind_ip {old_bind!r} -> '0.0.0.0'"
|
||||
f"bind_ip {old_bind!r} -> {ip!r}"
|
||||
)
|
||||
|
||||
tmp = SETTINGS + ".reconcile.tmp"
|
||||
|
||||
@@ -44,7 +44,9 @@ spec:
|
||||
# which IoT players can't reach; and
|
||||
# - net1 is DHCP and MA is not node-pinned, so any hard-coded IP goes
|
||||
# stale on the next reschedule or lease change (the Sendspin server
|
||||
# then fails to bind and dies, with no fallback).
|
||||
# then fails to bind and dies, with no fallback); and
|
||||
# - a 0.0.0.0 bind_ip makes Sendspin's mDNS browse query via eth0, so
|
||||
# Voice PEs on the IoT VLAN are not rediscovered after a restart.
|
||||
# So we recompute the v30iot IP from the live interface every boot and
|
||||
# write it into /data/settings.json before MA reads it. Multus attaches
|
||||
# net1 (with its DHCP lease) before any container runs, so this sees the
|
||||
@@ -52,7 +54,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.7 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
@@ -60,9 +62,33 @@ spec:
|
||||
- name: net-reconcile
|
||||
mountPath: /scripts
|
||||
readOnly: true
|
||||
# Pocket Casts Up Next as a library playlist, pending upstream. Copies
|
||||
# the image's provider and swaps in the patched __init__.py only while
|
||||
# the stock file is the one it was written against (2.10.7 = 2.10.8);
|
||||
# any other version keeps the stock provider.
|
||||
- name: pocketcasts-overlay
|
||||
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
command:
|
||||
- /bin/sh
|
||||
- -euc
|
||||
- |
|
||||
src=$(echo /app/venv/lib/python3*/site-packages/music_assistant/providers/pocketcasts)
|
||||
cp -a "$src/." /overlay/
|
||||
if echo "c88377d887148faf912ff217e021df3cd42bb7071dece0998b07c7bb12eb323e $src/__init__.py" | sha256sum -c --status; then
|
||||
cp /patch/__init__.py /overlay/__init__.py
|
||||
echo "pocketcasts: Up Next playlist patch applied"
|
||||
else
|
||||
echo "pocketcasts: stock provider changed upstream, patch skipped"
|
||||
fi
|
||||
volumeMounts:
|
||||
- name: pocketcasts-overlay
|
||||
mountPath: /overlay
|
||||
- name: pocketcasts-patch
|
||||
mountPath: /patch
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: music-assistant
|
||||
image: ghcr.io/music-assistant/server:2.10.7 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
env:
|
||||
- name: LOG_LEVEL
|
||||
value: info
|
||||
@@ -81,6 +107,8 @@ spec:
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: pocketcasts-overlay
|
||||
mountPath: /app/venv/lib/python3.14/site-packages/music_assistant/providers/pocketcasts
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
@@ -125,6 +153,11 @@ spec:
|
||||
configMap:
|
||||
name: music-assistant-net-reconcile
|
||||
defaultMode: 0555
|
||||
- name: pocketcasts-overlay
|
||||
emptyDir: {}
|
||||
- name: pocketcasts-patch
|
||||
configMap:
|
||||
name: music-assistant-pocketcasts-up-next
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
@@ -7,3 +7,10 @@ resources:
|
||||
- deployment.yaml
|
||||
- ingress.yaml
|
||||
- kumamonitors.yaml
|
||||
configMapGenerator:
|
||||
# Pocket Casts provider with the Up Next playlist, applied by the
|
||||
# pocketcasts-overlay initContainer. Hashed name: an edit rolls the pod.
|
||||
- name: music-assistant-pocketcasts-up-next
|
||||
namespace: music-assistant
|
||||
files:
|
||||
- __init__.py=pocketcasts-up-next/__init__.py
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
# initContainer (configmap-net-reconcile.yaml) discovers net1's leased
|
||||
# address the same way it used to discover the host's ens19 address —
|
||||
# scanning the pod's own interfaces for a 10.2.30.0/24 address — and
|
||||
# writes it as MA's publish_ip. bind_ip stays 0.0.0.0, which now binds
|
||||
# eth0 (pod) + net1; players reach the stream/slimproto/sendspin servers
|
||||
# on net1. Requires the cni-dhcp-daemon DaemonSet in infrastructure/multus.
|
||||
# writes it as MA's publish_ip and bind_ip; players reach the
|
||||
# stream/sendspin servers on net1. Requires the cni-dhcp-daemon DaemonSet
|
||||
# in infrastructure/multus.
|
||||
#
|
||||
# Mirrors matter-server-iot; the only difference is MA does not speak
|
||||
# Thread, so the matter NAD's accept_ra_rt_info_max_plen tuning (for the
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
"""Pocket Casts music provider for Music Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from music_assistant_models.enums import (
|
||||
ContentType,
|
||||
ImageType,
|
||||
MediaType,
|
||||
ProviderFeature,
|
||||
StreamType,
|
||||
)
|
||||
from music_assistant_models.errors import (
|
||||
LoginFailed,
|
||||
MediaNotFoundError,
|
||||
ProviderUnavailableError,
|
||||
ResourceTemporarilyUnavailable,
|
||||
RetriesExhausted,
|
||||
)
|
||||
from music_assistant_models.media_items import (
|
||||
AudioFormat,
|
||||
BrowseFolder,
|
||||
ItemMapping,
|
||||
MediaItemImage,
|
||||
MediaItemMetadata,
|
||||
MediaItemType,
|
||||
Playlist,
|
||||
Podcast,
|
||||
PodcastEpisode,
|
||||
ProviderMapping,
|
||||
SearchResults,
|
||||
UniqueList,
|
||||
)
|
||||
from music_assistant_models.streamdetails import StreamDetails
|
||||
|
||||
from music_assistant import MusicAssistant
|
||||
from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
|
||||
from music_assistant.controllers.cache import use_cache
|
||||
from music_assistant.helpers.podcast_parsers import rank_episodes_by_date
|
||||
from music_assistant.models.music_provider import MusicProvider
|
||||
|
||||
from .api_client import PocketCastsClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
|
||||
from music_assistant_models.provider import ProviderManifest
|
||||
|
||||
from music_assistant.models import ProviderInstanceType
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FULLY_PLAYED_THRESHOLD = 0.9
|
||||
SPECIAL_FOLDERS = ("up_next", "new_releases", "in_progress", "starred", "history")
|
||||
UP_NEXT_PLAYLIST_ID = "up_next"
|
||||
|
||||
SUPPORTED_FEATURES = {
|
||||
ProviderFeature.LIBRARY_PODCASTS,
|
||||
ProviderFeature.LIBRARY_PLAYLISTS,
|
||||
ProviderFeature.BROWSE,
|
||||
ProviderFeature.SEARCH,
|
||||
ProviderFeature.LIBRARY_PODCASTS_EDIT,
|
||||
}
|
||||
|
||||
BROWSE_FOLDER_ICONS: dict[str, str] = {
|
||||
"up_next": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
|
||||
"bD0iIzhGOTdBNCIgZD0iTTMgMi45NzE1OUMzIDIuNTY0OSAzLjQ1OTY4IDIuMzI4MzQgMy43OTA2Mi"
|
||||
"AyLjU2NDcyTDkuNDMwMzkgNi41OTMxM0M5LjcwOTU2IDYuNzkyNTQgOS43MDk1NiA3LjIwNzQ1IDku"
|
||||
"NDMwMzkgNy40MDY4NkwzLjc5MDYyIDExLjQzNTNDMy40NTk2OSAxMS42NzE2IDMgMTEuNDM1MSAzID"
|
||||
"ExLjAyODRWMi45NzE1OVoiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIG9wYWNpdHk9IjAuNS"
|
||||
"IgZD0iTTEyIDdDMTIgNi40NDc3MiAxMi40NDc3IDYgMTMgNkgyMUMyMS41NTIzIDYgMjIgNi40NDc3"
|
||||
"MiAyMiA3QzIyIDcuNTUyMjggMjEuNTUyMyA4IDIxIDhIMTNDMTIuNDQ3NyA4IDEyIDcuNTUyMjggMT"
|
||||
"IgN1pNOSAxMkM5IDExLjQ0NzcgOS40NDc3MiAxMSAxMCAxMUgyMUMyMS41NTIzIDExIDIyIDExLjQ0Nz"
|
||||
"cgMjIgMTJDMjIgMTIuNTUyMyAyMS41NTIzIDEzIDIxIDEzSDEwQzkuNDQ3NzIgMTMgOSAxMi41NTIz"
|
||||
"IDkgMTJaTTEwIDE2QzkuNDQ3NzIgMTYgOSAxNi40NDc3IDkgMTdDOSAxNy41NTIzIDkuNDQ3NzIgMT"
|
||||
"ggMTAgMThIMjFDMjEuNTUyMyAxOCAyMiAxNy41NTIzIDIyIDE3QzIyIDE2LjQ0NzcgMjEuNTUyMyAx"
|
||||
"NiAyMSAxNkgxMFoiLz4KPC9zdmc+Cg=="
|
||||
),
|
||||
"new_releases": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPGcgZmlsbD0i"
|
||||
"IzhGOTdBNCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwgMSkiPgogICAgICAgIDxwYXRoIGQ9Ik0xNC"
|
||||
"43OTM1IDQuNTU3MzVMMTUuNzc0MSAwLjc4OTIwOUMxNS45Njg4IDAuMDQxMDUwOSAxNy4wMzExIDAu"
|
||||
"MDQxMDcxOSAxNy4yMjU4IDAuNzg5MjM3TDE4LjIwNjIgNC41NTczMkMxOC4yNzcgNC44MjkxNSAxOC"
|
||||
"40OTM3IDUuMDM4NjggMTguNzY3NyA1LjEwMDIzTDIxLjc0MTkgNS43NjgyM0MyMi41MjI4IDUuOTQz"
|
||||
"NjIgMjIuNTIyOCA3LjA1NjM5IDIxLjc0MTkgNy4yMzE3N0wxOC43Njc3IDcuODk5NzdDMTguNDkzNy"
|
||||
"A3Ljk2MTMyIDE4LjI3NyA4LjE3MDg1IDE4LjIwNjIgOC40NDI2OEwxNy4yMjU4IDEyLjIxMDhDMTcu"
|
||||
"MDMxMSAxMi45NTg5IDE1Ljk2ODggMTIuOTU4OSAxNS43NzQxIDEyLjIxMDhMMTQuNzkzNSA4LjQ0Mj"
|
||||
"Y1QzE0LjcyMjcgOC4xNzA4MyAxNC41MDYxIDcuOTYxMzIgMTQuMjMyIDcuODk5NzdMMTEuMjU4IDcu"
|
||||
"MjMxNzdDMTAuNDc3MSA3LjA1NjM5IDEwLjQ3NzEgNS45NDM2MiAxMS4yNTggNS43NjgyNEwxNC4yMz"
|
||||
"IgNS4xMDAyM0MxNC41MDYxIDUuMDM4NjggMTQuNzIyNyA0LjgyOTE3IDE0Ljc5MzUgNC41NTczNVoi"
|
||||
"Lz4KICAgICAgICA8cGF0aCBvcGFjaXR5PSIwLjgiIGQ9Ik01LjI5OTQgOS4yNjgzTDYuMDMwNjYgNy"
|
||||
"4yNzc2M0M2LjE5MTEyIDYuODQwODUgNi44MDg4OCA2Ljg0MDg1IDYuOTY5MzQgNy4yNzc2M0w3Ljcw"
|
||||
"MDYgOS4yNjgzQzcuNzU0MjUgOS40MTQzNSA3Ljg3Mjg0IDkuNTI3MSA4LjAyMTQxIDkuNTczMzJMOS"
|
||||
"40NjU0MSAxMC4wMjI2QzkuOTM0MDMgMTAuMTY4MyA5LjkzNDAzIDEwLjgzMTYgOS40NjU0MSAxMC45"
|
||||
"Nzc0TDguMDIxNCAxMS40MjY3QzcuODcyODMgMTEuNDcyOSA3Ljc1NDI1IDExLjU4NTYgNy43MDA2ID"
|
||||
"ExLjczMTdMNi45NjkzMyAxMy43MjI0QzYuODA4ODggMTQuMTU5MiA2LjE5MTEyIDE0LjE1OTIgNi4w"
|
||||
"MzA2NiAxMy43MjI0TDUuMjk5NCAxMS43MzE3QzUuMjQ1NzUgMTEuNTg1NiA1LjEyNzE3IDExLjQ3Mj"
|
||||
"kgNC45Nzg2IDExLjQyNjdMMy41MzQ1OSAxMC45Nzc0QzMuMDY1OTcgMTAuODMxNiAzLjA2NTk3IDEw"
|
||||
"LjE2ODMgMy41MzQ1OSAxMC4wMjI2TDQuOTc4NTkgOS41NzMzMkM1LjEyNzE2IDkuNTI3MSA1LjI0NT"
|
||||
"c1IDkuNDE0MzUgNS4yOTk0IDkuMjY4M1oiLz4KICAgICAgICA8cGF0aCBvcGFjaXR5PSIwLjYiIGQ9"
|
||||
"Ik0xMC42ODgyIDE2LjAxNEwxMS41MjQ3IDEzLjQ1NDNDMTEuNjc0OSAxMi45OTQ3IDEyLjMyNTEgMT"
|
||||
"IuOTk0NyAxMi40NzUzIDEzLjQ1NDNMMTMuMzExOCAxNi4wMTRDMTMuMzYwNCAxNi4xNjI3IDEzLjQ3"
|
||||
"NTcgMTYuMjggMTMuNjIzNSAxNi4zMzEyTDE1LjYzNSAxNy4wMjc1QzE2LjA4MzYgMTcuMTgyOCAxNi"
|
||||
"4wODM2IDE3LjgxNzIgMTUuNjM1IDE3Ljk3MjVMMTMuNjIzNSAxOC42Njg4QzEzLjQ3NTcgMTguNzIg"
|
||||
"MTMuMzYwNCAxOC44MzczIDEzLjMxMTggMTguOTg2TDEyLjQ3NTMgMjEuNTQ1N0MxMi4zMjUxIDIyLj"
|
||||
"AwNTMgMTEuNjc0OSAyMi4wMDUzIDExLjUyNDcgMjEuNTQ1N0wxMC42ODgyIDE4Ljk4NkMxMC42Mzk2"
|
||||
"IDE4LjgzNzMgMTAuNTI0MyAxOC43MiAxMC4zNzY1IDE4LjY2ODhMOC4zNjQ5OCAxNy45NzI1QzcuOT"
|
||||
"E2MzggMTcuODE3MiA3LjkxNjM5IDE3LjE4MjggOC4zNjQ5OCAxNy4wMjc1TDEwLjM3NjUgMTYuMzMx"
|
||||
"MkMxMC41MjQzIDE2LjI4IDEwLjYzOTYgMTYuMTYyNyAxMC42ODgyIDE2LjAxNFoiLz4KICAgIDwvZz"
|
||||
"4KPC9zdmc+Cg=="
|
||||
),
|
||||
"in_progress": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
|
||||
"bD0iIzhGOTdBNCIgb3BhY2l0eT0iMC41IiBkPSJNNCAxMkM0IDE2LjQxODMgNy41ODE3MiAyMCAxMi"
|
||||
"AyMEMxNi40MTgzIDIwIDIwIDE2LjQxODMgMjAgMTJDMjAgNy41ODE3MiAxNi40MTgzIDQgMTIgNEM3"
|
||||
"LjU4MTcyIDQgNCA3LjU4MTcyIDQgMTJaTTE4IDEyQzE4IDE1LjMxMzcgMTUuMzEzNyAxOCAxMiAxOE"
|
||||
"M4LjY4NjI5IDE4IDYgMTUuMzEzNyA2IDEyQzYgOC42ODYyOSA4LjY4NjI5IDYgMTIgNkMxNS4zMTM3"
|
||||
"IDYgMTggOC42ODYyOSAxOCAxMloiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIGQ9Ik0xNi45Mj"
|
||||
"UzIDE4LjMwNDFDMjAuNDA2OSAxNS41ODM5IDIxLjAyNDMgMTAuNTU2NCAxOC4zMDQxIDcuMDc0NzJD"
|
||||
"MTYuODI2OSA1LjE4Mzk0IDE0LjYxNjcgNC4wODMyNyAxMi4yNjUgNC4wMDQyNEMxMS43MTMxIDMuOT"
|
||||
"g1NjkgMTEuMjUwNiA0LjQxODExIDExLjIzMiA0Ljk3MDA4QzExLjIxMzUgNS41MjIwNiAxMS42NDU5"
|
||||
"IDUuOTg0NTYgMTIuMTk3OSA2LjAwMzExQzEzLjk2MzkgNi4wNjI0NiAxNS42MTkzIDYuODg2ODYgMT"
|
||||
"YuNzI4MSA4LjMwNjA1QzE4Ljc2ODIgMTAuOTE3MyAxOC4zMDUyIDE0LjY4OCAxNS42OTQgMTYuNzI4"
|
||||
"MUMxNS4yNTg4IDE3LjA2ODEgMTUuMTgxNiAxNy42OTY1IDE1LjUyMTYgMTguMTMxOEMxNS44NjE2ID"
|
||||
"E4LjU2NyAxNi40OTAxIDE4LjY0NDEgMTYuOTI1MyAxOC4zMDQxWiIvPgo8L3N2Zz4K"
|
||||
),
|
||||
"starred": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
|
||||
"bD0iIzhGOTdBNCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMSwgLTAuNSkiIGQ9Ik0xMS41ODMxIDE3Lj"
|
||||
"Q3NzZMOC4wNzM4NCAxOS4yOTMzQzcuMDk0MTMgMTkuODAwMiA2LjQ0NjgxIDE5LjMxOTcgNi42MjQ2"
|
||||
"NiAxOC4yNDA0TDcuMjY3MDcgMTQuMzQxOEw0LjQ1NTgxIDExLjU2NTRDMy42NzA5NyAxMC43OTAzID"
|
||||
"MuOTI3OTEgMTAuMDI2MiA1LjAwOTM1IDkuODYxNzdMOC45MTU2NSA5LjI2ODAxTDEwLjY4NzUgNS43"
|
||||
"MzYzOEMxMS4xODIxIDQuNzUwNDIgMTEuOTg4MiA0Ljc1ODY3IDEyLjQ3ODggNS43MzYzOEwxNC4yNT"
|
||||
"A2IDkuMjY4MDFMMTguMTU2OSA5Ljg2MTc3QzE5LjI0NzQgMTAuMDI3NSAxOS40ODg3IDEwLjc5Njgg"
|
||||
"MTguNzEwNCAxMS41NjU0TDE1Ljg5OTIgMTQuMzQxOEwxNi41NDE2IDE4LjI0MDRDMTYuNzIwOSAxOS"
|
||||
"4zMjg4IDE2LjA2MzkgMTkuNzk2IDE1LjA5MjQgMTkuMjkzM0wxMS41ODMxIDE3LjQ3NzZaIi8+Cjwv"
|
||||
"c3ZnPgo="
|
||||
),
|
||||
"history": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
|
||||
"bD0iIzhGOTdBNCIgb3BhY2l0eT0iMC41IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMC41IDEzQz"
|
||||
"EwLjUgMTMuNTUyMyAxMC45NDc3IDE0IDExLjUgMTRIMTQuNUMxNS4wNTIzIDE0IDE1LjUgMTMuNTUy"
|
||||
"MyAxNS41IDEzQzE1LjUgMTIuNDQ3NyAxNS4wNTIzIDEyIDE0LjUgMTJIMTIuNUwxMi41IDEwQzEyLj"
|
||||
"UgOS40NDc3MiAxMi4wNTIzIDkgMTEuNSA5QzEwLjk0NzcgOSAxMC41IDkuNDQ3NzIgMTAuNSAxMFYx"
|
||||
"M1oiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1y"
|
||||
"dWxlPSJldmVub2RkIiBkPSJNMTIgMThDMTUuMzEzNyAxOCAxOCAxNS4zMTM3IDE4IDEyQzE4IDguNj"
|
||||
"g2MjkgMTUuMzEzNyA2IDEyIDZDOC42ODYyOSA2IDYgOC42ODYyOSA2IDEyQzYgMTUuMzEzNyA4LjY4"
|
||||
"NjI5IDE4IDEyIDE4Wk0xMiAyMEMxNi40MTgzIDIwIDIwIDE2LjQxODMgMjAgMTJDMjAgNy41ODE3Mi"
|
||||
"AxNi40MTgzIDQgMTIgNEM3LjU4MTcyIDQgNCA3LjU4MTcyIDQgMTJDNCAxNi40MTgzIDcuNTgxNzIg"
|
||||
"MjAgMTIgMjBaIi8+Cjwvc3ZnPgo="
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def setup(
|
||||
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
|
||||
) -> ProviderInstanceType:
|
||||
"""Initialize provider(instance) with given configuration."""
|
||||
return PocketCastsProvider(mass, manifest, config, SUPPORTED_FEATURES)
|
||||
|
||||
|
||||
class PocketCastsProvider(MusicProvider):
|
||||
"""Provider for Pocket Casts podcast service."""
|
||||
|
||||
_client: PocketCastsClient
|
||||
# episode uuids already mirrored to Pocket Casts Up Next/history this session, keyed as a
|
||||
# set since multi-room playback can have several episodes in progress on one instance
|
||||
_announced_episodes: set[str]
|
||||
|
||||
async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
|
||||
"""Return Config entries to configure this provider."""
|
||||
return ()
|
||||
|
||||
async def handle_async_init(self) -> None:
|
||||
"""Handle async initialization of the provider."""
|
||||
email = self.get_setup_value(CONF_USERNAME)
|
||||
password = self.get_setup_value(CONF_PASSWORD)
|
||||
if not email or not password:
|
||||
raise LoginFailed("Email and password are required for Pocket Casts")
|
||||
self._announced_episodes = set()
|
||||
self._client = PocketCastsClient(self.mass.http_session, self.logger)
|
||||
await self._client.login(str(email), str(password))
|
||||
|
||||
async def get_library_podcasts(self) -> AsyncGenerator[Podcast]:
|
||||
"""Get all podcasts from the user's library."""
|
||||
for podcast_data in await self._client.get_subscribed_podcasts():
|
||||
yield self._convert_podcast(podcast_data)
|
||||
|
||||
async def library_add(self, item: MediaItemType) -> bool:
|
||||
"""
|
||||
Subscribe to a podcast.
|
||||
|
||||
:param item: The media item to add to the library.
|
||||
"""
|
||||
if not isinstance(item, Podcast):
|
||||
return await super().library_add(item)
|
||||
await self._client.subscribe_podcast(item.item_id)
|
||||
return True
|
||||
|
||||
async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
|
||||
"""
|
||||
Unsubscribe from a podcast.
|
||||
|
||||
:param prov_item_id: The provider item ID to remove from the library.
|
||||
:param media_type: The media type of the item.
|
||||
"""
|
||||
if media_type != MediaType.PODCAST:
|
||||
return await super().library_remove(prov_item_id, media_type)
|
||||
await self._client.unsubscribe_podcast(prov_item_id)
|
||||
return True
|
||||
|
||||
@use_cache(3600 * 24)
|
||||
async def get_podcast(self, prov_podcast_id: str) -> Podcast:
|
||||
"""
|
||||
Get full details for a podcast.
|
||||
|
||||
:param prov_podcast_id: The provider podcast id.
|
||||
"""
|
||||
podcast_data = await self._client.get_podcast(prov_podcast_id)
|
||||
if not podcast_data:
|
||||
raise MediaNotFoundError(
|
||||
f"podcast://{prov_podcast_id} not found on provider {self.domain}"
|
||||
)
|
||||
return self._convert_podcast(podcast_data)
|
||||
|
||||
async def get_podcast_episodes(self, prov_podcast_id: str) -> AsyncGenerator[PodcastEpisode]:
|
||||
"""
|
||||
Get all episodes for a podcast, enriched with playback status.
|
||||
|
||||
:param prov_podcast_id: The provider podcast id.
|
||||
"""
|
||||
# fetch episode metadata, user status and show notes in parallel
|
||||
(podcast_name, episodes), in_progress, history, show_notes = await asyncio.gather(
|
||||
self._client.get_podcast_episodes(prov_podcast_id),
|
||||
self._client.get_in_progress_episodes(),
|
||||
self._client.get_history(),
|
||||
self._get_show_notes(prov_podcast_id),
|
||||
)
|
||||
in_progress_map = {ep.get("uuid"): ep for ep in in_progress}
|
||||
history_map = {ep.get("uuid"): ep for ep in history}
|
||||
|
||||
# the full-podcast payload carries no episode number, so rank on the publication date
|
||||
positions = rank_episodes_by_date([ep.get("published") or None for ep in episodes])
|
||||
for position, episode_data in zip(positions, episodes, strict=True):
|
||||
episode_item = self._convert_episode(
|
||||
episode_data,
|
||||
prov_podcast_id,
|
||||
show_notes.get(episode_data.get("uuid", "")),
|
||||
podcast_name,
|
||||
position=position,
|
||||
)
|
||||
if episode_item:
|
||||
self._enrich_episode_with_status(
|
||||
episode_item, episode_data, in_progress_map, history_map
|
||||
)
|
||||
yield episode_item
|
||||
|
||||
async def get_library_playlists(self) -> AsyncGenerator[Playlist]:
|
||||
"""Get the user's Up Next queue as a playlist."""
|
||||
yield self._get_up_next_playlist()
|
||||
|
||||
async def get_playlist(self, prov_playlist_id: str) -> Playlist:
|
||||
"""
|
||||
Get full details for a playlist.
|
||||
|
||||
:param prov_playlist_id: The provider playlist id.
|
||||
"""
|
||||
if prov_playlist_id != UP_NEXT_PLAYLIST_ID:
|
||||
raise MediaNotFoundError(
|
||||
f"playlist://{prov_playlist_id} not found on provider {self.domain}"
|
||||
)
|
||||
return self._get_up_next_playlist()
|
||||
|
||||
async def get_playlist_tracks(
|
||||
self, prov_playlist_id: str, page: int = 0
|
||||
) -> list[PodcastEpisode]:
|
||||
"""
|
||||
Get the episodes of a playlist, in Up Next order.
|
||||
|
||||
:param prov_playlist_id: The provider playlist id.
|
||||
:param page: Zero-based page index; Up Next is returned whole on the first page.
|
||||
"""
|
||||
if prov_playlist_id != UP_NEXT_PLAYLIST_ID:
|
||||
raise MediaNotFoundError(
|
||||
f"playlist://{prov_playlist_id} not found on provider {self.domain}"
|
||||
)
|
||||
if page > 0:
|
||||
return []
|
||||
episodes = await self._get_special_folder_episodes(UP_NEXT_PLAYLIST_ID)
|
||||
# the Up Next payload carries no duration or playback status, and marking an episode
|
||||
# played reports its duration as the position, so take both from the episode details
|
||||
details = await asyncio.gather(
|
||||
*(self.get_podcast_episode(episode.item_id) for episode in episodes),
|
||||
return_exceptions=True,
|
||||
)
|
||||
items: list[PodcastEpisode] = []
|
||||
for episode, detail in zip(episodes, details, strict=True):
|
||||
if isinstance(detail, PodcastEpisode):
|
||||
items.append(detail)
|
||||
elif isinstance(
|
||||
detail,
|
||||
MediaNotFoundError
|
||||
| LoginFailed
|
||||
| ProviderUnavailableError
|
||||
| ResourceTemporarilyUnavailable
|
||||
| RetriesExhausted,
|
||||
):
|
||||
self.logger.debug("Could not retrieve details for %s: %s", episode.name, detail)
|
||||
items.append(episode)
|
||||
else:
|
||||
raise detail
|
||||
return items
|
||||
|
||||
async def browse(self, path: str) -> list[MediaItemType | BrowseFolder]:
|
||||
"""
|
||||
Browse this provider's items.
|
||||
|
||||
:param path: The browse path to resolve.
|
||||
"""
|
||||
item_path = path.split("://", 1)[1] if "://" in path else path
|
||||
|
||||
if not item_path:
|
||||
# root level - special folders followed by subscribed podcasts
|
||||
items: list[MediaItemType | BrowseFolder] = list(self._create_browse_folders())
|
||||
for podcast_data in await self._client.get_subscribed_podcasts():
|
||||
items.append(self._convert_podcast(podcast_data))
|
||||
return items
|
||||
|
||||
if item_path in SPECIAL_FOLDERS:
|
||||
return list(await self._get_special_folder_episodes(item_path))
|
||||
|
||||
return [episode async for episode in self.get_podcast_episodes(item_path)]
|
||||
|
||||
async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
|
||||
"""
|
||||
Get streamable URL and details for the given media item.
|
||||
|
||||
:param item_id: The episode item id (format: podcast_uuid:episode_uuid).
|
||||
:param media_type: The media type of the item.
|
||||
"""
|
||||
_, episode_uuid = item_id.split(":", 1)
|
||||
episode_data = await self._client.get_episode_details(episode_uuid)
|
||||
|
||||
url = episode_data.get("url", "")
|
||||
if not url:
|
||||
raise MediaNotFoundError(f"No URL found for episode {item_id}")
|
||||
|
||||
return StreamDetails(
|
||||
item_id=item_id,
|
||||
provider=self.instance_id,
|
||||
audio_format=AudioFormat(
|
||||
content_type=ContentType.try_parse(episode_data.get("fileType", "audio/mpeg")),
|
||||
),
|
||||
media_type=MediaType.PODCAST_EPISODE,
|
||||
stream_type=StreamType.HTTP,
|
||||
path=url,
|
||||
duration=episode_data.get("duration"),
|
||||
can_seek=True,
|
||||
allow_seek=True,
|
||||
)
|
||||
|
||||
@use_cache(3600 * 24)
|
||||
async def search(
|
||||
self, search_query: str, media_types: list[MediaType], limit: int = 5
|
||||
) -> SearchResults:
|
||||
"""
|
||||
Search for podcasts.
|
||||
|
||||
:param search_query: The search query.
|
||||
:param media_types: The media types to include in the search.
|
||||
:param limit: The maximum number of items to return per type.
|
||||
"""
|
||||
results = SearchResults()
|
||||
if media_types and MediaType.PODCAST not in media_types:
|
||||
return results
|
||||
podcasts = await self._client.search_podcasts(search_query)
|
||||
results.podcasts = [self._convert_podcast(podcast) for podcast in podcasts[:limit]]
|
||||
return results
|
||||
|
||||
@use_cache(3600)
|
||||
async def get_podcast_episode(self, prov_item_id: str) -> PodcastEpisode:
|
||||
"""
|
||||
Get full details for a podcast episode.
|
||||
|
||||
:param prov_item_id: The episode item id (format: podcast_uuid:episode_uuid).
|
||||
"""
|
||||
podcast_uuid, episode_uuid = prov_item_id.split(":", 1)
|
||||
episode_data, show_notes, podcast_name = await asyncio.gather(
|
||||
self._client.get_episode_details(episode_uuid),
|
||||
self._get_show_notes(podcast_uuid),
|
||||
self._get_podcast_name(podcast_uuid),
|
||||
)
|
||||
episode_item = self._convert_episode(
|
||||
episode_data, podcast_uuid, show_notes.get(episode_uuid), podcast_name
|
||||
)
|
||||
if episode_item is None:
|
||||
raise MediaNotFoundError(f"Episode {episode_uuid} not found in podcast {podcast_uuid}")
|
||||
|
||||
# the API sends explicit nulls for these fields, so a dict default is not enough
|
||||
played_up_to = episode_data.get("playedUpTo") or 0
|
||||
duration = episode_data.get("duration") or 0
|
||||
playing_status = episode_data.get("playingStatus", 1) # 1=unplayed, 2=in_progress, 3=played
|
||||
if duration > 0:
|
||||
episode_item.duration = duration
|
||||
completed = playing_status == 3 or (
|
||||
duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
|
||||
)
|
||||
episode_item.fully_played = completed
|
||||
episode_item.resume_position_ms = 0 if completed else played_up_to * 1000
|
||||
|
||||
return episode_item
|
||||
|
||||
async def get_resume_position(
|
||||
self, item_id: str, media_type: MediaType
|
||||
) -> tuple[bool, int, datetime | None]:
|
||||
"""
|
||||
Return the (fully_played, position_ms, timestamp) resume point for an episode.
|
||||
|
||||
PocketCasts does not expose a reliable last-played timestamp, so the timestamp
|
||||
is always None.
|
||||
|
||||
:param item_id: The episode item id (format: podcast_uuid:episode_uuid).
|
||||
:param media_type: The media type (should be PODCAST_EPISODE).
|
||||
"""
|
||||
_, episode_uuid = item_id.split(":", 1)
|
||||
|
||||
try:
|
||||
in_progress = await self._client.get_in_progress_episodes()
|
||||
except (ProviderUnavailableError, ResourceTemporarilyUnavailable, RetriesExhausted) as err:
|
||||
# resume is best-effort; a transient failure should not break playback
|
||||
LOGGER.warning("Could not fetch resume position for %s: %s", episode_uuid, err)
|
||||
return (False, 0, None)
|
||||
|
||||
for ep in in_progress:
|
||||
if ep.get("uuid") == episode_uuid:
|
||||
played_up_to = int(ep.get("playedUpTo") or 0) # seconds from API
|
||||
duration = int(ep.get("duration") or 0)
|
||||
fully_played = duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
|
||||
LOGGER.debug(
|
||||
"Resume position for %s: %d ms (fully_played=%s)",
|
||||
episode_uuid,
|
||||
played_up_to * 1000,
|
||||
fully_played,
|
||||
)
|
||||
return (fully_played, played_up_to * 1000, None)
|
||||
|
||||
LOGGER.debug("No in-progress entry for %s; resuming from start", episode_uuid)
|
||||
return (False, 0, None)
|
||||
|
||||
async def on_played(
|
||||
self,
|
||||
media_type: MediaType,
|
||||
prov_item_id: str,
|
||||
fully_played: bool,
|
||||
position: int,
|
||||
media_item: MediaItemType,
|
||||
is_playing: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Sync playback progress for a podcast episode back to Pocket Casts.
|
||||
|
||||
Called by the Queue controller when a track is played, stopped/skipped, and
|
||||
periodically while playing.
|
||||
|
||||
:param media_type: The media type of the played item.
|
||||
:param prov_item_id: The provider item id (format: podcast_uuid:episode_uuid).
|
||||
:param fully_played: Whether the episode was played to the end.
|
||||
:param position: Last known position in seconds.
|
||||
:param media_item: The full media item details.
|
||||
:param is_playing: Whether the episode is currently playing.
|
||||
"""
|
||||
if media_type != MediaType.PODCAST_EPISODE or not isinstance(media_item, PodcastEpisode):
|
||||
return
|
||||
podcast_uuid, episode_uuid = prov_item_id.split(":", 1)
|
||||
|
||||
# MA reports fully_played=True when an episode is skipped/stopped, not only when it
|
||||
# truly ends, so confirm completion against the real position before marking it played.
|
||||
duration = media_item.duration or 0
|
||||
completed = fully_played and duration > 0 and position >= duration * FULLY_PLAYED_THRESHOLD
|
||||
if completed:
|
||||
self._announced_episodes.discard(episode_uuid)
|
||||
await self._client.mark_episode_played(podcast_uuid, episode_uuid)
|
||||
await self._client.remove_from_up_next(episode_uuid)
|
||||
await self._client.archive_episode(podcast_uuid, episode_uuid, archive=True)
|
||||
elif position == 0 and not is_playing:
|
||||
# the user explicitly marked the episode as unplayed
|
||||
self._announced_episodes.discard(episode_uuid)
|
||||
await self._client.mark_episode_unplayed(podcast_uuid, episode_uuid)
|
||||
await self._client.archive_episode(podcast_uuid, episode_uuid, archive=False)
|
||||
else:
|
||||
# on_played fires every progress tick, so mirror the start to Up Next/history only
|
||||
# once per session - re-announcing each tick would re-bump Up Next and spam the API.
|
||||
# A resume within the same session is intentionally not re-announced.
|
||||
if is_playing and episode_uuid not in self._announced_episodes:
|
||||
self._announced_episodes.add(episode_uuid)
|
||||
await self._announce_playback_start(podcast_uuid, episode_uuid, media_item)
|
||||
await self._client.update_episode_progress(podcast_uuid, episode_uuid, position)
|
||||
|
||||
def _convert_podcast(self, podcast_data: dict[str, Any]) -> Podcast:
|
||||
"""
|
||||
Convert raw Pocket Casts podcast data to a Podcast object.
|
||||
|
||||
:param podcast_data: Raw podcast data from the subscribed-list or full-podcast endpoint.
|
||||
"""
|
||||
uuid = podcast_data["uuid"]
|
||||
return Podcast(
|
||||
item_id=uuid,
|
||||
provider=self.instance_id,
|
||||
name=podcast_data.get("title", ""),
|
||||
publisher=podcast_data.get("author"),
|
||||
provider_mappings={
|
||||
ProviderMapping(
|
||||
item_id=uuid,
|
||||
provider_domain=self.domain,
|
||||
provider_instance=self.instance_id,
|
||||
)
|
||||
},
|
||||
metadata=MediaItemMetadata(
|
||||
description=podcast_data.get("description"),
|
||||
images=UniqueList(
|
||||
[
|
||||
MediaItemImage(
|
||||
type=ImageType.THUMB,
|
||||
path=f"https://static.pocketcasts.com/discover/images/280/{uuid}.jpg",
|
||||
provider=self.instance_id,
|
||||
remotely_accessible=True,
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
async def _get_podcast_name(self, prov_podcast_id: str) -> str:
|
||||
"""Return a podcast's name, empty when it cannot be looked up."""
|
||||
# the podcast lookup is cached, so this is one call per podcast per day at most
|
||||
try:
|
||||
return (await self.get_podcast(prov_podcast_id)).name
|
||||
except (
|
||||
MediaNotFoundError,
|
||||
LoginFailed,
|
||||
ProviderUnavailableError,
|
||||
ResourceTemporarilyUnavailable,
|
||||
RetriesExhausted,
|
||||
) as err:
|
||||
self.logger.debug("Could not retrieve podcast name for %s: %s", prov_podcast_id, err)
|
||||
return ""
|
||||
|
||||
async def _get_show_notes(self, prov_podcast_id: str) -> dict[str, dict[str, Any]]:
|
||||
"""Return show notes per episode uuid, empty when they cannot be read."""
|
||||
# show notes are supplementary, so a failure here must never break episode
|
||||
# resolution. The failure itself is not cached, so the next call tries again.
|
||||
try:
|
||||
return await self._fetch_show_notes(prov_podcast_id)
|
||||
except (
|
||||
LoginFailed,
|
||||
ProviderUnavailableError,
|
||||
ResourceTemporarilyUnavailable,
|
||||
RetriesExhausted,
|
||||
) as err:
|
||||
self.logger.debug("Could not retrieve show notes for %s: %s", prov_podcast_id, err)
|
||||
return {}
|
||||
|
||||
@use_cache(3600 * 24)
|
||||
async def _fetch_show_notes(self, prov_podcast_id: str) -> dict[str, dict[str, Any]]:
|
||||
"""Return show notes per episode uuid for the given podcast."""
|
||||
return await self._client.get_show_notes(prov_podcast_id)
|
||||
|
||||
def _convert_episode(
|
||||
self,
|
||||
episode_data: dict[str, Any],
|
||||
podcast_uuid: str,
|
||||
show_notes: dict[str, Any] | None = None,
|
||||
podcast_name: str = "",
|
||||
position: int = 0,
|
||||
) -> PodcastEpisode | None:
|
||||
"""
|
||||
Convert episode data to a PodcastEpisode, or None when it carries no episode uuid.
|
||||
|
||||
:param position: The episode's listing position. Defaults to 0 (unknown).
|
||||
"""
|
||||
episode_uuid = episode_data.get("uuid")
|
||||
if not episode_uuid:
|
||||
return None
|
||||
|
||||
# this is fed by two endpoints with different field schemas: the full-podcast JSON
|
||||
# uses snake_case (file_type) while /user/episode uses camelCase (fileType,
|
||||
# episodeNumber). Neither carries the description or artwork, which is what the
|
||||
# separate show notes lookup is for.
|
||||
item_id = f"{podcast_uuid}:{episode_uuid}"
|
||||
file_type = episode_data.get("fileType") or episode_data.get("file_type", "audio/mpeg")
|
||||
episode_item = PodcastEpisode(
|
||||
item_id=item_id,
|
||||
provider=self.instance_id,
|
||||
name=episode_data.get("title", "Unknown Episode"),
|
||||
podcast=ItemMapping(
|
||||
media_type=MediaType.PODCAST,
|
||||
item_id=podcast_uuid,
|
||||
provider=self.instance_id,
|
||||
name=podcast_name,
|
||||
),
|
||||
position=position,
|
||||
provider_mappings={
|
||||
ProviderMapping(
|
||||
item_id=item_id,
|
||||
provider_domain=self.domain,
|
||||
provider_instance=self.instance_id,
|
||||
audio_format=AudioFormat(content_type=ContentType.try_parse(file_type)),
|
||||
url=episode_data.get("url", ""),
|
||||
)
|
||||
},
|
||||
)
|
||||
if episode_data.get("duration"):
|
||||
episode_item.duration = int(episode_data["duration"])
|
||||
if title := episode_data.get("title"):
|
||||
episode_item.metadata.label = title
|
||||
details = show_notes or {}
|
||||
if description := details.get("description"):
|
||||
episode_item.metadata.description = description
|
||||
# only about half the episodes have their own artwork, the rest keep the podcast cover
|
||||
image_url = details.get("image") or (
|
||||
f"https://static.pocketcasts.com/discover/images/280/{podcast_uuid}.jpg"
|
||||
)
|
||||
episode_item.metadata.images = UniqueList(
|
||||
[
|
||||
MediaItemImage(
|
||||
type=ImageType.THUMB,
|
||||
path=image_url,
|
||||
provider=self.instance_id,
|
||||
remotely_accessible=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
return episode_item
|
||||
|
||||
def _enrich_episode_with_status(
|
||||
self,
|
||||
episode_item: PodcastEpisode,
|
||||
episode_data: dict[str, Any],
|
||||
in_progress_map: dict[str | None, dict[str, Any]],
|
||||
history_map: dict[str | None, dict[str, Any]],
|
||||
) -> None:
|
||||
"""
|
||||
Apply playback status to a PodcastEpisode from the in-progress/history data.
|
||||
|
||||
:param episode_item: The episode object to enrich in place.
|
||||
:param episode_data: Raw episode data dict from the API.
|
||||
:param in_progress_map: UUID-keyed map of in-progress episode data.
|
||||
:param history_map: UUID-keyed map of listen-history episode data.
|
||||
"""
|
||||
episode_uuid = episode_data.get("uuid")
|
||||
# history is "recently played", not "completed" - rely on the entry's real progress,
|
||||
# never on mere history membership. Both fields are always set so the library sync can
|
||||
# clear a stale completed/resume value (it only updates when both are non-None).
|
||||
status_data = in_progress_map.get(episode_uuid) or history_map.get(episode_uuid) or {}
|
||||
# feeds that omit a duration yield an explicit null rather than a missing key, so
|
||||
# coerce instead of relying on a dict default
|
||||
played_up_to = status_data.get("playedUpTo") or 0
|
||||
duration = status_data.get("duration") or episode_data.get("duration") or 0
|
||||
completed = status_data.get("playingStatus") == 3 or (
|
||||
duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
|
||||
)
|
||||
episode_item.fully_played = completed
|
||||
episode_item.resume_position_ms = 0 if completed else played_up_to * 1000
|
||||
|
||||
async def _get_special_folder_episodes(self, folder_name: str) -> list[PodcastEpisode]:
|
||||
"""
|
||||
Get episodes for a special browse folder.
|
||||
|
||||
:param folder_name: Name of the special folder (up_next, new_releases, etc.)
|
||||
"""
|
||||
folder_getters = {
|
||||
"up_next": self._client.get_up_next_episodes,
|
||||
"new_releases": self._client.get_new_releases,
|
||||
"in_progress": self._client.get_in_progress_episodes,
|
||||
"starred": self._client.get_starred_episodes,
|
||||
"history": self._client.get_history,
|
||||
}
|
||||
episode_list = await folder_getters[folder_name]()
|
||||
|
||||
# (episode, podcast uuid, podcast name) per episode, the name empty when the folder
|
||||
# payload does not carry it
|
||||
resolved: list[tuple[dict[str, Any], str, str]] = []
|
||||
for episode_data in episode_list:
|
||||
# the podcast reference is a string on some endpoints and an object on others
|
||||
podcast_field = episode_data.get("podcast")
|
||||
podcast_uuid: str | None
|
||||
if isinstance(podcast_field, str):
|
||||
podcast_uuid = podcast_field
|
||||
elif isinstance(podcast_field, dict):
|
||||
podcast_uuid = podcast_field.get("uuid")
|
||||
else:
|
||||
podcast_uuid = episode_data.get("podcastUuid")
|
||||
|
||||
if not podcast_uuid:
|
||||
continue
|
||||
# these folders mix podcasts, so the name is not known up front. Take it from the
|
||||
# payload where that carries it, in either of the two shapes
|
||||
payload_name = podcast_field.get("title") if isinstance(podcast_field, dict) else None
|
||||
resolved.append(
|
||||
(
|
||||
episode_data,
|
||||
podcast_uuid,
|
||||
payload_name or episode_data.get("podcastTitle") or "",
|
||||
)
|
||||
)
|
||||
|
||||
# every remaining name costs a full-podcast fetch, so look them up once per podcast and
|
||||
# all at once: serialising them would stall the browse for as long as the folder is deep
|
||||
missing = list({uuid for _, uuid, name in resolved if not name})
|
||||
looked_up = await asyncio.gather(*(self._get_podcast_name(uuid) for uuid in missing))
|
||||
names = dict(zip(missing, looked_up, strict=True))
|
||||
|
||||
items: list[PodcastEpisode] = []
|
||||
for episode_data, podcast_uuid, podcast_name in resolved:
|
||||
if episode_item := self._convert_episode(
|
||||
episode_data,
|
||||
podcast_uuid,
|
||||
show_notes=None,
|
||||
podcast_name=podcast_name or names.get(podcast_uuid, ""),
|
||||
):
|
||||
items.append(episode_item)
|
||||
return items
|
||||
|
||||
def _create_browse_folders(self) -> list[BrowseFolder]:
|
||||
"""Create special browse folders for root level."""
|
||||
folders = [
|
||||
("up_next", "Up Next"),
|
||||
("new_releases", "New Releases"),
|
||||
("in_progress", "In Progress"),
|
||||
("starred", "Starred"),
|
||||
("history", "History"),
|
||||
]
|
||||
return [
|
||||
BrowseFolder(
|
||||
item_id=folder_id,
|
||||
provider=self.instance_id,
|
||||
path=f"{self.instance_id}://{folder_id}",
|
||||
name=name,
|
||||
image=MediaItemImage(
|
||||
type=ImageType.THUMB,
|
||||
path=BROWSE_FOLDER_ICONS[folder_id],
|
||||
provider=self.instance_id,
|
||||
remotely_accessible=True,
|
||||
),
|
||||
)
|
||||
for folder_id, name in folders
|
||||
]
|
||||
|
||||
def _get_up_next_playlist(self) -> Playlist:
|
||||
"""Return the playlist that mirrors the user's Up Next queue."""
|
||||
return Playlist(
|
||||
item_id=UP_NEXT_PLAYLIST_ID,
|
||||
provider=self.instance_id,
|
||||
name="Up Next",
|
||||
owner="Pocket Casts",
|
||||
is_editable=False,
|
||||
supported_mediatypes={MediaType.PODCAST_EPISODE},
|
||||
provider_mappings={
|
||||
ProviderMapping(
|
||||
item_id=UP_NEXT_PLAYLIST_ID,
|
||||
provider_domain=self.domain,
|
||||
provider_instance=self.instance_id,
|
||||
# every account has its own Up Next queue
|
||||
is_unique=True,
|
||||
)
|
||||
},
|
||||
metadata=MediaItemMetadata(
|
||||
images=UniqueList(
|
||||
[
|
||||
MediaItemImage(
|
||||
type=ImageType.THUMB,
|
||||
path=BROWSE_FOLDER_ICONS[UP_NEXT_PLAYLIST_ID],
|
||||
provider=self.instance_id,
|
||||
remotely_accessible=True,
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
async def _announce_playback_start(
|
||||
self, podcast_uuid: str, episode_uuid: str, episode: PodcastEpisode
|
||||
) -> None:
|
||||
"""
|
||||
Mirror a playback start to Pocket Casts by adding the episode to Up Next and history.
|
||||
|
||||
:param podcast_uuid: The podcast UUID.
|
||||
:param episode_uuid: The episode UUID.
|
||||
:param episode: The episode that started playing.
|
||||
"""
|
||||
# source the url from the already-loaded item so no extra API call is needed; filtered
|
||||
# to our own mapping since merged library items can carry other providers' mappings
|
||||
url = next(
|
||||
(
|
||||
mapping.url
|
||||
for mapping in episode.provider_mappings
|
||||
if mapping.provider_instance == self.instance_id and mapping.url
|
||||
),
|
||||
"",
|
||||
)
|
||||
await self._client.play_now(
|
||||
episode_uuid=episode_uuid, podcast_uuid=podcast_uuid, title=episode.name, url=url
|
||||
)
|
||||
await self._client.add_to_history(
|
||||
episode_uuid=episode_uuid, podcast_uuid=podcast_uuid, title=episode.name, url=url
|
||||
)
|
||||
@@ -93,7 +93,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: lidarr
|
||||
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls45 # {"$imagepolicy": "flux-system:lidarr"}
|
||||
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
|
||||
env:
|
||||
- name: PUID
|
||||
value: "1000"
|
||||
@@ -140,7 +140,7 @@ spec:
|
||||
drop: ["ALL"]
|
||||
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
|
||||
- name: reconcile
|
||||
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls45 # {"$imagepolicy": "flux-system:lidarr"}
|
||||
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
|
||||
command: ["sh", "/scripts/reconcile.sh"]
|
||||
volumeMounts:
|
||||
- name: config
|
||||
|
||||
@@ -74,7 +74,7 @@ spec:
|
||||
# alpine/kubectl is the maintained replacement. Pin the tag
|
||||
# to the cluster's server version so kubectl is in lock-step
|
||||
# (mismatched skew can break exec streaming).
|
||||
image: docker.io/alpine/kubectl:1.37.1@sha256:b568e8beb6a79aee269c142343078a278b85e7710f1c0e7d8d6b21d1eb566a48
|
||||
image: docker.io/alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7
|
||||
env:
|
||||
- name: DATASET_TLV
|
||||
valueFrom:
|
||||
|
||||
@@ -47,7 +47,7 @@ spec:
|
||||
#
|
||||
# Hence socat is installed at startup and run inside this container.
|
||||
- name: otbr
|
||||
image: openthread/otbr:latest@sha256:a1ccea58241c2e0c2e16a55590cdd5651336462c3b4fa26e24a59607e155ef72
|
||||
image: openthread/otbr:latest@sha256:f3e43ccd86d6da87323873efa0c9b6f92a2a8cc75a804ea50e34c96680363e2e
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
- name: prepare-dirs
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -287,7 +287,7 @@ spec:
|
||||
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
|
||||
# anchor: bridge-tls-proxy
|
||||
- name: bridge-tls-proxy
|
||||
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
|
||||
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
|
||||
args:
|
||||
- "-d"
|
||||
- "TCP-LISTEN:11143,fork,reuseaddr,bind=127.0.0.1"
|
||||
|
||||
@@ -50,7 +50,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: syncthing
|
||||
image: syncthing/syncthing:2.1.6@sha256:4d0281bdcb4ed9baf503d251d0544ddd856f49fce3f114056257ae172b7fb670
|
||||
image: syncthing/syncthing:2.1.7@sha256:2b71da7c0d0a91a6ae5285ad079226fecc0a365d08b27720e1f7037b018f7546
|
||||
env:
|
||||
# UI bind: any-interface so the ClusterIP Service can reach
|
||||
# it. Network exposure is gated by the absence of an Ingress
|
||||
|
||||
@@ -59,7 +59,7 @@ spec:
|
||||
- name: renovate
|
||||
# Pinned by tag and digest. Renovate updates itself through
|
||||
# homelab's renovate.json, like any other image here.
|
||||
image: docker.io/renovate/renovate:44.151.2@sha256:9ea828b38867af0ca5f269e6c5feace9e74e7f035da150bb16f8c5f70bf2e6e8
|
||||
image: docker.io/renovate/renovate:44.181.8@sha256:b28b2de1dfd35de7e8198756d7f7cdd84ebc28c3f25e80f5b09e598d3faefb2a
|
||||
env:
|
||||
- name: RENOVATE_CONFIG_FILE
|
||||
value: /config/config.json
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: review-bot
|
||||
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-22-1854 # {"$imagepolicy": "flux-system:review-bot"}
|
||||
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-26-0647 # {"$imagepolicy": "flux-system:review-bot"}
|
||||
env:
|
||||
- name: REPOS
|
||||
value: konairius/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# The wiki's schedules. The service runs nothing on its own clock; these jobs
|
||||
# call its API with the `scheduler` key. Run one now:
|
||||
# kubectl -n ai create job --from=cronjob/wiki-encyclopedia wiki-encyclopedia-now
|
||||
# Generated once in-cluster, nothing in OpenBao.
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
metadata:
|
||||
name: wiki-scheduler-key
|
||||
namespace: ai
|
||||
spec:
|
||||
length: 64
|
||||
digits: 16
|
||||
symbols: 0
|
||||
allowRepeat: true
|
||||
noUpper: false
|
||||
secretKeys:
|
||||
- Auth__ApiKeys__scheduler
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: wiki-scheduler-key
|
||||
namespace: ai
|
||||
spec:
|
||||
refreshPolicy: CreatedOnce
|
||||
target:
|
||||
name: wiki-scheduler-key
|
||||
dataFrom:
|
||||
- sourceRef:
|
||||
generatorRef:
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
name: wiki-scheduler-key
|
||||
---
|
||||
# Interval sync; the Gitea push webhook is the fast path.
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: wiki-sync
|
||||
namespace: ai
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki-sync
|
||||
spec:
|
||||
schedule: "*/30 * * * *"
|
||||
timeZone: Etc/UTC
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 1
|
||||
failedJobsHistoryLimit: 3
|
||||
startingDeadlineSeconds: 600
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 1800
|
||||
ttlSecondsAfterFinished: 86400
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki-sync
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
enableServiceLinks: false
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: sync
|
||||
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- 'curl -fsS --max-time 1700 -X POST -H "X-Api-Key: $API_KEY" http://wiki.ai.svc.cluster.local:8080/api/v1/sync'
|
||||
env:
|
||||
- name: API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wiki-scheduler-key
|
||||
key: Auth__ApiKeys__scheduler
|
||||
resources:
|
||||
requests:
|
||||
cpu: 5m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
---
|
||||
# Plans and writes the house encyclopedia (/house). The endpoint syncs first and
|
||||
# answers 502 when the run fails or the Anthropic API stopped it.
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: wiki-encyclopedia
|
||||
namespace: ai
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki-encyclopedia
|
||||
spec:
|
||||
schedule: "0 6 * * *"
|
||||
timeZone: Europe/Berlin
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
startingDeadlineSeconds: 3600
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 11400
|
||||
ttlSecondsAfterFinished: 604800
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki-encyclopedia
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
enableServiceLinks: false
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: run
|
||||
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- 'curl -fsS --max-time 10800 -X POST -H "X-Api-Key: $API_KEY" http://wiki.ai.svc.cluster.local:8080/api/v1/encyclopedia/run'
|
||||
env:
|
||||
- name: API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wiki-scheduler-key
|
||||
key: Auth__ApiKeys__scheduler
|
||||
resources:
|
||||
requests:
|
||||
cpu: 5m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
@@ -24,7 +24,7 @@ metadata:
|
||||
namespace: ai
|
||||
annotations:
|
||||
# Keys, tokens and the Anthropic key arrive via envFrom; a rotated Secret rolls the pod.
|
||||
secret.reloader.stakater.com/reload: wiki-gitea,wiki-api-keys,wiki-typesafe,wiki-anthropic,wiki-api-basic-auth
|
||||
secret.reloader.stakater.com/reload: wiki-gitea,wiki-api-keys,wiki-scheduler-key,wiki-typesafe,wiki-anthropic,wiki-api-basic-auth
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki
|
||||
spec:
|
||||
@@ -53,7 +53,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: wiki
|
||||
image: gitea.renner.uno/konairius/wiki:build.1792419299 # {"$imagepolicy": "flux-system:wiki"}
|
||||
image: gitea.renner.uno/konairius/wiki:build.1793006975 # {"$imagepolicy": "flux-system:wiki"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
@@ -77,6 +77,10 @@ spec:
|
||||
value: https://auth.renner.uno/application/o/wiki/
|
||||
- name: Auth__Oidc__ClientId
|
||||
value: wiki
|
||||
- name: Wiki__Encyclopedia__Enabled
|
||||
value: "true"
|
||||
- name: Wiki__Encyclopedia__MaxArticles
|
||||
value: "0"
|
||||
# wiki-index BasicAuth users (htpasswd), checked by the service for the read API.
|
||||
- name: Auth__Basic__UsersPath
|
||||
value: /var/run/secrets/wiki-basic-auth
|
||||
@@ -85,6 +89,9 @@ spec:
|
||||
name: wiki-gitea
|
||||
- secretRef:
|
||||
name: wiki-api-keys
|
||||
# The key the wiki-sync and wiki-encyclopedia CronJobs call with (cronjobs.yaml).
|
||||
- secretRef:
|
||||
name: wiki-scheduler-key
|
||||
- secretRef:
|
||||
name: wiki-typesafe
|
||||
- secretRef:
|
||||
|
||||
@@ -4,3 +4,4 @@ resources:
|
||||
- externalsecrets.yaml
|
||||
- deployment.yaml
|
||||
- ingress.yaml
|
||||
- cronjobs.yaml
|
||||
|
||||
@@ -69,7 +69,7 @@ spec:
|
||||
# first build of that day — imagePullPolicy: Always does not help,
|
||||
# because the tag itself resolves stale. Update this when the image
|
||||
# is rebuilt.
|
||||
image: gitea.renner.uno/gitea_admin/bird@sha256:afd8c3a1fc21a9f2c67af366b3bf77ca9b948f07f66a4977821e4972c8fa6ae4
|
||||
image: gitea.renner.uno/gitea_admin/bird@sha256:65fc93efead1e9b065d2c78e748f6c3c1ad7216e28b757d9fa4e204fa2ffe9a9
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
@@ -95,7 +95,7 @@ spec:
|
||||
mountPath: /etc/bird
|
||||
containers:
|
||||
- name: bird
|
||||
image: gitea.renner.uno/gitea_admin/bird@sha256:afd8c3a1fc21a9f2c67af366b3bf77ca9b948f07f66a4977821e4972c8fa6ae4
|
||||
image: gitea.renner.uno/gitea_admin/bird@sha256:65fc93efead1e9b065d2c78e748f6c3c1ad7216e28b757d9fa4e204fa2ffe9a9
|
||||
resources:
|
||||
requests:
|
||||
cpu: "50m"
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: coredns
|
||||
namespace: kube-system
|
||||
data:
|
||||
Corefile: |
|
||||
# Delegate renner.uno to the in-cluster k8s_gateway resolver. It
|
||||
# watches Ingress resources and synthesises A/AAAA records that
|
||||
# point at the ingress-nginx LB, so pods resolving `auth.renner.uno`,
|
||||
# `audiobooks.renner.uno`, … stay inside the cluster. Names not
|
||||
# backed by an in-cluster Ingress fall through inside k8s_gateway
|
||||
# itself to the UDM (10.2.40.1 / fd00::1), which serves the split-horizon
|
||||
# renner.uno zone, preserving resolution of LXC/VM + physical services.
|
||||
# (The old Proxmox DNS VIP 10.2.40.53 is DECOMMISSIONED.) 10.96.0.11 is a
|
||||
# pinned ClusterIP of the k8s-gateway Service — avoids DNS-name
|
||||
# resolution during CoreDNS startup.
|
||||
# Cache tuning mirrors k8s-gateway's (issue #393): the UDM's dnsmasq
|
||||
# refuses connections in bursts, and because the public renner.uno zone
|
||||
# has a *.renner.uno wildcard CNAME that matches at every depth, a local
|
||||
# failure never returns NXDOMAIN — it resolves to the WAN proxy instead.
|
||||
# Correct answers carry TTL 5 (dnsmasq lease records) while the wrong
|
||||
# wildcard answer carries TTL 300, so one brief blip pins a wrong answer
|
||||
# for minutes. `success ... 60` raises the 5s TTL to a 60s floor and
|
||||
# `serve_stale 1h immediate` keeps serving through an upstream outage.
|
||||
# NOTE: only cache/serve_stale — the `template` plugin is deliberately NOT
|
||||
# reintroduced here (it broke cluster DNS in 7a09bad5).
|
||||
renner.uno:53 {
|
||||
errors
|
||||
cache 300 {
|
||||
success 9984 300 60
|
||||
denial 9984 300 30
|
||||
serve_stale 1h immediate
|
||||
}
|
||||
forward . 10.96.0.11
|
||||
loop
|
||||
reload
|
||||
}
|
||||
|
||||
.:53 {
|
||||
errors
|
||||
health {
|
||||
lameduck 5s
|
||||
}
|
||||
ready
|
||||
log . {
|
||||
class error
|
||||
}
|
||||
prometheus :9153
|
||||
|
||||
kubernetes cluster.local in-addr.arpa ip6.arpa {
|
||||
pods insecure
|
||||
fallthrough in-addr.arpa ip6.arpa
|
||||
ttl 30
|
||||
}
|
||||
forward . /etc/resolv.conf {
|
||||
max_concurrent 1000
|
||||
}
|
||||
# serve_stale mirrors the renner.uno:53 block (issue #393): when the
|
||||
# upstream resolver browns out, keep answering from expired cache
|
||||
# instead of SERVFAILing every public name cluster-wide. Seen on
|
||||
# 2026-07-30 01:22-01:26Z (~1150 SERVFAILs) — it killed a Velero Kopia
|
||||
# maintenance job, which runs with backoffLimit 0, on the Hetzner S3
|
||||
# endpoint (issue #439). cluster.local is exempt from the cache above,
|
||||
# so in-cluster Service records are never served stale.
|
||||
cache 30 {
|
||||
disable success cluster.local
|
||||
disable denial cluster.local
|
||||
serve_stale 1h immediate
|
||||
}
|
||||
loop
|
||||
reload
|
||||
loadbalance
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
# CoreDNS itself (Deployment, Corefile) is a Talos bootstrap manifest; only
|
||||
# its LoadBalancer Service lives here.
|
||||
resources:
|
||||
- configmap.yaml
|
||||
- service.yaml
|
||||
|
||||
@@ -85,10 +85,11 @@ spec:
|
||||
parameters: "0.0.0.0:9153"
|
||||
serviceType: ClusterIP
|
||||
service:
|
||||
# Pinned so the main CoreDNS Corefile can forward renner.uno
|
||||
# here without depending on DNS-based Service resolution during
|
||||
# CoreDNS startup (chicken-and-egg). Chosen from the low, sparsely
|
||||
# populated end of the service CIDR (kube-dns is 10.96.0.10).
|
||||
# Pinned because the nodes' resolver names it by address
|
||||
# (ResolverConfig in the Talos site patches); CoreDNS forwards to the
|
||||
# node, so every name reaches k8s-gateway that way. Chosen from the
|
||||
# low, sparsely populated end of the service CIDR (kube-dns is
|
||||
# 10.96.0.10).
|
||||
clusterIP: 10.96.0.11
|
||||
ipFamilyPolicy: PreferDualStack
|
||||
# The site's upstream resolvers (deployment.dnsPolicy/dnsConfig), from the
|
||||
|
||||
@@ -71,7 +71,7 @@ spec:
|
||||
# socket's parent, and refuses to start if the socket file is left
|
||||
# over from an unclean shutdown (bind: address already in use).
|
||||
- name: clean-socket
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
command: ["sh", "-c", "rm -f /run/cni/dhcp.sock"]
|
||||
volumeMounts:
|
||||
- name: run-cni
|
||||
@@ -83,7 +83,7 @@ spec:
|
||||
# Any image with a shell works: the binary executed here is the
|
||||
# host's, bind-mounted from /opt/cni/bin. Pinned to the same
|
||||
# alpine tag the sibling installer uses.
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
command:
|
||||
- /opt/cni/bin/dhcp
|
||||
- daemon
|
||||
|
||||
@@ -87,11 +87,9 @@ spec:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
# host/remote-node: kubelet probes + node-local reach (Ceph mons on host net)
|
||||
# world: image pulls and llama-server's Hugging Face model download
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- world
|
||||
# ICMP — egress mirror; see ingress block above.
|
||||
- toEndpoints:
|
||||
- matchExpressions:
|
||||
@@ -166,3 +164,76 @@ spec:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
---
|
||||
# Hugging Face model download on cold cache (llama-server, llama-embed, wiki-embed).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: models-egress-huggingface
|
||||
namespace: ai
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/name
|
||||
operator: In
|
||||
values:
|
||||
- llama-server
|
||||
- llama-embed
|
||||
- wiki-embed
|
||||
egress:
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toFQDNs:
|
||||
- matchName: "huggingface.co"
|
||||
- matchName: "hf.co"
|
||||
- matchPattern: "*.huggingface.co"
|
||||
- matchPattern: "*.hf.co"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
---
|
||||
# wiki: Anthropic API (distillation) and TypeSafe API (typed judgements).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: wiki-egress-apis
|
||||
namespace: ai
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: wiki
|
||||
egress:
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toFQDNs:
|
||||
- matchName: "api.anthropic.com"
|
||||
- matchName: "api.typesafe.ai"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
|
||||
@@ -66,11 +66,9 @@ spec:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
# host/remote-node: kubelet probes + node-local reach (Ceph mons on host net)
|
||||
# world: image pulls, OBC S3 endpoint reachable via the cluster's LB IP, migration pipeline API
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- world
|
||||
# ICMP — egress mirror; see ingress block above.
|
||||
- toEndpoints:
|
||||
- matchExpressions:
|
||||
@@ -94,3 +92,19 @@ spec:
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
---
|
||||
# audiobookshelf (metadata providers) and libation (Audible): HTTPS to the internet.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: audiobookshelf-egress-https
|
||||
namespace: audiobookshelf
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
egress:
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
|
||||
@@ -70,7 +70,6 @@ spec:
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- world
|
||||
# ICMP — egress mirror.
|
||||
- toEndpoints:
|
||||
- matchExpressions:
|
||||
@@ -94,3 +93,43 @@ spec:
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
---
|
||||
# authentik server and worker: SMTP relay (Proton) for mail, HTTPS for
|
||||
# outbound webhooks, OIDC/social sources and update checks.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: authentik-egress
|
||||
namespace: authentik
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: authentik
|
||||
app.kubernetes.io/part-of: authentik
|
||||
egress:
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toFQDNs:
|
||||
- matchName: "smtp.protonmail.ch"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "587"
|
||||
protocol: TCP
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# EGRESS ONLY: an ingress section would flip ingress to default-deny and
|
||||
# break the apiserver -> webhook admission calls.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-egress
|
||||
namespace: cert-manager
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
egress:
|
||||
# All in-cluster destinations (pods, nodes, kube-apiserver).
|
||||
- toEntities:
|
||||
- cluster
|
||||
# rules.dns lets Cilium learn FQDN mappings.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
---
|
||||
# Controller: ACME (Let's Encrypt prod + staging), Cloudflare DNS-01 API, and
|
||||
# DNS-01 self-check against the recursive nameservers.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: cert-manager-controller-egress-world
|
||||
namespace: cert-manager
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/component: controller
|
||||
app.kubernetes.io/name: cert-manager
|
||||
egress:
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toFQDNs:
|
||||
- matchName: "acme-v02.api.letsencrypt.org"
|
||||
- matchName: "acme-staging-v02.api.letsencrypt.org"
|
||||
- matchName: "api.cloudflare.com"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
# Mirrors dns01RecursiveNameservers in
|
||||
# kubernetes/infrastructure/cert-manager/helmrelease.yaml.
|
||||
- toCIDR:
|
||||
- 1.1.1.1/32
|
||||
- 8.8.8.8/32
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,34 @@
|
||||
# CNPG instance pods: the barman-cloud plugin sidecar archives WAL and base
|
||||
# backups to Hetzner S3. Additive only: every namespace holding a CNPG cluster
|
||||
# also carries a namespace egress policy covering in-cluster traffic, so
|
||||
# selecting these pods here does not cut anything else.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumClusterwideNetworkPolicy
|
||||
metadata:
|
||||
name: cnpg-barman-s3-egress
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
cnpg.io/podRole: instance
|
||||
egress:
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toFQDNs:
|
||||
- matchName: "fsn1.your-objectstorage.com"
|
||||
- matchPattern: "*.fsn1.your-objectstorage.com"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
@@ -24,10 +24,7 @@ spec:
|
||||
- host
|
||||
- remote-node
|
||||
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
|
||||
# home-assistant-ns.yaml for the rationale. INGRESS ONLY: do NOT
|
||||
# add an egress section to this CNP — that's what broke attempt
|
||||
# 66dbe9f (it transitioned this ns from default-allow egress to
|
||||
# egress-enforced-with-only-ICMP, killing DNS / apiserver / world).
|
||||
# home-assistant-ns.yaml for the rationale.
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
@@ -50,3 +47,39 @@ spec:
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
egress:
|
||||
# All in-cluster destinations (pods, nodes, kube-apiserver).
|
||||
- toEntities:
|
||||
- cluster
|
||||
# rules.dns lets Cilium learn FQDN mappings.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
---
|
||||
# lapi: CAPI + hub index. agent: hub/collection fetch.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: crowdsec-egress-world
|
||||
namespace: crowdsec
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
k8s-app: crowdsec
|
||||
egress:
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
|
||||
@@ -22,8 +22,7 @@ spec:
|
||||
- host
|
||||
- remote-node
|
||||
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
|
||||
# home-assistant-ns.yaml for the rationale. INGRESS ONLY: do NOT
|
||||
# add an egress section to this CNP.
|
||||
# home-assistant-ns.yaml for the rationale.
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
@@ -46,3 +45,21 @@ spec:
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
egress:
|
||||
# All in-cluster destinations (pods, nodes, kube-apiserver).
|
||||
- toEntities:
|
||||
- cluster
|
||||
# rules.dns lets Cilium learn FQDN mappings.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
|
||||
@@ -48,4 +48,109 @@ spec:
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
---
|
||||
# external-dns (Cloudflare provider): Cloudflare API.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: external-dns-egress
|
||||
namespace: apps
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: external-dns
|
||||
app.kubernetes.io/instance: external-dns
|
||||
egress:
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toFQDNs:
|
||||
- matchName: "api.cloudflare.com"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
---
|
||||
# external-dns-unifi: UniFi controller API on the UDM (UNIFI_HOST).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: external-dns-unifi-egress
|
||||
namespace: apps
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: external-dns
|
||||
app.kubernetes.io/instance: external-dns-unifi
|
||||
egress:
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toFQDNs:
|
||||
- matchName: "udm.renner.uno"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
---
|
||||
# gatus: DNS probes against the UDM resolver by IP; HTTP checks target in-cluster names.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: gatus-egress
|
||||
namespace: apps
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app: gatus
|
||||
egress:
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
---
|
||||
# ical-proxy: fetches caller-supplied calendar feed URLs.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: ical-proxy-egress
|
||||
namespace: apps
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ical-proxy
|
||||
egress:
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
- port: "80"
|
||||
protocol: TCP
|
||||
|
||||
@@ -92,11 +92,27 @@ spec:
|
||||
- ports:
|
||||
- port: "8644"
|
||||
protocol: TCP
|
||||
# Push mirror to GitHub and other external targets.
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
---
|
||||
# gitea: push mirrors, external webhooks and OIDC/OAuth sources over HTTPS.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: gitea-egress-https
|
||||
namespace: gitea
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea
|
||||
egress:
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
---
|
||||
# Traefik serves git.renner.uno / gitea.renner.uno (:3000) and the gitea-ssh
|
||||
# entrypoint (:2222). Lives beside the baseline so both land in one reconcile.
|
||||
|
||||
@@ -143,10 +143,6 @@ spec:
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
# World: IoT-VLAN devices, HA Cloud, object storage, and third-party
|
||||
# integration endpoints on the LAN and internet.
|
||||
- toEntities:
|
||||
- world
|
||||
# ICMP — egress mirror of the ingress ICMP allow above. Lets HA
|
||||
# send ICMP to any cluster pod (covers the kernel's ICMPv6
|
||||
# Destination Unreachable when CoreDNS responses land on a
|
||||
@@ -261,10 +257,7 @@ spec:
|
||||
# hairpin off the home-assistant-bgp LoadBalancer and are unusable from a
|
||||
# pod. It reaches HA at home-assistant-bgp.home-assistant.svc.cluster.local.
|
||||
#
|
||||
# Only the ingress half is needed here. Unlike the openbao rotator, the trmnl
|
||||
# namespace has no default-deny EGRESS covering this pod — terminus-egress is
|
||||
# scoped to the Terminus pods by label and does not select the renderer — so
|
||||
# there is no source-side rule to add.
|
||||
# The egress half is trmnl-baseline-egress (toEntities: cluster).
|
||||
#
|
||||
# Source scoped to the renderer pod specifically, not the whole trmnl
|
||||
# namespace: Terminus itself sits in that namespace and has no business
|
||||
@@ -351,3 +344,149 @@ spec:
|
||||
- ports:
|
||||
- port: "5432"
|
||||
protocol: TCP
|
||||
---
|
||||
# home-assistant: IoT-VLAN devices, HA Cloud, and third-party integration
|
||||
# endpoints on the LAN and internet.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: home-assistant-egress-world
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: home-assistant
|
||||
egress:
|
||||
- toEntities:
|
||||
- world
|
||||
---
|
||||
# shelly-manager: HTTP RPC to Shelly devices on the IoT VLAN, and mDNS
|
||||
# queries for their *.local names (sent out the default interface eth0).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: shelly-manager-egress
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: shelly-manager
|
||||
egress:
|
||||
- toCIDR:
|
||||
- 10.2.30.0/24
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "80"
|
||||
protocol: TCP
|
||||
- toCIDR:
|
||||
- 224.0.0.251/32
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "5353"
|
||||
protocol: UDP
|
||||
---
|
||||
# esphome: OTA/API to devices on the IoT VLAN; HTTPS for GitHub, PlatformIO and PyPI during builds.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: esphome-egress
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: esphome
|
||||
egress:
|
||||
- toCIDR:
|
||||
- 10.2.30.0/24
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
---
|
||||
# zigbee2mqtt: Zigbee coordinator (SLZB) TCP socket.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: zigbee2mqtt-egress
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: zigbee2mqtt
|
||||
egress:
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toFQDNs:
|
||||
- matchName: "slzb-zigbee.renner.uno"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "6638"
|
||||
protocol: TCP
|
||||
---
|
||||
# mqtt-account-controller: credential delivery to edge-1 (deliver.esp32 in mqtt-accounts.yaml).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: mqtt-account-controller-egress
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
egress:
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toFQDNs:
|
||||
- matchName: "edge-1.iot.renner.uno"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8443"
|
||||
protocol: TCP
|
||||
---
|
||||
# whisper and speech-to-phrase: model downloads over HTTPS.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: speech-models-egress
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/name
|
||||
operator: In
|
||||
values:
|
||||
- whisper
|
||||
- speech-to-phrase
|
||||
egress:
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
|
||||
@@ -33,7 +33,7 @@ spec:
|
||||
- host
|
||||
- remote-node
|
||||
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
|
||||
# home-assistant-ns.yaml for the rationale. INGRESS ONLY.
|
||||
# home-assistant-ns.yaml for the rationale.
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
@@ -56,3 +56,41 @@ spec:
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
egress:
|
||||
# All in-cluster destinations (pods, nodes, kube-apiserver).
|
||||
- toEntities:
|
||||
- cluster
|
||||
# rules.dns lets Cilium learn FQDN mappings.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
---
|
||||
# immich-server and machine-learning: HTTPS to the internet (model downloads, metadata/reverse geocoding, version checks).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: immich-egress-world
|
||||
namespace: immich
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/name
|
||||
operator: In
|
||||
values: [immich-server, immich-machine-learning]
|
||||
egress:
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
|
||||
@@ -33,7 +33,7 @@ spec:
|
||||
- host
|
||||
- remote-node
|
||||
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
|
||||
# home-assistant-ns.yaml for the rationale. INGRESS ONLY.
|
||||
# home-assistant-ns.yaml for the rationale.
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
@@ -56,3 +56,43 @@ spec:
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
egress:
|
||||
# All in-cluster destinations (pods, nodes, kube-apiserver).
|
||||
- toEntities:
|
||||
- cluster
|
||||
# rules.dns lets Cilium learn FQDN mappings.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
---
|
||||
# Token job pods install tooling at runtime (apt, dl.k8s.io).
|
||||
# Selected by the job-controller `job-name` label, so the influxdb StatefulSet pod gets no world egress.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: influxdb-token-jobs-egress-world
|
||||
namespace: influxdb
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchExpressions:
|
||||
- key: job-name
|
||||
operator: Exists
|
||||
egress:
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
- port: "80"
|
||||
protocol: TCP
|
||||
|
||||
@@ -9,6 +9,7 @@ resources:
|
||||
- audiobookshelf-ns.yaml
|
||||
- home-assistant-ns.yaml
|
||||
- spegel-ns.yaml
|
||||
- cert-manager-ns.yaml
|
||||
- crowdsec-ns.yaml
|
||||
- database-ns.yaml
|
||||
- immich-ns.yaml
|
||||
@@ -37,4 +38,5 @@ resources:
|
||||
- renovate-ns.yaml
|
||||
- hermes-ns.yaml
|
||||
- allow-cilium-health.yaml
|
||||
- cnpg-barman-s3.yaml
|
||||
- kube-system-hubble-relay.yaml
|
||||
|
||||
@@ -33,7 +33,7 @@ spec:
|
||||
- host
|
||||
- remote-node
|
||||
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
|
||||
# home-assistant-ns.yaml for the rationale. INGRESS ONLY.
|
||||
# home-assistant-ns.yaml for the rationale.
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
@@ -58,8 +58,7 @@ spec:
|
||||
- { type: 143, family: IPv6 }
|
||||
---
|
||||
# Egress for the app pods (synapse, mas, element-web),
|
||||
# selected by part-of so the mx-pg CNPG pods keep unrestricted egress for
|
||||
# the barman-cloud WAL archive to Hetzner (same trade as trmnl-ns.yaml).
|
||||
# selected by part-of; the mx-pg CNPG pods are covered by mx-pg-egress.
|
||||
#
|
||||
# Nothing here reaches the internet: that is the network half of
|
||||
# "federation off". The one exception is synapse-push-egress below.
|
||||
@@ -187,3 +186,32 @@ spec:
|
||||
- ports:
|
||||
- port: "8443"
|
||||
protocol: TCP
|
||||
---
|
||||
# mx-pg CNPG pods: in-cluster + DNS. Unselected by the app egress policies above;
|
||||
# S3 backup egress comes from the clusterwide CNPG policy.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: mx-pg-egress
|
||||
namespace: matrix
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
cnpg.io/cluster: mx-pg
|
||||
egress:
|
||||
- toEntities:
|
||||
- cluster
|
||||
# rules.dns lets Cilium learn FQDN mappings.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
|
||||
@@ -34,7 +34,7 @@ spec:
|
||||
- host
|
||||
- remote-node
|
||||
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
|
||||
# home-assistant-ns.yaml for the rationale. INGRESS ONLY.
|
||||
# home-assistant-ns.yaml for the rationale.
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
@@ -57,6 +57,24 @@ spec:
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
egress:
|
||||
# All in-cluster destinations (pods, nodes, kube-apiserver).
|
||||
- toEntities:
|
||||
- cluster
|
||||
# rules.dns lets Cilium learn FQDN mappings.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
---
|
||||
# Hermes Agent (hermes ns) reads metrics, alerts and logs: the Prometheus,
|
||||
# Alertmanager and Loki HTTP APIs, scoped to those pods and ports.
|
||||
@@ -84,3 +102,120 @@ spec:
|
||||
protocol: TCP
|
||||
- port: "3100"
|
||||
protocol: TCP
|
||||
---
|
||||
# Alertmanager: SMTP submission (smtp_smarthost in helmrelease-prometheus.yaml). Only receiver is email.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: alertmanager-egress-world
|
||||
namespace: monitoring
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: alertmanager
|
||||
egress:
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toFQDNs:
|
||||
- matchName: "smtp.protonmail.ch"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "587"
|
||||
protocol: TCP
|
||||
---
|
||||
# Prober: targets are defined by Probe objects (monitoring-rules), any port.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: blackbox-exporter-egress-world
|
||||
namespace: monitoring
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: prometheus-blackbox-exporter
|
||||
egress:
|
||||
- toEntities:
|
||||
- world
|
||||
---
|
||||
# Grafana: plugin download (grafana.com) + OAuth, and the legacy Prometheus datasource.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: grafana-egress-world
|
||||
namespace: monitoring
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: grafana
|
||||
egress:
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
# Datasource "Prometheus (legacy)".
|
||||
- toFQDNs:
|
||||
- matchName: "prometheus.srv.renner.uno"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "9090"
|
||||
protocol: TCP
|
||||
---
|
||||
# Prometheus: off-site Uptime Kuma scrape (additionalScrapeConfigs job uptime-kuma). No remoteWrite configured.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: prometheus-egress-world
|
||||
namespace: monitoring
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: prometheus
|
||||
egress:
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toFQDNs:
|
||||
- matchName: "kuma.renner.uno"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
|
||||
@@ -24,6 +24,10 @@ spec:
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toEntities:
|
||||
- kube-apiserver
|
||||
- toEndpoints:
|
||||
@@ -34,9 +38,9 @@ spec:
|
||||
- ports:
|
||||
- port: "8200"
|
||||
protocol: TCP
|
||||
# NetBird management API (netbird.renner.uno).
|
||||
- toEntities:
|
||||
- world
|
||||
# NetBird management API.
|
||||
- toFQDNs:
|
||||
- matchName: "netbird.renner.uno"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
|
||||
@@ -88,7 +88,6 @@ spec:
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- world
|
||||
# ICMP — egress mirror.
|
||||
- toEndpoints:
|
||||
- matchExpressions:
|
||||
@@ -268,3 +267,51 @@ spec:
|
||||
- ports:
|
||||
- port: "8000"
|
||||
protocol: TCP
|
||||
---
|
||||
# protonmail-bridge: Proton API (HTTPS) and update/status endpoints (HTTP).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: protonmail-bridge-egress
|
||||
namespace: paperless
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: protonmail-bridge
|
||||
egress:
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
- port: "80"
|
||||
protocol: TCP
|
||||
---
|
||||
# syncthing: relays/discovery (443), STUN (3478, 3479 for the NAT-type
|
||||
# change request), peer sync (22000), local discovery (21027).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: syncthing-egress
|
||||
namespace: paperless
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: syncthing
|
||||
egress:
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
- port: "3478"
|
||||
endPort: 3479
|
||||
protocol: UDP
|
||||
- port: "22000"
|
||||
protocol: TCP
|
||||
- port: "22000"
|
||||
protocol: UDP
|
||||
- port: "21027"
|
||||
protocol: UDP
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user