Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 58196acebc fix(authentik): embedded outpost config lives in the blueprint
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The embedded outpost's config is Authentik's own and does not depend on
authentik-gate-sync. A blueprint update of an existing outpost is partial
(importer: partial=True unless MUST_CREATED), so the entry sets config
without providers and the gates the controller attached stay.

config is replaced whole; the entry mirrors the live row
(authentik_outposts_outpost, 2026-10-23) with refresh_interval
minutes=1. kubernetes_namespace stays default: the outpost has the
Local Kubernetes Cluster connection, and the authentik namespace would
let the worker create an ak-outpost Service for it.

The controller drops embedded_config/ensure_embedded_config and only
attaches providers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:00:21 +02:00
claude-botandClaude Opus 5.5 95f3ff86b9 fix(authentik-gate-sync): refresh the embedded outpost every minute
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Each authentik-server replica's embedded outpost loses its websocket whenever gunicorn recycles the worker holding it (AUTHENTIK_WEB__MAX_REQUESTS). The reconnect backoff never resets (goauthentik/authentik#26463), so a replica then misses gate changes for up to 5 minutes and catches up only on the refresh interval. A 1-minute interval bounds that gap; worker recycling stays as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:19:49 +02:00
137 changed files with 1256 additions and 2731 deletions
+1 -1
View File
@@ -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.1@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
image: prom/prometheus:v3.15.0@sha256:efd719c99d83b060d9daefdcf00360461adf279f45ef5391f8d111892118753e
commands:
- sh scripts/ci/promtool-rules.sh
+5 -5
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
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.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
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.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
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.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
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.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
image: python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
environment:
PYTHONDONTWRITEBYTECODE: "1"
commands:
+1 -1
View File
@@ -13,7 +13,7 @@ when:
steps:
- name: validate-secrets
image: alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
environment:
TF_VAR_proxmox_api_url:
from_secret: proxmox_api_url
-34
View File
@@ -1,34 +0,0 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/ssh-ca/**"
- "ansible/plays/configure-ssh-ca.yml"
- "ansible/inventory/pve_hosts.yml"
- ".woodpecker/deploy-ssh-ca.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-ssh-ca"'
timeout: 30
steps:
- name: deploy-ssh-ca
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/configure-ssh-ca.yml -v
+1 -1
View File
@@ -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.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -6,7 +6,7 @@ when:
steps:
- name: go-test
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
image: golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61
commands:
- test -z "$(gofmt -l review-bot)"
- go -C review-bot vet ./...
+3 -3
View File
@@ -18,12 +18,12 @@ when:
steps:
- name: iac-scan
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
commands:
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
- name: image-scans
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
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.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+14 -13
View File
@@ -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
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:
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault; the
current live values come straight 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,14 +230,15 @@ 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.
The local broker is `allow_anonymous` on loopback — the trust boundary is the
host; nothing off-host can reach it.
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.
**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
**Bridge credential (REN-1307).** The two vault-password bridges are being
replaced by one bridge as the register account `edge-1`
(`kubernetes/apps/home-assistant/mqtt-accounts.yaml`). Its password is pushed
by mqtt-account-controller to `edge-mqtt-cred` (`:8443`, the controller's
esp32 API over HTTPS). That endpoint accepts only a client certificate from
the MQTT CA with CN `mqtt-account-controller`; edge-1 holds the public CA and
-8
View File
@@ -69,11 +69,3 @@ edge_host
# home, so it must NOT be used as ansible_host.
[claude_workstation]
claude-ws ansible_host=10.3.40.78 ansible_user=root
# Hosts that trust the OpenBao SSH user CA (plays/configure-ssh-ca.yml).
# Rolled out one host at a time: add the next host after the previous one
# was verified.
[ssh_ca]
proxy-1.renner.uno
proxy-2.renner.uno
netbird.renner.uno
-15
View File
@@ -1,15 +0,0 @@
---
# Trust the OpenBao SSH user CA (ssh-client-signer) on the hosts in [ssh_ca]:
# owner certificates (principal k.renner) for root/ladmin, Claude's 1h
# certificates (principal claude-bot) for its own claude-bot account with
# limited sudo. Existing authorized_keys stay untouched.
#
# serial: 1 so a host whose sshd rejects the change stops the run before the
# next host is touched.
- name: Configure the SSH user CA
hosts: ssh_ca
become: true
gather_facts: true
serial: 1
roles:
- ssh-ca
-18
View File
@@ -1,18 +0,0 @@
---
# SSH user CA (OpenBao ssh-client-signer). Hosts trust certificates signed by
# it; the public key is fetched from OpenBao on the controller at run time.
ssh_ca_public_key_url: "https://openbao.renner.uno/v1/ssh-client-signer/public_key"
# Owner certificates (role owner) carry this principal. It is accepted for
# whichever admin login the distro has; users that do not exist are skipped.
ssh_ca_owner_principal: k.renner
ssh_ca_owner_users:
- root
- ladmin
# Claude's own account. Certificates (role claude, 1h) carry the principal.
ssh_ca_claude_user: claude-bot
ssh_ca_claude_principal: claude-bot
# Units claude-bot may restart/reload via sudo on this host, e.g. [netbird].
ssh_ca_claude_sudo_units: []
-2
View File
@@ -1,2 +0,0 @@
# Public keys whose certificates sshd must reject (one per line, OpenSSH format).
# Revoking a key also revokes every certificate issued for it.
-150
View File
@@ -1,150 +0,0 @@
---
- name: Fetch the SSH CA public key from OpenBao
ansible.builtin.uri:
url: "{{ ssh_ca_public_key_url }}"
return_content: true
delegate_to: localhost
become: false
run_once: true
register: ssh_ca_key
- name: Check the fetched CA key is an SSH public key
ansible.builtin.assert:
that:
- ssh_ca_key.content is match('^ssh-ed25519 AAAA')
fail_msg: "Unexpected CA key from {{ ssh_ca_public_key_url }}"
run_once: true
- name: Install the trusted user CA key
ansible.builtin.copy:
content: "{{ ssh_ca_key.content | trim }}\n"
dest: /etc/ssh/trusted_user_ca_keys
owner: root
group: root
mode: "0644"
# sshd refuses all public key logins when RevokedKeys is set but unreadable,
# so the file is always deployed, even when empty.
- name: Install the revoked keys list
ansible.builtin.copy:
src: revoked_keys
dest: /etc/ssh/revoked_keys
owner: root
group: root
mode: "0644"
- name: Create the Claude account
ansible.builtin.user:
name: "{{ ssh_ca_claude_user }}"
shell: /bin/bash
create_home: true
password: "!"
- name: Look up the owner admin logins
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
fail_key: false
loop: "{{ ssh_ca_owner_users }}"
register: ssh_ca_owner_lookup
- name: Create the principals directory
ansible.builtin.file:
path: /etc/ssh/auth_principals
state: directory
owner: root
group: root
mode: "0755"
- name: Accept the owner principal for the existing admin logins
ansible.builtin.copy:
content: "{{ ssh_ca_owner_principal }}\n"
dest: "/etc/ssh/auth_principals/{{ item.item }}"
owner: root
group: root
mode: "0644"
loop: "{{ ssh_ca_owner_lookup.results }}"
loop_control:
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
- name: Accept the Claude principal for the Claude account
ansible.builtin.copy:
content: "{{ ssh_ca_claude_principal }}\n"
dest: "/etc/ssh/auth_principals/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0644"
- name: Install the Claude sudo rules
ansible.builtin.template:
src: sudoers-claude.j2
dest: "/etc/sudoers.d/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0440"
validate: visudo -cf %s
# A broken drop-in only bites on the next sshd restart, so it is tested here
# and removed again if the full config does not parse.
- name: Configure sshd for the user CA
block:
- name: Install the sshd drop-in
ansible.builtin.template:
src: ssh-ca.conf.j2
dest: /etc/ssh/sshd_config.d/50-ssh-ca.conf
owner: root
group: root
mode: "0644"
- name: Test the full sshd config
ansible.builtin.command: sshd -t
changed_when: false
rescue:
- name: Remove the drop-in that broke sshd -t
ansible.builtin.file:
path: /etc/ssh/sshd_config.d/50-ssh-ca.conf
state: absent
- name: Fail the host
ansible.builtin.fail:
msg: "sshd -t failed with the SSH CA drop-in; it was removed again"
- name: Read the effective sshd settings
ansible.builtin.command: sshd -T
changed_when: false
register: ssh_ca_effective
# Keyword case in sshd -T output differs between OpenSSH versions (mixed case
# on 10.6), so the lines are compared lowercased.
- name: Check sshd uses the CA, principals and revocation list
vars:
ssh_ca_effective_lines: "{{ ssh_ca_effective.stdout_lines | map('lower') | list }}"
ansible.builtin.assert:
that:
- "'trustedusercakeys /etc/ssh/trusted_user_ca_keys' in ssh_ca_effective_lines"
- "'authorizedprincipalsfile /etc/ssh/auth_principals/%u' in ssh_ca_effective_lines"
- "'revokedkeys /etc/ssh/revoked_keys' in ssh_ca_effective_lines"
fail_msg: "sshd -T does not show the SSH CA settings; another config line takes precedence"
# The unit is sshd.service on Arch and RedHat, ssh.service on Debian/Ubuntu.
# Ubuntu also lists sshd.service as a not-found alias, so the running one is
# picked. sshd -T only parses the files, so the running daemon is reloaded on
# every run; a reload keeps open sessions.
- name: Read the service list
ansible.builtin.service_facts:
- name: Reload the running sshd unit
vars:
ssh_ca_sshd_units: "{{ ansible_facts.services | dict2items | selectattr('key', 'in', ['sshd.service', 'ssh.service']) | selectattr('value.state', 'equalto', 'running') | map(attribute='key') | list }}"
block:
- name: Check exactly one sshd unit is running
ansible.builtin.assert:
that:
- ssh_ca_sshd_units | length == 1
fail_msg: "Expected one running sshd.service or ssh.service, found {{ ssh_ca_sshd_units }}"
- name: Reload sshd so the running daemon uses the CA config
ansible.builtin.systemd:
name: "{{ ssh_ca_sshd_units | first }}"
state: reloaded
@@ -1,4 +0,0 @@
# {{ ansible_managed }}
TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
RevokedKeys /etc/ssh/revoked_keys
@@ -1,6 +0,0 @@
# {{ ansible_managed }}
# NOEXEC: journalctl/systemctl open a pager, which must not spawn a shell.
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/journalctl, /usr/bin/journalctl *, /usr/bin/systemctl status, /usr/bin/systemctl status *, /usr/bin/systemctl list-units, /usr/bin/systemctl list-units *, /usr/bin/ss -tulpn, /usr/sbin/sshd -T
{% for unit in ssh_ca_claude_sudo_units %}
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/systemctl restart {{ unit }}, /usr/bin/systemctl reload {{ unit }}
{% endfor %}
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# Alpine plus jq. Nothing application-specific — a general-purpose base for any
# workload that needs to read JSON from a shell script.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# Alpine plus the openssl CLI. Nothing application-specific — named for what it
# is, so anything needing `openssl s_client` / `x509` from a shell script can use
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
RUN apk add --no-cache \
ansible \
+2 -2
View File
@@ -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:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS test
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS test
WORKDIR /src
COPY controller.py test_controller.py ./
RUN python3 -B -m unittest -v
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY --from=test /src/controller.py /opt/controller.py
@@ -45,7 +45,6 @@ INGRESS_NS = os.environ.get("INGRESS_NAMESPACE", "ingress")
DEFAULT_AUTHZ_FLOW = os.environ.get("DEFAULT_AUTHORIZATION_FLOW", "default-provider-authorization-implicit-consent")
INVALIDATION_FLOW = os.environ.get("INVALIDATION_FLOW", "default-provider-invalidation-flow")
COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", "renner.uno")
AK_BROWSER_URL = os.environ.get("AUTHENTIK_HOST_BROWSER", "https://auth.renner.uno/")
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
HTTP_TIMEOUT = 20
@@ -209,14 +208,6 @@ def resolve_backend(gate, service):
return sp["port"], selector, target
def embedded_config(config):
"""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}
def provider_body(gate, flows):
return {
"name": gate["name"],
@@ -349,14 +340,6 @@ def embedded_outpost(ak):
return found[0]
def ensure_embedded_config(ak):
outpost = embedded_outpost(ak)
want = embedded_config(outpost.get("config"))
if outpost.get("config") != want:
ak.req("PATCH", f"outposts/instances/{outpost['pk']}/", {"config": want})
LOG.info("embedded outpost: updated config")
def ensure_gate(ak, gate, flows):
name = gate["name"]
if gate["group"] and ak.one("core/groups/", name=gate["group"]) is None:
@@ -485,7 +468,6 @@ def reconcile():
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) if is_gated(i)]
shared = shared_hosts(sources)
flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")}
ensure_embedded_config(ak)
desired = set() # (kind, ns, name) of every Kubernetes object some gate wants
gates = set() # gate names whose Authentik side should exist
@@ -166,11 +166,6 @@ class Objects(unittest.TestCase):
def test_drift(self):
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": 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})
class FakeAuthentik:
"""In-memory stand-in for the endpoints the controller uses; records calls."""
@@ -299,15 +294,6 @@ class Reconcile(unittest.TestCase):
c.embedded_outpost(self.ak)
self.assertEqual(self.ak.get(f"outposts/instances/{ldap['pk']}/")["providers"], [7])
def test_embedded_config(self):
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_BROWSER_URL, c.AK_BROWSER_URL))
before = len(self.ak.calls)
c.ensure_embedded_config(self.ak)
self.assertEqual(self.ak.calls[before:], [])
def test_provider_moves_from_proxy_mode(self):
self.ak.add("providers/proxy/", {"name": self.g["name"], "mode": "proxy",
"internal_host": "http://llama-server.ai.svc.cluster.local:8080",
+2 -2
View File
@@ -1,6 +1,6 @@
FROM hetznercloud/cli:v1.70.2@sha256:3b3a8f66323a5754089df7939fd9d3f350127072deed13418f3d3052b4bbeeae AS hcloud
FROM hetznercloud/cli:v1.70.1@sha256:795ac09a5ecabd48cc13a8f1c3dc45983c8f75ae1277c70d621719e07f276ccb AS hcloud
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
RUN apk add --no-cache \
bash \
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# bird: BGP daemon for the pod-network mesh over pod0. Distro package, no build
# step — the image is the binary plus its runtime.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f
# 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 -2
View File
@@ -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:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 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:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
USER 65532
ENTRYPOINT ["/cloudflare-lb-sync"]
+2 -2
View File
@@ -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:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a AS fetch
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 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:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# Alpine rather than scratch: the installer needs a shell to copy the binaries
# onto the host. busybox covers cp/mv/chown/ls, so nothing is added.
+2 -2
View File
@@ -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:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce 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:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
+4 -4
View File
@@ -1,4 +1,4 @@
FROM nousresearch/hermes-agent:v0.22.5@sha256:9c5561579f3ffdf333401797094ac3a743738d6c25d40f4331c7bf77b694238d
FROM nousresearch/hermes-agent:v0.22.2@sha256:36099926da316742423544e055d4734777105ffe6341873af5459b957d0c2919
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.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=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=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 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
ARG TALOS_VERSION=v1.13.12
ARG VELERO_VERSION=v1.16.0
+2 -2
View File
@@ -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:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 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:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
COPY --from=build /out/kuma-sync /kuma-sync
USER 65532
ENTRYPOINT ["/kuma-sync"]
+1 -1
View File
@@ -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:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY controller.py /opt/controller.py
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
ARG TOFU_VERSION=1.11.0
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# Everything `bootstrap.sh vmtest` needs, so the rebuild test can run as a Job in
# the cluster instead of on a workstation. The workstation has 31G and the test
@@ -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.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
command:
- sh
- -c
@@ -70,7 +70,7 @@ spec:
mountPath: /ca
containers:
- name: audiobookshelf
image: ghcr.io/advplyr/audiobookshelf:2.37.2 # {"$imagepolicy": "flux-system:audiobookshelf"}
image: ghcr.io/advplyr/audiobookshelf:2.37.1 # {"$imagepolicy": "flux-system:audiobookshelf"}
ports:
- name: http
containerPort: 80
@@ -374,10 +374,39 @@ data:
name: lidarr-gate
state: absent
# The embedded outpost is not declared here: authentik-gate-sync owns its
# providers list (every gate) and the hosts in its config, and a
# blueprint entry must carry `providers`, which would detach the gates
# on every apply.
# No `providers`: authentik-gate-sync attaches the gates, and an update
# of an existing outpost is partial. `config` is replaced whole, so it
# carries every key, kubernetes_namespace included.
- model: authentik_outposts.outpost
identifiers:
name: authentik Embedded Outpost
state: present
attrs:
config:
log_level: info
# The embedded outpost builds the sign-in redirect from
# authentik_host, so it is the public URL.
authentik_host: "https://auth.renner.uno/"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
# A replica whose outpost websocket is down picks up gate changes
# only on this interval; the reconnect backoff never resets
# (goauthentik/authentik#26463).
refresh_interval: "minutes=1"
object_naming_template: "ak-outpost-%(name)s"
container_image: null
docker_labels: null
docker_network: ladmin_default
docker_map_ports: true
kubernetes_namespace: default
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: []
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# Shared LDAPS cert for the LDAP Outpost. Authentik's data model
# owns the cert per-LDAPProvider (no outpost-level cert field), so
@@ -706,7 +735,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.4-3c8f413
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.3-9dd92dd
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
@@ -1034,19 +1063,9 @@ 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. Only the
# Wiki Readers group passes.
# so the service verifies the ID token against the JWKS. No policy
# binding: every Authentik user may sign in.
- id: wiki-provider
model: authentik_providers_oauth2.oauth2provider
identifiers:
@@ -1082,83 +1101,6 @@ 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
+1 -3
View File
@@ -105,7 +105,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0757 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -114,8 +114,6 @@ spec:
env:
- name: AK_URL
value: http://authentik-server.authentik.svc.cluster.local
- name: AUTHENTIK_HOST_BROWSER
value: https://auth.renner.uno/
- name: DEFAULT_AUTHORIZATION_FLOW
value: default-provider-authorization-implicit-consent
- name: INTERVAL_SECONDS
+2 -2
View File
@@ -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.4"
version: "2026.8.3"
sourceRef:
kind: HelmRepository
name: authentik
@@ -58,7 +58,7 @@ spec:
# 2. Pinning means the deployed binary is traceable to a
# specific repo commit. Bump the tag whenever a new build
# is published from gitea.renner.uno/konairius/authentik-samba-bridge .
tag: "2026.8.4-3c8f413"
tag: "2026.8.3-9dd92dd"
envFrom:
# SAMBA_BRIDGE_DOMAIN_SID / _ENABLED_GROUP / _UID_OFFSET
# consumed by the bridge module at Django app startup. Missing
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-27-0807 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-22-1904 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
env:
- name: CLOUDFLARE_API_TOKEN
valueFrom:
+1 -1
View File
@@ -28,7 +28,7 @@ spec:
enableServiceLinks: false
containers:
- name: evcc
image: evcc/evcc:0.318.1@sha256:2f8163ab22f04863afd86cd511c09607ba71debd9eebd65511f3d654922a80d1
image: evcc/evcc:0.317.1@sha256:c50cd4f83c22b39722a76a76b8d428b69bf8ab3334209ed9ed24ec0e3c48dde5
# 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.
+2 -4
View File
@@ -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.47.1)
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.40.1)
# Web Service 4 = Woodpecker CI Infrastructure 4 = Mosquitto MQTT broker
# Web Service 5 = Grafana
# Web Service 6 = Home Assistant
@@ -290,11 +290,9 @@ 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.47.1
url: 10.2.40.1
interval: 30s
dns:
query-name: udm.renner.uno
+1 -1
View File
@@ -115,7 +115,7 @@ spec:
type: RuntimeDefault
containers:
- name: retention
image: docker.io/library/python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
image: docker.io/library/python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/python
- /opt/hermes-skills/gitea-issues/scripts/sync_webhook.py
+10 -10
View File
@@ -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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
command:
- sh
- -ec
@@ -457,7 +457,7 @@ spec:
readOnly: true
containers:
- name: hermes
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
args: ["gateway", "run"]
env:
- name: HERMES_DASHBOARD
@@ -88,7 +88,7 @@ spec:
# deliver, and the ClusterIP has no backend while HA restarts. The
# trap forwards SIGTERM so the pod still terminates promptly.
- name: socat-ipv4
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
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:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
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.1792997278 # {"$imagepolicy": "flux-system:shelly-manager"}
image: gitea.renner.uno/k.renner/shelly-manager:build.1792387459 # {"$imagepolicy": "flux-system:shelly-manager"}
imagePullPolicy: IfNotPresent
ports:
- name: http
@@ -177,7 +177,7 @@ spec:
# No probes: it only serves the API-key path, and a probe failure would
# also pull the pod out of the shelly-manager Service.
- name: api-proxy
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b9241c6e7b8e9a862f129d8d4199ab64b10390949a78bdd5603379b32c844083
env:
- name: POD_IP
valueFrom:
@@ -0,0 +1,179 @@
# 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.4 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.10.2 # {"$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:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:378d0c32f6562f7fd9629170797f57d2b953aa09be983b3daaf4809bb039ca00
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.5@sha256:65c4e34ec0ee0f30ea1f72b7411ec70a91c32fc50f067a8466ad2adb1e7420f2
image: ghostunnel/ghostunnel:v1.11.4@sha256:e5248233863aadd36383a11c92360f2981141881ef05e38360a6ada8630d36b1
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.143.1@sha256:377b66bec5645510ed52549bb00e648c273edc604d230a3f90fd2b2433afcb97
image: codercom/code-server:4.141.0@sha256:72ab24b5680ee597dd647b30d82625752f40109b5f61bef90ffbf1ea1f02dbb8
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:e8ceedc73bd80992ae8d58a7d24c58b8322146c354f1d2e81a97db4452068ec0
image: alpine/git:2.54.0@sha256:03228d7ded483b0b3e55e1de7fa660d70d3e1bb432fa7021d29e192640de11aa
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.10.0@sha256:1dff19a59070cdb523b76576cdc74d200f862d43c1407809088855920cf57145
image: ghcr.io/esphome/esphome:2026.9.1@sha256:776a845d508909bf426d2ee7bdd81796361ebe6a7f091807ce1d6d0b48985413
imagePullPolicy: IfNotPresent
# Default entrypoint runs the dashboard (Device Builder) against the
# mounted /config — no command override needed.
@@ -12,20 +12,26 @@ resources:
- config-sync-token-externalsecret.yaml
- config-sync-gitea-externalsecret.yaml
- matrix-externalsecret.yaml
- mosquitto-admin-password.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-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. It logs in to
# the broker as register account `zigbee2mqtt-bridge` (mqtt-accounts.yaml).
# 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).
- zigbee2mqtt-coordinator-backup-externalsecret.yaml
- zigbee2mqtt-config.yaml
- zigbee2mqtt-pvc.yaml
@@ -1,32 +0,0 @@
# 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
@@ -0,0 +1,394 @@
---
# 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,15 +77,18 @@ data:
allow_anonymous false
# 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).
# Two account sources side by side (REN-802 step 2, tested on 2.0.22 in
# REN-900, document `coexistence`):
# Dynamic Security: accounts from the register mqtt-accounts.yaml,
# managed live by mqtt-account-controller over $CONTROL.
# password_file/acl_file: the legacy users, unchanged until each client
# moves to the register (step 3).
# Dynamic Security is asked first. For a username it does not know, and
# for a request none of its roles match, it defers to the files. A
# register entry named `admin` therefore replaces the file login.
# register entry with the same name as a legacy user therefore replaces
# that user's password, while its acl_file block still applies to
# whatever the entry's roles do not cover. Delete the legacy user when
# its client moves.
#
# The acl_file check of 2.0 allows every SUBSCRIBE and checks only on
# delivery, so the Dynamic Security default must deny publishClientReceive.
@@ -64,15 +64,67 @@ 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 admin "$ADMIN_PASSWORD"
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).
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
@@ -91,11 +143,38 @@ spec:
chmod 0600 /mosquitto/auth/passwd /mosquitto/auth/acl /mosquitto/auth/dynamic-security.json
exec mosquitto -c /mosquitto/config/mosquitto.conf
env:
# mosquitto-admin-password.yaml
- name: HA_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-credentials
key: homeassistant-password
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-admin-password
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
key: password
ports:
- name: mqtt
@@ -152,12 +231,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, 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.
# 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.
startupProbe:
tcpSocket:
port: mqtt
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,36 @@
# 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,7 +5,9 @@
# mqtt-accounts.yaml, every INTERVAL (2m).
#
# Manages the production broker `mosquitto` over its mTLS listener :8884
# (REN-802 step 2, REN-900).
# (REN-802 step 2, REN-900). The legacy users in that broker's password_file
# are not Dynamic Security clients, so the controller never sees or touches
# them.
apiVersion: v1
kind: ServiceAccount
metadata:
@@ -85,9 +87,6 @@ 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:
@@ -100,11 +99,6 @@ 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:
@@ -225,7 +219,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-b1f294d@sha256:8081adfee171c36cfa83734e5e7ccc3aad3debf1c0154488de70143674cde85f
image: gitea.renner.uno/konairius/mqtt-account-controller:main-cb96b1a@sha256:9d9b9e83a9c0f9f567e57edacab11cf4c58e8c0e49e38e8d7b51421cd1242911
env:
- name: BROKER_URL
value: ssl://mosquitto.home-assistant.svc.cluster.local:8884
@@ -7,50 +7,51 @@
# configMapGenerator in kustomization.yaml). An empty list is refused, so it
# never revokes everyone.
#
# The controller manages the production broker `mosquitto` (REN-900).
# `test-cert` is the certificate test account from REN-888.
# The controller manages the production broker `mosquitto` (REN-900). Its
# legacy users (password_file in mosquitto-deployment.yaml) are not listed
# here; each moves here with its client (REN-802 step 3). `test-cert` is the
# certificate test account from REN-888.
#
# A cert account needs its Secret `mqtt-<name>-tls`, a password account
# delivered to a namespace its Secret `mqtt-<name>-password`, added to the
# A cert account needs its Secret `mqtt-<name>-tls` added to the
# `mqtt-account-controller-secrets-<namespace>` Role in
# 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 #"]
# mqtt-account-controller.yaml, or revoking it fails with 403.
- name: test-cert
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite test/cert/#"]
# The broker's own liveness/readiness probe (mosquitto-deployment.yaml).
# Not named `health`: a Dynamic Security client of that name would shadow the
# legacy password user while the old broker pod still probes with it.
- name: mosquitto-probe
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite health/#"]
# Zigbee2MQTT and its two sidecars (discovery-guard, interview-watch), one
# certificate for the pod (REN-953).
# certificate for the pod. Same ACL as the legacy user `zigbee2mqtt`; not
# named like it, so this entry cannot shadow the password login of a running
# Z2M (REN-953).
- name: zigbee2mqtt-bridge
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite zigbee2mqtt/#", "write homeassistant/#", "read homeassistant/status"]
# evcc (evcc namespace, REN-953). evcc takes the certificate PEMs by env
# (EVCC_MQTT_*), see apps/evcc/deployment.yaml.
# evcc (evcc namespace). Same ACL as the legacy user `evcc`, different name
# so it cannot shadow the running password login (REN-953). evcc takes the
# certificate PEMs by env (EVCC_MQTT_*), see apps/evcc/deployment.yaml.
- name: evcc-client
auth: cert
deliver: {namespace: evcc}
acl: ["read open3e/#", "readwrite evcc/#", "read energy/#"]
# Home Assistant, via the ghostunnel sidecar `mqtt-tls` in its pod (HA keeps
# client certificates only as uploaded PEMs in its config entry, so a renewal
# could not reach it without a reconfigure, REN-953).
# could not reach it without a reconfigure). Same ACL as the legacy user
# `homeassistant`; not named like it, so it cannot shadow that login (REN-953).
- name: home-assistant
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite #"]
# edge-1's local mosquitto bridge (REN-1307): one bridge for open3e/e3oncan.
# The controller pushes
# edge-1's local mosquitto bridge (REN-1307): one bridge for open3e/e3oncan
# and sml2mqtt, replacing the legacy password users `open3e` and `sml2mqtt`
# (different name, so it cannot shadow their logins). The controller pushes
# the password to edge-mqtt-cred on edge-1 (ansible role mqtt-bridge) over
# HTTPS with its own client certificate, and pins edge-1's self-generated
# server certificate on first contact (key edge-1.pin in mqtt-account-hashes;
@@ -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 mqtt-zigbee2mqtt-bridge-tls # exists (register account zigbee2mqtt-bridge)
kubectl -n home-assistant get secret mosquitto-zigbee2mqtt-credentials # exists (bootstrap Job ran)
kubectl -n home-assistant get externalsecret zigbee2mqtt-coordinator-backup # SecretSyncedError / NotReady — EXPECTED
kubectl -n home-assistant get deploy zigbee2mqtt # 0/0 — EXPECTED
```
Z2M logs in to the broker by certificate on `:8884` as the register account
`zigbee2mqtt-bridge` (`mqtt-accounts.yaml`).
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).
## Step 1 — export the Open Coordinator Backup (non-disruptive)
+1 -1
View File
@@ -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.12-alpine # {"$imagepolicy": "flux-system:redis-immich"}
image: redis:7.4.11-alpine # {"$imagepolicy": "flux-system:redis-immich"}
args:
- redis-server
- --save
+1 -1
View File
@@ -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.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
command:
- sh
- -c
@@ -57,7 +57,7 @@ spec:
runAsNonRoot: false
initContainers:
- name: wait-for-influxd
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
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:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
+2 -2
View File
@@ -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:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
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:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
+1 -1
View File
@@ -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:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2"
tag: "2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac"
# Single replica — InfluxDB OSS doesn't cluster. RWO Ceph-block + Velero
# backups (pending) is the durability story.
persistence:
+1 -1
View File
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-22-1858 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
+1 -1
View File
@@ -72,7 +72,7 @@ spec:
type: RuntimeDefault
containers:
- name: element-web
image: ghcr.io/element-hq/element-web:v1.12.32@sha256:f8700633d4336258af7a05b53865a4f479e0d628cf2d9ca83d28b5939314d4ce
image: ghcr.io/element-hq/element-web:v1.12.30@sha256:3e7dbd4424de9e11c812bc740acf2725b2abe66ea0d0802023b56fd85d3fa09a
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+1 -1
View File
@@ -137,7 +137,7 @@ spec:
type: RuntimeDefault
containers:
- name: mas
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1@sha256:02712e68c90f3743fedbd5e3343517481b2b4f246a9fd9e2f050dc2553033847
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0@sha256:e089f1048a1d4a9a492ed17b9fe759100f1bd619407b001f5927928d88b780c4
# The server applies database migrations and syncs the upstream
# providers from the config on start.
args: ["server", "--config", "/config/config.yaml"]
+1 -1
View File
@@ -124,7 +124,7 @@ spec:
type: RuntimeDefault
containers:
- name: livekit
image: livekit/livekit-server:v1.13.11@sha256:25cd902de7cacbd5f1f779f6a6ac1eae20ba8f56b0935934ce6b0ab80d1e3d20
image: livekit/livekit-server:v1.13.10@sha256:89ca9ce0791e18d6e03bf6e2c49ceb27e1c754eae8bd50c989386c1a5963791a
args:
- --config
- /config/livekit.yaml
+2 -2
View File
@@ -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.27.1-debug@sha256:75f369b206785f1480f4a9f8dd81d7f9f3c6793c40fd184358ecd5d7acae17d4
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0-debug@sha256:46f7480f5ea4243ccb6fd2c447ea859e0230c8b3c0e676ee285d4ae6ca344ee3
command:
- /busybox/sh
- -ec
@@ -234,7 +234,7 @@ spec:
mountPath: /data
containers:
- name: synapse
image: ghcr.io/element-hq/synapse:v1.163.2@sha256:c3c21919a322f74d177a43bce8031adc81ef839ddd117a8dd03cfec350e76e02
image: ghcr.io/element-hq/synapse:v1.162.0@sha256:6b84a7bbac36f080b2d2e51e0289cf1b08b349598ea44a558df38d558f2c2311
# 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,9 +5,7 @@
#
# 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. 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).
# a deleted record green for an hour.
#
# `release` is load-bearing — probeSelector matches on it.
apiVersion: monitoring.coreos.com/v1
@@ -27,8 +25,8 @@ spec:
targets:
staticConfig:
static:
- "[fd02:47::1]:53"
- "10.2.47.1:53"
- "[fd00::1]:53"
- "10.2.40.1:53"
# instance is the resolver and identical across these Probes, so the
# queried name has to come from a label.
labels:
@@ -51,8 +49,8 @@ spec:
targets:
staticConfig:
static:
- "[fd02:47::1]:53"
- "10.2.47.1:53"
- "[fd00::1]:53"
- "10.2.40.1:53"
labels:
dns_name: mqtt.renner.uno
---
@@ -73,8 +71,8 @@ spec:
targets:
staticConfig:
static:
- "[fd02:47::1]:53"
- "10.2.47.1:53"
- "[fd00::1]:53"
- "10.2.40.1:53"
labels:
dns_name: ldap.renner.uno
---
@@ -95,8 +93,8 @@ spec:
targets:
staticConfig:
static:
- "[fd02:47::1]:53"
- "10.2.47.1:53"
- "[fd00::1]:53"
- "10.2.40.1:53"
labels:
dns_name: syslog.renner.uno
---
@@ -117,8 +115,8 @@ spec:
targets:
staticConfig:
static:
- "[fd02:47::1]:53"
- "10.2.47.1:53"
- "[fd00::1]:53"
- "10.2.40.1:53"
labels:
dns_name: ftp.paperless.renner.uno
---
@@ -139,7 +137,7 @@ spec:
targets:
staticConfig:
static:
- "[fd02:47::1]:53"
- "10.2.47.1:53"
- "[fd00::1]:53"
- "10.2.40.1:53"
labels:
dns_name: syncthing.paperless.renner.uno
@@ -82,9 +82,9 @@ spec:
#
# The pod regex is the Deployment's name shape
# (mosquitto-<replicaset hash>-<suffix>) and not `mosquitto-.*` on
# purpose: other pods in the namespace start with `mosquitto-`
# (mosquitto-dynsec-test), and a Ready one must not hide a broker
# that is down.
# 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.
expr: |
absent(kube_pod_status_ready{namespace="home-assistant", pod=~"mosquitto-[a-z0-9]{6,10}-[a-z0-9]{5}", condition="true"} == 1)
for: 5m
@@ -137,13 +137,13 @@ tests:
exp_alerts: []
# --- MosquittoDown ------------------------------------------------------
# The broker goes not-Ready at t=10m while the mosquitto-dynsec-test pod is
# Ready. That pod must not mask the broker.
# The broker goes not-Ready at t=10m while a credentials-bootstrap Job pod is
# Ready. The Job 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-dynsec-test-6d9f8b7c5-r4t8w",condition="true"}'
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-credentials-bootstrap-q7w2e",condition="true"}'
values: '1x30'
alert_rule_test:
- eval_time: 14m
@@ -32,9 +32,6 @@ 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.13.1"
version: "92.3.0"
sourceRef:
kind: HelmRepository
name: prometheus-community
@@ -21,13 +21,10 @@ 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 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.
- 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.
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 -
@@ -94,15 +91,15 @@ data:
.setdefault("values", {})
)
old_pub, old_bind = values.get("publish_ip"), values.get("bind_ip")
if (old_pub, old_bind) == (ip, ip):
if (old_pub, old_bind) == (ip, "0.0.0.0"):
print("[reconcile] publish_ip/bind_ip already correct; no change")
return 0
values["publish_ip"] = ip
values["bind_ip"] = ip
values["bind_ip"] = "0.0.0.0"
print(
f"[reconcile] publish_ip {old_pub!r} -> {ip!r}, "
f"bind_ip {old_bind!r} -> {ip!r}"
f"bind_ip {old_bind!r} -> '0.0.0.0'"
)
tmp = SETTINGS + ".reconcile.tmp"
@@ -44,9 +44,7 @@ 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); 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.
# then fails to bind and dies, with no fallback).
# 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
@@ -54,7 +52,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.8 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.7 # {"$imagepolicy": "flux-system:music-assistant"}
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
volumeMounts:
- name: data
@@ -62,33 +60,9 @@ 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.8 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.7 # {"$imagepolicy": "flux-system:music-assistant"}
env:
- name: LOG_LEVEL
value: info
@@ -107,8 +81,6 @@ spec:
volumeMounts:
- name: data
mountPath: /data
- name: pocketcasts-overlay
mountPath: /app/venv/lib/python3.14/site-packages/music_assistant/providers/pocketcasts
livenessProbe:
httpGet:
path: /
@@ -153,11 +125,6 @@ spec:
configMap:
name: music-assistant-net-reconcile
defaultMode: 0555
- name: pocketcasts-overlay
emptyDir: {}
- name: pocketcasts-patch
configMap:
name: music-assistant-pocketcasts-up-next
---
apiVersion: v1
kind: Service
@@ -7,10 +7,3 @@ 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 and bind_ip; players reach the
# stream/sendspin servers on net1. Requires the cni-dhcp-daemon DaemonSet
# in infrastructure/multus.
# 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.
#
# 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
@@ -1,817 +0,0 @@
"""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
)
+2 -2
View File
@@ -93,7 +93,7 @@ spec:
type: RuntimeDefault
containers:
- name: lidarr
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls45 # {"$imagepolicy": "flux-system:lidarr"}
env:
- name: PUID
value: "1000"
@@ -140,7 +140,7 @@ spec:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
- name: reconcile
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls45 # {"$imagepolicy": "flux-system:lidarr"}
command: ["sh", "/scripts/reconcile.sh"]
volumeMounts:
- name: config
+1 -1
View File
@@ -74,7 +74,7 @@ spec:
# alpine/kubectl is the maintained replacement. Pin the tag
# to the cluster's server version so kubectl is in lock-step
# (mismatched skew can break exec streaming).
image: docker.io/alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7
image: docker.io/alpine/kubectl:1.37.1@sha256:b568e8beb6a79aee269c142343078a278b85e7710f1c0e7d8d6b21d1eb566a48
env:
- name: DATASET_TLV
valueFrom:
+1 -1
View File
@@ -47,7 +47,7 @@ spec:
#
# Hence socat is installed at startup and run inside this container.
- name: otbr
image: openthread/otbr:latest@sha256:f3e43ccd86d6da87323873efa0c9b6f92a2a8cc75a804ea50e34c96680363e2e
image: openthread/otbr:latest@sha256:a1ccea58241c2e0c2e16a55590cdd5651336462c3b4fa26e24a59607e155ef72
command:
- bash
- -c
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
type: RuntimeDefault
initContainers:
- name: prepare-dirs
image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command:
- sh
- -c
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
type: RuntimeDefault
containers:
- name: gotenberg
image: gotenberg/gotenberg:8.39.0 # {"$imagepolicy": "flux-system:gotenberg"}
image: gotenberg/gotenberg:8.38.1 # {"$imagepolicy": "flux-system:gotenberg"}
args:
- "gotenberg"
- "--chromium-disable-javascript=true"
+1 -1
View File
@@ -287,7 +287,7 @@ spec:
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
# anchor: bridge-tls-proxy
- name: bridge-tls-proxy
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
args:
- "-d"
- "TCP-LISTEN:11143,fork,reuseaddr,bind=127.0.0.1"
+1 -1
View File
@@ -25,7 +25,7 @@ spec:
type: RuntimeDefault
containers:
- name: redis
image: redis:8.10.3-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
image: redis:8.10.2-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
args:
- redis-server
- --save
+1 -1
View File
@@ -50,7 +50,7 @@ spec:
type: RuntimeDefault
containers:
- name: syncthing
image: syncthing/syncthing:2.1.7@sha256:2b71da7c0d0a91a6ae5285ad079226fecc0a365d08b27720e1f7037b018f7546
image: syncthing/syncthing:2.1.6@sha256:4d0281bdcb4ed9baf503d251d0544ddd856f49fce3f114056257ae172b7fb670
env:
# UI bind: any-interface so the ClusterIP Service can reach
# it. Network exposure is gated by the absence of an Ingress
+1 -1
View File
@@ -59,7 +59,7 @@ spec:
- name: renovate
# Pinned by tag and digest. Renovate updates itself through
# homelab's renovate.json, like any other image here.
image: docker.io/renovate/renovate:44.181.8@sha256:b28b2de1dfd35de7e8198756d7f7cdd84ebc28c3f25e80f5b09e598d3faefb2a
image: docker.io/renovate/renovate:44.151.2@sha256:9ea828b38867af0ca5f269e6c5feace9e74e7f035da150bb16f8c5f70bf2e6e8
env:
- name: RENOVATE_CONFIG_FILE
value: /config/config.json
+1 -1
View File
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: review-bot
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-27-0757 # {"$imagepolicy": "flux-system:review-bot"}
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-22-1854 # {"$imagepolicy": "flux-system:review-bot"}
env:
- name: REPOS
value: konairius/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
# Valkey major version tracks upstream's compose.yml (9.x). Sidekiq
# is the only consumer; a major bump is a separate migration.
- name: valkey
image: valkey/valkey:9.1.3-alpine # {"$imagepolicy": "flux-system:valkey-trmnl"}
image: valkey/valkey:9.1.2-alpine # {"$imagepolicy": "flux-system:valkey-trmnl"}
args:
- valkey-server
- --save
-152
View File
@@ -1,152 +0,0 @@
# The wiki's schedules. The service runs nothing on its own clock; these jobs
# call its API with the `scheduler` key. Run one now:
# kubectl -n ai create job --from=cronjob/wiki-encyclopedia wiki-encyclopedia-now
# Generated once in-cluster, nothing in OpenBao.
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: wiki-scheduler-key
namespace: ai
spec:
length: 64
digits: 16
symbols: 0
allowRepeat: true
noUpper: false
secretKeys:
- Auth__ApiKeys__scheduler
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: wiki-scheduler-key
namespace: ai
spec:
refreshPolicy: CreatedOnce
target:
name: wiki-scheduler-key
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: wiki-scheduler-key
---
# Interval sync; the Gitea push webhook is the fast path.
apiVersion: batch/v1
kind: CronJob
metadata:
name: wiki-sync
namespace: ai
labels:
app.kubernetes.io/name: wiki-sync
spec:
schedule: "*/30 * * * *"
timeZone: Etc/UTC
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
startingDeadlineSeconds: 600
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 1800
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app.kubernetes.io/name: wiki-sync
spec:
restartPolicy: Never
enableServiceLinks: false
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: sync
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
command:
- sh
- -c
- 'curl -fsS --retry 12 --retry-delay 10 --retry-connrefused --retry-max-time 120 --max-time 1700 -X POST -H "X-Api-Key: $API_KEY" http://wiki.ai.svc.cluster.local:8080/api/v1/sync'
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: wiki-scheduler-key
key: Auth__ApiKeys__scheduler
resources:
requests:
cpu: 5m
memory: 16Mi
limits:
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
---
# Plans and writes the house encyclopedia (/house). The endpoint syncs first and
# answers 502 when the run fails or the Anthropic API stopped it.
apiVersion: batch/v1
kind: CronJob
metadata:
name: wiki-encyclopedia
namespace: ai
labels:
app.kubernetes.io/name: wiki-encyclopedia
spec:
schedule: "0 6 * * *"
timeZone: Europe/Berlin
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
startingDeadlineSeconds: 3600
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 11400
ttlSecondsAfterFinished: 604800
template:
metadata:
labels:
app.kubernetes.io/name: wiki-encyclopedia
spec:
restartPolicy: Never
enableServiceLinks: false
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: run
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
command:
- sh
- -c
- 'curl -fsS --max-time 10800 -X POST -H "X-Api-Key: $API_KEY" http://wiki.ai.svc.cluster.local:8080/api/v1/encyclopedia/run'
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: wiki-scheduler-key
key: Auth__ApiKeys__scheduler
resources:
requests:
cpu: 5m
memory: 16Mi
limits:
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
+2 -9
View File
@@ -24,7 +24,7 @@ metadata:
namespace: ai
annotations:
# Keys, tokens and the Anthropic key arrive via envFrom; a rotated Secret rolls the pod.
secret.reloader.stakater.com/reload: wiki-gitea,wiki-api-keys,wiki-scheduler-key,wiki-typesafe,wiki-anthropic,wiki-api-basic-auth
secret.reloader.stakater.com/reload: wiki-gitea,wiki-api-keys,wiki-typesafe,wiki-anthropic,wiki-api-basic-auth
labels:
app.kubernetes.io/name: wiki
spec:
@@ -53,7 +53,7 @@ spec:
type: RuntimeDefault
containers:
- name: wiki
image: gitea.renner.uno/konairius/wiki:build.1793086240 # {"$imagepolicy": "flux-system:wiki"}
image: gitea.renner.uno/konairius/wiki:build.1792419299 # {"$imagepolicy": "flux-system:wiki"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -77,10 +77,6 @@ spec:
value: https://auth.renner.uno/application/o/wiki/
- name: Auth__Oidc__ClientId
value: wiki
- name: Wiki__Encyclopedia__Enabled
value: "true"
- name: Wiki__Encyclopedia__MaxArticles
value: "0"
# wiki-index BasicAuth users (htpasswd), checked by the service for the read API.
- name: Auth__Basic__UsersPath
value: /var/run/secrets/wiki-basic-auth
@@ -89,9 +85,6 @@ spec:
name: wiki-gitea
- secretRef:
name: wiki-api-keys
# The key the wiki-sync and wiki-encyclopedia CronJobs call with (cronjobs.yaml).
- secretRef:
name: wiki-scheduler-key
- secretRef:
name: wiki-typesafe
- secretRef:
-1
View File
@@ -4,4 +4,3 @@ resources:
- externalsecrets.yaml
- deployment.yaml
- ingress.yaml
- cronjobs.yaml
@@ -69,7 +69,7 @@ spec:
# first build of that day — imagePullPolicy: Always does not help,
# because the tag itself resolves stale. Update this when the image
# is rebuilt.
image: gitea.renner.uno/gitea_admin/bird@sha256:65fc93efead1e9b065d2c78e748f6c3c1ad7216e28b757d9fa4e204fa2ffe9a9
image: gitea.renner.uno/gitea_admin/bird@sha256:afd8c3a1fc21a9f2c67af366b3bf77ca9b948f07f66a4977821e4972c8fa6ae4
command: ["/bin/sh", "-c"]
args:
- |
@@ -95,7 +95,7 @@ spec:
mountPath: /etc/bird
containers:
- name: bird
image: gitea.renner.uno/gitea_admin/bird@sha256:65fc93efead1e9b065d2c78e748f6c3c1ad7216e28b757d9fa4e204fa2ffe9a9
image: gitea.renner.uno/gitea_admin/bird@sha256:afd8c3a1fc21a9f2c67af366b3bf77ca9b948f07f66a4977821e4972c8fa6ae4
resources:
requests:
cpu: "50m"
@@ -0,0 +1,74 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns
namespace: kube-system
data:
Corefile: |
# Delegate renner.uno to the in-cluster k8s_gateway resolver. It
# watches Ingress resources and synthesises A/AAAA records that
# point at the ingress-nginx LB, so pods resolving `auth.renner.uno`,
# `audiobooks.renner.uno`, … stay inside the cluster. Names not
# backed by an in-cluster Ingress fall through inside k8s_gateway
# itself to the UDM (10.2.40.1 / fd00::1), which serves the split-horizon
# renner.uno zone, preserving resolution of LXC/VM + physical services.
# (The old Proxmox DNS VIP 10.2.40.53 is DECOMMISSIONED.) 10.96.0.11 is a
# pinned ClusterIP of the k8s-gateway Service — avoids DNS-name
# resolution during CoreDNS startup.
# Cache tuning mirrors k8s-gateway's (issue #393): the UDM's dnsmasq
# refuses connections in bursts, and because the public renner.uno zone
# has a *.renner.uno wildcard CNAME that matches at every depth, a local
# failure never returns NXDOMAIN — it resolves to the WAN proxy instead.
# Correct answers carry TTL 5 (dnsmasq lease records) while the wrong
# wildcard answer carries TTL 300, so one brief blip pins a wrong answer
# for minutes. `success ... 60` raises the 5s TTL to a 60s floor and
# `serve_stale 1h immediate` keeps serving through an upstream outage.
# NOTE: only cache/serve_stale — the `template` plugin is deliberately NOT
# reintroduced here (it broke cluster DNS in 7a09bad5).
renner.uno:53 {
errors
cache 300 {
success 9984 300 60
denial 9984 300 30
serve_stale 1h immediate
}
forward . 10.96.0.11
loop
reload
}
.:53 {
errors
health {
lameduck 5s
}
ready
log . {
class error
}
prometheus :9153
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
forward . /etc/resolv.conf {
max_concurrent 1000
}
# serve_stale mirrors the renner.uno:53 block (issue #393): when the
# upstream resolver browns out, keep answering from expired cache
# instead of SERVFAILing every public name cluster-wide. Seen on
# 2026-07-30 01:22-01:26Z (~1150 SERVFAILs) — it killed a Velero Kopia
# maintenance job, which runs with backoffLimit 0, on the Hetzner S3
# endpoint (issue #439). cluster.local is exempt from the cache above,
# so in-cluster Service records are never served stale.
cache 30 {
disable success cluster.local
disable denial cluster.local
serve_stale 1h immediate
}
loop
reload
loadbalance
}
@@ -1,6 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# CoreDNS itself (Deployment, Corefile) is a Talos bootstrap manifest; only
# its LoadBalancer Service lives here.
resources:
- configmap.yaml
- service.yaml
@@ -85,11 +85,10 @@ spec:
parameters: "0.0.0.0:9153"
serviceType: ClusterIP
service:
# Pinned because the nodes' resolver names it by address
# (ResolverConfig in the Talos site patches); CoreDNS forwards to the
# node, so every name reaches k8s-gateway that way. Chosen from the
# low, sparsely populated end of the service CIDR (kube-dns is
# 10.96.0.10).
# Pinned so the main CoreDNS Corefile can forward renner.uno
# here without depending on DNS-based Service resolution during
# CoreDNS startup (chicken-and-egg). Chosen from the low, sparsely
# populated end of the service CIDR (kube-dns is 10.96.0.10).
clusterIP: 10.96.0.11
ipFamilyPolicy: PreferDualStack
# The site's upstream resolvers (deployment.dnsPolicy/dnsConfig), from the
@@ -71,7 +71,7 @@ spec:
# socket's parent, and refuses to start if the socket file is left
# over from an unclean shutdown (bind: address already in use).
- name: clean-socket
image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command: ["sh", "-c", "rm -f /run/cni/dhcp.sock"]
volumeMounts:
- name: run-cni
@@ -83,7 +83,7 @@ spec:
# Any image with a shell works: the binary executed here is the
# host's, bind-mounted from /opt/cni/bin. Pinned to the same
# alpine tag the sibling installer uses.
image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command:
- /opt/cni/bin/dhcp
- daemon
@@ -87,9 +87,11 @@ spec:
- port: "53"
protocol: UDP
# host/remote-node: kubelet probes + node-local reach (Ceph mons on host net)
# world: image pulls and llama-server's Hugging Face model download
- toEntities:
- host
- remote-node
- world
# ICMP — egress mirror; see ingress block above.
- toEndpoints:
- matchExpressions:
@@ -164,76 +166,3 @@ spec:
- ports:
- port: "8080"
protocol: TCP
---
# Hugging Face model download on cold cache (llama-server, llama-embed, wiki-embed).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: models-egress-huggingface
namespace: ai
spec:
endpointSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- llama-server
- llama-embed
- wiki-embed
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "huggingface.co"
- matchName: "hf.co"
- matchPattern: "*.huggingface.co"
- matchPattern: "*.hf.co"
toPorts:
- ports:
- port: "443"
protocol: TCP
---
# wiki: Anthropic API (distillation) and TypeSafe API (typed judgements).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: wiki-egress-apis
namespace: ai
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: wiki
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "api.anthropic.com"
- matchName: "api.typesafe.ai"
toPorts:
- ports:
- port: "443"
protocol: TCP
@@ -66,9 +66,11 @@ spec:
- port: "53"
protocol: UDP
# host/remote-node: kubelet probes + node-local reach (Ceph mons on host net)
# world: image pulls, OBC S3 endpoint reachable via the cluster's LB IP, migration pipeline API
- toEntities:
- host
- remote-node
- world
# ICMP — egress mirror; see ingress block above.
- toEndpoints:
- matchExpressions:
@@ -92,19 +94,3 @@ spec:
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
---
# audiobookshelf (metadata providers) and libation (Audible): HTTPS to the internet.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: audiobookshelf-egress-https
namespace: audiobookshelf
spec:
endpointSelector: {}
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP

Some files were not shown because too many files have changed in this diff Show More