Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ef130da2e | ||
|
|
2e677483db | ||
|
|
532b853929 | ||
|
|
af7103fd75 | ||
|
|
1984b01e5d | ||
|
|
e02ad7639b | ||
|
|
80f5310653 | ||
|
|
9895b7f4f4 | ||
|
|
c380c7c2b8 | ||
|
|
0f86537bb1 | ||
|
|
31459dac6d | ||
|
|
57c96a9a62 | ||
|
|
ffd24865ee | ||
|
|
439e4b4b83 | ||
|
|
622a4cc593 | ||
|
|
96ea510143 | ||
|
|
60d3acc202 | ||
|
|
cb697e2812 | ||
|
|
1e26d5cdb2 | ||
|
|
fbd3fbd119 | ||
|
|
3c05098780 | ||
|
|
5ecd17d26b | ||
|
|
f29e9f965a | ||
|
|
42a3a31460 | ||
|
|
188784d406 | ||
|
|
6f5c4f7b81 | ||
|
|
76f3d4b6b5 | ||
|
|
67ff176875 | ||
|
|
2ddb9544e6 | ||
|
|
25e70dc555 | ||
|
|
a4b18f44c3 | ||
|
|
49856fe577 | ||
|
|
576611faae | ||
|
|
cc898ec553 | ||
|
|
7d53c99062 | ||
|
|
8ce5ebd8ac | ||
|
|
9258947fef | ||
|
|
1955a11b81 | ||
|
|
da614f83fe | ||
|
|
e5c99655e5 | ||
|
|
f04f449163 | ||
|
|
32d0f0cc7b |
@@ -319,7 +319,7 @@ steps:
|
||||
# writes its stripped mirror under mktemp and never into the workspace.
|
||||
- name: promtool
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: prom/prometheus:v3.15.0@sha256:efd719c99d83b060d9daefdcf00360461adf279f45ef5391f8d111892118753e
|
||||
image: prom/prometheus:v3.15.1@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
|
||||
commands:
|
||||
- sh scripts/ci/promtool-rules.sh
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ steps:
|
||||
# bridge, and the secret guard.
|
||||
- name: hermes-skill-tests
|
||||
depends_on: []
|
||||
image: python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
|
||||
image: python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
|
||||
environment:
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
commands:
|
||||
|
||||
@@ -13,7 +13,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: validate-secrets
|
||||
image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
image: alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
environment:
|
||||
TF_VAR_proxmox_api_url:
|
||||
from_secret: proxmox_api_url
|
||||
|
||||
@@ -6,7 +6,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: go-test
|
||||
image: golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61
|
||||
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
|
||||
commands:
|
||||
- test -z "$(gofmt -l review-bot)"
|
||||
- go -C review-bot vet ./...
|
||||
|
||||
@@ -18,12 +18,12 @@ when:
|
||||
|
||||
steps:
|
||||
- name: iac-scan
|
||||
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
|
||||
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
|
||||
commands:
|
||||
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
|
||||
|
||||
- name: image-scans
|
||||
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
|
||||
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
|
||||
commands:
|
||||
- trivy image --download-db-only
|
||||
- |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# Alpine plus jq. Nothing application-specific — a general-purpose base for any
|
||||
# workload that needs to read JSON from a shell script.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# Alpine plus the openssl CLI. Nothing application-specific — named for what it
|
||||
# is, so anything needing `openssl s_client` / `x509` from a shell script can use
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
RUN apk add --no-cache \
|
||||
ansible \
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
# The unit tests gate the image: the final stage copies the controller out of
|
||||
# this stage, so a failing test fails the build.
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS test
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS test
|
||||
WORKDIR /src
|
||||
COPY controller.py test_controller.py ./
|
||||
RUN python3 -B -m unittest -v
|
||||
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY --from=test /src/controller.py /opt/controller.py
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM hetznercloud/cli:v1.70.1@sha256:795ac09a5ecabd48cc13a8f1c3dc45983c8f75ae1277c70d621719e07f276ccb AS hcloud
|
||||
FROM hetznercloud/cli:v1.70.2@sha256:3b3a8f66323a5754089df7939fd9d3f350127072deed13418f3d3052b4bbeeae AS hcloud
|
||||
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# bird: BGP daemon for the pod-network mesh over pod0. Distro package, no build
|
||||
# step — the image is the binary plus its runtime.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f
|
||||
FROM node:24-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
|
||||
|
||||
# Default to the latest Claude Code. Pinning a fixed version is the wrong
|
||||
# default here: old releases eventually stop working against the API, so a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Cloudflare. The unit tests (no tags, so no envtest) gate the image: the
|
||||
# final stage copies the binary out of this stage, so a failing test fails the
|
||||
# build.
|
||||
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
|
||||
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
@@ -10,7 +10,7 @@ COPY . .
|
||||
RUN go test ./...
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cloudflare-lb-sync ./cmd/cloudflare-lb-sync
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
|
||||
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
|
||||
USER 65532
|
||||
ENTRYPOINT ["/cloudflare-lb-sync"]
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
# amd64 only, matching the ARCH the installer already hardcoded and the cluster's
|
||||
# node architecture. Add a second stage if a different arch is ever needed.
|
||||
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 AS fetch
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a AS fetch
|
||||
|
||||
ARG CNI_PLUGINS_VERSION=v1.9.1
|
||||
ARG CNI_PLUGINS_SHA256=b98f74a0f8522f0a83867178729c1aa70f2158f90c45a2ca8fa791db1c76b303
|
||||
@@ -61,7 +61,7 @@ RUN set -eu; \
|
||||
[ -x "/plugins/$p" ] || { echo "FATAL: /plugins/$p missing or not executable" >&2; exit 1; }; \
|
||||
done
|
||||
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# Alpine rather than scratch: the installer needs a shell to copy the binaries
|
||||
# onto the host. busybox covers cp/mv/chown/ls, so nothing is added.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
# namespace, so can0 is only reachable with network_mode: host. That is also
|
||||
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
|
||||
# no capabilities, so both run unprivileged.
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS build
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
|
||||
|
||||
# Pins live here, in the artifact, rather than in Ansible variables.
|
||||
#
|
||||
@@ -56,7 +56,7 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
|
||||
&& rm -rf /opt/e3oncan/.git
|
||||
|
||||
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
|
||||
|
||||
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
|
||||
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM nousresearch/hermes-agent:v0.22.2@sha256:36099926da316742423544e055d4734777105ffe6341873af5459b957d0c2919
|
||||
FROM nousresearch/hermes-agent:v0.22.5@sha256:9c5561579f3ffdf333401797094ac3a743738d6c25d40f4331c7bf77b694238d
|
||||
|
||||
USER root
|
||||
|
||||
@@ -6,9 +6,9 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/siderolabs/talosctl:v1.13.10@sha256:3116f64c97ddffb53bd3c5bf87c8614d99f04df4326177dbbb7702b5087f0e4d /talosctl /usr/local/bin/talosctl
|
||||
COPY --from=alpine/kubectl:1.36.3@sha256:dae261106d5555008acede256b7102c8a6decde7209198434f5999f051460f57 /usr/local/bin/kubectl /usr/local/bin/kubectl
|
||||
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.5@sha256:704d5529535570a495249b643159c93a9aa88ffca07813c974a54b7ba6edbd86 /usr/local/bin/flux /usr/local/bin/flux
|
||||
COPY --from=ghcr.io/siderolabs/talosctl:v1.14.4@sha256:b3233234327350f20ed194f3fad83d90a64344c1940f839c7b0c0abef50c8c33 /talosctl /usr/local/bin/talosctl
|
||||
COPY --from=alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7 /usr/local/bin/kubectl /usr/local/bin/kubectl
|
||||
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.6@sha256:b1ac18156f227af9a524b842a96f2c20986c7a779ef721df3ba4e4540f449d76 /usr/local/bin/flux /usr/local/bin/flux
|
||||
COPY --from=quay.io/openbao/openbao:2.7.1@sha256:6d2b93856e3fcf7b18ad855a0b51eaba474dc8b79cf554379ea32034797d2acf /usr/bin/bao /usr/local/bin/bao
|
||||
COPY --from=gitea/tea:0.16.0@sha256:af50a2060ce4d8fd76b7ebe75a3d6db4bb0c615c5bc8f2bdfd8ea83207c4752d /usr/bin/tea /usr/local/bin/tea
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
ARG TALOS_VERSION=v1.13.12
|
||||
ARG VELERO_VERSION=v1.16.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. The unit tests
|
||||
# (no tags, so no Docker and no envtest) gate the image: the final stage copies
|
||||
# the binary out of this stage, so a failing test fails the build.
|
||||
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
|
||||
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
@@ -9,7 +9,7 @@ COPY . .
|
||||
RUN go test ./...
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/kuma-sync ./cmd/kuma-sync
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
|
||||
COPY --from=build /out/kuma-sync /kuma-sync
|
||||
USER 65532
|
||||
ENTRYPOINT ["/kuma-sync"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
|
||||
# public label. Standard library only, so the image is the interpreter plus one
|
||||
# file; runs unprivileged as its own user.
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY controller.py /opt/controller.py
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
ARG TOFU_VERSION=1.11.0
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# Everything `bootstrap.sh vmtest` needs, so the rebuild test can run as a Job in
|
||||
# the cluster instead of on a workstation. The workstation has 31G and the test
|
||||
|
||||
@@ -48,7 +48,7 @@ spec:
|
||||
# cert when doing server-side OIDC token exchange. Bundle both
|
||||
# roots into one PEM since NODE_EXTRA_CA_CERTS accepts one file.
|
||||
- name: fetch-le-staging-roots
|
||||
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
|
||||
image: curlimages/curl:8.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -28,7 +28,7 @@ spec:
|
||||
enableServiceLinks: false
|
||||
containers:
|
||||
- name: evcc
|
||||
image: evcc/evcc:0.317.1@sha256:c50cd4f83c22b39722a76a76b8d428b69bf8ab3334209ed9ed24ec0e3c48dde5
|
||||
image: evcc/evcc:0.318.1@sha256:2f8163ab22f04863afd86cd511c09607ba71debd9eebd65511f3d654922a80d1
|
||||
# evcc auto-discovers /etc/evcc.yaml on its default search path;
|
||||
# the file is delivered (with the MQTT password) via the
|
||||
# sops-encrypted evcc-config Secret mounted below.
|
||||
|
||||
@@ -20,7 +20,7 @@ metadata:
|
||||
# services group infrastructure group
|
||||
# Web Service 1 = Authentik Infrastructure 1 = retired (was PBS 2)
|
||||
# Web Service 2 = Gitea Infrastructure 2 = retired (was Proxmox VE)
|
||||
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.40.1)
|
||||
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.47.1)
|
||||
# Web Service 4 = Woodpecker CI Infrastructure 4 = Mosquitto MQTT broker
|
||||
# Web Service 5 = Grafana
|
||||
# Web Service 6 = Home Assistant
|
||||
@@ -290,9 +290,11 @@ data:
|
||||
# a backup server that nothing backs up to reports on nothing. Re-add
|
||||
# alongside the first host that is declared to back up to it.
|
||||
|
||||
# The UDM on the segment pod traffic leaves on (k8s-gateway-site in
|
||||
# talos/patches/site-homelab.yaml).
|
||||
- name: Infrastructure 3
|
||||
group: infrastructure
|
||||
url: 10.2.40.1
|
||||
url: 10.2.47.1
|
||||
interval: 30s
|
||||
dns:
|
||||
query-name: udm.renner.uno
|
||||
|
||||
@@ -115,7 +115,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: retention
|
||||
image: docker.io/library/python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
|
||||
image: docker.io/library/python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
|
||||
command: ["python3", "/app/package-retention.py"]
|
||||
env:
|
||||
# In-cluster Service; the public name hairpins off the ingress
|
||||
|
||||
@@ -88,7 +88,7 @@ spec:
|
||||
# deliver, and the ClusterIP has no backend while HA restarts. The
|
||||
# trap forwards SIGTERM so the pod still terminates promptly.
|
||||
- name: socat-ipv4
|
||||
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
|
||||
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/sh
|
||||
@@ -130,7 +130,7 @@ spec:
|
||||
# depends on the kernel's bindv6only default rather than on anything
|
||||
# stated here, so the roles are made explicit instead of inherited.
|
||||
- name: socat-ipv6
|
||||
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
|
||||
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/sh
|
||||
|
||||
@@ -61,7 +61,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: shelly-manager
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1792387459 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1792997278 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
@@ -177,7 +177,7 @@ spec:
|
||||
# No probes: it only serves the API-key path, and a probe failure would
|
||||
# also pull the pod out of the shelly-manager Service.
|
||||
- name: api-proxy
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b9241c6e7b8e9a862f129d8d4199ab64b10390949a78bdd5603379b32c844083
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
|
||||
env:
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
|
||||
@@ -192,7 +192,7 @@ spec:
|
||||
# substitution to POD_IP so nginx-runtime variables like $host
|
||||
# / $http_x_forwarded_for in the template stay intact.
|
||||
- name: proxy
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:378d0c32f6562f7fd9629170797f57d2b953aa09be983b3daaf4809bb039ca00
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
|
||||
env:
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
@@ -236,7 +236,7 @@ spec:
|
||||
# readiness probe on purpose: a broker outage must not take HA out of
|
||||
# its Service endpoints.
|
||||
- name: mqtt-tls
|
||||
image: ghostunnel/ghostunnel:v1.11.4@sha256:e5248233863aadd36383a11c92360f2981141881ef05e38360a6ada8630d36b1
|
||||
image: ghostunnel/ghostunnel:v1.11.5@sha256:65c4e34ec0ee0f30ea1f72b7411ec70a91c32fc50f067a8466ad2adb1e7420f2
|
||||
args:
|
||||
- client
|
||||
- --listen=127.0.0.1:11883
|
||||
@@ -275,7 +275,7 @@ spec:
|
||||
# any LAN client can edit, matching the user's trust-the-network
|
||||
# model. Runs as root so its writes match HA's root-owned files.
|
||||
- name: code-server
|
||||
image: codercom/code-server:4.141.0@sha256:72ab24b5680ee597dd647b30d82625752f40109b5f61bef90ffbf1ea1f02dbb8
|
||||
image: codercom/code-server:4.143.1@sha256:377b66bec5645510ed52549bb00e648c273edc604d230a3f90fd2b2433afcb97
|
||||
args:
|
||||
- "--auth=none"
|
||||
- "--bind-addr=0.0.0.0:8080"
|
||||
@@ -337,7 +337,7 @@ spec:
|
||||
# dedicated long-lived token.
|
||||
# Runs as root to match HA's root-owned /config files. See config-sync.sh.
|
||||
- name: config-sync
|
||||
image: alpine/git:2.54.0@sha256:03228d7ded483b0b3e55e1de7fa660d70d3e1bb432fa7021d29e192640de11aa
|
||||
image: alpine/git:2.54.0@sha256:e8ceedc73bd80992ae8d58a7d24c58b8322146c354f1d2e81a97db4452068ec0
|
||||
command: ["/bin/sh", "/scripts/sync.sh"]
|
||||
env:
|
||||
- name: SYNC_BRANCH
|
||||
|
||||
@@ -36,7 +36,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: esphome
|
||||
image: ghcr.io/esphome/esphome:2026.9.1@sha256:776a845d508909bf426d2ee7bdd81796361ebe6a7f091807ce1d6d0b48985413
|
||||
image: ghcr.io/esphome/esphome:2026.10.0@sha256:1dff19a59070cdb523b76576cdc74d200f862d43c1407809088855920cf57145
|
||||
imagePullPolicy: IfNotPresent
|
||||
# Default entrypoint runs the dashboard (Device Builder) against the
|
||||
# mounted /config — no command override needed.
|
||||
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
# Pinned to 7.4-alpine to match the LXC 225 compose exactly.
|
||||
# Any redis major-version bump is a separate migration.
|
||||
- name: redis
|
||||
image: redis:7.4.11-alpine # {"$imagepolicy": "flux-system:redis-immich"}
|
||||
image: redis:7.4.12-alpine # {"$imagepolicy": "flux-system:redis-immich"}
|
||||
args:
|
||||
- redis-server
|
||||
- --save
|
||||
|
||||
@@ -66,7 +66,7 @@ spec:
|
||||
# The library PVC is populated by the seed phase before this
|
||||
# Deployment reconciles, so no rsync init is needed here.
|
||||
- name: fetch-le-staging-roots
|
||||
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
|
||||
image: curlimages/curl:8.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -57,7 +57,7 @@ spec:
|
||||
runAsNonRoot: false
|
||||
initContainers:
|
||||
- name: wait-for-influxd
|
||||
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
|
||||
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
|
||||
env:
|
||||
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
|
||||
- { name: INFLUX_ORG, value: renner }
|
||||
@@ -80,7 +80,7 @@ spec:
|
||||
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
|
||||
containers:
|
||||
- name: create
|
||||
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
|
||||
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
|
||||
env:
|
||||
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
|
||||
- { name: INFLUX_ORG, value: renner }
|
||||
|
||||
@@ -88,7 +88,7 @@ spec:
|
||||
# state). Two-stage so we don't try to create an auth before
|
||||
# the chart's `influx setup` has finished.
|
||||
- name: wait-for-influxd
|
||||
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
|
||||
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
|
||||
env:
|
||||
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
|
||||
- { name: INFLUX_ORG, value: renner }
|
||||
@@ -111,7 +111,7 @@ spec:
|
||||
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
|
||||
containers:
|
||||
- name: create
|
||||
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
|
||||
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
|
||||
env:
|
||||
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
|
||||
- { name: INFLUX_ORG, value: renner }
|
||||
|
||||
@@ -55,7 +55,7 @@ spec:
|
||||
repository: influxdb
|
||||
# Was pinned to VM 108's 2.7.4 for the one-time `influx restore`; that
|
||||
# migration is done. No downgrade below 2.9 without `influxd downgrade`.
|
||||
tag: "2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac"
|
||||
tag: "2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2"
|
||||
# Single replica — InfluxDB OSS doesn't cluster. RWO Ceph-block + Velero
|
||||
# backups (pending) is the durability story.
|
||||
persistence:
|
||||
|
||||
@@ -72,7 +72,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: element-web
|
||||
image: ghcr.io/element-hq/element-web:v1.12.30@sha256:3e7dbd4424de9e11c812bc740acf2725b2abe66ea0d0802023b56fd85d3fa09a
|
||||
image: ghcr.io/element-hq/element-web:v1.12.32@sha256:f8700633d4336258af7a05b53865a4f479e0d628cf2d9ca83d28b5939314d4ce
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -137,7 +137,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: mas
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0@sha256:e089f1048a1d4a9a492ed17b9fe759100f1bd619407b001f5927928d88b780c4
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1@sha256:02712e68c90f3743fedbd5e3343517481b2b4f246a9fd9e2f050dc2553033847
|
||||
# The server applies database migrations and syncs the upstream
|
||||
# providers from the config on start.
|
||||
args: ["server", "--config", "/config/config.yaml"]
|
||||
|
||||
@@ -124,7 +124,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: livekit
|
||||
image: livekit/livekit-server:v1.13.10@sha256:89ca9ce0791e18d6e03bf6e2c49ceb27e1c754eae8bd50c989386c1a5963791a
|
||||
image: livekit/livekit-server:v1.13.11@sha256:25cd902de7cacbd5f1f779f6a6ac1eae20ba8f56b0935934ce6b0ab80d1e3d20
|
||||
args:
|
||||
- --config
|
||||
- /config/livekit.yaml
|
||||
|
||||
@@ -179,7 +179,7 @@ spec:
|
||||
# first. The marker on the data PVC makes later starts skip it.
|
||||
# Debug image: the plain one has no shell for the marker check.
|
||||
- name: migrate-to-mas
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0-debug@sha256:46f7480f5ea4243ccb6fd2c447ea859e0230c8b3c0e676ee285d4ae6ca344ee3
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1-debug@sha256:75f369b206785f1480f4a9f8dd81d7f9f3c6793c40fd184358ecd5d7acae17d4
|
||||
command:
|
||||
- /busybox/sh
|
||||
- -ec
|
||||
@@ -234,7 +234,7 @@ spec:
|
||||
mountPath: /data
|
||||
containers:
|
||||
- name: synapse
|
||||
image: ghcr.io/element-hq/synapse:v1.162.0@sha256:6b84a7bbac36f080b2d2e51e0289cf1b08b349598ea44a558df38d558f2c2311
|
||||
image: ghcr.io/element-hq/synapse:v1.163.2@sha256:c3c21919a322f74d177a43bce8031adc81ef839ddd117a8dd03cfec350e76e02
|
||||
# Bypass /start.py: it expects to run as root and template a config
|
||||
# from env. Default mode is generate-missing-and-run, which creates
|
||||
# the signing key on first start.
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
#
|
||||
# The UDM is queried directly rather than through the cluster resolver:
|
||||
# k8s-gateway caches these with `serve_stale 1h immediate`, which would keep
|
||||
# a deleted record green for an hour.
|
||||
# a deleted record green for an hour. The addresses are the UDM on the segment
|
||||
# pod traffic leaves on, the same upstream k8s-gateway uses (k8s-gateway-site
|
||||
# in talos/patches/site-homelab.yaml).
|
||||
#
|
||||
# `release` is load-bearing — probeSelector matches on it.
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
@@ -25,8 +27,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
# instance is the resolver and identical across these Probes, so the
|
||||
# queried name has to come from a label.
|
||||
labels:
|
||||
@@ -49,8 +51,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: mqtt.renner.uno
|
||||
---
|
||||
@@ -71,8 +73,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: ldap.renner.uno
|
||||
---
|
||||
@@ -93,8 +95,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: syslog.renner.uno
|
||||
---
|
||||
@@ -115,8 +117,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: ftp.paperless.renner.uno
|
||||
---
|
||||
@@ -137,7 +139,7 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: syncthing.paperless.renner.uno
|
||||
|
||||
@@ -32,6 +32,9 @@ spec:
|
||||
deploymentMode: SingleBinary
|
||||
loki:
|
||||
auth_enabled: false
|
||||
# No usage reports to stats.grafana.org (egress is not allowed anyway).
|
||||
analytics:
|
||||
reporting_enabled: false
|
||||
commonConfig:
|
||||
replication_factor: 1
|
||||
storage:
|
||||
|
||||
@@ -17,7 +17,7 @@ spec:
|
||||
chart:
|
||||
spec:
|
||||
chart: kube-prometheus-stack
|
||||
version: "92.3.0"
|
||||
version: "92.13.1"
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: prometheus-community
|
||||
|
||||
@@ -21,10 +21,13 @@ data:
|
||||
the pod's default route — which players on the IoT VLAN cannot route to.
|
||||
The v30iot address lives on the net1 macvlan child (see
|
||||
network-attachment.yaml).
|
||||
- bind_ip must be 0.0.0.0. The Sendspin server (aiosendspin, port 8927) has
|
||||
NO all-interfaces fallback: if bind_ip is a specific IP that is not local
|
||||
it fails to bind and the server never starts, and its proxy then targets
|
||||
that dead IP instead of 127.0.0.1. Binding 0.0.0.0 avoids both.
|
||||
- bind_ip must be the same live v30iot address. aiosendspin starts its
|
||||
zeroconf on [bind_ip], or on InterfaceChoice.Default for 0.0.0.0; Default
|
||||
sends mDNS queries via the main-table default route (eth0, net1 routes
|
||||
live in the sbr table), so MA never queries the IoT VLAN for
|
||||
_sendspin._tcp and Voice PEs are only rediscovered when they announce
|
||||
on their own. The Sendspin server has no all-interfaces bind fallback, so
|
||||
bind_ip must be local at start; it is, because it is read from net1 here.
|
||||
|
||||
The v30iot IP is DHCP and node-dependent (MA is not node-pinned), so a stored
|
||||
IP is a stale assumption the moment MA reschedules or the lease changes -
|
||||
@@ -91,15 +94,15 @@ data:
|
||||
.setdefault("values", {})
|
||||
)
|
||||
old_pub, old_bind = values.get("publish_ip"), values.get("bind_ip")
|
||||
if (old_pub, old_bind) == (ip, "0.0.0.0"):
|
||||
if (old_pub, old_bind) == (ip, ip):
|
||||
print("[reconcile] publish_ip/bind_ip already correct; no change")
|
||||
return 0
|
||||
|
||||
values["publish_ip"] = ip
|
||||
values["bind_ip"] = "0.0.0.0"
|
||||
values["bind_ip"] = ip
|
||||
print(
|
||||
f"[reconcile] publish_ip {old_pub!r} -> {ip!r}, "
|
||||
f"bind_ip {old_bind!r} -> '0.0.0.0'"
|
||||
f"bind_ip {old_bind!r} -> {ip!r}"
|
||||
)
|
||||
|
||||
tmp = SETTINGS + ".reconcile.tmp"
|
||||
|
||||
@@ -44,7 +44,9 @@ spec:
|
||||
# which IoT players can't reach; and
|
||||
# - net1 is DHCP and MA is not node-pinned, so any hard-coded IP goes
|
||||
# stale on the next reschedule or lease change (the Sendspin server
|
||||
# then fails to bind and dies, with no fallback).
|
||||
# then fails to bind and dies, with no fallback); and
|
||||
# - a 0.0.0.0 bind_ip makes Sendspin's mDNS browse query via eth0, so
|
||||
# Voice PEs on the IoT VLAN are not rediscovered after a restart.
|
||||
# So we recompute the v30iot IP from the live interface every boot and
|
||||
# write it into /data/settings.json before MA reads it. Multus attaches
|
||||
# net1 (with its DHCP lease) before any container runs, so this sees the
|
||||
@@ -60,6 +62,30 @@ 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"}
|
||||
@@ -81,6 +107,8 @@ spec:
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: pocketcasts-overlay
|
||||
mountPath: /app/venv/lib/python3.14/site-packages/music_assistant/providers/pocketcasts
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
@@ -125,6 +153,11 @@ spec:
|
||||
configMap:
|
||||
name: music-assistant-net-reconcile
|
||||
defaultMode: 0555
|
||||
- name: pocketcasts-overlay
|
||||
emptyDir: {}
|
||||
- name: pocketcasts-patch
|
||||
configMap:
|
||||
name: music-assistant-pocketcasts-up-next
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
@@ -7,3 +7,10 @@ resources:
|
||||
- deployment.yaml
|
||||
- ingress.yaml
|
||||
- kumamonitors.yaml
|
||||
configMapGenerator:
|
||||
# Pocket Casts provider with the Up Next playlist, applied by the
|
||||
# pocketcasts-overlay initContainer. Hashed name: an edit rolls the pod.
|
||||
- name: music-assistant-pocketcasts-up-next
|
||||
namespace: music-assistant
|
||||
files:
|
||||
- __init__.py=pocketcasts-up-next/__init__.py
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
# initContainer (configmap-net-reconcile.yaml) discovers net1's leased
|
||||
# address the same way it used to discover the host's ens19 address —
|
||||
# scanning the pod's own interfaces for a 10.2.30.0/24 address — and
|
||||
# writes it as MA's publish_ip. bind_ip stays 0.0.0.0, which now binds
|
||||
# eth0 (pod) + net1; players reach the stream/slimproto/sendspin servers
|
||||
# on net1. Requires the cni-dhcp-daemon DaemonSet in infrastructure/multus.
|
||||
# writes it as MA's publish_ip and bind_ip; players reach the
|
||||
# stream/sendspin servers on net1. Requires the cni-dhcp-daemon DaemonSet
|
||||
# in infrastructure/multus.
|
||||
#
|
||||
# Mirrors matter-server-iot; the only difference is MA does not speak
|
||||
# Thread, so the matter NAD's accept_ra_rt_info_max_plen tuning (for the
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
"""Pocket Casts music provider for Music Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from music_assistant_models.enums import (
|
||||
ContentType,
|
||||
ImageType,
|
||||
MediaType,
|
||||
ProviderFeature,
|
||||
StreamType,
|
||||
)
|
||||
from music_assistant_models.errors import (
|
||||
LoginFailed,
|
||||
MediaNotFoundError,
|
||||
ProviderUnavailableError,
|
||||
ResourceTemporarilyUnavailable,
|
||||
RetriesExhausted,
|
||||
)
|
||||
from music_assistant_models.media_items import (
|
||||
AudioFormat,
|
||||
BrowseFolder,
|
||||
ItemMapping,
|
||||
MediaItemImage,
|
||||
MediaItemMetadata,
|
||||
MediaItemType,
|
||||
Playlist,
|
||||
Podcast,
|
||||
PodcastEpisode,
|
||||
ProviderMapping,
|
||||
SearchResults,
|
||||
UniqueList,
|
||||
)
|
||||
from music_assistant_models.streamdetails import StreamDetails
|
||||
|
||||
from music_assistant import MusicAssistant
|
||||
from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
|
||||
from music_assistant.controllers.cache import use_cache
|
||||
from music_assistant.helpers.podcast_parsers import rank_episodes_by_date
|
||||
from music_assistant.models.music_provider import MusicProvider
|
||||
|
||||
from .api_client import PocketCastsClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
|
||||
from music_assistant_models.provider import ProviderManifest
|
||||
|
||||
from music_assistant.models import ProviderInstanceType
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FULLY_PLAYED_THRESHOLD = 0.9
|
||||
SPECIAL_FOLDERS = ("up_next", "new_releases", "in_progress", "starred", "history")
|
||||
UP_NEXT_PLAYLIST_ID = "up_next"
|
||||
|
||||
SUPPORTED_FEATURES = {
|
||||
ProviderFeature.LIBRARY_PODCASTS,
|
||||
ProviderFeature.LIBRARY_PLAYLISTS,
|
||||
ProviderFeature.BROWSE,
|
||||
ProviderFeature.SEARCH,
|
||||
ProviderFeature.LIBRARY_PODCASTS_EDIT,
|
||||
}
|
||||
|
||||
BROWSE_FOLDER_ICONS: dict[str, str] = {
|
||||
"up_next": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
|
||||
"bD0iIzhGOTdBNCIgZD0iTTMgMi45NzE1OUMzIDIuNTY0OSAzLjQ1OTY4IDIuMzI4MzQgMy43OTA2Mi"
|
||||
"AyLjU2NDcyTDkuNDMwMzkgNi41OTMxM0M5LjcwOTU2IDYuNzkyNTQgOS43MDk1NiA3LjIwNzQ1IDku"
|
||||
"NDMwMzkgNy40MDY4NkwzLjc5MDYyIDExLjQzNTNDMy40NTk2OSAxMS42NzE2IDMgMTEuNDM1MSAzID"
|
||||
"ExLjAyODRWMi45NzE1OVoiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIG9wYWNpdHk9IjAuNS"
|
||||
"IgZD0iTTEyIDdDMTIgNi40NDc3MiAxMi40NDc3IDYgMTMgNkgyMUMyMS41NTIzIDYgMjIgNi40NDc3"
|
||||
"MiAyMiA3QzIyIDcuNTUyMjggMjEuNTUyMyA4IDIxIDhIMTNDMTIuNDQ3NyA4IDEyIDcuNTUyMjggMT"
|
||||
"IgN1pNOSAxMkM5IDExLjQ0NzcgOS40NDc3MiAxMSAxMCAxMUgyMUMyMS41NTIzIDExIDIyIDExLjQ0Nz"
|
||||
"cgMjIgMTJDMjIgMTIuNTUyMyAyMS41NTIzIDEzIDIxIDEzSDEwQzkuNDQ3NzIgMTMgOSAxMi41NTIz"
|
||||
"IDkgMTJaTTEwIDE2QzkuNDQ3NzIgMTYgOSAxNi40NDc3IDkgMTdDOSAxNy41NTIzIDkuNDQ3NzIgMT"
|
||||
"ggMTAgMThIMjFDMjEuNTUyMyAxOCAyMiAxNy41NTIzIDIyIDE3QzIyIDE2LjQ0NzcgMjEuNTUyMyAx"
|
||||
"NiAyMSAxNkgxMFoiLz4KPC9zdmc+Cg=="
|
||||
),
|
||||
"new_releases": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPGcgZmlsbD0i"
|
||||
"IzhGOTdBNCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwgMSkiPgogICAgICAgIDxwYXRoIGQ9Ik0xNC"
|
||||
"43OTM1IDQuNTU3MzVMMTUuNzc0MSAwLjc4OTIwOUMxNS45Njg4IDAuMDQxMDUwOSAxNy4wMzExIDAu"
|
||||
"MDQxMDcxOSAxNy4yMjU4IDAuNzg5MjM3TDE4LjIwNjIgNC41NTczMkMxOC4yNzcgNC44MjkxNSAxOC"
|
||||
"40OTM3IDUuMDM4NjggMTguNzY3NyA1LjEwMDIzTDIxLjc0MTkgNS43NjgyM0MyMi41MjI4IDUuOTQz"
|
||||
"NjIgMjIuNTIyOCA3LjA1NjM5IDIxLjc0MTkgNy4yMzE3N0wxOC43Njc3IDcuODk5NzdDMTguNDkzNy"
|
||||
"A3Ljk2MTMyIDE4LjI3NyA4LjE3MDg1IDE4LjIwNjIgOC40NDI2OEwxNy4yMjU4IDEyLjIxMDhDMTcu"
|
||||
"MDMxMSAxMi45NTg5IDE1Ljk2ODggMTIuOTU4OSAxNS43NzQxIDEyLjIxMDhMMTQuNzkzNSA4LjQ0Mj"
|
||||
"Y1QzE0LjcyMjcgOC4xNzA4MyAxNC41MDYxIDcuOTYxMzIgMTQuMjMyIDcuODk5NzdMMTEuMjU4IDcu"
|
||||
"MjMxNzdDMTAuNDc3MSA3LjA1NjM5IDEwLjQ3NzEgNS45NDM2MiAxMS4yNTggNS43NjgyNEwxNC4yMz"
|
||||
"IgNS4xMDAyM0MxNC41MDYxIDUuMDM4NjggMTQuNzIyNyA0LjgyOTE3IDE0Ljc5MzUgNC41NTczNVoi"
|
||||
"Lz4KICAgICAgICA8cGF0aCBvcGFjaXR5PSIwLjgiIGQ9Ik01LjI5OTQgOS4yNjgzTDYuMDMwNjYgNy"
|
||||
"4yNzc2M0M2LjE5MTEyIDYuODQwODUgNi44MDg4OCA2Ljg0MDg1IDYuOTY5MzQgNy4yNzc2M0w3Ljcw"
|
||||
"MDYgOS4yNjgzQzcuNzU0MjUgOS40MTQzNSA3Ljg3Mjg0IDkuNTI3MSA4LjAyMTQxIDkuNTczMzJMOS"
|
||||
"40NjU0MSAxMC4wMjI2QzkuOTM0MDMgMTAuMTY4MyA5LjkzNDAzIDEwLjgzMTYgOS40NjU0MSAxMC45"
|
||||
"Nzc0TDguMDIxNCAxMS40MjY3QzcuODcyODMgMTEuNDcyOSA3Ljc1NDI1IDExLjU4NTYgNy43MDA2ID"
|
||||
"ExLjczMTdMNi45NjkzMyAxMy43MjI0QzYuODA4ODggMTQuMTU5MiA2LjE5MTEyIDE0LjE1OTIgNi4w"
|
||||
"MzA2NiAxMy43MjI0TDUuMjk5NCAxMS43MzE3QzUuMjQ1NzUgMTEuNTg1NiA1LjEyNzE3IDExLjQ3Mj"
|
||||
"kgNC45Nzg2IDExLjQyNjdMMy41MzQ1OSAxMC45Nzc0QzMuMDY1OTcgMTAuODMxNiAzLjA2NTk3IDEw"
|
||||
"LjE2ODMgMy41MzQ1OSAxMC4wMjI2TDQuOTc4NTkgOS41NzMzMkM1LjEyNzE2IDkuNTI3MSA1LjI0NT"
|
||||
"c1IDkuNDE0MzUgNS4yOTk0IDkuMjY4M1oiLz4KICAgICAgICA8cGF0aCBvcGFjaXR5PSIwLjYiIGQ9"
|
||||
"Ik0xMC42ODgyIDE2LjAxNEwxMS41MjQ3IDEzLjQ1NDNDMTEuNjc0OSAxMi45OTQ3IDEyLjMyNTEgMT"
|
||||
"IuOTk0NyAxMi40NzUzIDEzLjQ1NDNMMTMuMzExOCAxNi4wMTRDMTMuMzYwNCAxNi4xNjI3IDEzLjQ3"
|
||||
"NTcgMTYuMjggMTMuNjIzNSAxNi4zMzEyTDE1LjYzNSAxNy4wMjc1QzE2LjA4MzYgMTcuMTgyOCAxNi"
|
||||
"4wODM2IDE3LjgxNzIgMTUuNjM1IDE3Ljk3MjVMMTMuNjIzNSAxOC42Njg4QzEzLjQ3NTcgMTguNzIg"
|
||||
"MTMuMzYwNCAxOC44MzczIDEzLjMxMTggMTguOTg2TDEyLjQ3NTMgMjEuNTQ1N0MxMi4zMjUxIDIyLj"
|
||||
"AwNTMgMTEuNjc0OSAyMi4wMDUzIDExLjUyNDcgMjEuNTQ1N0wxMC42ODgyIDE4Ljk4NkMxMC42Mzk2"
|
||||
"IDE4LjgzNzMgMTAuNTI0MyAxOC43MiAxMC4zNzY1IDE4LjY2ODhMOC4zNjQ5OCAxNy45NzI1QzcuOT"
|
||||
"E2MzggMTcuODE3MiA3LjkxNjM5IDE3LjE4MjggOC4zNjQ5OCAxNy4wMjc1TDEwLjM3NjUgMTYuMzMx"
|
||||
"MkMxMC41MjQzIDE2LjI4IDEwLjYzOTYgMTYuMTYyNyAxMC42ODgyIDE2LjAxNFoiLz4KICAgIDwvZz"
|
||||
"4KPC9zdmc+Cg=="
|
||||
),
|
||||
"in_progress": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
|
||||
"bD0iIzhGOTdBNCIgb3BhY2l0eT0iMC41IiBkPSJNNCAxMkM0IDE2LjQxODMgNy41ODE3MiAyMCAxMi"
|
||||
"AyMEMxNi40MTgzIDIwIDIwIDE2LjQxODMgMjAgMTJDMjAgNy41ODE3MiAxNi40MTgzIDQgMTIgNEM3"
|
||||
"LjU4MTcyIDQgNCA3LjU4MTcyIDQgMTJaTTE4IDEyQzE4IDE1LjMxMzcgMTUuMzEzNyAxOCAxMiAxOE"
|
||||
"M4LjY4NjI5IDE4IDYgMTUuMzEzNyA2IDEyQzYgOC42ODYyOSA4LjY4NjI5IDYgMTIgNkMxNS4zMTM3"
|
||||
"IDYgMTggOC42ODYyOSAxOCAxMloiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIGQ9Ik0xNi45Mj"
|
||||
"UzIDE4LjMwNDFDMjAuNDA2OSAxNS41ODM5IDIxLjAyNDMgMTAuNTU2NCAxOC4zMDQxIDcuMDc0NzJD"
|
||||
"MTYuODI2OSA1LjE4Mzk0IDE0LjYxNjcgNC4wODMyNyAxMi4yNjUgNC4wMDQyNEMxMS43MTMxIDMuOT"
|
||||
"g1NjkgMTEuMjUwNiA0LjQxODExIDExLjIzMiA0Ljk3MDA4QzExLjIxMzUgNS41MjIwNiAxMS42NDU5"
|
||||
"IDUuOTg0NTYgMTIuMTk3OSA2LjAwMzExQzEzLjk2MzkgNi4wNjI0NiAxNS42MTkzIDYuODg2ODYgMT"
|
||||
"YuNzI4MSA4LjMwNjA1QzE4Ljc2ODIgMTAuOTE3MyAxOC4zMDUyIDE0LjY4OCAxNS42OTQgMTYuNzI4"
|
||||
"MUMxNS4yNTg4IDE3LjA2ODEgMTUuMTgxNiAxNy42OTY1IDE1LjUyMTYgMTguMTMxOEMxNS44NjE2ID"
|
||||
"E4LjU2NyAxNi40OTAxIDE4LjY0NDEgMTYuOTI1MyAxOC4zMDQxWiIvPgo8L3N2Zz4K"
|
||||
),
|
||||
"starred": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
|
||||
"bD0iIzhGOTdBNCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMSwgLTAuNSkiIGQ9Ik0xMS41ODMxIDE3Lj"
|
||||
"Q3NzZMOC4wNzM4NCAxOS4yOTMzQzcuMDk0MTMgMTkuODAwMiA2LjQ0NjgxIDE5LjMxOTcgNi42MjQ2"
|
||||
"NiAxOC4yNDA0TDcuMjY3MDcgMTQuMzQxOEw0LjQ1NTgxIDExLjU2NTRDMy42NzA5NyAxMC43OTAzID"
|
||||
"MuOTI3OTEgMTAuMDI2MiA1LjAwOTM1IDkuODYxNzdMOC45MTU2NSA5LjI2ODAxTDEwLjY4NzUgNS43"
|
||||
"MzYzOEMxMS4xODIxIDQuNzUwNDIgMTEuOTg4MiA0Ljc1ODY3IDEyLjQ3ODggNS43MzYzOEwxNC4yNT"
|
||||
"A2IDkuMjY4MDFMMTguMTU2OSA5Ljg2MTc3QzE5LjI0NzQgMTAuMDI3NSAxOS40ODg3IDEwLjc5Njgg"
|
||||
"MTguNzEwNCAxMS41NjU0TDE1Ljg5OTIgMTQuMzQxOEwxNi41NDE2IDE4LjI0MDRDMTYuNzIwOSAxOS"
|
||||
"4zMjg4IDE2LjA2MzkgMTkuNzk2IDE1LjA5MjQgMTkuMjkzM0wxMS41ODMxIDE3LjQ3NzZaIi8+Cjwv"
|
||||
"c3ZnPgo="
|
||||
),
|
||||
"history": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
|
||||
"bD0iIzhGOTdBNCIgb3BhY2l0eT0iMC41IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMC41IDEzQz"
|
||||
"EwLjUgMTMuNTUyMyAxMC45NDc3IDE0IDExLjUgMTRIMTQuNUMxNS4wNTIzIDE0IDE1LjUgMTMuNTUy"
|
||||
"MyAxNS41IDEzQzE1LjUgMTIuNDQ3NyAxNS4wNTIzIDEyIDE0LjUgMTJIMTIuNUwxMi41IDEwQzEyLj"
|
||||
"UgOS40NDc3MiAxMi4wNTIzIDkgMTEuNSA5QzEwLjk0NzcgOSAxMC41IDkuNDQ3NzIgMTAuNSAxMFYx"
|
||||
"M1oiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1y"
|
||||
"dWxlPSJldmVub2RkIiBkPSJNMTIgMThDMTUuMzEzNyAxOCAxOCAxNS4zMTM3IDE4IDEyQzE4IDguNj"
|
||||
"g2MjkgMTUuMzEzNyA2IDEyIDZDOC42ODYyOSA2IDYgOC42ODYyOSA2IDEyQzYgMTUuMzEzNyA4LjY4"
|
||||
"NjI5IDE4IDEyIDE4Wk0xMiAyMEMxNi40MTgzIDIwIDIwIDE2LjQxODMgMjAgMTJDMjAgNy41ODE3Mi"
|
||||
"AxNi40MTgzIDQgMTIgNEM3LjU4MTcyIDQgNCA3LjU4MTcyIDQgMTJDNCAxNi40MTgzIDcuNTgxNzIg"
|
||||
"MjAgMTIgMjBaIi8+Cjwvc3ZnPgo="
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def setup(
|
||||
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
|
||||
) -> ProviderInstanceType:
|
||||
"""Initialize provider(instance) with given configuration."""
|
||||
return PocketCastsProvider(mass, manifest, config, SUPPORTED_FEATURES)
|
||||
|
||||
|
||||
class PocketCastsProvider(MusicProvider):
|
||||
"""Provider for Pocket Casts podcast service."""
|
||||
|
||||
_client: PocketCastsClient
|
||||
# episode uuids already mirrored to Pocket Casts Up Next/history this session, keyed as a
|
||||
# set since multi-room playback can have several episodes in progress on one instance
|
||||
_announced_episodes: set[str]
|
||||
|
||||
async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
|
||||
"""Return Config entries to configure this provider."""
|
||||
return ()
|
||||
|
||||
async def handle_async_init(self) -> None:
|
||||
"""Handle async initialization of the provider."""
|
||||
email = self.get_setup_value(CONF_USERNAME)
|
||||
password = self.get_setup_value(CONF_PASSWORD)
|
||||
if not email or not password:
|
||||
raise LoginFailed("Email and password are required for Pocket Casts")
|
||||
self._announced_episodes = set()
|
||||
self._client = PocketCastsClient(self.mass.http_session, self.logger)
|
||||
await self._client.login(str(email), str(password))
|
||||
|
||||
async def get_library_podcasts(self) -> AsyncGenerator[Podcast]:
|
||||
"""Get all podcasts from the user's library."""
|
||||
for podcast_data in await self._client.get_subscribed_podcasts():
|
||||
yield self._convert_podcast(podcast_data)
|
||||
|
||||
async def library_add(self, item: MediaItemType) -> bool:
|
||||
"""
|
||||
Subscribe to a podcast.
|
||||
|
||||
:param item: The media item to add to the library.
|
||||
"""
|
||||
if not isinstance(item, Podcast):
|
||||
return await super().library_add(item)
|
||||
await self._client.subscribe_podcast(item.item_id)
|
||||
return True
|
||||
|
||||
async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
|
||||
"""
|
||||
Unsubscribe from a podcast.
|
||||
|
||||
:param prov_item_id: The provider item ID to remove from the library.
|
||||
:param media_type: The media type of the item.
|
||||
"""
|
||||
if media_type != MediaType.PODCAST:
|
||||
return await super().library_remove(prov_item_id, media_type)
|
||||
await self._client.unsubscribe_podcast(prov_item_id)
|
||||
return True
|
||||
|
||||
@use_cache(3600 * 24)
|
||||
async def get_podcast(self, prov_podcast_id: str) -> Podcast:
|
||||
"""
|
||||
Get full details for a podcast.
|
||||
|
||||
:param prov_podcast_id: The provider podcast id.
|
||||
"""
|
||||
podcast_data = await self._client.get_podcast(prov_podcast_id)
|
||||
if not podcast_data:
|
||||
raise MediaNotFoundError(
|
||||
f"podcast://{prov_podcast_id} not found on provider {self.domain}"
|
||||
)
|
||||
return self._convert_podcast(podcast_data)
|
||||
|
||||
async def get_podcast_episodes(self, prov_podcast_id: str) -> AsyncGenerator[PodcastEpisode]:
|
||||
"""
|
||||
Get all episodes for a podcast, enriched with playback status.
|
||||
|
||||
:param prov_podcast_id: The provider podcast id.
|
||||
"""
|
||||
# fetch episode metadata, user status and show notes in parallel
|
||||
(podcast_name, episodes), in_progress, history, show_notes = await asyncio.gather(
|
||||
self._client.get_podcast_episodes(prov_podcast_id),
|
||||
self._client.get_in_progress_episodes(),
|
||||
self._client.get_history(),
|
||||
self._get_show_notes(prov_podcast_id),
|
||||
)
|
||||
in_progress_map = {ep.get("uuid"): ep for ep in in_progress}
|
||||
history_map = {ep.get("uuid"): ep for ep in history}
|
||||
|
||||
# the full-podcast payload carries no episode number, so rank on the publication date
|
||||
positions = rank_episodes_by_date([ep.get("published") or None for ep in episodes])
|
||||
for position, episode_data in zip(positions, episodes, strict=True):
|
||||
episode_item = self._convert_episode(
|
||||
episode_data,
|
||||
prov_podcast_id,
|
||||
show_notes.get(episode_data.get("uuid", "")),
|
||||
podcast_name,
|
||||
position=position,
|
||||
)
|
||||
if episode_item:
|
||||
self._enrich_episode_with_status(
|
||||
episode_item, episode_data, in_progress_map, history_map
|
||||
)
|
||||
yield episode_item
|
||||
|
||||
async def get_library_playlists(self) -> AsyncGenerator[Playlist]:
|
||||
"""Get the user's Up Next queue as a playlist."""
|
||||
yield self._get_up_next_playlist()
|
||||
|
||||
async def get_playlist(self, prov_playlist_id: str) -> Playlist:
|
||||
"""
|
||||
Get full details for a playlist.
|
||||
|
||||
:param prov_playlist_id: The provider playlist id.
|
||||
"""
|
||||
if prov_playlist_id != UP_NEXT_PLAYLIST_ID:
|
||||
raise MediaNotFoundError(
|
||||
f"playlist://{prov_playlist_id} not found on provider {self.domain}"
|
||||
)
|
||||
return self._get_up_next_playlist()
|
||||
|
||||
async def get_playlist_tracks(
|
||||
self, prov_playlist_id: str, page: int = 0
|
||||
) -> list[PodcastEpisode]:
|
||||
"""
|
||||
Get the episodes of a playlist, in Up Next order.
|
||||
|
||||
:param prov_playlist_id: The provider playlist id.
|
||||
:param page: Zero-based page index; Up Next is returned whole on the first page.
|
||||
"""
|
||||
if prov_playlist_id != UP_NEXT_PLAYLIST_ID:
|
||||
raise MediaNotFoundError(
|
||||
f"playlist://{prov_playlist_id} not found on provider {self.domain}"
|
||||
)
|
||||
if page > 0:
|
||||
return []
|
||||
episodes = await self._get_special_folder_episodes(UP_NEXT_PLAYLIST_ID)
|
||||
# the Up Next payload carries no duration or playback status, and marking an episode
|
||||
# played reports its duration as the position, so take both from the episode details
|
||||
details = await asyncio.gather(
|
||||
*(self.get_podcast_episode(episode.item_id) for episode in episodes),
|
||||
return_exceptions=True,
|
||||
)
|
||||
items: list[PodcastEpisode] = []
|
||||
for episode, detail in zip(episodes, details, strict=True):
|
||||
if isinstance(detail, PodcastEpisode):
|
||||
items.append(detail)
|
||||
elif isinstance(
|
||||
detail,
|
||||
MediaNotFoundError
|
||||
| LoginFailed
|
||||
| ProviderUnavailableError
|
||||
| ResourceTemporarilyUnavailable
|
||||
| RetriesExhausted,
|
||||
):
|
||||
self.logger.debug("Could not retrieve details for %s: %s", episode.name, detail)
|
||||
items.append(episode)
|
||||
else:
|
||||
raise detail
|
||||
return items
|
||||
|
||||
async def browse(self, path: str) -> list[MediaItemType | BrowseFolder]:
|
||||
"""
|
||||
Browse this provider's items.
|
||||
|
||||
:param path: The browse path to resolve.
|
||||
"""
|
||||
item_path = path.split("://", 1)[1] if "://" in path else path
|
||||
|
||||
if not item_path:
|
||||
# root level - special folders followed by subscribed podcasts
|
||||
items: list[MediaItemType | BrowseFolder] = list(self._create_browse_folders())
|
||||
for podcast_data in await self._client.get_subscribed_podcasts():
|
||||
items.append(self._convert_podcast(podcast_data))
|
||||
return items
|
||||
|
||||
if item_path in SPECIAL_FOLDERS:
|
||||
return list(await self._get_special_folder_episodes(item_path))
|
||||
|
||||
return [episode async for episode in self.get_podcast_episodes(item_path)]
|
||||
|
||||
async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
|
||||
"""
|
||||
Get streamable URL and details for the given media item.
|
||||
|
||||
:param item_id: The episode item id (format: podcast_uuid:episode_uuid).
|
||||
:param media_type: The media type of the item.
|
||||
"""
|
||||
_, episode_uuid = item_id.split(":", 1)
|
||||
episode_data = await self._client.get_episode_details(episode_uuid)
|
||||
|
||||
url = episode_data.get("url", "")
|
||||
if not url:
|
||||
raise MediaNotFoundError(f"No URL found for episode {item_id}")
|
||||
|
||||
return StreamDetails(
|
||||
item_id=item_id,
|
||||
provider=self.instance_id,
|
||||
audio_format=AudioFormat(
|
||||
content_type=ContentType.try_parse(episode_data.get("fileType", "audio/mpeg")),
|
||||
),
|
||||
media_type=MediaType.PODCAST_EPISODE,
|
||||
stream_type=StreamType.HTTP,
|
||||
path=url,
|
||||
duration=episode_data.get("duration"),
|
||||
can_seek=True,
|
||||
allow_seek=True,
|
||||
)
|
||||
|
||||
@use_cache(3600 * 24)
|
||||
async def search(
|
||||
self, search_query: str, media_types: list[MediaType], limit: int = 5
|
||||
) -> SearchResults:
|
||||
"""
|
||||
Search for podcasts.
|
||||
|
||||
:param search_query: The search query.
|
||||
:param media_types: The media types to include in the search.
|
||||
:param limit: The maximum number of items to return per type.
|
||||
"""
|
||||
results = SearchResults()
|
||||
if media_types and MediaType.PODCAST not in media_types:
|
||||
return results
|
||||
podcasts = await self._client.search_podcasts(search_query)
|
||||
results.podcasts = [self._convert_podcast(podcast) for podcast in podcasts[:limit]]
|
||||
return results
|
||||
|
||||
@use_cache(3600)
|
||||
async def get_podcast_episode(self, prov_item_id: str) -> PodcastEpisode:
|
||||
"""
|
||||
Get full details for a podcast episode.
|
||||
|
||||
:param prov_item_id: The episode item id (format: podcast_uuid:episode_uuid).
|
||||
"""
|
||||
podcast_uuid, episode_uuid = prov_item_id.split(":", 1)
|
||||
episode_data, show_notes, podcast_name = await asyncio.gather(
|
||||
self._client.get_episode_details(episode_uuid),
|
||||
self._get_show_notes(podcast_uuid),
|
||||
self._get_podcast_name(podcast_uuid),
|
||||
)
|
||||
episode_item = self._convert_episode(
|
||||
episode_data, podcast_uuid, show_notes.get(episode_uuid), podcast_name
|
||||
)
|
||||
if episode_item is None:
|
||||
raise MediaNotFoundError(f"Episode {episode_uuid} not found in podcast {podcast_uuid}")
|
||||
|
||||
# the API sends explicit nulls for these fields, so a dict default is not enough
|
||||
played_up_to = episode_data.get("playedUpTo") or 0
|
||||
duration = episode_data.get("duration") or 0
|
||||
playing_status = episode_data.get("playingStatus", 1) # 1=unplayed, 2=in_progress, 3=played
|
||||
if duration > 0:
|
||||
episode_item.duration = duration
|
||||
completed = playing_status == 3 or (
|
||||
duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
|
||||
)
|
||||
episode_item.fully_played = completed
|
||||
episode_item.resume_position_ms = 0 if completed else played_up_to * 1000
|
||||
|
||||
return episode_item
|
||||
|
||||
async def get_resume_position(
|
||||
self, item_id: str, media_type: MediaType
|
||||
) -> tuple[bool, int, datetime | None]:
|
||||
"""
|
||||
Return the (fully_played, position_ms, timestamp) resume point for an episode.
|
||||
|
||||
PocketCasts does not expose a reliable last-played timestamp, so the timestamp
|
||||
is always None.
|
||||
|
||||
:param item_id: The episode item id (format: podcast_uuid:episode_uuid).
|
||||
:param media_type: The media type (should be PODCAST_EPISODE).
|
||||
"""
|
||||
_, episode_uuid = item_id.split(":", 1)
|
||||
|
||||
try:
|
||||
in_progress = await self._client.get_in_progress_episodes()
|
||||
except (ProviderUnavailableError, ResourceTemporarilyUnavailable, RetriesExhausted) as err:
|
||||
# resume is best-effort; a transient failure should not break playback
|
||||
LOGGER.warning("Could not fetch resume position for %s: %s", episode_uuid, err)
|
||||
return (False, 0, None)
|
||||
|
||||
for ep in in_progress:
|
||||
if ep.get("uuid") == episode_uuid:
|
||||
played_up_to = int(ep.get("playedUpTo") or 0) # seconds from API
|
||||
duration = int(ep.get("duration") or 0)
|
||||
fully_played = duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
|
||||
LOGGER.debug(
|
||||
"Resume position for %s: %d ms (fully_played=%s)",
|
||||
episode_uuid,
|
||||
played_up_to * 1000,
|
||||
fully_played,
|
||||
)
|
||||
return (fully_played, played_up_to * 1000, None)
|
||||
|
||||
LOGGER.debug("No in-progress entry for %s; resuming from start", episode_uuid)
|
||||
return (False, 0, None)
|
||||
|
||||
async def on_played(
|
||||
self,
|
||||
media_type: MediaType,
|
||||
prov_item_id: str,
|
||||
fully_played: bool,
|
||||
position: int,
|
||||
media_item: MediaItemType,
|
||||
is_playing: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Sync playback progress for a podcast episode back to Pocket Casts.
|
||||
|
||||
Called by the Queue controller when a track is played, stopped/skipped, and
|
||||
periodically while playing.
|
||||
|
||||
:param media_type: The media type of the played item.
|
||||
:param prov_item_id: The provider item id (format: podcast_uuid:episode_uuid).
|
||||
:param fully_played: Whether the episode was played to the end.
|
||||
:param position: Last known position in seconds.
|
||||
:param media_item: The full media item details.
|
||||
:param is_playing: Whether the episode is currently playing.
|
||||
"""
|
||||
if media_type != MediaType.PODCAST_EPISODE or not isinstance(media_item, PodcastEpisode):
|
||||
return
|
||||
podcast_uuid, episode_uuid = prov_item_id.split(":", 1)
|
||||
|
||||
# MA reports fully_played=True when an episode is skipped/stopped, not only when it
|
||||
# truly ends, so confirm completion against the real position before marking it played.
|
||||
duration = media_item.duration or 0
|
||||
completed = fully_played and duration > 0 and position >= duration * FULLY_PLAYED_THRESHOLD
|
||||
if completed:
|
||||
self._announced_episodes.discard(episode_uuid)
|
||||
await self._client.mark_episode_played(podcast_uuid, episode_uuid)
|
||||
await self._client.remove_from_up_next(episode_uuid)
|
||||
await self._client.archive_episode(podcast_uuid, episode_uuid, archive=True)
|
||||
elif position == 0 and not is_playing:
|
||||
# the user explicitly marked the episode as unplayed
|
||||
self._announced_episodes.discard(episode_uuid)
|
||||
await self._client.mark_episode_unplayed(podcast_uuid, episode_uuid)
|
||||
await self._client.archive_episode(podcast_uuid, episode_uuid, archive=False)
|
||||
else:
|
||||
# on_played fires every progress tick, so mirror the start to Up Next/history only
|
||||
# once per session - re-announcing each tick would re-bump Up Next and spam the API.
|
||||
# A resume within the same session is intentionally not re-announced.
|
||||
if is_playing and episode_uuid not in self._announced_episodes:
|
||||
self._announced_episodes.add(episode_uuid)
|
||||
await self._announce_playback_start(podcast_uuid, episode_uuid, media_item)
|
||||
await self._client.update_episode_progress(podcast_uuid, episode_uuid, position)
|
||||
|
||||
def _convert_podcast(self, podcast_data: dict[str, Any]) -> Podcast:
|
||||
"""
|
||||
Convert raw Pocket Casts podcast data to a Podcast object.
|
||||
|
||||
:param podcast_data: Raw podcast data from the subscribed-list or full-podcast endpoint.
|
||||
"""
|
||||
uuid = podcast_data["uuid"]
|
||||
return Podcast(
|
||||
item_id=uuid,
|
||||
provider=self.instance_id,
|
||||
name=podcast_data.get("title", ""),
|
||||
publisher=podcast_data.get("author"),
|
||||
provider_mappings={
|
||||
ProviderMapping(
|
||||
item_id=uuid,
|
||||
provider_domain=self.domain,
|
||||
provider_instance=self.instance_id,
|
||||
)
|
||||
},
|
||||
metadata=MediaItemMetadata(
|
||||
description=podcast_data.get("description"),
|
||||
images=UniqueList(
|
||||
[
|
||||
MediaItemImage(
|
||||
type=ImageType.THUMB,
|
||||
path=f"https://static.pocketcasts.com/discover/images/280/{uuid}.jpg",
|
||||
provider=self.instance_id,
|
||||
remotely_accessible=True,
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
async def _get_podcast_name(self, prov_podcast_id: str) -> str:
|
||||
"""Return a podcast's name, empty when it cannot be looked up."""
|
||||
# the podcast lookup is cached, so this is one call per podcast per day at most
|
||||
try:
|
||||
return (await self.get_podcast(prov_podcast_id)).name
|
||||
except (
|
||||
MediaNotFoundError,
|
||||
LoginFailed,
|
||||
ProviderUnavailableError,
|
||||
ResourceTemporarilyUnavailable,
|
||||
RetriesExhausted,
|
||||
) as err:
|
||||
self.logger.debug("Could not retrieve podcast name for %s: %s", prov_podcast_id, err)
|
||||
return ""
|
||||
|
||||
async def _get_show_notes(self, prov_podcast_id: str) -> dict[str, dict[str, Any]]:
|
||||
"""Return show notes per episode uuid, empty when they cannot be read."""
|
||||
# show notes are supplementary, so a failure here must never break episode
|
||||
# resolution. The failure itself is not cached, so the next call tries again.
|
||||
try:
|
||||
return await self._fetch_show_notes(prov_podcast_id)
|
||||
except (
|
||||
LoginFailed,
|
||||
ProviderUnavailableError,
|
||||
ResourceTemporarilyUnavailable,
|
||||
RetriesExhausted,
|
||||
) as err:
|
||||
self.logger.debug("Could not retrieve show notes for %s: %s", prov_podcast_id, err)
|
||||
return {}
|
||||
|
||||
@use_cache(3600 * 24)
|
||||
async def _fetch_show_notes(self, prov_podcast_id: str) -> dict[str, dict[str, Any]]:
|
||||
"""Return show notes per episode uuid for the given podcast."""
|
||||
return await self._client.get_show_notes(prov_podcast_id)
|
||||
|
||||
def _convert_episode(
|
||||
self,
|
||||
episode_data: dict[str, Any],
|
||||
podcast_uuid: str,
|
||||
show_notes: dict[str, Any] | None = None,
|
||||
podcast_name: str = "",
|
||||
position: int = 0,
|
||||
) -> PodcastEpisode | None:
|
||||
"""
|
||||
Convert episode data to a PodcastEpisode, or None when it carries no episode uuid.
|
||||
|
||||
:param position: The episode's listing position. Defaults to 0 (unknown).
|
||||
"""
|
||||
episode_uuid = episode_data.get("uuid")
|
||||
if not episode_uuid:
|
||||
return None
|
||||
|
||||
# this is fed by two endpoints with different field schemas: the full-podcast JSON
|
||||
# uses snake_case (file_type) while /user/episode uses camelCase (fileType,
|
||||
# episodeNumber). Neither carries the description or artwork, which is what the
|
||||
# separate show notes lookup is for.
|
||||
item_id = f"{podcast_uuid}:{episode_uuid}"
|
||||
file_type = episode_data.get("fileType") or episode_data.get("file_type", "audio/mpeg")
|
||||
episode_item = PodcastEpisode(
|
||||
item_id=item_id,
|
||||
provider=self.instance_id,
|
||||
name=episode_data.get("title", "Unknown Episode"),
|
||||
podcast=ItemMapping(
|
||||
media_type=MediaType.PODCAST,
|
||||
item_id=podcast_uuid,
|
||||
provider=self.instance_id,
|
||||
name=podcast_name,
|
||||
),
|
||||
position=position,
|
||||
provider_mappings={
|
||||
ProviderMapping(
|
||||
item_id=item_id,
|
||||
provider_domain=self.domain,
|
||||
provider_instance=self.instance_id,
|
||||
audio_format=AudioFormat(content_type=ContentType.try_parse(file_type)),
|
||||
url=episode_data.get("url", ""),
|
||||
)
|
||||
},
|
||||
)
|
||||
if episode_data.get("duration"):
|
||||
episode_item.duration = int(episode_data["duration"])
|
||||
if title := episode_data.get("title"):
|
||||
episode_item.metadata.label = title
|
||||
details = show_notes or {}
|
||||
if description := details.get("description"):
|
||||
episode_item.metadata.description = description
|
||||
# only about half the episodes have their own artwork, the rest keep the podcast cover
|
||||
image_url = details.get("image") or (
|
||||
f"https://static.pocketcasts.com/discover/images/280/{podcast_uuid}.jpg"
|
||||
)
|
||||
episode_item.metadata.images = UniqueList(
|
||||
[
|
||||
MediaItemImage(
|
||||
type=ImageType.THUMB,
|
||||
path=image_url,
|
||||
provider=self.instance_id,
|
||||
remotely_accessible=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
return episode_item
|
||||
|
||||
def _enrich_episode_with_status(
|
||||
self,
|
||||
episode_item: PodcastEpisode,
|
||||
episode_data: dict[str, Any],
|
||||
in_progress_map: dict[str | None, dict[str, Any]],
|
||||
history_map: dict[str | None, dict[str, Any]],
|
||||
) -> None:
|
||||
"""
|
||||
Apply playback status to a PodcastEpisode from the in-progress/history data.
|
||||
|
||||
:param episode_item: The episode object to enrich in place.
|
||||
:param episode_data: Raw episode data dict from the API.
|
||||
:param in_progress_map: UUID-keyed map of in-progress episode data.
|
||||
:param history_map: UUID-keyed map of listen-history episode data.
|
||||
"""
|
||||
episode_uuid = episode_data.get("uuid")
|
||||
# history is "recently played", not "completed" - rely on the entry's real progress,
|
||||
# never on mere history membership. Both fields are always set so the library sync can
|
||||
# clear a stale completed/resume value (it only updates when both are non-None).
|
||||
status_data = in_progress_map.get(episode_uuid) or history_map.get(episode_uuid) or {}
|
||||
# feeds that omit a duration yield an explicit null rather than a missing key, so
|
||||
# coerce instead of relying on a dict default
|
||||
played_up_to = status_data.get("playedUpTo") or 0
|
||||
duration = status_data.get("duration") or episode_data.get("duration") or 0
|
||||
completed = status_data.get("playingStatus") == 3 or (
|
||||
duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
|
||||
)
|
||||
episode_item.fully_played = completed
|
||||
episode_item.resume_position_ms = 0 if completed else played_up_to * 1000
|
||||
|
||||
async def _get_special_folder_episodes(self, folder_name: str) -> list[PodcastEpisode]:
|
||||
"""
|
||||
Get episodes for a special browse folder.
|
||||
|
||||
:param folder_name: Name of the special folder (up_next, new_releases, etc.)
|
||||
"""
|
||||
folder_getters = {
|
||||
"up_next": self._client.get_up_next_episodes,
|
||||
"new_releases": self._client.get_new_releases,
|
||||
"in_progress": self._client.get_in_progress_episodes,
|
||||
"starred": self._client.get_starred_episodes,
|
||||
"history": self._client.get_history,
|
||||
}
|
||||
episode_list = await folder_getters[folder_name]()
|
||||
|
||||
# (episode, podcast uuid, podcast name) per episode, the name empty when the folder
|
||||
# payload does not carry it
|
||||
resolved: list[tuple[dict[str, Any], str, str]] = []
|
||||
for episode_data in episode_list:
|
||||
# the podcast reference is a string on some endpoints and an object on others
|
||||
podcast_field = episode_data.get("podcast")
|
||||
podcast_uuid: str | None
|
||||
if isinstance(podcast_field, str):
|
||||
podcast_uuid = podcast_field
|
||||
elif isinstance(podcast_field, dict):
|
||||
podcast_uuid = podcast_field.get("uuid")
|
||||
else:
|
||||
podcast_uuid = episode_data.get("podcastUuid")
|
||||
|
||||
if not podcast_uuid:
|
||||
continue
|
||||
# these folders mix podcasts, so the name is not known up front. Take it from the
|
||||
# payload where that carries it, in either of the two shapes
|
||||
payload_name = podcast_field.get("title") if isinstance(podcast_field, dict) else None
|
||||
resolved.append(
|
||||
(
|
||||
episode_data,
|
||||
podcast_uuid,
|
||||
payload_name or episode_data.get("podcastTitle") or "",
|
||||
)
|
||||
)
|
||||
|
||||
# every remaining name costs a full-podcast fetch, so look them up once per podcast and
|
||||
# all at once: serialising them would stall the browse for as long as the folder is deep
|
||||
missing = list({uuid for _, uuid, name in resolved if not name})
|
||||
looked_up = await asyncio.gather(*(self._get_podcast_name(uuid) for uuid in missing))
|
||||
names = dict(zip(missing, looked_up, strict=True))
|
||||
|
||||
items: list[PodcastEpisode] = []
|
||||
for episode_data, podcast_uuid, podcast_name in resolved:
|
||||
if episode_item := self._convert_episode(
|
||||
episode_data,
|
||||
podcast_uuid,
|
||||
show_notes=None,
|
||||
podcast_name=podcast_name or names.get(podcast_uuid, ""),
|
||||
):
|
||||
items.append(episode_item)
|
||||
return items
|
||||
|
||||
def _create_browse_folders(self) -> list[BrowseFolder]:
|
||||
"""Create special browse folders for root level."""
|
||||
folders = [
|
||||
("up_next", "Up Next"),
|
||||
("new_releases", "New Releases"),
|
||||
("in_progress", "In Progress"),
|
||||
("starred", "Starred"),
|
||||
("history", "History"),
|
||||
]
|
||||
return [
|
||||
BrowseFolder(
|
||||
item_id=folder_id,
|
||||
provider=self.instance_id,
|
||||
path=f"{self.instance_id}://{folder_id}",
|
||||
name=name,
|
||||
image=MediaItemImage(
|
||||
type=ImageType.THUMB,
|
||||
path=BROWSE_FOLDER_ICONS[folder_id],
|
||||
provider=self.instance_id,
|
||||
remotely_accessible=True,
|
||||
),
|
||||
)
|
||||
for folder_id, name in folders
|
||||
]
|
||||
|
||||
def _get_up_next_playlist(self) -> Playlist:
|
||||
"""Return the playlist that mirrors the user's Up Next queue."""
|
||||
return Playlist(
|
||||
item_id=UP_NEXT_PLAYLIST_ID,
|
||||
provider=self.instance_id,
|
||||
name="Up Next",
|
||||
owner="Pocket Casts",
|
||||
is_editable=False,
|
||||
supported_mediatypes={MediaType.PODCAST_EPISODE},
|
||||
provider_mappings={
|
||||
ProviderMapping(
|
||||
item_id=UP_NEXT_PLAYLIST_ID,
|
||||
provider_domain=self.domain,
|
||||
provider_instance=self.instance_id,
|
||||
# every account has its own Up Next queue
|
||||
is_unique=True,
|
||||
)
|
||||
},
|
||||
metadata=MediaItemMetadata(
|
||||
images=UniqueList(
|
||||
[
|
||||
MediaItemImage(
|
||||
type=ImageType.THUMB,
|
||||
path=BROWSE_FOLDER_ICONS[UP_NEXT_PLAYLIST_ID],
|
||||
provider=self.instance_id,
|
||||
remotely_accessible=True,
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
async def _announce_playback_start(
|
||||
self, podcast_uuid: str, episode_uuid: str, episode: PodcastEpisode
|
||||
) -> None:
|
||||
"""
|
||||
Mirror a playback start to Pocket Casts by adding the episode to Up Next and history.
|
||||
|
||||
:param podcast_uuid: The podcast UUID.
|
||||
:param episode_uuid: The episode UUID.
|
||||
:param episode: The episode that started playing.
|
||||
"""
|
||||
# source the url from the already-loaded item so no extra API call is needed; filtered
|
||||
# to our own mapping since merged library items can carry other providers' mappings
|
||||
url = next(
|
||||
(
|
||||
mapping.url
|
||||
for mapping in episode.provider_mappings
|
||||
if mapping.provider_instance == self.instance_id and mapping.url
|
||||
),
|
||||
"",
|
||||
)
|
||||
await self._client.play_now(
|
||||
episode_uuid=episode_uuid, podcast_uuid=podcast_uuid, title=episode.name, url=url
|
||||
)
|
||||
await self._client.add_to_history(
|
||||
episode_uuid=episode_uuid, podcast_uuid=podcast_uuid, title=episode.name, url=url
|
||||
)
|
||||
@@ -74,7 +74,7 @@ spec:
|
||||
# alpine/kubectl is the maintained replacement. Pin the tag
|
||||
# to the cluster's server version so kubectl is in lock-step
|
||||
# (mismatched skew can break exec streaming).
|
||||
image: docker.io/alpine/kubectl:1.37.1@sha256:b568e8beb6a79aee269c142343078a278b85e7710f1c0e7d8d6b21d1eb566a48
|
||||
image: docker.io/alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7
|
||||
env:
|
||||
- name: DATASET_TLV
|
||||
valueFrom:
|
||||
|
||||
@@ -47,7 +47,7 @@ spec:
|
||||
#
|
||||
# Hence socat is installed at startup and run inside this container.
|
||||
- name: otbr
|
||||
image: openthread/otbr:latest@sha256:a1ccea58241c2e0c2e16a55590cdd5651336462c3b4fa26e24a59607e155ef72
|
||||
image: openthread/otbr:latest@sha256:f3e43ccd86d6da87323873efa0c9b6f92a2a8cc75a804ea50e34c96680363e2e
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
- name: prepare-dirs
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -287,7 +287,7 @@ spec:
|
||||
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
|
||||
# anchor: bridge-tls-proxy
|
||||
- name: bridge-tls-proxy
|
||||
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
|
||||
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
|
||||
args:
|
||||
- "-d"
|
||||
- "TCP-LISTEN:11143,fork,reuseaddr,bind=127.0.0.1"
|
||||
|
||||
@@ -25,7 +25,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:8.10.2-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
|
||||
image: redis:8.10.3-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
|
||||
args:
|
||||
- redis-server
|
||||
- --save
|
||||
|
||||
@@ -50,7 +50,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: syncthing
|
||||
image: syncthing/syncthing:2.1.6@sha256:4d0281bdcb4ed9baf503d251d0544ddd856f49fce3f114056257ae172b7fb670
|
||||
image: syncthing/syncthing:2.1.7@sha256:2b71da7c0d0a91a6ae5285ad079226fecc0a365d08b27720e1f7037b018f7546
|
||||
env:
|
||||
# UI bind: any-interface so the ClusterIP Service can reach
|
||||
# it. Network exposure is gated by the absence of an Ingress
|
||||
|
||||
@@ -59,7 +59,7 @@ spec:
|
||||
- name: renovate
|
||||
# Pinned by tag and digest. Renovate updates itself through
|
||||
# homelab's renovate.json, like any other image here.
|
||||
image: docker.io/renovate/renovate:44.151.2@sha256:9ea828b38867af0ca5f269e6c5feace9e74e7f035da150bb16f8c5f70bf2e6e8
|
||||
image: docker.io/renovate/renovate:44.181.8@sha256:b28b2de1dfd35de7e8198756d7f7cdd84ebc28c3f25e80f5b09e598d3faefb2a
|
||||
env:
|
||||
- name: RENOVATE_CONFIG_FILE
|
||||
value: /config/config.json
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: review-bot
|
||||
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-25-1455 # {"$imagepolicy": "flux-system:review-bot"}
|
||||
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-26-0647 # {"$imagepolicy": "flux-system:review-bot"}
|
||||
env:
|
||||
- name: REPOS
|
||||
value: konairius/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# The wiki's schedules. The service runs nothing on its own clock; these jobs
|
||||
# call its API with the `scheduler` key. Run one now:
|
||||
# kubectl -n ai create job --from=cronjob/wiki-encyclopedia wiki-encyclopedia-now
|
||||
# Generated once in-cluster, nothing in OpenBao.
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
metadata:
|
||||
name: wiki-scheduler-key
|
||||
namespace: ai
|
||||
spec:
|
||||
length: 64
|
||||
digits: 16
|
||||
symbols: 0
|
||||
allowRepeat: true
|
||||
noUpper: false
|
||||
secretKeys:
|
||||
- Auth__ApiKeys__scheduler
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: wiki-scheduler-key
|
||||
namespace: ai
|
||||
spec:
|
||||
refreshPolicy: CreatedOnce
|
||||
target:
|
||||
name: wiki-scheduler-key
|
||||
dataFrom:
|
||||
- sourceRef:
|
||||
generatorRef:
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
name: wiki-scheduler-key
|
||||
---
|
||||
# Interval sync; the Gitea push webhook is the fast path.
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: wiki-sync
|
||||
namespace: ai
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki-sync
|
||||
spec:
|
||||
schedule: "*/30 * * * *"
|
||||
timeZone: Etc/UTC
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 1
|
||||
failedJobsHistoryLimit: 3
|
||||
startingDeadlineSeconds: 600
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 1800
|
||||
ttlSecondsAfterFinished: 86400
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki-sync
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
enableServiceLinks: false
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: sync
|
||||
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- 'curl -fsS --max-time 1700 -X POST -H "X-Api-Key: $API_KEY" http://wiki.ai.svc.cluster.local:8080/api/v1/sync'
|
||||
env:
|
||||
- name: API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wiki-scheduler-key
|
||||
key: Auth__ApiKeys__scheduler
|
||||
resources:
|
||||
requests:
|
||||
cpu: 5m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
---
|
||||
# Plans and writes the house encyclopedia (/house). The endpoint syncs first and
|
||||
# answers 502 when the run fails or the Anthropic API stopped it.
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: wiki-encyclopedia
|
||||
namespace: ai
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki-encyclopedia
|
||||
spec:
|
||||
schedule: "0 6 * * *"
|
||||
timeZone: Europe/Berlin
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
startingDeadlineSeconds: 3600
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 11400
|
||||
ttlSecondsAfterFinished: 604800
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki-encyclopedia
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
enableServiceLinks: false
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: run
|
||||
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- 'curl -fsS --max-time 10800 -X POST -H "X-Api-Key: $API_KEY" http://wiki.ai.svc.cluster.local:8080/api/v1/encyclopedia/run'
|
||||
env:
|
||||
- name: API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wiki-scheduler-key
|
||||
key: Auth__ApiKeys__scheduler
|
||||
resources:
|
||||
requests:
|
||||
cpu: 5m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
@@ -24,7 +24,7 @@ metadata:
|
||||
namespace: ai
|
||||
annotations:
|
||||
# Keys, tokens and the Anthropic key arrive via envFrom; a rotated Secret rolls the pod.
|
||||
secret.reloader.stakater.com/reload: wiki-gitea,wiki-api-keys,wiki-typesafe,wiki-anthropic,wiki-api-basic-auth
|
||||
secret.reloader.stakater.com/reload: wiki-gitea,wiki-api-keys,wiki-scheduler-key,wiki-typesafe,wiki-anthropic,wiki-api-basic-auth
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki
|
||||
spec:
|
||||
@@ -53,7 +53,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: wiki
|
||||
image: gitea.renner.uno/konairius/wiki:build.1792939775 # {"$imagepolicy": "flux-system:wiki"}
|
||||
image: gitea.renner.uno/konairius/wiki:build.1793006975 # {"$imagepolicy": "flux-system:wiki"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
@@ -89,6 +89,9 @@ spec:
|
||||
name: wiki-gitea
|
||||
- secretRef:
|
||||
name: wiki-api-keys
|
||||
# The key the wiki-sync and wiki-encyclopedia CronJobs call with (cronjobs.yaml).
|
||||
- secretRef:
|
||||
name: wiki-scheduler-key
|
||||
- secretRef:
|
||||
name: wiki-typesafe
|
||||
- secretRef:
|
||||
|
||||
@@ -4,3 +4,4 @@ resources:
|
||||
- externalsecrets.yaml
|
||||
- deployment.yaml
|
||||
- ingress.yaml
|
||||
- cronjobs.yaml
|
||||
|
||||
@@ -69,7 +69,7 @@ spec:
|
||||
# first build of that day — imagePullPolicy: Always does not help,
|
||||
# because the tag itself resolves stale. Update this when the image
|
||||
# is rebuilt.
|
||||
image: gitea.renner.uno/gitea_admin/bird@sha256:afd8c3a1fc21a9f2c67af366b3bf77ca9b948f07f66a4977821e4972c8fa6ae4
|
||||
image: gitea.renner.uno/gitea_admin/bird@sha256:65fc93efead1e9b065d2c78e748f6c3c1ad7216e28b757d9fa4e204fa2ffe9a9
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
@@ -95,7 +95,7 @@ spec:
|
||||
mountPath: /etc/bird
|
||||
containers:
|
||||
- name: bird
|
||||
image: gitea.renner.uno/gitea_admin/bird@sha256:afd8c3a1fc21a9f2c67af366b3bf77ca9b948f07f66a4977821e4972c8fa6ae4
|
||||
image: gitea.renner.uno/gitea_admin/bird@sha256:65fc93efead1e9b065d2c78e748f6c3c1ad7216e28b757d9fa4e204fa2ffe9a9
|
||||
resources:
|
||||
requests:
|
||||
cpu: "50m"
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: coredns
|
||||
namespace: kube-system
|
||||
data:
|
||||
Corefile: |
|
||||
# Delegate renner.uno to the in-cluster k8s_gateway resolver. It
|
||||
# watches Ingress resources and synthesises A/AAAA records that
|
||||
# point at the ingress-nginx LB, so pods resolving `auth.renner.uno`,
|
||||
# `audiobooks.renner.uno`, … stay inside the cluster. Names not
|
||||
# backed by an in-cluster Ingress fall through inside k8s_gateway
|
||||
# itself to the UDM (10.2.40.1 / fd00::1), which serves the split-horizon
|
||||
# renner.uno zone, preserving resolution of LXC/VM + physical services.
|
||||
# (The old Proxmox DNS VIP 10.2.40.53 is DECOMMISSIONED.) 10.96.0.11 is a
|
||||
# pinned ClusterIP of the k8s-gateway Service — avoids DNS-name
|
||||
# resolution during CoreDNS startup.
|
||||
# Cache tuning mirrors k8s-gateway's (issue #393): the UDM's dnsmasq
|
||||
# refuses connections in bursts, and because the public renner.uno zone
|
||||
# has a *.renner.uno wildcard CNAME that matches at every depth, a local
|
||||
# failure never returns NXDOMAIN — it resolves to the WAN proxy instead.
|
||||
# Correct answers carry TTL 5 (dnsmasq lease records) while the wrong
|
||||
# wildcard answer carries TTL 300, so one brief blip pins a wrong answer
|
||||
# for minutes. `success ... 60` raises the 5s TTL to a 60s floor and
|
||||
# `serve_stale 1h immediate` keeps serving through an upstream outage.
|
||||
# NOTE: only cache/serve_stale — the `template` plugin is deliberately NOT
|
||||
# reintroduced here (it broke cluster DNS in 7a09bad5).
|
||||
renner.uno:53 {
|
||||
errors
|
||||
cache 300 {
|
||||
success 9984 300 60
|
||||
denial 9984 300 30
|
||||
serve_stale 1h immediate
|
||||
}
|
||||
forward . 10.96.0.11
|
||||
loop
|
||||
reload
|
||||
}
|
||||
|
||||
.:53 {
|
||||
errors
|
||||
health {
|
||||
lameduck 5s
|
||||
}
|
||||
ready
|
||||
log . {
|
||||
class error
|
||||
}
|
||||
prometheus :9153
|
||||
|
||||
kubernetes cluster.local in-addr.arpa ip6.arpa {
|
||||
pods insecure
|
||||
fallthrough in-addr.arpa ip6.arpa
|
||||
ttl 30
|
||||
}
|
||||
forward . /etc/resolv.conf {
|
||||
max_concurrent 1000
|
||||
}
|
||||
# serve_stale mirrors the renner.uno:53 block (issue #393): when the
|
||||
# upstream resolver browns out, keep answering from expired cache
|
||||
# instead of SERVFAILing every public name cluster-wide. Seen on
|
||||
# 2026-07-30 01:22-01:26Z (~1150 SERVFAILs) — it killed a Velero Kopia
|
||||
# maintenance job, which runs with backoffLimit 0, on the Hetzner S3
|
||||
# endpoint (issue #439). cluster.local is exempt from the cache above,
|
||||
# so in-cluster Service records are never served stale.
|
||||
cache 30 {
|
||||
disable success cluster.local
|
||||
disable denial cluster.local
|
||||
serve_stale 1h immediate
|
||||
}
|
||||
loop
|
||||
reload
|
||||
loadbalance
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
# CoreDNS itself (Deployment, Corefile) is a Talos bootstrap manifest; only
|
||||
# its LoadBalancer Service lives here.
|
||||
resources:
|
||||
- configmap.yaml
|
||||
- service.yaml
|
||||
|
||||
@@ -85,10 +85,11 @@ spec:
|
||||
parameters: "0.0.0.0:9153"
|
||||
serviceType: ClusterIP
|
||||
service:
|
||||
# Pinned so the main CoreDNS Corefile can forward renner.uno
|
||||
# here without depending on DNS-based Service resolution during
|
||||
# CoreDNS startup (chicken-and-egg). Chosen from the low, sparsely
|
||||
# populated end of the service CIDR (kube-dns is 10.96.0.10).
|
||||
# Pinned because the nodes' resolver names it by address
|
||||
# (ResolverConfig in the Talos site patches); CoreDNS forwards to the
|
||||
# node, so every name reaches k8s-gateway that way. Chosen from the
|
||||
# low, sparsely populated end of the service CIDR (kube-dns is
|
||||
# 10.96.0.10).
|
||||
clusterIP: 10.96.0.11
|
||||
ipFamilyPolicy: PreferDualStack
|
||||
# The site's upstream resolvers (deployment.dnsPolicy/dnsConfig), from the
|
||||
|
||||
@@ -288,7 +288,8 @@ spec:
|
||||
- port: "80"
|
||||
protocol: TCP
|
||||
---
|
||||
# syncthing: relays/discovery (443), STUN (3478), peer sync (22000), local discovery (21027).
|
||||
# syncthing: relays/discovery (443), STUN (3478, 3479 for the NAT-type
|
||||
# change request), peer sync (22000), local discovery (21027).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
@@ -306,6 +307,7 @@ spec:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
- port: "3478"
|
||||
endPort: 3479
|
||||
protocol: UDP
|
||||
- port: "22000"
|
||||
protocol: TCP
|
||||
|
||||
@@ -71,7 +71,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: rotate
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-25 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-26 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
args: ["ha-token"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@@ -133,7 +133,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: rotate
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-25 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-26 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
args: ["ha-token"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@@ -195,7 +195,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: rotate
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-25 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-26 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
args: ["ha-token"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
@@ -11,7 +11,7 @@ spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: check
|
||||
image: docker.io/nicolaka/netshoot:v0.13@sha256:a20c2531bf35436ed3766cd6cfe89d352b050ccc4d7005ce6400adf97503da1b
|
||||
image: docker.io/nicolaka/netshoot:v0.16@sha256:b09d9b21381f47a79b3cbcb30da25266dc17186ea00ae65e99fdc51396f48e70
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
|
||||
@@ -20,8 +20,9 @@ machine:
|
||||
- 10.2.46.0/24
|
||||
- fd02:46::/64
|
||||
# The UDM on the ingress segment. Published here rather than as a route
|
||||
# because bird owns table 47 and originates the default from it; this is the
|
||||
# one place the address is stated.
|
||||
# because bird owns table 47 and originates the default from it. Bird reads
|
||||
# it from here; k8s-gateway-site below names it again as the pods' resolver,
|
||||
# because a Talos inline manifest cannot read a node annotation.
|
||||
nodeAnnotations:
|
||||
net.renner.uno/egress-gw-v6: fd02:47::1
|
||||
net.renner.uno/egress-gw-v4: 10.2.47.1
|
||||
@@ -63,7 +64,10 @@ cluster:
|
||||
directRoutingDevice: pod0
|
||||
ipv4NativeRoutingCIDR: 10.0.0.0/8
|
||||
ipv6NativeRoutingCIDR: fd00::/8
|
||||
# The site's upstream for k8s-gateway (its pod resolv.conf): the UDM.
|
||||
# The site's upstream for k8s-gateway (its pod resolv.conf): the UDM on
|
||||
# the segment pod traffic leaves on, ingress0 (net.renner.uno/egress-gw-*
|
||||
# above). The UDM's DNS answers out of the interface a query arrived on,
|
||||
# so its address on another segment gets no reply to a pod source.
|
||||
- name: k8s-gateway-site
|
||||
contents: |
|
||||
apiVersion: v1
|
||||
@@ -79,8 +83,8 @@ cluster:
|
||||
dnsPolicy: None
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- 10.2.40.1
|
||||
- fd00::1
|
||||
- fd02:47::1
|
||||
- 10.2.47.1
|
||||
---
|
||||
# All pod traffic goes to table 47, and the table decides — longest prefix
|
||||
# match, no exceptions to carve out. bird fills it with the node's attached
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"matchPackageNames": [
|
||||
"apache/tika",
|
||||
"deluan/navidrome",
|
||||
"docker.gitea.com/gitea-mcp-server",
|
||||
"eclipse-mosquitto",
|
||||
"everythingsmarthome/everything-presence-mmwave-configurator",
|
||||
"ghcr.io/advplyr/audiobookshelf",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# Nothing in the final image but the binary and the CA bundle it needs to reach the Mistral API
|
||||
# (the binary has no shell and no package manager to patch or to exec).
|
||||
FROM golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61 AS build
|
||||
FROM golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5 AS build
|
||||
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# lives inside the binary rather than in /etc/ssl. (In-cluster traffic to
|
||||
# OpenBao and Home Assistant is plain HTTP anyway; TLS is here so the same
|
||||
# binary can take over the external-API rotators — Cloudflare, Proxmox.)
|
||||
FROM rust:1.99-alpine@sha256:0cce0a5e0e8ba67b455257a3a02a1d99005f382748789d6464460028810f1627 AS build
|
||||
FROM rust:1.99-alpine@sha256:b1d5dd5a22a09404d3e798c0cf051b689077360374eb505fa5bb93c479693d87 AS build
|
||||
|
||||
# musl-dev provides the C runtime bits the linker needs for a fully static
|
||||
# binary; nothing from it ends up in the final image.
|
||||
|
||||
Reference in New Issue
Block a user