Compare commits

...

5 Commits

Author SHA1 Message Date
unkin-agent 55151439d8 Replace legacy jellyfin app with fafflix (adult, cheeztv pattern)
ci/woodpecker/pr/vector-test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/kubeconform Pipeline was successful
Rebuild the adult media instance as 'fafflix' following the same HA pattern
as the kids instance (cheeztv): Postgres-backed jellyfin-ha fork, Valkey
transcode-lease store, shared-RWX transcode, CNPG + k8up/restic backups,
static CephFS media PVs.

- Add apps/base/fafflix (namespace/labels/secrets/buckets/Vault path fafflix-*)
- fafflix mounts the shared movies/tv subvolumes' adult subtree at
  /media/{movies,tv} plus the kids subtree at /media/{movies,tv}-kids so it can
  resume kids content started on cheeztv; new unique static PV volumeHandles
- Keep serving the legacy hostname jellyfin.k8s.syd1.au.unkin.net (fafflix-tls);
  dedicated fafflix domain deferred, no new public host
- config PVC on cephfs-raid5-delete
- Remove apps/base/jellyfin + overlay; swap jellyfin->fafflix in the media
  ApplicationSet glob and AppProject namespace destination
- No data currently on the adult instance, so the wipe/replace is sanctioned
2026-08-25 20:03:34 +10:00
unkin-agent 94da740a9d Add cheeztv kids Jellyfin instance + fafflix kids mounts (#413)
## Why
Kids need their own safe Jellyfin front (cheeztv.unkin.net) with a library that only ever exposes kids titles, while those same titles stay resumable in the existing adult instance (fafflix). The fafflix.unkin.net domain switch is deliberately out of scope.

## How
- **New `cheeztv` instance** (`apps/base/cheeztv`): mirrors the fafflix HA Jellyfin stack 1:1 - same `jellyfin-ha:v0.1.3` fork image, 2-replica StatefulSet, RWX transcode PVC + HA takeover, Intel iGPU transcode, CNPG Postgres + PgBouncer, Valkey transcode store, k8up config backup, VMPodScrape.
- **Kids-only media**: its own static PVs on the *same* shared movies/tv CephFS subvolumes, mounted with `subPath: kids` so it sees only `movies/kids` and `tvshows/kids`.
- **Fully separate state**: own namespace, config PVC, `cheeztv-postgres` cluster, `cheeztv-valkey`, and `cnpg-cheeztv` / `cheeztv-config-backup` RGW buckets - nothing shared with fafflix.
- **DNS/cert**: `cheeztv.unkin.net` via internal Traefik + external-dns (198.18.200.4) + cert-manager `cheeztv-tls`, matching logviewer.unkin.net; plus the cluster hostname `cheeztv.k8s.syd1.au.unkin.net` mirroring fafflix.
- **fafflix change**: ADDS `movies/kids` + `tvshows/kids` subPath mounts alongside its current media mounts so kids libraries are resumable there. Existing mounts, hostname and ingress untouched.
- Registers cheeztv in the media ApplicationSet generator and AppProject destinations.

## Validation
kubeconform clean: cheeztv 28/28, jellyfin 25/25 valid. No arr/watchstate/domain changes.

Note: the k8up restic repo password must be seeded at `kv/kubernetes/namespace/cheeztv/default/k8up-restic` (same manual step as fafflix); templated Vault policy already grants read, no terraform-vault change needed.
Reviewed-on: #413
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-25 19:59:08 +10:00
unkin-agent b419635fc2 arrstack: add kids backend tier (sonarr/radarr/nzbget-kids) (#412)
Splits the arrstack media pipeline into an adult and a kids tier so kids content lives in its own libraries, databases, and downloader while reusing the shared prowlarr, CNPG cluster, Valkey, media PVCs, and S3 buckets. Backend workloads only — no routing/ingress, no arrproxy, no adult-instance changes in this PR.

How:
- sonarr-kids / radarr-kids: mirror the adult -unkin fork Deployments (same images/tags, 3 active-active replicas) on their own Postgres DBs (sonarr-kids-main / radarr-kids-main), UrlBase /3df803/sonarr and /3df803/radarr, and kids MediaCover/Backup S3 prefixes.
- Media mounts use the tvshows/kids and movies/kids subPaths of the shared media-tv / media-movies PVCs, so kids libraries are isolated on the same CephFS subvolumes.
- nzbget-kids: second downloader on its own RWO config PVC, completed downloads land in the kids media subtrees for same-filesystem hardlink imports.
- postgres: two managed roles + Database CRDs and their VSO-synced <app>-kids-db credential secrets.
- per-app VMPodScrape for each kids exportarr sidecar; new apps registered in the base kustomization.

Validated: kustomize build + kubeconform (96/96 valid), yamllint + pre-commit clean.
Reviewed-on: #412
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-25 08:03:21 +10:00
unkin-agent a064d9a3c4 arrproxy: bump images to v0.3.1 (#411)
Deploy the new space-pirate UI background released in arrproxy v0.3.1.

- Bump arrproxy-api image to v0.3.1
- Bump arrproxy-ui image to v0.3.1

Reviewed-on: #411
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-24 21:42:28 +10:00
unkin-agent aea319cec0 authentik: ClientIP session affinity on server service (#410)
## Why

Tofu applies against authentik hit a cross-replica read-back race: a create lands on one of the 3 server pods and the provider's immediate read-back hits another pod that returns stale data, failing the run with "inconsistent result after apply" (upstream bug goauthentik/terraform-provider-authentik#954, still open). ClientIP session affinity keeps a whole apply run on a single pod, sidestepping the race until the provider is fixed.

## Changes

- Sets `server.service.sessionAffinity: ClientIP` with `sessionAffinityConfig.clientIP.timeoutSeconds: 60` in the au-syd1 authentik overlay values (rendered by the vendored chart's `templates/server/service.yaml`)

Reviewed-on: #410
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 23:13:51 +10:00
73 changed files with 2177 additions and 143 deletions
@@ -33,7 +33,7 @@ spec:
type: RuntimeDefault
containers:
- name: api
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-api:v0.3.0
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-api:v0.3.1
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
@@ -31,7 +31,7 @@ spec:
type: RuntimeDefault
containers:
- name: ui
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-ui:v0.3.0
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-ui:v0.3.1
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
+3
View File
@@ -19,5 +19,8 @@ resources:
- radarr
- prowlarr
- nzbget
- sonarr-kids
- radarr-kids
- nzbget-kids
- arrproxy
- mediamover
@@ -0,0 +1,142 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nzbget-kids
namespace: arrstack
spec:
replicas: 1
strategy:
# RWO config PVC + single queue state: never run two pods at once.
type: Recreate
selector:
matchLabels:
app: nzbget-kids
template:
metadata:
labels:
app: nzbget-kids
spec:
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Seed download layout onto the kids media subtrees (not /config or an
# emptyDir) so completed kids downloads land beside the kids arr libraries
# and imports are same-filesystem hardlink moves. The media mounts use the
# tvshows/kids and movies/kids subPaths, so the in-container paths match
# the adult nzbget while the data stays scoped to the kids subtree. Reuses
# the image's own template and appends the path/category overrides once;
# the grep guard keeps re-runs idempotent so admin UI edits survive.
- name: seed-config
image: docker.io/linuxserver/nzbget:version-v26.2
command:
- sh
- -c
- |
set -e
if [ ! -f /config/nzbget.conf ]; then
cp /app/nzbget/share/nzbget/nzbget.conf /config/nzbget.conf
fi
if ! grep -q '# arrstack-managed' /config/nzbget.conf; then
cat >> /config/nzbget.conf << 'CONF'
# arrstack-managed download layout (appended once; last value wins).
# Downloads land on the shared media PVCs by category so sonarr-kids/
# radarr-kids import with atomic hardlink moves (download dir +
# library share one filesystem per media type). InterDir is empty:
# nzbget writes each download straight into its category DestDir, so
# BOTH tv and movies stay on their own PVC with no cross-filesystem
# intermediate copy.
MainDir=/media/tv
InterDir=
DestDir=/media/tv/downloads
NzbDir=/config/nzb
QueueDir=/config/queue
TempDir=/config/tmp
ControlIP=0.0.0.0
ControlPort=6789
Category1.Name=tv
Category1.DestDir=/media/tv/downloads
Category2.Name=movies
Category2.DestDir=/media/movies/downloads
CONF
fi
mkdir -p /media/tv/series /media/tv/downloads /media/movies/films /media/movies/downloads
chown 1000:1000 /config/nzbget.conf \
/media/tv /media/tv/series /media/tv/downloads \
/media/movies /media/movies/films /media/movies/downloads
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
volumeMounts:
- name: config
mountPath: /config
- name: media-tv
mountPath: /media/tv
subPath: tvshows/kids
- name: media-movies
mountPath: /media/movies
subPath: movies/kids
containers:
- name: nzbget
image: docker.io/linuxserver/nzbget:version-v26.2
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 6789
protocol: TCP
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: Australia/Sydney
livenessProbe:
# nzbget's root path requires auth (401); a TCP check is the
# dependency-free liveness signal for the web/JSON-RPC server.
tcpSocket:
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
# Headroom for par2 repair + unpack of large downloads.
cpu: "2"
memory: 2Gi
volumeMounts:
- name: config
mountPath: /config
- name: media-tv
mountPath: /media/tv
subPath: tvshows/kids
- name: media-movies
mountPath: /media/movies
subPath: movies/kids
volumes:
- name: config
persistentVolumeClaim:
claimName: nzbget-kids-config
- name: media-tv
persistentVolumeClaim:
claimName: media-tv
- name: media-movies
persistentVolumeClaim:
claimName: media-movies
@@ -0,0 +1,8 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pvc-config.yaml
- deployment.yaml
- service.yaml
@@ -0,0 +1,16 @@
---
# NZBGet (kids) config + queue/temp state. RWO on cephrbd (block, fast-delete).
# The download data itself lives on the shared media PVCs, not here.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nzbget-kids-config
namespace: arrstack
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: cephrbd-fast-delete
volumeMode: Filesystem
@@ -0,0 +1,15 @@
---
apiVersion: v1
kind: Service
metadata:
name: nzbget-kids
namespace: arrstack
spec:
ports:
- name: http
port: 6789
protocol: TCP
targetPort: http
selector:
app: nzbget-kids
type: ClusterIP
@@ -86,6 +86,30 @@ spec:
connectionLimit: -1
passwordSecret:
name: prowlarr-db
- name: sonarr-kids
ensure: present
comment: Sonarr (kids) application role (owns sonarr-kids-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: sonarr-kids-db
- name: radarr-kids
ensure: present
comment: Radarr (kids) application role (owns radarr-kids-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: radarr-kids-db
enablePDB: true
enableSuperuserAccess: false
failoverDelay: 0
@@ -0,0 +1,15 @@
---
# Per-app database owned by the radarr-kids managed role. The fork's provider
# runs its own schema migrations on first start (advisory-locked, so only one
# replica migrates). retain: the database survives a Database CRD delete.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: radarr-kids-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: radarr-kids-main
owner: radarr-kids
databaseReclaimPolicy: retain
@@ -0,0 +1,15 @@
---
# Per-app database owned by the sonarr-kids managed role. The fork's provider
# runs its own schema migrations on first start (advisory-locked, so only one
# replica migrates). retain: the database survives a Database CRD delete.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: sonarr-kids-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: sonarr-kids-main
owner: sonarr-kids
databaseReclaimPolicy: retain
@@ -9,3 +9,5 @@ resources:
- database-sonarr.yaml
- database-radarr.yaml
- database-prowlarr.yaml
- database-sonarr-kids.yaml
- database-radarr-kids.yaml
@@ -58,3 +58,37 @@ spec:
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-kids-db
namespace: arrstack
spec:
destination:
create: true
name: sonarr-kids-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr-kids-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-kids-db
namespace: arrstack
spec:
destination:
create: true
name: radarr-kids-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr-kids-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -0,0 +1,25 @@
---
# Non-secret env for the -unkin2 fork (kids tier). Identical mechanism to the
# adult radarr, pointed at its own shared-Postgres database (radarr-kids-main)
# and its own UrlBase (/3df803/radarr) so arrproxy path-routing reaches the kids
# instance separately. Shares the one arrstack Valkey (keys namespaced by the
# fork's radarr:ratelimit: prefix). User/Password/ApiKey come from Secrets (see
# deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: radarr-kids-env
namespace: arrstack
data:
Radarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Radarr__Postgres__Port: "5432"
Radarr__Postgres__MainDb: radarr-kids-main
Radarr__Log__DbEnabled: "false"
Radarr__Auth__Method: External
Radarr__Auth__Required: DisabledForLocalAddresses
Radarr__App__InstanceName: Radarr
Radarr__Server__Port: "7878"
Radarr__Server__UrlBase: /3df803/radarr
Radarr__Update__Mechanism: External
Radarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Radarr__Redis__Port: "6379"
@@ -0,0 +1,245 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: radarr-kids
namespace: arrstack
annotations:
# Reloader rolls the Deployment when radarr-kids-env changes (plain envFrom
# ConfigMap does not trigger a rollout on its own).
configmap.reloader.stakater.com/auto: "true"
spec:
# Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the radarr-kids Service. RollingUpdate is
# safe — no SQLite, no RWO lock.
replicas: 3
strategy:
type: RollingUpdate
selector:
matchLabels:
app: radarr-kids
template:
metadata:
labels:
app: radarr-kids
spec:
securityContext:
# Fork image has no USER; pin it to a non-root UID and group-write the
# shared RWX CephFS media subtree. OnRootMismatch avoids a recursive
# chown of the whole media tree.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Gate the app on its own Postgres database+role being reachable.
# waitfordb reads the PG* env as a libpq fallback, so the password never
# lands in argv.
- name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env:
- name: WAITFORDB_TIMEOUT
value: 5m
- name: WAITFORDB_SSLMODE
value: disable
- name: PGHOST
value: arrstack-postgres-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: radarr-kids-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: radarr-kids-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: radarr-kids-db
key: password
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
containers:
- name: radarr
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/radarr:v6.4.2-unkin7
imagePullPolicy: IfNotPresent
command:
- /app/Radarr
args:
- -nobrowser
- -data=/config
# Bypass the single-instance guard so multiple replicas can share one
# /config. Cross-replica safety is the Postgres layer, not a lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 7878
protocol: TCP
envFrom:
- configMapRef:
name: radarr-kids-env
env:
- name: Radarr__Postgres__User
valueFrom:
secretKeyRef:
name: radarr-kids-db
key: username
- name: Radarr__Postgres__Password
valueFrom:
secretKeyRef:
name: radarr-kids-db
key: password
- name: Radarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: radarr-kids-apikey
key: apitoken
# MediaCover object store (shared arrstack-media Ceph RGW bucket,
# partitioned by the radarr-kids key prefix).
- name: Radarr__MediaCover__S3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: S3_ENDPOINT
- name: Radarr__MediaCover__S3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_ACCESS_KEY_ID
- name: Radarr__MediaCover__S3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_SECRET_ACCESS_KEY
- name: Radarr__MediaCover__S3__Bucket
value: arrstack-media
- name: Radarr__MediaCover__S3__Prefix
value: radarr-kids
- name: Radarr__MediaCover__S3__ForcePathStyle
value: "true"
- name: Radarr__MediaCover__S3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket,
# partitioned by the radarr-kids key prefix).
- name: Radarr__BackupS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: S3_ENDPOINT
- name: Radarr__BackupS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_ACCESS_KEY_ID
- name: Radarr__BackupS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_SECRET_ACCESS_KEY
- name: Radarr__BackupS3__Bucket
value: arrstack-backups
- name: Radarr__BackupS3__Prefix
value: radarr-kids
- name: Radarr__BackupS3__ForcePathStyle
value: "true"
- name: Radarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /3df803/radarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /3df803/radarr/ping
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: config
mountPath: /config
# Kids movies subtree of the shared media-movies PVC (same CephFS
# subvolume the adult radarr writes and jellyfin reads).
- name: media-movies
mountPath: /media/movies
subPath: movies/kids
- name: vault-ca
mountPath: /etc/ssl/vault-ca
readOnly: true
# exportarr sidecar: polls the local replica's API and exposes Prometheus
# metrics on :9708 (scraped by the radarr-kids-exportarr VMPodScrape).
- name: exportarr
image: ghcr.io/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent
args:
- radarr
env:
- name: PORT
value: "9708"
# URL includes the /3df803/radarr UrlBase (Radarr__Server__UrlBase).
- name: URL
value: http://localhost:7878/3df803/radarr
- name: APIKEY
valueFrom:
secretKeyRef:
name: radarr-kids-apikey
key: apitoken
ports:
- name: metrics
containerPort: 9708
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
volumes:
- name: config
emptyDir: {}
- name: media-movies
persistentVolumeClaim:
claimName: media-movies
# Estate CA for validating the Ceph RGW (s3.ceph.unkin.net) TLS cert.
- name: vault-ca
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
@@ -0,0 +1,10 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- vmpodscrape.yaml
@@ -0,0 +1,15 @@
---
apiVersion: v1
kind: Service
metadata:
name: radarr-kids
namespace: arrstack
spec:
ports:
- name: http
port: 7878
protocol: TCP
targetPort: http
selector:
app: radarr-kids
type: ClusterIP
@@ -0,0 +1,25 @@
---
# radarr-kids API key. Seeded out-of-band at
# kv/kubernetes/namespace/arrstack/default/radarr-kids (key: apitoken); the
# default k8s role's templated policy already grants read on
# kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs it into the radarr-kids-apikey Secret consumed by the Deployment.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-kids-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: radarr-kids-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr-kids
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -0,0 +1,16 @@
---
# Scrape the exportarr sidecar (:9708) on every radarr-kids pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the radarr-kids Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: radarr-kids-exportarr
namespace: arrstack
spec:
selector:
matchLabels:
app: radarr-kids
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -0,0 +1,25 @@
---
# Non-secret env for the -unkin2 fork (kids tier). Identical mechanism to the
# adult sonarr, pointed at its own shared-Postgres database (sonarr-kids-main)
# and its own UrlBase (/3df803/sonarr) so arrproxy path-routing reaches the kids
# instance separately. Shares the one arrstack Valkey (keys namespaced by the
# fork's sonarr:ratelimit: prefix). User/Password/ApiKey come from Secrets (see
# deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: sonarr-kids-env
namespace: arrstack
data:
Sonarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Sonarr__Postgres__Port: "5432"
Sonarr__Postgres__MainDb: sonarr-kids-main
Sonarr__Log__DbEnabled: "false"
Sonarr__Auth__Method: External
Sonarr__Auth__Required: DisabledForLocalAddresses
Sonarr__App__InstanceName: Sonarr
Sonarr__Server__Port: "8989"
Sonarr__Server__UrlBase: /3df803/sonarr
Sonarr__Update__Mechanism: External
Sonarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Sonarr__Redis__Port: "6379"
@@ -0,0 +1,245 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sonarr-kids
namespace: arrstack
annotations:
# Reloader rolls the Deployment when sonarr-kids-env changes (plain envFrom
# ConfigMap does not trigger a rollout on its own).
configmap.reloader.stakater.com/auto: "true"
spec:
# Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the sonarr-kids Service. RollingUpdate is
# safe — no SQLite, no RWO lock.
replicas: 3
strategy:
type: RollingUpdate
selector:
matchLabels:
app: sonarr-kids
template:
metadata:
labels:
app: sonarr-kids
spec:
securityContext:
# Fork image has no USER; pin it to a non-root UID and group-write the
# shared RWX CephFS media subtree. OnRootMismatch avoids a recursive
# chown of the whole media tree.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Gate the app on its own Postgres database+role being reachable.
# waitfordb reads the PG* env as a libpq fallback, so the password never
# lands in argv.
- name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env:
- name: WAITFORDB_TIMEOUT
value: 5m
- name: WAITFORDB_SSLMODE
value: disable
- name: PGHOST
value: arrstack-postgres-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: sonarr-kids-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: sonarr-kids-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: sonarr-kids-db
key: password
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
containers:
- name: sonarr
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/sonarr:v5.0.0-unkin6
imagePullPolicy: IfNotPresent
command:
- /app/Sonarr
args:
- -nobrowser
- -data=/config
# Bypass the single-instance guard so multiple replicas can share one
# /config. Cross-replica safety is the Postgres layer, not a lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 8989
protocol: TCP
envFrom:
- configMapRef:
name: sonarr-kids-env
env:
- name: Sonarr__Postgres__User
valueFrom:
secretKeyRef:
name: sonarr-kids-db
key: username
- name: Sonarr__Postgres__Password
valueFrom:
secretKeyRef:
name: sonarr-kids-db
key: password
- name: Sonarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: sonarr-kids-apikey
key: apitoken
# MediaCover object store (shared arrstack-media Ceph RGW bucket,
# partitioned by the sonarr-kids key prefix).
- name: Sonarr__MediaCoverS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: S3_ENDPOINT
- name: Sonarr__MediaCoverS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_ACCESS_KEY_ID
- name: Sonarr__MediaCoverS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_SECRET_ACCESS_KEY
- name: Sonarr__MediaCoverS3__Bucket
value: arrstack-media
- name: Sonarr__MediaCoverS3__Prefix
value: sonarr-kids
- name: Sonarr__MediaCoverS3__ForcePathStyle
value: "true"
- name: Sonarr__MediaCoverS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket,
# partitioned by the sonarr-kids key prefix).
- name: Sonarr__BackupS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: S3_ENDPOINT
- name: Sonarr__BackupS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_ACCESS_KEY_ID
- name: Sonarr__BackupS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_SECRET_ACCESS_KEY
- name: Sonarr__BackupS3__Bucket
value: arrstack-backups
- name: Sonarr__BackupS3__Prefix
value: sonarr-kids
- name: Sonarr__BackupS3__ForcePathStyle
value: "true"
- name: Sonarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /3df803/sonarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /3df803/sonarr/ping
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: config
mountPath: /config
# Kids TV subtree of the shared media-tv PVC (same CephFS subvolume
# the adult sonarr writes and jellyfin reads).
- name: media-tv
mountPath: /media/tv
subPath: tvshows/kids
- name: vault-ca
mountPath: /etc/ssl/vault-ca
readOnly: true
# exportarr sidecar: polls the local replica's API and exposes Prometheus
# metrics on :9707 (scraped by the sonarr-kids-exportarr VMPodScrape).
- name: exportarr
image: ghcr.io/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent
args:
- sonarr
env:
- name: PORT
value: "9707"
# URL includes the /3df803/sonarr UrlBase (Sonarr__Server__UrlBase).
- name: URL
value: http://localhost:8989/3df803/sonarr
- name: APIKEY
valueFrom:
secretKeyRef:
name: sonarr-kids-apikey
key: apitoken
ports:
- name: metrics
containerPort: 9707
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
volumes:
- name: config
emptyDir: {}
- name: media-tv
persistentVolumeClaim:
claimName: media-tv
# Estate CA for validating the Ceph RGW (s3.ceph.unkin.net) TLS cert.
- name: vault-ca
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
@@ -0,0 +1,10 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- vmpodscrape.yaml
@@ -0,0 +1,15 @@
---
apiVersion: v1
kind: Service
metadata:
name: sonarr-kids
namespace: arrstack
spec:
ports:
- name: http
port: 8989
protocol: TCP
targetPort: http
selector:
app: sonarr-kids
type: ClusterIP
@@ -0,0 +1,25 @@
---
# sonarr-kids API key. Seeded out-of-band at
# kv/kubernetes/namespace/arrstack/default/sonarr-kids (key: apitoken); the
# default k8s role's templated policy already grants read on
# kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs it into the sonarr-kids-apikey Secret consumed by the Deployment.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-kids-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: sonarr-kids-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr-kids
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -0,0 +1,16 @@
---
# Scrape the exportarr sidecar (:9707) on every sonarr-kids pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the sonarr-kids Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: sonarr-kids-exportarr
namespace: arrstack
spec:
selector:
matchLabels:
app: sonarr-kids
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -1,32 +1,32 @@
---
# Second Ceph RGW (S3) bucket owned by the existing jellyfin backup user
# (cnpg-jellyfin-backup, defined in cnpg_backup.yaml) — one user, two buckets:
# Second Ceph RGW (S3) bucket owned by the existing cheeztv backup user
# (cnpg-cheeztv-backup, defined in cnpg_backup.yaml) — one user, two buckets:
# the CNPG barman bucket plus this one, which k8up uses to hold restic backups
# of the jellyfin-config PVC. The BucketAccess emits read-write S3 creds into a
# of the cheeztv-config PVC. The BucketAccess emits read-write S3 creds into a
# Secret the k8up Schedule consumes.
apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket
metadata:
name: jellyfin-config-backup
namespace: jellyfin
name: cheeztv-config-backup
namespace: cheeztv
spec:
placementTarget: ec
bucketName: jellyfin-config-backup
ownerRef: cnpg-jellyfin-backup
bucketName: cheeztv-config-backup
ownerRef: cnpg-cheeztv-backup
versioning: false
tags:
app: jellyfin
app: cheeztv
purpose: config-backup
retainOnDelete: true
---
apiVersion: ceph.unkin.net/v1alpha1
kind: BucketAccess
metadata:
name: jellyfin-config-backup
namespace: jellyfin
name: cheeztv-config-backup
namespace: cheeztv
spec:
bucketRef: jellyfin-config-backup
bucketRef: cheeztv-config-backup
level: read-write
# Operator writes AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (+ S3_ENDPOINT,
# BUCKET_NAME) into this Secret; the k8up Schedule reads the access keys.
secretName: jellyfin-config-backup-s3
secretName: cheeztv-config-backup-s3
@@ -1,31 +1,31 @@
---
# Ceph RGW (S3) backup target for the jellyfin CNPG cluster, provisioned by the
# Ceph RGW (S3) backup target for the cheeztv CNPG cluster, provisioned by the
# in-estate cephrgw-operator: one dedicated bucket + owner user. CNPG reads the
# S3 credential Secret from its own namespace.
apiVersion: ceph.unkin.net/v1alpha1
kind: ObjectStoreUser
metadata:
name: cnpg-jellyfin-backup
namespace: jellyfin
name: cnpg-cheeztv-backup
namespace: cheeztv
spec:
displayName: "CNPG backup owner (jellyfin)"
uid: cnpg-jellyfin-backup
displayName: "CNPG backup owner (cheeztv)"
uid: cnpg-cheeztv-backup
maxBuckets: 5
secretName: cnpg-jellyfin-backup-s3
secretName: cnpg-cheeztv-backup-s3
retainOnDelete: true
---
apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket
metadata:
name: cnpg-jellyfin
namespace: jellyfin
name: cnpg-cheeztv
namespace: cheeztv
spec:
placementTarget: ec
bucketName: cnpg-jellyfin
ownerRef: cnpg-jellyfin-backup
bucketName: cnpg-cheeztv
ownerRef: cnpg-cheeztv-backup
versioning: false
tags:
app: jellyfin
app: cheeztv
purpose: cnpg-backup
retainOnDelete: true
---
@@ -34,12 +34,12 @@ spec:
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: cnpg-jellyfin-nightly
namespace: jellyfin
name: cnpg-cheeztv-nightly
namespace: cheeztv
spec:
schedule: "0 35 3 * * *"
immediate: false
backupOwnerReference: self
method: barmanObjectStore
cluster:
name: jellyfin-postgres
name: cheeztv-postgres
@@ -1,17 +1,17 @@
---
# Main Jellyfin database. The jellyfin-ha fork's experimental EF Core provider
# Main Jellyfin database. The cheeztv-ha fork's experimental EF Core provider
# moves the entire Jellyfin DB (incl. library items) off SQLite into PostgreSQL,
# which is what makes a shared-nothing multi-replica deployment possible. No
# bootstrap secret is given, so CNPG generates the jellyfin-postgres-app secret
# bootstrap secret is given, so CNPG generates the cheeztv-postgres-app secret
# (username/password/dbname) that the StatefulSet composes its DSN from.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: jellyfin-postgres
namespace: jellyfin
name: cheeztv-postgres
namespace: cheeztv
spec:
# Exclude the operator-managed data PVCs (jellyfin-postgres-N) from the
# jellyfin-config k8up Schedule (skipWithoutAnnotation is false cluster-wide,
# Exclude the operator-managed data PVCs (cheeztv-postgres-N) from the
# cheeztv-config k8up Schedule (skipWithoutAnnotation is false cluster-wide,
# so unannotated PVCs are swept in). Postgres has its own barmanObjectStore
# backup below; restic must not touch the raw RWO data volumes.
inheritedMetadata:
@@ -23,19 +23,19 @@ spec:
retentionPolicy: 30d
barmanObjectStore:
# Dedicated per-cluster Ceph RGW bucket (cephrgw-operator provisions it).
destinationPath: s3://cnpg-jellyfin
destinationPath: s3://cnpg-cheeztv
endpointURL: https://s3.ceph.unkin.net
endpointCA:
name: vault-ca-cert
key: ca.crt
s3Credentials:
accessKeyId:
name: cnpg-jellyfin-backup-s3
name: cnpg-cheeztv-backup-s3
key: AWS_ACCESS_KEY_ID
secretAccessKey:
name: cnpg-jellyfin-backup-s3
name: cnpg-cheeztv-backup-s3
key: AWS_SECRET_ACCESS_KEY
serverName: jellyfin
serverName: cheeztv
data:
compression: bzip2
jobs: 2
@@ -44,11 +44,11 @@ spec:
maxParallel: 2
bootstrap:
initdb:
database: jellyfin
database: cheeztv
encoding: UTF8
localeCType: C
localeCollate: C
owner: jellyfin
owner: cheeztv
enablePDB: true
enableSuperuserAccess: false
failoverDelay: 0
@@ -1,15 +1,15 @@
---
# PgBouncer pooler in front of the jellyfin-postgres cluster. Jellyfin connects
# here (jellyfin-postgres-pooler:5432) rather than the -rw service so EF Core's
# PgBouncer pooler in front of the cheeztv-postgres cluster. Jellyfin connects
# here (cheeztv-postgres-pooler:5432) rather than the -rw service so EF Core's
# connection churn is absorbed by the pool.
apiVersion: postgresql.cnpg.io/v1
kind: Pooler
metadata:
name: jellyfin-postgres-pooler
namespace: jellyfin
name: cheeztv-postgres-pooler
namespace: cheeztv
spec:
cluster:
name: jellyfin-postgres
name: cheeztv-postgres
instances: 2
pgbouncer:
parameters:
@@ -20,7 +20,7 @@ spec:
template:
metadata:
labels:
app: jellyfin-pooler
app: cheeztv-pooler
spec:
affinity:
podAntiAffinity:
@@ -30,7 +30,7 @@ spec:
- key: app
operator: In
values:
- jellyfin-pooler
- cheeztv-pooler
topologyKey: kubernetes.io/hostname
containers: []
type: rw
+81
View File
@@ -0,0 +1,81 @@
---
# Friendly public hostname for the kids instance, cheeztv.unkin.net. Same
# internal-Traefik + external-dns pattern as logviewer.unkin.net: external-dns
# publishes the A record at the internal LB VIP (198.18.200.4) and cert-manager
# mints cheeztv-tls (CN cheeztv.unkin.net) off the Vault-PKI cluster issuer.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
labels:
traefik.io/instance: internal
annotations:
cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: cheeztv.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: cheeztv.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: cheeztv
namespace: cheeztv
spec:
gatewayClassName: traefik-internal
listeners:
- allowedRoutes:
namespaces:
from: Same
hostname: cheeztv.unkin.net
name: http
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: cheeztv.unkin.net
name: https
port: 443
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
name: cheeztv-tls
mode: Terminate
---
# Cluster hostname variant, mirroring fafflix's jellyfin.k8s.syd1.au.unkin.net
# gateway 1:1 (internal Traefik, external-dns at 198.18.200.4). Own cert secret
# so each hostname gets its own leaf.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
labels:
traefik.io/instance: internal
annotations:
cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: cheeztv.k8s.syd1.au.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: cheeztv.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: cheeztv-internal
namespace: cheeztv
spec:
gatewayClassName: traefik-internal
listeners:
- allowedRoutes:
namespaces:
from: Same
hostname: cheeztv.k8s.syd1.au.unkin.net
name: http
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: cheeztv.k8s.syd1.au.unkin.net
name: https
port: 443
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
name: cheeztv-internal-tls
mode: Terminate
+98
View File
@@ -0,0 +1,98 @@
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: http-redirect
namespace: cheeztv
spec:
hostnames:
- cheeztv.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cheeztv
sectionName: http
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
matches:
- path:
type: PathPrefix
value: /
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: cheeztv-route
namespace: cheeztv
spec:
hostnames:
- cheeztv.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cheeztv
sectionName: https
rules:
- backendRefs:
- group: ""
kind: Service
name: cheeztv
port: 8096
weight: 1
matches:
- path:
type: PathPrefix
value: /
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: http-redirect-internal
namespace: cheeztv
spec:
hostnames:
- cheeztv.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cheeztv-internal
sectionName: http
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
matches:
- path:
type: PathPrefix
value: /
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: cheeztv-route-internal
namespace: cheeztv
spec:
hostnames:
- cheeztv.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cheeztv-internal
sectionName: https
rules:
- backendRefs:
- group: ""
kind: Service
name: cheeztv
port: 8096
weight: 1
matches:
- path:
type: PathPrefix
value: /
@@ -2,4 +2,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: jellyfin
name: cheeztv
@@ -4,10 +4,10 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: jellyfin
namespace: jellyfin
name: cheeztv
namespace: cheeztv
spec:
minAvailable: 1
selector:
matchLabels:
app: jellyfin
app: cheeztv
@@ -1,11 +1,11 @@
---
# Static PV for the shared MOVIES CephFS subvolume. Same rootPath as arrstack's
# movies PV so radarr writes and jellyfin reads the identical library tree; each
# movies PV so radarr writes and cheeztv reads the identical library tree; each
# namespace gets its own PV (unique name + volumeHandle) pinned by claimRef.
apiVersion: v1
kind: PersistentVolume
metadata:
name: jellyfin-media-movies
name: cheeztv-media-movies
spec:
capacity:
storage: 1Ti
@@ -15,11 +15,11 @@ spec:
storageClassName: ""
volumeMode: Filesystem
claimRef:
namespace: jellyfin
name: jellyfin-media-movies
namespace: cheeztv
name: cheeztv-media-movies
csi:
driver: cephfs.csi.ceph.com
volumeHandle: jellyfin-media-movies-static
volumeHandle: cheeztv-media-movies-static
nodeStageSecretRef:
name: csi-cephfs-secret
namespace: csi-cephfs
@@ -1,11 +1,11 @@
---
# Static PV for the shared TV CephFS subvolume. Same rootPath as arrstack's
# TV PV so sonarr writes and jellyfin reads the identical library tree; each
# TV PV so sonarr writes and cheeztv reads the identical library tree; each
# namespace gets its own PV (unique name + volumeHandle) pinned by claimRef.
apiVersion: v1
kind: PersistentVolume
metadata:
name: jellyfin-media-tv
name: cheeztv-media-tv
spec:
capacity:
storage: 1Ti
@@ -15,11 +15,11 @@ spec:
storageClassName: ""
volumeMode: Filesystem
claimRef:
namespace: jellyfin
name: jellyfin-media-tv
namespace: cheeztv
name: cheeztv-media-tv
csi:
driver: cephfs.csi.ceph.com
volumeHandle: jellyfin-media-tv-static
volumeHandle: cheeztv-media-tv-static
nodeStageSecretRef:
name: csi-cephfs-secret
namespace: csi-cephfs
@@ -1,17 +1,17 @@
---
# Jellyfin config: metadata images, plugins, subtitles and config XML. Shared
# ReadWriteMany across replicas (all pods read/write the same library metadata);
# the main library DB now lives in PostgreSQL, not here. Retain — this is state.
# the main library DB now lives in PostgreSQL, not here (on CephFS, raid5-delete).
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jellyfin-config
namespace: jellyfin
name: cheeztv-config
namespace: cheeztv
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 20Gi
storageClassName: cephfs-raid5-retain
storageClassName: cephfs-raid5-delete
volumeMode: Filesystem
@@ -1,16 +1,16 @@
---
# Movie library, shared read-many across replicas. Statically bound to the
# jellyfin-media-movies PV (shared CephFS subvolume also used by arrstack/radarr).
# cheeztv-media-movies PV (shared CephFS subvolume also used by arrstack/radarr).
# storageClassName "" + volumeName disables dynamic provisioning and binds the
# pre-created static PV.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jellyfin-media-movies
namespace: jellyfin
name: cheeztv-media-movies
namespace: cheeztv
annotations:
# Exclude from the jellyfin-config k8up Schedule (skipWithoutAnnotation is
# false cluster-wide, so unannotated PVCs are swept in). Only jellyfin-config
# Exclude from the cheeztv-config k8up Schedule (skipWithoutAnnotation is
# false cluster-wide, so unannotated PVCs are swept in). Only cheeztv-config
# is backed up; the media library is not restic-backup material.
k8up.io/backup: "false"
spec:
@@ -20,5 +20,5 @@ spec:
requests:
storage: 1Ti
storageClassName: ""
volumeName: jellyfin-media-movies
volumeName: cheeztv-media-movies
volumeMode: Filesystem
@@ -1,16 +1,16 @@
---
# TV library, shared read-many across replicas. Statically bound to the
# jellyfin-media-tv PV (shared CephFS subvolume also used by arrstack/sonarr).
# cheeztv-media-tv PV (shared CephFS subvolume also used by arrstack/sonarr).
# storageClassName "" + volumeName disables dynamic provisioning and binds the
# pre-created static PV.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jellyfin-media-tv
namespace: jellyfin
name: cheeztv-media-tv
namespace: cheeztv
annotations:
# Exclude from the jellyfin-config k8up Schedule (skipWithoutAnnotation is
# false cluster-wide, so unannotated PVCs are swept in). Only jellyfin-config
# Exclude from the cheeztv-config k8up Schedule (skipWithoutAnnotation is
# false cluster-wide, so unannotated PVCs are swept in). Only cheeztv-config
# is backed up; the media library is not restic-backup material.
k8up.io/backup: "false"
spec:
@@ -20,5 +20,5 @@ spec:
requests:
storage: 1Ti
storageClassName: ""
volumeName: jellyfin-media-tv
volumeName: cheeztv-media-tv
volumeMode: Filesystem
@@ -6,10 +6,10 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jellyfin-transcode
namespace: jellyfin
name: cheeztv-transcode
namespace: cheeztv
annotations:
# Exclude from the jellyfin-config k8up Schedule (skipWithoutAnnotation is
# Exclude from the cheeztv-config k8up Schedule (skipWithoutAnnotation is
# false cluster-wide, so unannotated PVCs are swept in). Transcode is RWX
# scratch — nothing to back up.
k8up.io/backup: "false"
@@ -1,8 +1,8 @@
---
# k8up Schedule: restic backups of the jellyfin-config PVC (library metadata,
# k8up Schedule: restic backups of the cheeztv-config PVC (library metadata,
# plugins, config XML) to the dedicated Ceph RGW config-backup bucket. S3 creds
# come from the cephrgw BucketAccess Secret (jellyfin-config-backup-s3); the
# restic repo password comes from Vault via the jellyfin-k8up-restic Secret.
# come from the cephrgw BucketAccess Secret (cheeztv-config-backup-s3); the
# restic repo password comes from Vault via the cheeztv-k8up-restic Secret.
#
# s3.ceph.unkin.net presents the internal unkin.net CA, which the k8up/restic
# image does not trust by default, so the reflected vault-ca-cert Secret is
@@ -10,21 +10,21 @@
apiVersion: k8up.io/v1
kind: Schedule
metadata:
name: jellyfin-config
namespace: jellyfin
name: cheeztv-config
namespace: cheeztv
spec:
backend:
repoPasswordSecretRef:
name: jellyfin-k8up-restic
name: cheeztv-k8up-restic
key: password
s3:
endpoint: https://s3.ceph.unkin.net
bucket: jellyfin-config-backup
bucket: cheeztv-config-backup
accessKeyIDSecretRef:
name: jellyfin-config-backup-s3
name: cheeztv-config-backup-s3
key: AWS_ACCESS_KEY_ID
secretAccessKeySecretRef:
name: jellyfin-config-backup-s3
name: cheeztv-config-backup-s3
key: AWS_SECRET_ACCESS_KEY
tlsOptions:
caCert: /etc/k8up/ca/ca.crt
@@ -2,8 +2,8 @@
apiVersion: v1
kind: Service
metadata:
name: jellyfin
namespace: jellyfin
name: cheeztv
namespace: cheeztv
spec:
internalTrafficPolicy: Cluster
ports:
@@ -12,7 +12,7 @@ spec:
protocol: TCP
targetPort: http
selector:
app: jellyfin
app: cheeztv
# Pin each client to one replica to reduce transcode-session churn/takeover.
sessionAffinity: ClientIP
type: ClusterIP
@@ -2,24 +2,24 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: jellyfin
namespace: jellyfin
name: cheeztv
namespace: cheeztv
spec:
# HA: two replicas coordinate transcode session ownership through Valkey and
# resume each other's HLS segments off the shared RWX transcode PVC. Stable
# pod names (jellyfin-0/1) are the lease owner identity, hence StatefulSet.
# pod names (cheeztv-0/1) are the lease owner identity, hence StatefulSet.
replicas: 2
serviceName: jellyfin
serviceName: cheeztv
podManagementPolicy: Parallel
updateStrategy:
type: RollingUpdate
selector:
matchLabels:
app: jellyfin
app: cheeztv
template:
metadata:
labels:
app: jellyfin
app: cheeztv
spec:
securityContext:
# Group-write the shared RWX volumes and grant the render/video groups so
@@ -41,7 +41,7 @@ spec:
podAffinityTerm:
labelSelector:
matchLabels:
app: jellyfin
app: cheeztv
topologyKey: kubernetes.io/hostname
initContainers:
# Seed the fork's PostgreSQL provider (database.xml) and Intel iGPU
@@ -110,7 +110,7 @@ spec:
- name: config
mountPath: /config
containers:
- name: jellyfin
- name: cheeztv
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/jellyfin-ha:v0.1.3
imagePullPolicy: IfNotPresent
ports:
@@ -137,9 +137,9 @@ spec:
# Config dir must differ from the data root (Jellyfin sanity check).
- name: JELLYFIN_CONFIG_DIR
value: /config/config
# Distributed transcode session store (jellyfin-ha additions).
# Distributed transcode session store (cheeztv-ha additions).
- name: Jellyfin__TranscodeStore__RedisConnectionString
value: "valkey-jellyfin-valkey:6379,abortConnect=false"
value: "valkey-cheeztv-valkey:6379,abortConnect=false"
- name: Jellyfin__TranscodeStore__LeaseDurationSeconds
value: "30"
# PostgreSQL main DB via the CNPG-generated app secret, routed through
@@ -148,22 +148,22 @@ spec:
- name: PGUSER
valueFrom:
secretKeyRef:
name: jellyfin-postgres-app
name: cheeztv-postgres-app
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: jellyfin-postgres-app
name: cheeztv-postgres-app
key: password
- name: PGDB
valueFrom:
secretKeyRef:
name: jellyfin-postgres-app
name: cheeztv-postgres-app
key: dbname
- name: POSTGRES_CONNECTION_STRING
value: "postgresql://$(PGUSER):$(PGPASSWORD)@jellyfin-postgres-pooler:5432/$(PGDB)"
value: "postgresql://$(PGUSER):$(PGPASSWORD)@cheeztv-postgres-pooler:5432/$(PGDB)"
- name: DATABASE_URL
value: "postgresql://$(PGUSER):$(PGPASSWORD)@jellyfin-postgres-pooler:5432/$(PGDB)"
value: "postgresql://$(PGUSER):$(PGPASSWORD)@cheeztv-postgres-pooler:5432/$(PGDB)"
livenessProbe:
httpGet:
path: /health
@@ -208,30 +208,38 @@ spec:
- name: cache
mountPath: /cache
- name: media-tv
# Kids-only instance: mount just the tvshows/kids subtree of the
# shared TV subvolume (subPath kids) so cheeztv's library never
# exposes the adult tree. Path matches fafflix's /media/tv-kids
# mount so the same episode resolves identically across instances.
mountPath: /media/tv
subPath: kids
readOnly: true
- name: media-movies
# Kids-only instance: mount just the movies/kids subtree of the
# shared movies subvolume (subPath kids).
mountPath: /media/movies
subPath: kids
readOnly: true
volumes:
- name: config
persistentVolumeClaim:
claimName: jellyfin-config
claimName: cheeztv-config
- name: transcode
persistentVolumeClaim:
claimName: jellyfin-transcode
claimName: cheeztv-transcode
- name: media-tv
persistentVolumeClaim:
claimName: jellyfin-media-tv
claimName: cheeztv-media-tv
- name: media-movies
persistentVolumeClaim:
claimName: jellyfin-media-movies
claimName: cheeztv-media-movies
volumeClaimTemplates:
# Per-pod scratch cache — RWO, disposable, one PVC per replica.
- metadata:
name: cache
annotations:
# Exclude the per-pod cache PVCs from the jellyfin-config k8up Schedule
# Exclude the per-pod cache PVCs from the cheeztv-config k8up Schedule
# (skipWithoutAnnotation is false cluster-wide). Cache is disposable and
# RWO — it would also fail to mount into the backup pod while in use.
k8up.io/backup: "false"
@@ -19,20 +19,18 @@
apiVersion: valkey.io/v1alpha1
kind: ValkeyCluster
metadata:
name: jellyfin-valkey
namespace: jellyfin
name: cheeztv-valkey
namespace: cheeztv
spec:
shards: 1
replicas: 2
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/valkey/valkey:9.0.0
image: docker.io/valkey/valkey:9.0.0
# redis_exporter sidecar (:9121, port name `metrics`) on every ValkeyNode pod;
# the operator manages a dedicated _exporter ACL user for it. Image overridden
# from the operator default (bare dockerhub oliver006/redis_exporter:v1.80.0)
# to the artifactapi-proxied pin. Scraped by the valkey-exporter VMPodScrape
# in vmpodscrape.yaml alongside this file.
# the operator manages a dedicated _exporter ACL user for it. Image version
# pinned; scraped by valkey-exporter VMPodScrape in vmpodscrape.yaml.
exporter:
enabled: true
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0
image: docker.io/oliver006/redis_exporter:v1.89.0
scheduling:
node:
spread:
@@ -3,12 +3,12 @@ apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: default
namespace: jellyfin
namespace: cheeztv
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
allowedNamespaces:
- jellyfin
- cheeztv
kubernetes:
audiences:
- vault
@@ -1,24 +1,24 @@
---
# restic repository password for the k8up jellyfin-config backups. Seeded at
# kv/kubernetes/namespace/jellyfin/default/k8up-restic (key: password); the
# restic repository password for the k8up cheeztv-config backups. Seeded at
# kv/kubernetes/namespace/cheeztv/default/k8up-restic (key: password); the
# default k8s role's templated policy already grants read here, so no
# terraform-vault change is needed. VSO syncs it into the jellyfin-k8up-restic
# terraform-vault change is needed. VSO syncs it into the cheeztv-k8up-restic
# Secret that the Schedule references via backend.repoPasswordSecretRef.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: jellyfin-k8up-restic
namespace: jellyfin
name: cheeztv-k8up-restic
namespace: cheeztv
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: jellyfin-k8up-restic
name: cheeztv-k8up-restic
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/jellyfin/default/k8up-restic
path: kubernetes/namespace/cheeztv/default/k8up-restic
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -1,6 +1,6 @@
---
# Scrape the operator-injected redis_exporter sidecar (:9121, port name
# `metrics`) on the jellyfin-valkey ValkeyNode pods. The valkey-operator gives
# `metrics`) on the cheeztv-valkey ValkeyNode pods. The valkey-operator gives
# its pods fixed labels only (no pod-label passthrough on the ValkeyCluster CR),
# so select on the operator-managed labels. Picked up by the observability
# VMAgent (selectAllByDefault).
@@ -8,7 +8,7 @@ apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: valkey-exporter
namespace: jellyfin
namespace: cheeztv
spec:
selector:
matchLabels:
@@ -0,0 +1,32 @@
---
# Second Ceph RGW (S3) bucket owned by the existing fafflix backup user
# (cnpg-fafflix-backup, defined in cnpg_backup.yaml) — one user, two buckets:
# the CNPG barman bucket plus this one, which k8up uses to hold restic backups
# of the fafflix-config PVC. The BucketAccess emits read-write S3 creds into a
# Secret the k8up Schedule consumes.
apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket
metadata:
name: fafflix-config-backup
namespace: fafflix
spec:
placementTarget: ec
bucketName: fafflix-config-backup
ownerRef: cnpg-fafflix-backup
versioning: false
tags:
app: fafflix
purpose: config-backup
retainOnDelete: true
---
apiVersion: ceph.unkin.net/v1alpha1
kind: BucketAccess
metadata:
name: fafflix-config-backup
namespace: fafflix
spec:
bucketRef: fafflix-config-backup
level: read-write
# Operator writes AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (+ S3_ENDPOINT,
# BUCKET_NAME) into this Secret; the k8up Schedule reads the access keys.
secretName: fafflix-config-backup-s3
+45
View File
@@ -0,0 +1,45 @@
---
# Ceph RGW (S3) backup target for the fafflix CNPG cluster, provisioned by the
# in-estate cephrgw-operator: one dedicated bucket + owner user. CNPG reads the
# S3 credential Secret from its own namespace.
apiVersion: ceph.unkin.net/v1alpha1
kind: ObjectStoreUser
metadata:
name: cnpg-fafflix-backup
namespace: fafflix
spec:
displayName: "CNPG backup owner (fafflix)"
uid: cnpg-fafflix-backup
maxBuckets: 5
secretName: cnpg-fafflix-backup-s3
retainOnDelete: true
---
apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket
metadata:
name: cnpg-fafflix
namespace: fafflix
spec:
placementTarget: ec
bucketName: cnpg-fafflix
ownerRef: cnpg-fafflix-backup
versioning: false
tags:
app: fafflix
purpose: cnpg-backup
retainOnDelete: true
---
# Nightly base backup on top of always-on WAL archiving. Scheduled off-peak and
# staggered from the other CNPG clusters (6-field cron, seconds first).
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: cnpg-fafflix-nightly
namespace: fafflix
spec:
schedule: "0 35 3 * * *"
immediate: false
backupOwnerReference: self
method: barmanObjectStore
cluster:
name: fafflix-postgres
+126
View File
@@ -0,0 +1,126 @@
---
# Main Jellyfin database. The fafflix-ha fork's experimental EF Core provider
# moves the entire Jellyfin DB (incl. library items) off SQLite into PostgreSQL,
# which is what makes a shared-nothing multi-replica deployment possible. No
# bootstrap secret is given, so CNPG generates the fafflix-postgres-app secret
# (username/password/dbname) that the StatefulSet composes its DSN from.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: fafflix-postgres
namespace: fafflix
spec:
# Exclude the operator-managed data PVCs (fafflix-postgres-N) from the
# fafflix-config k8up Schedule (skipWithoutAnnotation is false cluster-wide,
# so unannotated PVCs are swept in). Postgres has its own barmanObjectStore
# backup below; restic must not touch the raw RWO data volumes.
inheritedMetadata:
annotations:
k8up.io/backup: "false"
affinity:
podAntiAffinityType: preferred
backup:
retentionPolicy: 30d
barmanObjectStore:
# Dedicated per-cluster Ceph RGW bucket (cephrgw-operator provisions it).
destinationPath: s3://cnpg-fafflix
endpointURL: https://s3.ceph.unkin.net
endpointCA:
name: vault-ca-cert
key: ca.crt
s3Credentials:
accessKeyId:
name: cnpg-fafflix-backup-s3
key: AWS_ACCESS_KEY_ID
secretAccessKey:
name: cnpg-fafflix-backup-s3
key: AWS_SECRET_ACCESS_KEY
serverName: fafflix
data:
compression: bzip2
jobs: 2
wal:
compression: zstd
maxParallel: 2
bootstrap:
initdb:
database: fafflix
encoding: UTF8
localeCType: C
localeCollate: C
owner: fafflix
enablePDB: true
enableSuperuserAccess: false
failoverDelay: 0
# PG 17 — accepted by the fork's Npgsql/EF Core provider (needs PG14+); the
# provider generates its own migrations on first start.
imageName: ghcr.io/cloudnative-pg/postgresql:17-system-trixie
instances: 3
logLevel: info
maxSyncReplicas: 0
minSyncReplicas: 0
monitoring:
customQueriesConfigMap:
- key: queries
name: cnpg-default-monitoring
disableDefaultQueries: false
enablePodMonitor: false
postgresql:
parameters:
archive_mode: "on"
archive_timeout: 5min
dynamic_shared_memory_type: posix
effective_cache_size: 256MB
full_page_writes: "on"
log_destination: csvlog
log_directory: /controller/log
log_filename: postgres
log_rotation_age: "0"
log_rotation_size: "0"
log_truncate_on_rotation: "false"
logging_collector: "on"
max_connections: "200"
max_parallel_workers: "16"
max_replication_slots: "16"
max_worker_processes: "16"
shared_buffers: 128MB
shared_memory_type: mmap
ssl_max_protocol_version: TLSv1.3
ssl_min_protocol_version: TLSv1.3
wal_keep_size: 256MB
wal_level: logical
wal_log_hints: "on"
wal_receiver_timeout: 5s
wal_sender_timeout: 5s
syncReplicaElectionConstraint:
enabled: false
primaryUpdateMethod: restart
primaryUpdateStrategy: unsupervised
probes:
liveness:
isolationCheck:
connectionTimeout: 1000
enabled: true
requestTimeout: 1000
replicationSlots:
highAvailability:
enabled: true
slotPrefix: _cnpg_
synchronizeReplicas:
enabled: true
updateInterval: 30
resources:
limits:
cpu: "1"
memory: 1Gi
requests:
cpu: 50m
memory: 512Mi
smartShutdownTimeout: 180
startDelay: 3600
stopDelay: 1800
storage:
resizeInUseVolumes: true
size: 10Gi
storageClass: cephrbd-fast-delete
switchoverDelay: 3600
+36
View File
@@ -0,0 +1,36 @@
---
# PgBouncer pooler in front of the fafflix-postgres cluster. Jellyfin connects
# here (fafflix-postgres-pooler:5432) rather than the -rw service so EF Core's
# connection churn is absorbed by the pool.
apiVersion: postgresql.cnpg.io/v1
kind: Pooler
metadata:
name: fafflix-postgres-pooler
namespace: fafflix
spec:
cluster:
name: fafflix-postgres
instances: 2
pgbouncer:
parameters:
default_pool_size: "50"
max_client_conn: "200"
paused: false
poolMode: session
template:
metadata:
labels:
app: fafflix-pooler
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values:
- fafflix-pooler
topologyKey: kubernetes.io/hostname
containers: []
type: rw
@@ -1,4 +1,9 @@
---
# Fafflix (adult instance) keeps serving the legacy jellyfin hostname
# (jellyfin.k8s.syd1.au.unkin.net) so the switch to a dedicated fafflix domain
# can be deferred. Same internal-Traefik + external-dns pattern the old jellyfin app
# used: external-dns publishes the A record at the internal LB VIP
# (198.18.200.4) and cert-manager mints fafflix-tls off the Vault-PKI issuer.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
@@ -10,8 +15,8 @@ metadata:
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: jellyfin.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: jellyfin
namespace: jellyfin
name: fafflix
namespace: fafflix
spec:
gatewayClassName: traefik-internal
listeners:
@@ -33,5 +38,5 @@ spec:
certificateRefs:
- group: ""
kind: Secret
name: jellyfin-tls
name: fafflix-tls
mode: Terminate
@@ -3,14 +3,14 @@ apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: http-redirect
namespace: jellyfin
namespace: fafflix
spec:
hostnames:
- jellyfin.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: jellyfin
name: fafflix
sectionName: http
rules:
- filters:
@@ -26,21 +26,21 @@ spec:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: jellyfin-route
namespace: jellyfin
name: fafflix-route
namespace: fafflix
spec:
hostnames:
- jellyfin.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: jellyfin
name: fafflix
sectionName: https
rules:
- backendRefs:
- group: ""
kind: Service
name: jellyfin
name: fafflix
port: 8096
weight: 1
matches:
+26
View File
@@ -0,0 +1,26 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- cnpg_cluster.yaml
- cnpg_pooler.yaml
- cnpg_backup.yaml
- cephrgw-config-backup.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
- schedule.yaml
- pvc-config.yaml
- pvc-transcode.yaml
- pv-media-tv.yaml
- pv-media-movies.yaml
- pvc-media-tv.yaml
- pvc-media-movies.yaml
- statefulset.yaml
- pdb.yaml
- service.yaml
- valkey.yaml
- vmpodscrape.yaml
- gateway.yaml
- httproute.yaml
+5
View File
@@ -0,0 +1,5 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: fafflix
+13
View File
@@ -0,0 +1,13 @@
---
# Keep at least one Jellyfin replica serving through voluntary disruptions
# (node drains, rollouts) so active streams can fail over rather than drop.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: fafflix
namespace: fafflix
spec:
minAvailable: 1
selector:
matchLabels:
app: fafflix
+30
View File
@@ -0,0 +1,30 @@
---
# Static PV for the shared MOVIES CephFS subvolume. Same rootPath as arrstack's
# movies PV so radarr writes and fafflix reads the identical library tree; each
# namespace gets its own PV (unique name + volumeHandle) pinned by claimRef.
apiVersion: v1
kind: PersistentVolume
metadata:
name: fafflix-media-movies
spec:
capacity:
storage: 1Ti
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
volumeMode: Filesystem
claimRef:
namespace: fafflix
name: fafflix-media-movies
csi:
driver: cephfs.csi.ceph.com
volumeHandle: fafflix-media-movies-static
nodeStageSecretRef:
name: csi-cephfs-secret
namespace: csi-cephfs
volumeAttributes:
staticVolume: "true"
clusterID: cephfs_csi_ssd_ec_4_1
fsName: cephfs
rootPath: /volumes/csi_ssd_ec_4_1/media-movies/e95d8ace-c736-465a-acc3-0c3e46dcede9
+30
View File
@@ -0,0 +1,30 @@
---
# Static PV for the shared TV CephFS subvolume. Same rootPath as arrstack's
# TV PV so sonarr writes and fafflix reads the identical library tree; each
# namespace gets its own PV (unique name + volumeHandle) pinned by claimRef.
apiVersion: v1
kind: PersistentVolume
metadata:
name: fafflix-media-tv
spec:
capacity:
storage: 1Ti
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
volumeMode: Filesystem
claimRef:
namespace: fafflix
name: fafflix-media-tv
csi:
driver: cephfs.csi.ceph.com
volumeHandle: fafflix-media-tv-static
nodeStageSecretRef:
name: csi-cephfs-secret
namespace: csi-cephfs
volumeAttributes:
staticVolume: "true"
clusterID: cephfs_csi_ssd_ec_4_1
fsName: cephfs
rootPath: /volumes/csi_ssd_ec_4_1/media-tv/4692957d-f5df-4f72-b9c9-56e4ee6d1333
+17
View File
@@ -0,0 +1,17 @@
---
# Jellyfin config: metadata images, plugins, subtitles and config XML. Shared
# ReadWriteMany across replicas (all pods read/write the same library metadata);
# the main library DB now lives in PostgreSQL, not here (on CephFS, raid5-delete).
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fafflix-config
namespace: fafflix
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 20Gi
storageClassName: cephfs-raid5-delete
volumeMode: Filesystem
+24
View File
@@ -0,0 +1,24 @@
---
# Movie library, shared read-many across replicas. Statically bound to the
# fafflix-media-movies PV (shared CephFS subvolume also used by arrstack/radarr).
# storageClassName "" + volumeName disables dynamic provisioning and binds the
# pre-created static PV.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fafflix-media-movies
namespace: fafflix
annotations:
# Exclude from the fafflix-config k8up Schedule (skipWithoutAnnotation is
# false cluster-wide, so unannotated PVCs are swept in). Only fafflix-config
# is backed up; the media library is not restic-backup material.
k8up.io/backup: "false"
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 1Ti
storageClassName: ""
volumeName: fafflix-media-movies
volumeMode: Filesystem
+24
View File
@@ -0,0 +1,24 @@
---
# TV library, shared read-many across replicas. Statically bound to the
# fafflix-media-tv PV (shared CephFS subvolume also used by arrstack/sonarr).
# storageClassName "" + volumeName disables dynamic provisioning and binds the
# pre-created static PV.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fafflix-media-tv
namespace: fafflix
annotations:
# Exclude from the fafflix-config k8up Schedule (skipWithoutAnnotation is
# false cluster-wide, so unannotated PVCs are swept in). Only fafflix-config
# is backed up; the media library is not restic-backup material.
k8up.io/backup: "false"
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 1Ti
storageClassName: ""
volumeName: fafflix-media-tv
volumeMode: Filesystem
+23
View File
@@ -0,0 +1,23 @@
---
# Shared transcode scratch. ReadWriteMany is the hard requirement for the HA
# fork: a taking-over pod must read the in-flight HLS segments written by the
# pod it replaces. Scratch data (delete reclaim); raid5 avoids the raid6
# double-parity write penalty on the many small HLS segment writes.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fafflix-transcode
namespace: fafflix
annotations:
# Exclude from the fafflix-config k8up Schedule (skipWithoutAnnotation is
# false cluster-wide, so unannotated PVCs are swept in). Transcode is RWX
# scratch — nothing to back up.
k8up.io/backup: "false"
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 100Gi
storageClassName: cephfs-raid5-delete
volumeMode: Filesystem
+58
View File
@@ -0,0 +1,58 @@
---
# k8up Schedule: restic backups of the fafflix-config PVC (library metadata,
# plugins, config XML) to the dedicated Ceph RGW config-backup bucket. S3 creds
# come from the cephrgw BucketAccess Secret (fafflix-config-backup-s3); the
# restic repo password comes from Vault via the fafflix-k8up-restic Secret.
#
# s3.ceph.unkin.net presents the internal unkin.net CA, which the k8up/restic
# image does not trust by default, so the reflected vault-ca-cert Secret is
# mounted into every job pod and pointed at via backend.tlsOptions.caCert.
apiVersion: k8up.io/v1
kind: Schedule
metadata:
name: fafflix-config
namespace: fafflix
spec:
backend:
repoPasswordSecretRef:
name: fafflix-k8up-restic
key: password
s3:
endpoint: https://s3.ceph.unkin.net
bucket: fafflix-config-backup
accessKeyIDSecretRef:
name: fafflix-config-backup-s3
key: AWS_ACCESS_KEY_ID
secretAccessKeySecretRef:
name: fafflix-config-backup-s3
key: AWS_SECRET_ACCESS_KEY
tlsOptions:
caCert: /etc/k8up/ca/ca.crt
volumeMounts:
- name: vault-ca
mountPath: /etc/k8up/ca
readOnly: true
backup:
schedule: "0 2 * * *"
failedJobsHistoryLimit: 3
successfulJobsHistoryLimit: 3
volumes:
- name: vault-ca
secret:
secretName: vault-ca-cert
prune:
schedule: "0 3 * * 0"
retention:
keepDaily: 14
keepWeekly: 8
keepMonthly: 12
volumes:
- name: vault-ca
secret:
secretName: vault-ca-cert
check:
schedule: "0 4 * * 0"
volumes:
- name: vault-ca
secret:
secretName: vault-ca-cert
+18
View File
@@ -0,0 +1,18 @@
---
apiVersion: v1
kind: Service
metadata:
name: fafflix
namespace: fafflix
spec:
internalTrafficPolicy: Cluster
ports:
- name: http
port: 8096
protocol: TCP
targetPort: http
selector:
app: fafflix
# Pin each client to one replica to reduce transcode-session churn/takeover.
sessionAffinity: ClientIP
type: ClusterIP
+267
View File
@@ -0,0 +1,267 @@
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: fafflix
namespace: fafflix
spec:
# HA: two replicas coordinate transcode session ownership through Valkey and
# resume each other's HLS segments off the shared RWX transcode PVC. Stable
# pod names (fafflix-0/1) are the lease owner identity, hence StatefulSet.
replicas: 2
serviceName: fafflix
podManagementPolicy: Parallel
updateStrategy:
type: RollingUpdate
selector:
matchLabels:
app: fafflix
template:
metadata:
labels:
app: fafflix
spec:
securityContext:
# Group-write the shared RWX volumes and grant the render/video groups so
# the runAsUser 1000 process can open the Intel DRI render node injected
# by the device plugin.
fsGroup: 1000
supplementalGroups:
- 44
- 105
- 109
seccompProfile:
type: RuntimeDefault
affinity:
# Spread the two replicas across nodes for node-level HA. Soft so a
# single-GPU-node cluster still schedules both (i915 has 4 shared slots).
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: fafflix
topologyKey: kubernetes.io/hostname
initContainers:
# Seed the fork's PostgreSQL provider (database.xml) and Intel iGPU
# hardware transcode settings (encoding.xml) before Jellyfin starts.
# Runs as root to chown into the shared config volume; mirrors the fork
# Helm chart's inject-db-config. Each file is written only when absent so
# admin changes persisted to the shared RWX /config survive pod restarts.
- name: inject-config
image: busybox:1.37.0
command:
- sh
- -c
- |
mkdir -p /config/config
chown 1000:1000 /config/config
chmod 775 /config/config
if [ ! -e /config/config/database.xml ]; then
cat > /config/config/database.xml << 'DBEOF'
<?xml version="1.0" encoding="utf-8"?>
<DatabaseConfigurationOptions>
<DatabaseType>Jellyfin-PostgreSQL</DatabaseType>
<LockingBehavior>NoLock</LockingBehavior>
</DatabaseConfigurationOptions>
DBEOF
chown 1000:1000 /config/config/database.xml
chmod 664 /config/config/database.xml
fi
# VAAPI on the Intel render node the device plugin injects
# (/dev/dri/renderD128 — ffmpeg's default DRM node, reachable via
# the render/video supplementalGroups). Without this the attached
# iGPU is idle and every transcode runs in software. Omitted
# elements fall back to the fork's EncodingOptions defaults.
if [ ! -e /config/config/encoding.xml ]; then
cat > /config/config/encoding.xml << 'ENCEOF'
<?xml version="1.0" encoding="utf-8"?>
<EncodingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<EncodingThreadCount>-1</EncodingThreadCount>
<HardwareAccelerationType>vaapi</HardwareAccelerationType>
<VaapiDevice>/dev/dri/renderD128</VaapiDevice>
<EnableHardwareEncoding>true</EnableHardwareEncoding>
<AllowHevcEncoding>true</AllowHevcEncoding>
<AllowAv1Encoding>false</AllowAv1Encoding>
<EnableIntelLowPowerH264HwEncoder>false</EnableIntelLowPowerH264HwEncoder>
<EnableIntelLowPowerHevcHwEncoder>false</EnableIntelLowPowerHevcHwEncoder>
<EnableTonemapping>false</EnableTonemapping>
<EnableVppTonemapping>false</EnableVppTonemapping>
<HardwareDecodingCodecs>
<string>h264</string>
<string>hevc</string>
<string>vc1</string>
<string>vp9</string>
</HardwareDecodingCodecs>
</EncodingOptions>
ENCEOF
chown 1000:1000 /config/config/encoding.xml
chmod 664 /config/config/encoding.xml
fi
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
volumeMounts:
- name: config
mountPath: /config
containers:
- name: fafflix
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/jellyfin-ha:v0.1.3
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8096
protocol: TCP
env:
# Pod identity for the Valkey transcode lease owner. The fork reads
# JELLYFIN_INSTANCE_ID (falling back to MachineName); the stable
# StatefulSet pod name gives each replica a unique lease identity so
# takeover can target a dead replica. JELLYFIN_HA_POD_NAME is set for
# parity with the fork Helm chart (nothing currently reads it).
- name: JELLYFIN_INSTANCE_ID
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: JELLYFIN_HA_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
# Multiple replicas must not each answer UDP auto-discovery.
- name: JELLYFIN_Network__AutoDiscovery
value: "false"
# Config dir must differ from the data root (Jellyfin sanity check).
- name: JELLYFIN_CONFIG_DIR
value: /config/config
# Distributed transcode session store (fafflix-ha additions).
- name: Jellyfin__TranscodeStore__RedisConnectionString
value: "valkey-fafflix-valkey:6379,abortConnect=false"
- name: Jellyfin__TranscodeStore__LeaseDurationSeconds
value: "30"
# PostgreSQL main DB via the CNPG-generated app secret, routed through
# the PgBouncer pooler. Composed with $(VAR) expansion so the password
# is never rendered into the manifest; CNPG passwords are URL-safe.
- name: PGUSER
valueFrom:
secretKeyRef:
name: fafflix-postgres-app
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: fafflix-postgres-app
key: password
- name: PGDB
valueFrom:
secretKeyRef:
name: fafflix-postgres-app
key: dbname
- name: POSTGRES_CONNECTION_STRING
value: "postgresql://$(PGUSER):$(PGPASSWORD)@fafflix-postgres-pooler:5432/$(PGDB)"
- name: DATABASE_URL
value: "postgresql://$(PGUSER):$(PGPASSWORD)@fafflix-postgres-pooler:5432/$(PGDB)"
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: "1"
memory: 1Gi
gpu.intel.com/i915: "1"
limits:
cpu: "4"
memory: 6Gi
# Intel iGPU (QSV/VA-API) slot. Requesting it pins the pod to a
# GPU-labelled node and injects /dev/dri/renderD* automatically, so
# no /dev/dri hostPath or privileged container is needed. VA-API is
# pre-enabled via the seeded encoding.xml (see inject-config), so
# transcodes use the iGPU on first boot with no manual UI step.
gpu.intel.com/i915: "1"
securityContext:
runAsUser: 1000
runAsGroup: 1000
volumeMounts:
- name: config
mountPath: /config
- name: transcode
# Fork's real transcode temp path. RWX so a surviving pod reads the
# in-flight .ts/.m3u8 segments of the pod it takes over. A per-pod
# volume here silently breaks HA takeover.
mountPath: /config/transcodes
- name: cache
mountPath: /cache
- name: media-tv
# Adult instance: mount the tvshows/adult subtree of the shared TV
# subvolume (subPath adult) as fafflix's primary TV library. Same
# rootPath the cheeztv instance reads under subPath kids, so an
# episode resolves identically across instances.
mountPath: /media/tv
subPath: adult
readOnly: true
- name: media-tv
# Also mount the kids TV subtree (subPath kids) so fafflix can
# resume playback of kids content started on cheeztv — same
# underlying subvolume, different subtree, distinct mount path.
mountPath: /media/tv-kids
subPath: kids
readOnly: true
- name: media-movies
# Adult instance: mount the movies/adult subtree of the shared
# movies subvolume (subPath adult) as fafflix's primary movie
# library.
mountPath: /media/movies
subPath: adult
readOnly: true
- name: media-movies
# Also mount the kids movies subtree (subPath kids) for cross-resume
# of kids content started on cheeztv.
mountPath: /media/movies-kids
subPath: kids
readOnly: true
volumes:
- name: config
persistentVolumeClaim:
claimName: fafflix-config
- name: transcode
persistentVolumeClaim:
claimName: fafflix-transcode
- name: media-tv
persistentVolumeClaim:
claimName: fafflix-media-tv
- name: media-movies
persistentVolumeClaim:
claimName: fafflix-media-movies
volumeClaimTemplates:
# Per-pod scratch cache — RWO, disposable, one PVC per replica.
- metadata:
name: cache
annotations:
# Exclude the per-pod cache PVCs from the fafflix-config k8up Schedule
# (skipWithoutAnnotation is false cluster-wide). Cache is disposable and
# RWO — it would also fail to mount into the backup pod while in use.
k8up.io/backup: "false"
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 30Gi
storageClassName: cephrbd-fast-delete
volumeMode: Filesystem
+47
View File
@@ -0,0 +1,47 @@
---
# HA Valkey managed by valkey-operator as Jellyfin's distributed transcode-session
# store. shards:1 + replicas:2 is one primary with two replicas in a single shard
# group (three ValkeyNodes total): losing the primary triggers an automatic
# failover to a replica, so a node/pod loss no longer drops the shared transcode
# lease state the two Jellyfin replicas coordinate through. The operator always
# runs Valkey cluster-mode-enabled with protected-mode off and leaves the built-in
# `default` user passwordless, so clients connect with no auth/TLS (parity with the
# old open redis); StackExchange.Redis seeds off the single headless service and
# auto-discovers the topology plus failovers, so the connection string is
# unchanged. scheduling.node.spread.shard:Required renders pod anti-affinity that
# keeps the three nodes on distinct hosts (kubernetes.io/hostname), so one host
# loss removes at most one node. podDisruptionBudget.mode:Cluster lets the operator
# manage a quorum-aware PDB. Persistence stays omitted so /data is an emptyDir:
# lease state is ephemeral (30s TTLs), replication+failover already provide
# redundancy and a replaced node re-syncs from the primary, and an operator-managed
# PVC cannot carry the k8up.io/backup:"false" annotation the namespace's
# cluster-wide k8up Schedule needs to skip in-use RWO volumes.
apiVersion: valkey.io/v1alpha1
kind: ValkeyCluster
metadata:
name: fafflix-valkey
namespace: fafflix
spec:
shards: 1
replicas: 2
image: docker.io/valkey/valkey:9.0.0
# redis_exporter sidecar (:9121, port name `metrics`) on every ValkeyNode pod;
# the operator manages a dedicated _exporter ACL user for it. Image version
# pinned; scraped by valkey-exporter VMPodScrape in vmpodscrape.yaml.
exporter:
enabled: true
image: docker.io/oliver006/redis_exporter:v1.89.0
scheduling:
node:
spread:
shard:
mode: Required
podDisruptionBudget:
mode: Cluster
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
+20
View File
@@ -0,0 +1,20 @@
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: default
namespace: fafflix
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
allowedNamespaces:
- fafflix
kubernetes:
audiences:
- vault
role: default
serviceAccount: default
tokenExpirationSeconds: 600
method: kubernetes
mount: k8s/au/syd1
vaultConnectionRef: vso-system/default
+24
View File
@@ -0,0 +1,24 @@
---
# restic repository password for the k8up fafflix-config backups. Seeded at
# kv/kubernetes/namespace/fafflix/default/k8up-restic (key: password); the
# default k8s role's templated policy already grants read here, so no
# terraform-vault change is needed. VSO syncs it into the fafflix-k8up-restic
# Secret that the Schedule references via backend.repoPasswordSecretRef.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: fafflix-k8up-restic
namespace: fafflix
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: fafflix-k8up-restic
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/fafflix/default/k8up-restic
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
+19
View File
@@ -0,0 +1,19 @@
---
# Scrape the operator-injected redis_exporter sidecar (:9121, port name
# `metrics`) on the fafflix-valkey ValkeyNode pods. The valkey-operator gives
# its pods fixed labels only (no pod-label passthrough on the ValkeyCluster CR),
# so select on the operator-managed labels. Picked up by the observability
# VMAgent (selectAllByDefault).
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: valkey-exporter
namespace: fafflix
spec:
selector:
matchLabels:
app.kubernetes.io/managed-by: valkey-operator
app.kubernetes.io/component: valkey-node
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -78,6 +78,11 @@ server:
configmap.reloader.stakater.com/auto: "true"
ingress:
enabled: false
service:
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 60
resources:
limits:
cpu: "2"
@@ -3,4 +3,4 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../../base/jellyfin
- ../../../base/cheeztv
@@ -0,0 +1,6 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../../base/fafflix
+2 -1
View File
@@ -10,7 +10,8 @@ spec:
repoURL: https://git.unkin.net/unkin/argocd-apps
revision: HEAD
directories:
- path: apps/overlays/*/jellyfin
- path: apps/overlays/*/fafflix
- path: apps/overlays/*/cheeztv
- path: apps/overlays/*/arrstack
template:
metadata:
+3 -1
View File
@@ -9,7 +9,9 @@ spec:
sourceRepos:
- https://git.unkin.net/unkin/argocd-apps
destinations:
- namespace: 'jellyfin'
- namespace: 'fafflix'
server: https://kubernetes.default.svc
- namespace: 'cheeztv'
server: https://kubernetes.default.svc
- namespace: 'arrstack'
server: https://kubernetes.default.svc