Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e412e6448 | ||
|
|
7f61ad0ce9 | ||
|
|
6503161146 | ||
|
|
01133ca106 | ||
|
|
ce5be4d067 | ||
|
|
150e8229fd | ||
|
|
0247b3dfd5 | ||
|
|
aee26df4b8 | ||
|
|
0bf2cd309e | ||
|
|
73d85b4aa8 | ||
|
|
b78cacb6c4 | ||
|
|
b753ba4776 | ||
|
|
84c0561146 | ||
|
|
be4e605d65 | ||
|
|
10cb1c2152 | ||
|
|
ae8ce26a01 | ||
|
|
1f1125c49c | ||
|
|
1467596309 | ||
|
|
8ef130da2e | ||
|
|
2e677483db | ||
|
|
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 | ||
|
|
6998845854 | ||
|
|
430c710fd0 | ||
|
|
6accd1d3cd | ||
|
|
7bf1681c2d | ||
|
|
c9cd0783df | ||
|
|
7e57b42316 | ||
|
|
9afb56536c | ||
|
|
23c089c8b7 | ||
|
|
a3eb69ee20 | ||
|
|
08004a508a | ||
|
|
3d3510b7bd | ||
|
|
bd9f0cb18d |
@@ -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
|
||||
|
||||
@@ -95,9 +95,8 @@ the VM enrolled as `claude-ws-190-112.nb.renner.uno` / `100.96.190.112` (+ IPv6)
|
||||
> inventory targets the tunnel DHCP IP.
|
||||
|
||||
> **DNS trap.** `claude-ws.obg.renner.uno` resolves to the VM's lease **only at
|
||||
> the off-site DNS** (`@10.3.40.1`). The home resolver catch-alls `*.renner.uno`
|
||||
> to the public proxy (`162.55.50.97`, the Netbird VPS) — so from home the FQDN
|
||||
> lands on `root@netbird`, **not** the VM. Never use it as `ansible_host`.
|
||||
> the off-site DNS** (`@10.3.40.1`). From home this FQDN does not resolve (it
|
||||
> exists only at the off-site DNS @10.3.40.1). Never use it as `ansible_host`.
|
||||
|
||||
**Optional — mesh route to the k8s API (redundant read path):** to reach the API
|
||||
over the mesh too, add a Netbird dashboard **network route** advertising the
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# Enforces the decisions the in-cluster CrowdSec already detects, at the point
|
||||
# where public traffic actually enters: these hosts terminate TLS for every
|
||||
# *.renner.uno name. A drop here never reaches the mesh, the relay or the
|
||||
# public renner.uno name. A drop here never reaches the mesh, the relay or the
|
||||
# cluster.
|
||||
#
|
||||
# Detection stays in the cluster. The reverse-proxy produces no parseable
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -210,9 +210,10 @@ def resolve_backend(gate, service):
|
||||
|
||||
|
||||
def embedded_config(config):
|
||||
"""The embedded outpost's config with the hosts the gates depend on: the API
|
||||
over the in-cluster Service, the browser sent to the public URL."""
|
||||
return {**(config or {}), "authentik_host": AK_URL, "authentik_host_insecure": False,
|
||||
"""The embedded outpost's config with the host the gates depend on. The
|
||||
embedded outpost builds the sign-in redirect from authentik_host, so it is
|
||||
the public URL, which is also Authentik's default for it."""
|
||||
return {**(config or {}), "authentik_host": AK_BROWSER_URL, "authentik_host_insecure": False,
|
||||
"authentik_host_browser": AK_BROWSER_URL}
|
||||
|
||||
|
||||
|
||||
@@ -167,8 +167,8 @@ class Objects(unittest.TestCase):
|
||||
self.assertEqual(c.drift({"a": 1, "b": 2}, {"a": 1, "b": 3}), {"b": 3})
|
||||
|
||||
def test_embedded_config_keeps_other_keys(self):
|
||||
cfg = c.embedded_config({"log_level": "debug", "authentik_host": "https://auth.renner.uno/"})
|
||||
self.assertEqual(cfg, {"log_level": "debug", "authentik_host": c.AK_URL,
|
||||
cfg = c.embedded_config({"log_level": "debug", "authentik_host": c.AK_URL})
|
||||
self.assertEqual(cfg, {"log_level": "debug", "authentik_host": c.AK_BROWSER_URL,
|
||||
"authentik_host_insecure": False, "authentik_host_browser": c.AK_BROWSER_URL})
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ class Reconcile(unittest.TestCase):
|
||||
c.ensure_embedded_config(self.ak)
|
||||
cfg = self.ak.get(f"outposts/instances/{self.embedded['pk']}/")["config"]
|
||||
self.assertEqual((cfg["log_level"], cfg["authentik_host"], cfg["authentik_host_browser"]),
|
||||
("info", c.AK_URL, c.AK_BROWSER_URL))
|
||||
("info", c.AK_BROWSER_URL, c.AK_BROWSER_URL))
|
||||
before = len(self.ak.calls)
|
||||
c.ensure_embedded_config(self.ak)
|
||||
self.assertEqual(self.ak.calls[before:], [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,8 +4,8 @@ metadata:
|
||||
name: audiobookshelf
|
||||
namespace: audiobookshelf
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: audiobooks.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -706,7 +706,7 @@ data:
|
||||
# return entries instead of short-circuiting. Built from
|
||||
# the samba-bridge repo at
|
||||
# gitea.renner.uno/konairius/authentik-samba-bridge .
|
||||
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.3-9dd92dd
|
||||
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.4-3c8f413
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
@@ -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,83 @@ 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
|
||||
|
||||
# OpenBao human login (auth/oidc, role owner). Confidential client:
|
||||
# OpenBao's OIDC method requires a secret. Authentik generates it; it
|
||||
# lives only in OpenBao's auth/oidc/config, never in git.
|
||||
- id: openbao-admins-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: OpenBao Admins
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
# Members may log in to OpenBao via OIDC. Add/remove members HERE.
|
||||
users:
|
||||
- !Find [authentik_core.user, [username, k.renner]]
|
||||
|
||||
- id: openbao-provider
|
||||
model: authentik_providers_oauth2.oauth2provider
|
||||
identifiers:
|
||||
name: OpenBao
|
||||
state: present
|
||||
attrs:
|
||||
authentication_flow: null
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
client_type: confidential
|
||||
client_id: openbao
|
||||
# See the Matrix (MAS) provider: a blueprint-made provider starts
|
||||
# with no grant types.
|
||||
grant_types:
|
||||
- authorization_code
|
||||
- refresh_token
|
||||
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
|
||||
property_mappings:
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
|
||||
redirect_uris:
|
||||
# bao login -method=oidc (CLI listener).
|
||||
- url: http://localhost:8250/oidc/callback
|
||||
matching_mode: strict
|
||||
- url: https://openbao.renner.uno/ui/vault/auth/oidc/oidc/callback
|
||||
matching_mode: strict
|
||||
|
||||
# The slug is the issuer path: https://auth.renner.uno/application/o/openbao/
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: openbao
|
||||
state: present
|
||||
attrs:
|
||||
name: OpenBao
|
||||
slug: openbao
|
||||
provider: !KeyOf openbao-provider
|
||||
meta_launch_url: https://openbao.renner.uno
|
||||
meta_description: "OpenBao secrets"
|
||||
|
||||
# Only the OpenBao Admins group passes.
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, openbao]]
|
||||
group: !KeyOf openbao-admins-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-22-1858 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -20,7 +20,7 @@ spec:
|
||||
# Pinned to match the samba-bridge fork's app image (server.Dockerfile
|
||||
# AUTHENTIK_VERSION). Bump in lockstep with the bridge rebuild — a
|
||||
# floating range drifts the chart ahead of the pinned app image.
|
||||
version: "2026.8.3"
|
||||
version: "2026.8.4"
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: authentik
|
||||
@@ -58,7 +58,7 @@ spec:
|
||||
# 2. Pinning means the deployed binary is traceable to a
|
||||
# specific repo commit. Bump the tag whenever a new build
|
||||
# is published from gitea.renner.uno/konairius/authentik-samba-bridge .
|
||||
tag: "2026.8.3-9dd92dd"
|
||||
tag: "2026.8.4-3c8f413"
|
||||
envFrom:
|
||||
# SAMBA_BRIDGE_DOMAIN_SID / _ENABLED_GROUP / _UID_OFFSET
|
||||
# consumed by the bridge module at Django app startup. Missing
|
||||
@@ -161,8 +161,8 @@ spec:
|
||||
enabled: true
|
||||
ingressClassName: traefik
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the
|
||||
# UDM external-dns instance skips it, LAN uses the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: auth.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -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-27-0807 # {"$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
|
||||
|
||||
@@ -4,8 +4,8 @@ metadata:
|
||||
name: gatus
|
||||
namespace: apps
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: status.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gitea-mcp
|
||||
image: docker.gitea.com/gitea-mcp-server:1.10.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
image: docker.gitea.com/gitea-mcp-server:1.11.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
|
||||
command:
|
||||
- /app/gitea-mcp
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: gitea-mcp.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: git.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -90,7 +90,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: gitea.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -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.1793088058 # {"$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.1793088058 # {"$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.1793088058 # {"$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.1793088058 # {"$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.1793088058 # {"$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.1793088058 # {"$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.1793088058 # {"$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.1793088058 # {"$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.1793088058 # {"$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.1793088058 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
|
||||
args: ["gateway", "run"]
|
||||
env:
|
||||
- name: HERMES_DASHBOARD
|
||||
|
||||
@@ -19,8 +19,11 @@ expert's PR goes back to the card that opened it, without a manual step.
|
||||
labels, PR reviews, PR comments, PR open/close) posts to
|
||||
`http://hermes.hermes.svc.cluster.local:8644/webhooks/gitea-issues`.
|
||||
The CronJob `gitea-webhook-sync` (namespace hermes, every 15 min) keeps that hook in
|
||||
place, creates the domain labels and backfills, all only in repos where hermes-bot
|
||||
has issue write (org labels for their orgs, repo labels for user-owned ones): open
|
||||
place, creates the domain labels and backfills, all in every non-archived repo with
|
||||
issues of the orgs hermes-bot belongs to, where an org team grants it issue write
|
||||
(today: all repos of `konairius` through team `Bots`). Labels are org labels only.
|
||||
User-owned repos and repos where hermes-bot is only a direct collaborator get neither
|
||||
labels nor backfill; their new issues still become cards through the system hook. Backfill: open
|
||||
issues without a card comment are replayed to the route as signed `opened`
|
||||
deliveries, at most 10 per run, and the reviews of open hermes-bot PRs are replayed
|
||||
(step 8). Its site-admin PAT lives at OpenBao `apps/gitea-webhook-sync/admin`,
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
active system hook carries the desired name, a new one is created first and every
|
||||
other hook with the prefix is deleted afterwards.
|
||||
2. Domain labels. hestia/atlas/apollo/clio/daedalus are created only where Hermes
|
||||
works issues: repos with an issue tracker in which BOT_LOGIN (hermes-bot) has
|
||||
issue write. They become org labels in each org owning such a repo and repo labels
|
||||
in such user-owned repos (org labels do not reach those). Existing labels are left
|
||||
as they are; only missing ones are created.
|
||||
works issues: the non-archived org repos with an issue tracker in which BOT_LOGIN
|
||||
(hermes-bot) may write issues through an org team (it labels and comments). They
|
||||
are org labels of those orgs, never repo labels; user-owned repos are out of scope.
|
||||
Existing labels are left as they are; only missing ones are created.
|
||||
3. Backfill. Open issues of the same repos without the bridge's card comment are
|
||||
replayed to the Hermes webhook route as signed `opened` deliveries, at most
|
||||
BACKFILL_LIMIT per run and none started after RUN_BUDGET seconds, in random order
|
||||
@@ -60,9 +60,10 @@ BACKFILL_SPACING = float(os.environ.get("BACKFILL_SPACING_SECONDS", "3"))
|
||||
RUN_BUDGET = float(os.environ.get("RUN_BUDGET_SECONDS", "900"))
|
||||
DELIVER_TIMEOUT = 60
|
||||
CARD_MARKER = "<!-- hermes-kanban-card:"
|
||||
# Labels and backfill only where this account may write issues (it labels and comments).
|
||||
# Labels and backfill only in org repos this account may write issues in (it labels and comments).
|
||||
BOT_LOGIN = os.environ.get("BOT_LOGIN", "hermes-bot")
|
||||
BOT_WRITE = ("write", "admin", "owner")
|
||||
ISSUES_UNIT = "repo.issues"
|
||||
STARTED = time.monotonic()
|
||||
# Review replay (step 4): reviews by these accounts on open BOT_LOGIN PRs.
|
||||
REVIEW_BOT_LOGIN = os.environ.get("REVIEW_BOT_LOGIN", "review-bot")
|
||||
@@ -110,13 +111,11 @@ def api(method: str, path: str, token: str, body: dict | None = None):
|
||||
|
||||
|
||||
def paged(path: str, token: str) -> list[dict]:
|
||||
"""All pages of a list endpoint; /repos/search wraps its list in {"data": [...]}."""
|
||||
"""All pages of a list endpoint."""
|
||||
out, page = [], 1
|
||||
sep = "&" if "?" in path else "?"
|
||||
while True:
|
||||
batch = api("GET", f"{path}{sep}page={page}&limit=50", token) or []
|
||||
if isinstance(batch, dict):
|
||||
batch = batch.get("data") or []
|
||||
out.extend(batch)
|
||||
if len(batch) < 50:
|
||||
return out
|
||||
@@ -160,25 +159,56 @@ def reconcile(token: str, secret: str) -> str:
|
||||
|
||||
# --- 2. domain labels --------------------------------------------------------
|
||||
|
||||
def team_writes_issues(team: dict) -> bool:
|
||||
"""Issue write from the team's unit map (Gitea >= 1.20), else its team-wide mode."""
|
||||
if team.get("permission") in ("admin", "owner"):
|
||||
return True
|
||||
units = team.get("units_map") or {}
|
||||
return (units.get(ISSUES_UNIT) if units else team.get("permission")) in BOT_WRITE
|
||||
|
||||
|
||||
def issue_repos(token: str, errors: list[str] | None = None) -> list[dict]:
|
||||
"""Repos whose issues Hermes works: issue tracker on, not archived, and hermes-bot may
|
||||
write issues there (it sets the labels and comments). A site admin sees every repo.
|
||||
A repo whose permission lookup fails is skipped and named in `errors`."""
|
||||
out = []
|
||||
for r in paged("/repos/search", token):
|
||||
if not r.get("has_issues", True) or r.get("archived"):
|
||||
"""Repos whose issues Hermes works: in each org BOT_LOGIN belongs to, the repos of its
|
||||
teams that grant issue write (every org repo for an includes_all_repositories team),
|
||||
issue tracker on, not archived. Not /collaborators/{user}/permission: that reports
|
||||
the repo-wide access mode, which stays "none" when the write comes from a unit-based
|
||||
team. The admin route lists private orgs too and needs only the PAT's admin scope
|
||||
(/users/{u}/orgs also requires read:user). An org or team whose lookup fails is
|
||||
skipped and named in `errors`."""
|
||||
def failed(what: str, exc: RuntimeError) -> None:
|
||||
if errors is None:
|
||||
raise exc
|
||||
errors.append(f"{what}: {exc}")
|
||||
|
||||
found: dict[str, dict] = {}
|
||||
for org in paged(f"/admin/users/{q(BOT_LOGIN)}/orgs", token):
|
||||
name = org.get("username") or org.get("name") or ""
|
||||
if not name:
|
||||
continue
|
||||
owner, _, name = r["full_name"].partition("/")
|
||||
try:
|
||||
perm = api("GET", f"/repos/{q(owner)}/{q(name)}/collaborators/{q(BOT_LOGIN)}/permission", token)
|
||||
teams = paged(f"/orgs/{q(name)}/teams", token)
|
||||
except RuntimeError as exc:
|
||||
if errors is None:
|
||||
raise
|
||||
errors.append(f"{r['full_name']}: {exc}")
|
||||
failed(name, exc)
|
||||
continue
|
||||
if (perm or {}).get("permission") in BOT_WRITE:
|
||||
out.append(r)
|
||||
return out
|
||||
for team in teams:
|
||||
if not team_writes_issues(team):
|
||||
continue
|
||||
try:
|
||||
try:
|
||||
api("GET", f"/teams/{team['id']}/members/{q(BOT_LOGIN)}", token)
|
||||
except RuntimeError as exc:
|
||||
if str(exc).endswith("HTTP 404"):
|
||||
continue # not a member of this team
|
||||
raise
|
||||
repos = paged(f"/orgs/{q(name)}/repos" if team.get("includes_all_repositories")
|
||||
else f"/teams/{team['id']}/repos", token)
|
||||
except RuntimeError as exc:
|
||||
failed(f"{name} team {team.get('name')}", exc)
|
||||
continue
|
||||
for r in repos:
|
||||
if r.get("has_issues", True) and not r.get("archived"):
|
||||
found.setdefault(r["full_name"], r)
|
||||
return [found[k] for k in sorted(found)]
|
||||
|
||||
|
||||
def ensure_labels_at(path: str, token: str) -> list[str]:
|
||||
@@ -192,18 +222,14 @@ def ensure_labels_at(path: str, token: str) -> list[str]:
|
||||
|
||||
|
||||
def ensure_labels(token: str, repos: list[dict]) -> str:
|
||||
"""Org labels in each org that owns one of `repos`, repo labels in the user-owned ones."""
|
||||
orgs = {n for o in paged("/admin/orgs", token) if (n := o.get("username") or o.get("name"))}
|
||||
owners = {(r.get("owner") or {}).get("login") for r in repos}
|
||||
targets = [f"/orgs/{q(org)}/labels" for org in sorted(orgs & owners)]
|
||||
targets += [f"/repos/{q(r['owner']['login'])}/{q(r['name'])}/labels" for r in repos
|
||||
if (r.get("owner") or {}).get("login") not in orgs]
|
||||
"""Org labels in each org that owns one of `repos` (all org repos); never repo labels."""
|
||||
orgs = sorted({o for r in repos if (o := (r.get("owner") or {}).get("login"))})
|
||||
actions = []
|
||||
for path in targets:
|
||||
created = ensure_labels_at(path, token)
|
||||
for org in orgs:
|
||||
created = ensure_labels_at(f"/orgs/{q(org)}/labels", token)
|
||||
if created:
|
||||
actions.append(f"{path.rsplit('/labels', 1)[0]}: created {','.join(created)}")
|
||||
return "; ".join(actions) or f"labels present in {len(targets)} orgs/repos"
|
||||
actions.append(f"/orgs/{org}: created {','.join(created)}")
|
||||
return "; ".join(actions) or f"labels present in {len(orgs)} orgs"
|
||||
|
||||
|
||||
# --- 3. backfill -------------------------------------------------------------
|
||||
@@ -372,9 +398,9 @@ def main() -> int:
|
||||
def load_repos() -> str:
|
||||
errors: list[str] = []
|
||||
repos.extend(issue_repos(token, errors))
|
||||
msg = f"{len(repos)} with issues writable by {BOT_LOGIN}"
|
||||
msg = f"{len(repos)} org repos with issues writable by {BOT_LOGIN}"
|
||||
if errors: # the other repos still get labels and backfill; the Job fails
|
||||
raise RuntimeError(f"{msg}; permission lookup failed, skipped: {'; '.join(errors)}")
|
||||
raise RuntimeError(f"{msg}; team lookup failed, skipped: {'; '.join(errors)}")
|
||||
return msg
|
||||
|
||||
failures = 0
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
is a TLS-passthrough service on :443 with a PROXY v2 header, going to Traefik `websecure`.
|
||||
There are no UDM port forwards and no other public L4 path. LB VIPs (`bgp-pool`,
|
||||
`fd99::/64`, `10.2.99.0/24`) are LAN/mesh-only.
|
||||
- DNS labels on an Ingress: `dns.renner.uno/internal` writes the UDM (LAN) record
|
||||
(opt-in), `dns.renner.uno/public` writes the Cloudflare CNAME to `proxy.renner.uno`, and
|
||||
`netbird.renner.uno/public` makes `netbird-service-sync` publish the proxy service. A
|
||||
public host needs both public labels.
|
||||
- `netbird-service-sync` publishes **only hosts on Ingresses** labelled
|
||||
`dns.renner.uno/public` (or `netbird.renner.uno/public`), and it reads no other kind.
|
||||
`netbird.renner.uno/public`, and it reads no other kind.
|
||||
To publish a TLS host served by an `IngressRouteTCP` (TLS terminated by Traefik), put the
|
||||
host on its **own** labelled Ingress annotated
|
||||
`traefik.ingress.kubernetes.io/router.entrypoints: web` with **no** `tls:` section, and
|
||||
|
||||
@@ -16,15 +16,15 @@ monitor, and its history starts over.
|
||||
the KumaMonitor `healthCheckExprs` entry with `current: "true"` (see SKILL.md pitfalls).
|
||||
|
||||
## Which hosts are public
|
||||
The public set is exactly the hosts on Ingresses labelled `dns.renner.uno/public` or
|
||||
`netbird.renner.uno/public`. To confirm it, do a TLS handshake against the proxy-1 and
|
||||
A public host needs both labels on its Ingress: `netbird.renner.uno/public` for the proxy
|
||||
service and `dns.renner.uno/public` for the Cloudflare CNAME. To confirm it, do a TLS handshake against the proxy-1 and
|
||||
proxy-2 addresses (resolved via DoH) with each candidate hostname as the SNI. Only public hosts
|
||||
return a CA-verified certificate. The proxies do not answer for their own names, so a
|
||||
handshake with `proxy-N.renner.uno` as the SNI fails.
|
||||
|
||||
## Checks per host (owner's spec: DNS + HTTP reachability + response validation)
|
||||
- **DNS.** A cluster host has no record of its own and resolves through the `*.renner.uno`
|
||||
wildcard. Check `resolveType: CNAME` with `conditions: [{operator: equals, value:
|
||||
- **DNS.** A cluster host's public record is a CNAME to `proxy.renner.uno` created by
|
||||
external-dns from `dns.renner.uno/public`. Check `resolveType: CNAME` with `conditions: [{operator: equals, value:
|
||||
proxy.renner.uno}]`. An external host checks its own AAAA record, or A when it has no
|
||||
AAAA. Use `resolveServer: "2606:4700:4700::1111,1.1.1.1"` so IPv6 comes first; Kuma
|
||||
fails over through the list in order.
|
||||
@@ -71,7 +71,7 @@ reached Kuma.
|
||||
parent exists and is a group.
|
||||
- Emulate each http/keyword/json-query check the way Kuma does it: axios, Kuma's
|
||||
`evaluateJsonQuery` logic via `jsonata`, and a custom `lookup` that resolves through DoH,
|
||||
because this pod's DNS is split-horizon. Run each wildcard host pinned to proxy-1 and to
|
||||
because this pod's DNS is split-horizon. Run each public host pinned to proxy-1 and to
|
||||
proxy-2.
|
||||
- DNS monitors can't be checked from the cluster, because outbound :53 to external
|
||||
resolvers is blocked. After merge, read `monitor_status` from Prometheus instead
|
||||
|
||||
@@ -701,62 +701,120 @@ def repo(full, has_issues=True, archived=False):
|
||||
"has_issues": has_issues, "archived": archived}
|
||||
|
||||
|
||||
def team(tid, name, issues="write", all_repos=True, permission="write"):
|
||||
return {"id": tid, "name": name, "includes_all_repositories": all_repos,
|
||||
"permission": permission, "units_map": {"repo.issues": issues, "repo.code": "write"}}
|
||||
|
||||
|
||||
def fake_gitea(orgs, teams, members, repos, fail=()):
|
||||
"""api() stand-in: orgs of the bot, teams per org, bot's team ids, repos per org or team."""
|
||||
seen = []
|
||||
|
||||
def fake(method, path, token, body=None):
|
||||
p = path.split("?")[0]
|
||||
seen.append(p)
|
||||
if p in fail:
|
||||
raise RuntimeError(f"GET {p}: HTTP 500")
|
||||
if p == "/admin/users/hermes-bot/orgs":
|
||||
return [{"username": o} for o in orgs]
|
||||
if p.startswith("/orgs/") and p.endswith("/teams"):
|
||||
return teams.get(p.split("/")[2], [])
|
||||
if p.startswith("/teams/") and "/members/" in p:
|
||||
if int(p.split("/")[2]) in members:
|
||||
return {"login": "hermes-bot"}
|
||||
raise RuntimeError(f"GET {p}: HTTP 404")
|
||||
if p.endswith("/repos"):
|
||||
return repos.get(p, [])
|
||||
raise AssertionError(f"unexpected {method} {p}")
|
||||
|
||||
return fake, seen
|
||||
|
||||
|
||||
class DomainLabels(unittest.TestCase):
|
||||
def test_labels_only_for_orgs_and_user_repos_hermes_works(self):
|
||||
def test_org_labels_only_never_repo_labels(self):
|
||||
calls = []
|
||||
|
||||
def fake(method, path, token, body=None):
|
||||
calls.append((method, path, body))
|
||||
if path.startswith("/admin/orgs"):
|
||||
return [{"username": "konairius"}, {"username": "unrelated-org"}]
|
||||
if path.startswith("/orgs/konairius/labels"):
|
||||
return [{"name": "Atlas"}, {"name": "bug"}]
|
||||
return []
|
||||
|
||||
repos = [repo("konairius/homelab"), repo("k.renner/turm1388")]
|
||||
repos = [repo("konairius/homelab"), repo("konairius/shelly-manager")]
|
||||
with mock.patch.object(sync, "api", side_effect=fake):
|
||||
msg = sync.ensure_labels("tok", repos)
|
||||
posts = [(p, b["name"]) for m, p, b in calls if m == "POST"]
|
||||
self.assertEqual(sorted(n for p, n in posts if p == "/orgs/konairius/labels"),
|
||||
["apollo", "clio", "daedalus", "hestia"]) # atlas exists (any case)
|
||||
self.assertEqual(len([p for p, _ in posts if p == "/repos/k.renner/turm1388/labels"]), 5)
|
||||
self.assertFalse([p for p, _ in posts if p.startswith("/repos/konairius/")]) # org labels reach it
|
||||
self.assertFalse([c for c in calls if "unrelated-org" in c[1]]) # no repo of it is worked
|
||||
self.assertFalse([c for c in calls if c[1].startswith("/repos/")]) # org labels reach them
|
||||
self.assertFalse([c for c in calls if c[1].startswith("/admin/")])
|
||||
self.assertIn("created", msg)
|
||||
|
||||
def test_repos_without_issues_archived_or_bot_write_are_skipped(self):
|
||||
perms = {"a/x": "write", "a/w": "admin", "gitea_admin/_cargo-index": "read", "a/n": "none"}
|
||||
seen = []
|
||||
|
||||
def fake(method, path, token, body=None):
|
||||
if path.startswith("/repos/search"):
|
||||
return {"ok": True, "data": [
|
||||
repo("a/x"), repo("a/w"), repo("gitea_admin/_cargo-index"), repo("a/n"),
|
||||
repo("a/y", has_issues=False), repo("a/z", archived=True)]}
|
||||
seen.append(path)
|
||||
full = path.split("/repos/")[1].split("/collaborators/")[0]
|
||||
return {"permission": perms[full]}
|
||||
|
||||
def test_team_write_covers_every_org_repo_without_collaborator(self):
|
||||
# Live 2026-10-21: hermes-bot is only in team Bots (all repos, issues write);
|
||||
# /collaborators/hermes-bot/permission says "none" for 9 of the 10 repos.
|
||||
org_repos = [repo("konairius/homelab"), repo("konairius/shelly-manager"),
|
||||
repo("konairius/ical-proxy"), repo("konairius/off", has_issues=False),
|
||||
repo("konairius/old", archived=True)]
|
||||
fake, seen = fake_gitea(
|
||||
orgs=["konairius"],
|
||||
teams={"konairius": [team(1, "Owners", "owner", permission="owner"), team(2, "Bots"),
|
||||
team(5, "Reviewers", "read", permission="none")]},
|
||||
members={2},
|
||||
repos={"/orgs/konairius/repos": org_repos})
|
||||
with mock.patch.object(sync, "api", side_effect=fake):
|
||||
got = [r["full_name"] for r in sync.issue_repos("tok")]
|
||||
self.assertEqual(got, ["a/x", "a/w"])
|
||||
self.assertIn("/repos/a/x/collaborators/hermes-bot/permission", seen)
|
||||
self.assertFalse([p for p in seen if p.startswith(("/repos/a/y/", "/repos/a/z/"))])
|
||||
self.assertEqual(got, ["konairius/homelab", "konairius/ical-proxy", "konairius/shelly-manager"])
|
||||
self.assertFalse([p for p in seen if "/collaborators/" in p or p.startswith("/repos/")])
|
||||
self.assertNotIn("/teams/5/members/hermes-bot", seen) # read-only team is not even checked
|
||||
|
||||
def test_failed_permission_lookup_skips_only_that_repo(self):
|
||||
def fake(method, path, token, body=None):
|
||||
if path.startswith("/repos/search"):
|
||||
return {"data": [repo("a/x"), repo("a/broken"), repo("a/w")]}
|
||||
if path.startswith("/repos/a/broken/"):
|
||||
raise RuntimeError("GET /repos/a/broken/...: HTTP 500")
|
||||
return {"permission": "write"}
|
||||
def test_team_with_selected_repos_and_no_issue_write(self):
|
||||
fake, seen = fake_gitea(
|
||||
orgs=["a", "b"],
|
||||
teams={"a": [team(3, "Some", all_repos=False)],
|
||||
"b": [team(4, "CodeOnly", issues="read")]},
|
||||
members={3, 4},
|
||||
repos={"/teams/3/repos": [repo("a/x"), repo("a/y")],
|
||||
"/orgs/a/repos": [repo("a/x"), repo("a/y"), repo("a/not-in-team")],
|
||||
"/orgs/b/repos": [repo("b/z")]})
|
||||
with mock.patch.object(sync, "api", side_effect=fake):
|
||||
got = [r["full_name"] for r in sync.issue_repos("tok")]
|
||||
self.assertEqual(got, ["a/x", "a/y"])
|
||||
self.assertNotIn("/orgs/b/repos", seen)
|
||||
|
||||
def test_team_issue_write_from_units_map_or_team_mode(self):
|
||||
self.assertTrue(sync.team_writes_issues(team(2, "Bots")))
|
||||
self.assertFalse(sync.team_writes_issues(team(5, "Reviewers", "read", permission="none")))
|
||||
self.assertTrue(sync.team_writes_issues(team(1, "Owners", "read", permission="owner")))
|
||||
self.assertTrue(sync.team_writes_issues({"permission": "write"})) # no units_map
|
||||
self.assertFalse(sync.team_writes_issues({"permission": "read", "units_map": {}}))
|
||||
|
||||
def test_user_repos_are_out_of_scope(self):
|
||||
fake, seen = fake_gitea(orgs=[], teams={}, members=set(), repos={})
|
||||
with mock.patch.object(sync, "api", side_effect=fake):
|
||||
self.assertEqual(sync.issue_repos("tok"), [])
|
||||
self.assertEqual(seen, ["/admin/users/hermes-bot/orgs"]) # no /repos/search over user repos
|
||||
|
||||
def test_failed_team_lookup_skips_only_that_team(self):
|
||||
fake, _ = fake_gitea(
|
||||
orgs=["a", "broken"],
|
||||
teams={"a": [team(3, "Bots", all_repos=False), team(6, "More", all_repos=False)]},
|
||||
members={3, 6},
|
||||
repos={"/teams/3/repos": [repo("a/x")], "/teams/6/repos": [repo("a/w"), repo("a/x")]},
|
||||
fail={"/orgs/broken/teams", "/teams/6/members/hermes-bot"})
|
||||
errors = []
|
||||
with mock.patch.object(sync, "api", side_effect=fake):
|
||||
got = [r["full_name"] for r in sync.issue_repos("tok", errors)]
|
||||
self.assertEqual(got, ["a/x", "a/w"])
|
||||
self.assertEqual(len(errors), 1)
|
||||
self.assertIn("a/broken", errors[0])
|
||||
self.assertEqual(got, ["a/x"])
|
||||
self.assertEqual(len(errors), 2)
|
||||
self.assertIn("a team More", errors[0])
|
||||
self.assertTrue(errors[1].startswith("broken:"))
|
||||
|
||||
def test_failed_lookup_raises_without_error_list(self):
|
||||
fake, _ = fake_gitea(orgs=["broken"], teams={}, members=set(), repos={},
|
||||
fail={"/orgs/broken/teams"})
|
||||
with mock.patch.object(sync, "api", side_effect=fake), self.assertRaises(RuntimeError):
|
||||
sync.issue_repos("tok")
|
||||
|
||||
|
||||
class Backfill(unittest.TestCase):
|
||||
|
||||
@@ -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
|
||||
@@ -90,8 +90,7 @@ spec:
|
||||
# BLE radios: ESP32 esphome bluetooth_proxy nodes (e.g. the M5 Atom
|
||||
# Echo) are AUTO-DISCOVERED on the IoT VLAN via mDNS
|
||||
# (_esphomelib._tcp) over the same net1 macvlan route as the Shellys —
|
||||
# no static hostname/DNS (which the *.renner.uno wildcard would poison
|
||||
# over IPv6). The shared fleet Noise key (= the dashboard's
|
||||
# no static hostname/DNS. The shared fleet Noise key (= the dashboard's
|
||||
# shelly_ble_proxy_api_key) lets shelly-manager complete the encrypted
|
||||
# native-API handshake to each discovered proxy: DeviceInfo on
|
||||
# /ble-radios today, the BLE scan/GATT provisioning path next.
|
||||
@@ -178,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)
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ metadata:
|
||||
name: immich
|
||||
namespace: immich
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: immich.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
# Pinned to 7.4-alpine to match the LXC 225 compose exactly.
|
||||
# Any redis major-version bump is a separate migration.
|
||||
- name: redis
|
||||
image: redis:7.4.11-alpine # {"$imagepolicy": "flux-system:redis-immich"}
|
||||
image: redis:7.4.12-alpine # {"$imagepolicy": "flux-system:redis-immich"}
|
||||
args:
|
||||
- redis-server
|
||||
- --save
|
||||
|
||||
@@ -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-27-0801 # {"$imagepolicy": "flux-system:kuma-sync"}
|
||||
env:
|
||||
- name: KUMA_URL
|
||||
value: https://kuma.renner.uno
|
||||
|
||||
@@ -15,9 +15,8 @@ metadata:
|
||||
name: llama-server
|
||||
namespace: ai
|
||||
labels:
|
||||
# Public: Cloudflare wildcard → tunnel → NetBird proxy → Traefik. The UDM
|
||||
# external-dns instance skips anything carrying this, and LAN clients reach
|
||||
# the same hostname through the hairpin NAT.
|
||||
# Public: Cloudflare CNAME → NetBird proxy → Traefik. No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: llm.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Public (REN-663): each Ingress carries dns.renner.uno/public, so
|
||||
# netbird-service-sync publishes its host through the NetBird proxy
|
||||
# (Cloudflare wildcard -> proxy.renner.uno -> TLS passthrough to Traefik) and
|
||||
# external-dns-unifi skips it; LAN clients follow the public answer and the UDM
|
||||
# hairpins back in. Same path as audiobooks/immich. Federation stays off.
|
||||
# (explicit Cloudflare CNAME -> proxy.renner.uno -> TLS passthrough to Traefik)
|
||||
# and there is no dns.renner.uno/internal label, so external-dns-unifi writes
|
||||
# no UDM record; LAN clients follow the public answer and the UDM hairpins
|
||||
# back in. Same path as audiobooks/immich. Federation stays off.
|
||||
#
|
||||
# Synapse: only the client-facing paths. /_synapse/admin stays off the
|
||||
# Ingress (in-cluster only); /_matrix/federation and /_matrix/key are not
|
||||
@@ -13,8 +14,8 @@ metadata:
|
||||
name: synapse
|
||||
namespace: matrix
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
@@ -123,8 +124,8 @@ metadata:
|
||||
name: element-web
|
||||
namespace: matrix
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
@@ -154,8 +155,8 @@ metadata:
|
||||
name: mas
|
||||
namespace: matrix
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: matrix.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -136,7 +136,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: account.matrix.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -202,7 +202,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: element.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -289,7 +289,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: rtc.matrix.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -354,7 +354,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: turn.matrix.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,8 +25,8 @@ spec:
|
||||
summary: "{{ $labels.dns_name }} no longer resolves to its LAN address"
|
||||
description: >-
|
||||
The UDM stopped answering for a name external-dns publishes from
|
||||
a LoadBalancer Service, or answered with the public
|
||||
*.renner.uno wildcard instead of the record. Clients that use
|
||||
a LoadBalancer Service, or answered without the
|
||||
record (no answer, or a public CNAME). Clients that use
|
||||
the name rather than the address lose the service while
|
||||
everything in the cluster stays healthy — nothing else in the
|
||||
stack reports this. Check the external-dns alerts first, then
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,8 +31,9 @@ spec:
|
||||
{{ $value | humanizePercentage }} of probes in the last 10m got
|
||||
the Ingress address for {{ $labels.dns_name }} ({{ $labels.record }})
|
||||
from k8s-gateway. Pods then resolve Ingress names through the UDM
|
||||
and the public wildcard (CNAME proxy.renner.uno) and leave the
|
||||
cluster. Check the rendered Corefile in the k8s-gateway ConfigMap
|
||||
and the public zone (CNAME proxy.renner.uno for public hosts)
|
||||
and leave the cluster. Check the rendered Corefile in the
|
||||
k8s-gateway ConfigMap
|
||||
(kube-system) for the renner.uno zone, the k8s-gateway logs, and
|
||||
whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml
|
||||
still matches the dns_k8s_gateway_auth_* modules in
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,7 @@ tests:
|
||||
record: A
|
||||
exp_annotations:
|
||||
summary: "k8s-gateway does not answer auth.renner.uno A with the Traefik VIP"
|
||||
description: "20% of probes in the last 10m got the Ingress address for auth.renner.uno (A) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public wildcard (CNAME proxy.renner.uno) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
|
||||
description: "20% of probes in the last 10m got the Ingress address for auth.renner.uno (A) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public zone (CNAME proxy.renner.uno for public hosts) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
|
||||
- eval_time: 17m
|
||||
alertname: K8sGatewayProbesAbsent
|
||||
exp_alerts: []
|
||||
@@ -53,7 +53,7 @@ tests:
|
||||
record: AAAA
|
||||
exp_annotations:
|
||||
summary: "k8s-gateway does not answer auth.renner.uno AAAA with the Traefik VIP"
|
||||
description: "50% of probes in the last 10m got the Ingress address for auth.renner.uno (AAAA) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public wildcard (CNAME proxy.renner.uno) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
|
||||
description: "50% of probes in the last 10m got the Ingress address for auth.renner.uno (AAAA) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public zone (CNAME proxy.renner.uno for public hosts) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
|
||||
|
||||
# Two consecutive failures in an otherwise healthy hour stay silent
|
||||
# (average exactly 0.8).
|
||||
|
||||
@@ -78,11 +78,10 @@ spec:
|
||||
# passed per scrape. The Probes in monitoring-rules/probes-dns.yaml
|
||||
# point these at the UDM.
|
||||
#
|
||||
# Both answer checks are load-bearing. valid_rcodes alone passes a
|
||||
# deleted record two labels deep (NOERROR, no answer), and the public
|
||||
# *.renner.uno wildcard answers one label deep with a CNAME to
|
||||
# proxy.renner.uno — NOERROR with the WAN proxy behind it. Requiring
|
||||
# an A and rejecting a CNAME separates a real record from both.
|
||||
# Both answer checks are load-bearing: valid_rcodes alone passes a
|
||||
# deleted record (NOERROR, no answer, any depth). Requiring an A
|
||||
# separates a real record from that; a CNAME means the UDM answered with a
|
||||
# public-zone target instead of the LAN address.
|
||||
dns_lan_ha:
|
||||
prober: dns
|
||||
timeout: 5s
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# target of the *.renner.uno wildcard
|
||||
# target of every public CNAME (NetBird proxy load balancer)
|
||||
hostname: proxy.renner.uno
|
||||
resolveType: AAAA
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -228,7 +228,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# Cloudflare-proxied apex, not the wildcard
|
||||
# Cloudflare-proxied apex
|
||||
hostname: renner.uno
|
||||
resolveType: AAAA
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,8 +4,8 @@ metadata:
|
||||
name: music-assistant
|
||||
namespace: music-assistant
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: music.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: navidrome.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -101,7 +101,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: lidarr.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user