From ed75a6d1e1539cc29ede2952b4c44d8e32c01d7a Mon Sep 17 00:00:00 2001 From: Ben Vin Date: Sun, 5 Jul 2026 22:32:24 +1000 Subject: [PATCH 01/10] Add jellyfin (HA fork) app under a new media project Deploys the jellyfin-ha fork (git.unkin.net/unkin/jellyfin-ha) to au-syd1 via ArgoCD, under a dedicated media AppProject/ApplicationSet rather than extending platform. - New media AppProject + media-apps ApplicationSet (watches apps/overlays/*/jellyfin); registered in the argocd kustomizations - apps/base/jellyfin: namespace, deployment (single replica to start), service, in-namespace Redis (transcode session store), gateway + httproute at jellyfin.k8s.syd1.au.unkin.net - Storage: RWO config (cephrbd), RWX transcode scratch and RWX media library (cephfs) per the HA fork's pod-takeover requirement - au-syd1 overlay --- apps/base/jellyfin/deployment.yaml | 80 +++++++++++++++++++ apps/base/jellyfin/gateway.yaml | 37 +++++++++ apps/base/jellyfin/httproute.yaml | 49 ++++++++++++ apps/base/jellyfin/kustomization.yaml | 16 ++++ apps/base/jellyfin/namespace.yaml | 5 ++ apps/base/jellyfin/pvc-config.yaml | 15 ++++ apps/base/jellyfin/pvc-media.yaml | 17 ++++ apps/base/jellyfin/pvc-transcode.yaml | 17 ++++ apps/base/jellyfin/redis-deployment.yaml | 66 +++++++++++++++ apps/base/jellyfin/redis-pvc.yaml | 14 ++++ apps/base/jellyfin/redis-service.yaml | 17 ++++ apps/base/jellyfin/service.yaml | 17 ++++ .../au-syd1/jellyfin/kustomization.yaml | 6 ++ argocd/applicationsets/kustomization.yaml | 1 + argocd/applicationsets/media.yaml | 31 +++++++ argocd/projects/kustomization.yaml | 1 + argocd/projects/media.yaml | 19 +++++ 17 files changed, 408 insertions(+) create mode 100644 apps/base/jellyfin/deployment.yaml create mode 100644 apps/base/jellyfin/gateway.yaml create mode 100644 apps/base/jellyfin/httproute.yaml create mode 100644 apps/base/jellyfin/kustomization.yaml create mode 100644 apps/base/jellyfin/namespace.yaml create mode 100644 apps/base/jellyfin/pvc-config.yaml create mode 100644 apps/base/jellyfin/pvc-media.yaml create mode 100644 apps/base/jellyfin/pvc-transcode.yaml create mode 100644 apps/base/jellyfin/redis-deployment.yaml create mode 100644 apps/base/jellyfin/redis-pvc.yaml create mode 100644 apps/base/jellyfin/redis-service.yaml create mode 100644 apps/base/jellyfin/service.yaml create mode 100644 apps/overlays/au-syd1/jellyfin/kustomization.yaml create mode 100644 argocd/applicationsets/media.yaml create mode 100644 argocd/projects/media.yaml diff --git a/apps/base/jellyfin/deployment.yaml b/apps/base/jellyfin/deployment.yaml new file mode 100644 index 0000000..7aaed32 --- /dev/null +++ b/apps/base/jellyfin/deployment.yaml @@ -0,0 +1,80 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: jellyfin + namespace: jellyfin +spec: + # Start single-replica. Scaling to >1 (true HA) is a follow-up once the Redis + # transcode store and RWX transcode scratch are validated end-to-end. + replicas: 1 + strategy: + # Config PVC is RWO; a Recreate rollout avoids two pods contending for it. + type: Recreate + selector: + matchLabels: + app: jellyfin + template: + metadata: + labels: + app: jellyfin + spec: + containers: + - name: jellyfin + image: git.unkin.net/unkin/jellyfin-ha:v0.1.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8096 + protocol: TCP + env: + # Distributed transcode session store (jellyfin-ha additions). + - name: Jellyfin__TranscodeStore__RedisConnectionString + value: "redis:6379,abortConnect=false" + - name: Jellyfin__TranscodeStore__LeaseDurationSeconds + value: "30" + 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 + limits: + cpu: "4" + memory: 6Gi + volumeMounts: + - name: config + mountPath: /config + - name: cache + mountPath: /cache + - name: transcode + mountPath: /transcode + - name: media + mountPath: /media + readOnly: true + volumes: + - name: config + persistentVolumeClaim: + claimName: jellyfin-config + - name: cache + emptyDir: {} + - name: transcode + persistentVolumeClaim: + claimName: jellyfin-transcode + - name: media + persistentVolumeClaim: + claimName: jellyfin-media diff --git a/apps/base/jellyfin/gateway.yaml b/apps/base/jellyfin/gateway.yaml new file mode 100644 index 0000000..a491519 --- /dev/null +++ b/apps/base/jellyfin/gateway.yaml @@ -0,0 +1,37 @@ +--- +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: jellyfin.k8s.syd1.au.unkin.net + 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 +spec: + gatewayClassName: traefik-internal + listeners: + - allowedRoutes: + namespaces: + from: Same + hostname: jellyfin.k8s.syd1.au.unkin.net + name: http + port: 80 + protocol: HTTP + - allowedRoutes: + namespaces: + from: Same + hostname: jellyfin.k8s.syd1.au.unkin.net + name: https + port: 443 + protocol: HTTPS + tls: + certificateRefs: + - group: "" + kind: Secret + name: jellyfin-tls + mode: Terminate diff --git a/apps/base/jellyfin/httproute.yaml b/apps/base/jellyfin/httproute.yaml new file mode 100644 index 0000000..1778dcd --- /dev/null +++ b/apps/base/jellyfin/httproute.yaml @@ -0,0 +1,49 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: http-redirect + namespace: jellyfin +spec: + hostnames: + - jellyfin.k8s.syd1.au.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: jellyfin + 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: jellyfin-route + namespace: jellyfin +spec: + hostnames: + - jellyfin.k8s.syd1.au.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: jellyfin + sectionName: https + rules: + - backendRefs: + - group: "" + kind: Service + name: jellyfin + port: 8096 + weight: 1 + matches: + - path: + type: PathPrefix + value: / diff --git a/apps/base/jellyfin/kustomization.yaml b/apps/base/jellyfin/kustomization.yaml new file mode 100644 index 0000000..b0ada69 --- /dev/null +++ b/apps/base/jellyfin/kustomization.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace.yaml + - pvc-config.yaml + - pvc-transcode.yaml + - pvc-media.yaml + - deployment.yaml + - service.yaml + - redis-deployment.yaml + - redis-pvc.yaml + - redis-service.yaml + - gateway.yaml + - httproute.yaml diff --git a/apps/base/jellyfin/namespace.yaml b/apps/base/jellyfin/namespace.yaml new file mode 100644 index 0000000..3122e11 --- /dev/null +++ b/apps/base/jellyfin/namespace.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: jellyfin diff --git a/apps/base/jellyfin/pvc-config.yaml b/apps/base/jellyfin/pvc-config.yaml new file mode 100644 index 0000000..3bef28c --- /dev/null +++ b/apps/base/jellyfin/pvc-config.yaml @@ -0,0 +1,15 @@ +--- +# Jellyfin config + SQLite library database. Single-writer, block storage. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: jellyfin-config + namespace: jellyfin +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 20Gi + storageClassName: cephrbd-fast-retain + volumeMode: Filesystem diff --git a/apps/base/jellyfin/pvc-media.yaml b/apps/base/jellyfin/pvc-media.yaml new file mode 100644 index 0000000..8b60434 --- /dev/null +++ b/apps/base/jellyfin/pvc-media.yaml @@ -0,0 +1,17 @@ +--- +# Media library, shared read-many across replicas. Retain — this holds the +# actual media and must survive PVC deletion. Empty on first deploy; populating +# it is out of scope for this app. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: jellyfin-media + namespace: jellyfin +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 1Ti + storageClassName: cephfs-raid6-retain + volumeMode: Filesystem diff --git a/apps/base/jellyfin/pvc-transcode.yaml b/apps/base/jellyfin/pvc-transcode.yaml new file mode 100644 index 0000000..b33c821 --- /dev/null +++ b/apps/base/jellyfin/pvc-transcode.yaml @@ -0,0 +1,17 @@ +--- +# 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, so delete reclaim policy. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: jellyfin-transcode + namespace: jellyfin +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 100Gi + storageClassName: cephfs-raid6-delete + volumeMode: Filesystem diff --git a/apps/base/jellyfin/redis-deployment.yaml b/apps/base/jellyfin/redis-deployment.yaml new file mode 100644 index 0000000..eeae15b --- /dev/null +++ b/apps/base/jellyfin/redis-deployment.yaml @@ -0,0 +1,66 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: jellyfin +spec: + replicas: 1 + selector: + matchLabels: + app: redis + strategy: + type: Recreate + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7-alpine + imagePullPolicy: IfNotPresent + command: + - redis-server + - --save + - "20" + - "1" + ports: + - containerPort: 6379 + name: redis + protocol: TCP + livenessProbe: + exec: + command: + - redis-cli + - ping + failureThreshold: 3 + initialDelaySeconds: 30 + periodSeconds: 30 + successThreshold: 1 + timeoutSeconds: 5 + readinessProbe: + exec: + command: + - redis-cli + - ping + failureThreshold: 3 + initialDelaySeconds: 5 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 5 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 128Mi + volumeMounts: + - mountPath: /data + name: data + restartPolicy: Always + volumes: + - name: data + persistentVolumeClaim: + claimName: jellyfin-redis-data diff --git a/apps/base/jellyfin/redis-pvc.yaml b/apps/base/jellyfin/redis-pvc.yaml new file mode 100644 index 0000000..36a4315 --- /dev/null +++ b/apps/base/jellyfin/redis-pvc.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: jellyfin-redis-data + namespace: jellyfin +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi + storageClassName: cephrbd-fast-delete + volumeMode: Filesystem diff --git a/apps/base/jellyfin/redis-service.yaml b/apps/base/jellyfin/redis-service.yaml new file mode 100644 index 0000000..6e0e34f --- /dev/null +++ b/apps/base/jellyfin/redis-service.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: jellyfin +spec: + internalTrafficPolicy: Cluster + ports: + - name: redis + port: 6379 + protocol: TCP + targetPort: redis + selector: + app: redis + sessionAffinity: None + type: ClusterIP diff --git a/apps/base/jellyfin/service.yaml b/apps/base/jellyfin/service.yaml new file mode 100644 index 0000000..38b5c1d --- /dev/null +++ b/apps/base/jellyfin/service.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: jellyfin + namespace: jellyfin +spec: + internalTrafficPolicy: Cluster + ports: + - name: http + port: 8096 + protocol: TCP + targetPort: http + selector: + app: jellyfin + sessionAffinity: None + type: ClusterIP diff --git a/apps/overlays/au-syd1/jellyfin/kustomization.yaml b/apps/overlays/au-syd1/jellyfin/kustomization.yaml new file mode 100644 index 0000000..d8d5df6 --- /dev/null +++ b/apps/overlays/au-syd1/jellyfin/kustomization.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../../base/jellyfin diff --git a/argocd/applicationsets/kustomization.yaml b/argocd/applicationsets/kustomization.yaml index b54cc35..c95dedd 100644 --- a/argocd/applicationsets/kustomization.yaml +++ b/argocd/applicationsets/kustomization.yaml @@ -5,6 +5,7 @@ kind: Kustomization resources: - aitooling.yaml - logging.yaml + - media.yaml - observability.yaml - platform.yaml - storage.yaml diff --git a/argocd/applicationsets/media.yaml b/argocd/applicationsets/media.yaml new file mode 100644 index 0000000..9fe99a5 --- /dev/null +++ b/argocd/applicationsets/media.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: media-apps + namespace: argocd +spec: + generators: + - git: + repoURL: https://git.unkin.net/unkin/argocd-apps + revision: HEAD + directories: + - path: apps/overlays/*/jellyfin + template: + metadata: + name: 'media-{{path[3]}}' + spec: + project: media + source: + repoURL: https://git.unkin.net/unkin/argocd-apps + targetRevision: HEAD + path: '{{path}}' + destination: + server: https://kubernetes.default.svc + namespace: '{{path[3]}}' + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - ServerSideApply=true diff --git a/argocd/projects/kustomization.yaml b/argocd/projects/kustomization.yaml index b54cc35..c95dedd 100644 --- a/argocd/projects/kustomization.yaml +++ b/argocd/projects/kustomization.yaml @@ -5,6 +5,7 @@ kind: Kustomization resources: - aitooling.yaml - logging.yaml + - media.yaml - observability.yaml - platform.yaml - storage.yaml diff --git a/argocd/projects/media.yaml b/argocd/projects/media.yaml new file mode 100644 index 0000000..652c226 --- /dev/null +++ b/argocd/projects/media.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: media + namespace: argocd +spec: + description: Media services + sourceRepos: + - https://git.unkin.net/unkin/argocd-apps + destinations: + - namespace: 'jellyfin' + server: https://kubernetes.default.svc + clusterResourceWhitelist: + - group: '' + kind: Namespace + namespaceResourceWhitelist: + - group: '*' + kind: '*' -- 2.47.3 From f89e1c8260f2d3d3f0e4c75ff7e421a909b18d5d Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Mon, 10 Aug 2026 23:26:52 +1000 Subject: [PATCH 02/10] Rework jellyfin-ha into a true-HA StatefulSet deployment Turn the single-replica jellyfin-ha app into a proper high-availability deployment so the fork's Redis-coordinated distributed transcoding and PostgreSQL main database can actually be exercised. - Replace the Deployment with a 2-replica StatefulSet for stable pod identity; set JELLYFIN_INSTANCE_ID from metadata.name (the fork's Redis transcode-lease owner id), add soft podAntiAffinity and a PDB minAvailable 1. - Move the main Jellyfin DB to PostgreSQL via a CloudNativePG trio (3-instance Cluster, PgBouncer Pooler, Ceph RGW barman backups) mirroring the litellm pattern; an init container writes database.xml selecting the fork's Jellyfin-PostgreSQL provider and the DSN is composed from the CNPG-generated app secret pointed at the pooler. - Share /config on an RWX cephfs PVC across replicas; keep /cache per-pod via a volumeClaimTemplate. - Fix the transcode mount to the fork's real path /config/transcodes on the RWX PVC (raid5) so a surviving pod can resume the segments of the pod it takes over. - Add Intel iGPU hardware transcoding via the gpu.intel.com/i915 device plugin resource plus render/video supplemental groups. - Switch the Service to sessionAffinity ClientIP to reduce transcode churn. - Disable UDP auto-discovery. Library scans still run on every replica; a single-scanner leader election is a planned follow-up. --- apps/base/jellyfin/cnpg_backup.yaml | 45 ++++++ apps/base/jellyfin/cnpg_cluster.yaml | 119 +++++++++++++++ apps/base/jellyfin/cnpg_pooler.yaml | 36 +++++ apps/base/jellyfin/deployment.yaml | 80 ----------- apps/base/jellyfin/kustomization.yaml | 6 +- apps/base/jellyfin/pdb.yaml | 13 ++ apps/base/jellyfin/pvc-config.yaml | 8 +- apps/base/jellyfin/pvc-transcode.yaml | 5 +- apps/base/jellyfin/service.yaml | 3 +- apps/base/jellyfin/statefulset.yaml | 199 ++++++++++++++++++++++++++ 10 files changed, 427 insertions(+), 87 deletions(-) create mode 100644 apps/base/jellyfin/cnpg_backup.yaml create mode 100644 apps/base/jellyfin/cnpg_cluster.yaml create mode 100644 apps/base/jellyfin/cnpg_pooler.yaml delete mode 100644 apps/base/jellyfin/deployment.yaml create mode 100644 apps/base/jellyfin/pdb.yaml create mode 100644 apps/base/jellyfin/statefulset.yaml diff --git a/apps/base/jellyfin/cnpg_backup.yaml b/apps/base/jellyfin/cnpg_backup.yaml new file mode 100644 index 0000000..723c82c --- /dev/null +++ b/apps/base/jellyfin/cnpg_backup.yaml @@ -0,0 +1,45 @@ +--- +# Ceph RGW (S3) backup target for the jellyfin 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 +spec: + displayName: "CNPG backup owner (jellyfin)" + uid: cnpg-jellyfin-backup + maxBuckets: 5 + secretName: cnpg-jellyfin-backup-s3 + retainOnDelete: true +--- +apiVersion: ceph.unkin.net/v1alpha1 +kind: Bucket +metadata: + name: cnpg-jellyfin + namespace: jellyfin +spec: + placementTarget: ec + bucketName: cnpg-jellyfin + ownerRef: cnpg-jellyfin-backup + versioning: false + tags: + app: jellyfin + 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-jellyfin-nightly + namespace: jellyfin +spec: + schedule: "0 35 3 * * *" + immediate: false + backupOwnerReference: self + method: barmanObjectStore + cluster: + name: jellyfin-postgres diff --git a/apps/base/jellyfin/cnpg_cluster.yaml b/apps/base/jellyfin/cnpg_cluster.yaml new file mode 100644 index 0000000..9acd6c4 --- /dev/null +++ b/apps/base/jellyfin/cnpg_cluster.yaml @@ -0,0 +1,119 @@ +--- +# Main Jellyfin database. The jellyfin-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 +# (username/password/dbname) that the StatefulSet composes its DSN from. +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: jellyfin-postgres + namespace: jellyfin +spec: + affinity: + podAntiAffinityType: preferred + backup: + retentionPolicy: 30d + barmanObjectStore: + # Dedicated per-cluster Ceph RGW bucket (cephrgw-operator provisions it). + destinationPath: s3://cnpg-jellyfin + endpointURL: https://s3.ceph.unkin.net + endpointCA: + name: vault-ca-cert + key: ca.crt + s3Credentials: + accessKeyId: + name: cnpg-jellyfin-backup-s3 + key: AWS_ACCESS_KEY_ID + secretAccessKey: + name: cnpg-jellyfin-backup-s3 + key: AWS_SECRET_ACCESS_KEY + serverName: jellyfin + data: + compression: bzip2 + jobs: 2 + wal: + compression: zstd + maxParallel: 2 + bootstrap: + initdb: + database: jellyfin + encoding: UTF8 + localeCType: C + localeCollate: C + owner: jellyfin + 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 diff --git a/apps/base/jellyfin/cnpg_pooler.yaml b/apps/base/jellyfin/cnpg_pooler.yaml new file mode 100644 index 0000000..50f9263 --- /dev/null +++ b/apps/base/jellyfin/cnpg_pooler.yaml @@ -0,0 +1,36 @@ +--- +# 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 +# connection churn is absorbed by the pool. +apiVersion: postgresql.cnpg.io/v1 +kind: Pooler +metadata: + name: jellyfin-postgres-pooler + namespace: jellyfin +spec: + cluster: + name: jellyfin-postgres + instances: 2 + pgbouncer: + parameters: + default_pool_size: "50" + max_client_conn: "200" + paused: false + poolMode: session + template: + metadata: + labels: + app: jellyfin-pooler + spec: + affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchExpressions: + - key: app + operator: In + values: + - jellyfin-pooler + topologyKey: kubernetes.io/hostname + containers: [] + type: rw diff --git a/apps/base/jellyfin/deployment.yaml b/apps/base/jellyfin/deployment.yaml deleted file mode 100644 index 7aaed32..0000000 --- a/apps/base/jellyfin/deployment.yaml +++ /dev/null @@ -1,80 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: jellyfin - namespace: jellyfin -spec: - # Start single-replica. Scaling to >1 (true HA) is a follow-up once the Redis - # transcode store and RWX transcode scratch are validated end-to-end. - replicas: 1 - strategy: - # Config PVC is RWO; a Recreate rollout avoids two pods contending for it. - type: Recreate - selector: - matchLabels: - app: jellyfin - template: - metadata: - labels: - app: jellyfin - spec: - containers: - - name: jellyfin - image: git.unkin.net/unkin/jellyfin-ha:v0.1.0 - imagePullPolicy: IfNotPresent - ports: - - name: http - containerPort: 8096 - protocol: TCP - env: - # Distributed transcode session store (jellyfin-ha additions). - - name: Jellyfin__TranscodeStore__RedisConnectionString - value: "redis:6379,abortConnect=false" - - name: Jellyfin__TranscodeStore__LeaseDurationSeconds - value: "30" - 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 - limits: - cpu: "4" - memory: 6Gi - volumeMounts: - - name: config - mountPath: /config - - name: cache - mountPath: /cache - - name: transcode - mountPath: /transcode - - name: media - mountPath: /media - readOnly: true - volumes: - - name: config - persistentVolumeClaim: - claimName: jellyfin-config - - name: cache - emptyDir: {} - - name: transcode - persistentVolumeClaim: - claimName: jellyfin-transcode - - name: media - persistentVolumeClaim: - claimName: jellyfin-media diff --git a/apps/base/jellyfin/kustomization.yaml b/apps/base/jellyfin/kustomization.yaml index b0ada69..2fdb41c 100644 --- a/apps/base/jellyfin/kustomization.yaml +++ b/apps/base/jellyfin/kustomization.yaml @@ -4,10 +4,14 @@ kind: Kustomization resources: - namespace.yaml + - cnpg_cluster.yaml + - cnpg_pooler.yaml + - cnpg_backup.yaml - pvc-config.yaml - pvc-transcode.yaml - pvc-media.yaml - - deployment.yaml + - statefulset.yaml + - pdb.yaml - service.yaml - redis-deployment.yaml - redis-pvc.yaml diff --git a/apps/base/jellyfin/pdb.yaml b/apps/base/jellyfin/pdb.yaml new file mode 100644 index 0000000..4b21b5b --- /dev/null +++ b/apps/base/jellyfin/pdb.yaml @@ -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: jellyfin + namespace: jellyfin +spec: + minAvailable: 1 + selector: + matchLabels: + app: jellyfin diff --git a/apps/base/jellyfin/pvc-config.yaml b/apps/base/jellyfin/pvc-config.yaml index 3bef28c..53e6a4f 100644 --- a/apps/base/jellyfin/pvc-config.yaml +++ b/apps/base/jellyfin/pvc-config.yaml @@ -1,5 +1,7 @@ --- -# Jellyfin config + SQLite library database. Single-writer, block storage. +# 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. apiVersion: v1 kind: PersistentVolumeClaim metadata: @@ -7,9 +9,9 @@ metadata: namespace: jellyfin spec: accessModes: - - ReadWriteOnce + - ReadWriteMany resources: requests: storage: 20Gi - storageClassName: cephrbd-fast-retain + storageClassName: cephfs-raid5-retain volumeMode: Filesystem diff --git a/apps/base/jellyfin/pvc-transcode.yaml b/apps/base/jellyfin/pvc-transcode.yaml index b33c821..d23721a 100644 --- a/apps/base/jellyfin/pvc-transcode.yaml +++ b/apps/base/jellyfin/pvc-transcode.yaml @@ -1,7 +1,8 @@ --- # 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, so delete reclaim policy. +# 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: @@ -13,5 +14,5 @@ spec: resources: requests: storage: 100Gi - storageClassName: cephfs-raid6-delete + storageClassName: cephfs-raid5-delete volumeMode: Filesystem diff --git a/apps/base/jellyfin/service.yaml b/apps/base/jellyfin/service.yaml index 38b5c1d..a8c810e 100644 --- a/apps/base/jellyfin/service.yaml +++ b/apps/base/jellyfin/service.yaml @@ -13,5 +13,6 @@ spec: targetPort: http selector: app: jellyfin - sessionAffinity: None + # Pin each client to one replica to reduce transcode-session churn/takeover. + sessionAffinity: ClientIP type: ClusterIP diff --git a/apps/base/jellyfin/statefulset.yaml b/apps/base/jellyfin/statefulset.yaml new file mode 100644 index 0000000..8bed69d --- /dev/null +++ b/apps/base/jellyfin/statefulset.yaml @@ -0,0 +1,199 @@ +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: jellyfin + namespace: jellyfin +spec: + # HA: two replicas coordinate transcode session ownership through Redis 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. + replicas: 2 + serviceName: jellyfin + podManagementPolicy: Parallel + updateStrategy: + type: RollingUpdate + selector: + matchLabels: + app: jellyfin + template: + metadata: + labels: + app: jellyfin + 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: jellyfin + topologyKey: kubernetes.io/hostname + initContainers: + # Select the fork's experimental PostgreSQL provider by writing + # database.xml before Jellyfin starts. Runs as root to chown into the + # shared config volume; mirrors the fork Helm chart's inject-db-config. + - name: inject-db-config + image: busybox:1.37.0 + command: + - sh + - -c + - | + mkdir -p /config/config + chown 1000:1000 /config/config + chmod 775 /config/config + cat > /config/config/database.xml << 'DBEOF' + + + Jellyfin-PostgreSQL + NoLock + + DBEOF + chown 1000:1000 /config/config/database.xml + chmod 664 /config/config/database.xml + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 100m + memory: 64Mi + volumeMounts: + - name: config + mountPath: /config + containers: + - name: jellyfin + image: git.unkin.net/unkin/jellyfin-ha:v0.1.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8096 + protocol: TCP + env: + # Pod identity for the Redis 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 (jellyfin-ha additions). + - name: Jellyfin__TranscodeStore__RedisConnectionString + value: "redis: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: jellyfin-postgres-app + key: username + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: jellyfin-postgres-app + key: password + - name: PGDB + valueFrom: + secretKeyRef: + name: jellyfin-postgres-app + key: dbname + - name: POSTGRES_CONNECTION_STRING + value: "postgresql://$(PGUSER):$(PGPASSWORD)@jellyfin-postgres-pooler:5432/$(PGDB)" + - name: DATABASE_URL + value: "postgresql://$(PGUSER):$(PGPASSWORD)@jellyfin-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. Enable + # QSV/VA-API once in the Jellyfin admin UI; it persists to /config. + 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 + mountPath: /media + readOnly: true + volumes: + - name: config + persistentVolumeClaim: + claimName: jellyfin-config + - name: transcode + persistentVolumeClaim: + claimName: jellyfin-transcode + - name: media + persistentVolumeClaim: + claimName: jellyfin-media + volumeClaimTemplates: + # Per-pod scratch cache — RWO, disposable, one PVC per replica. + - metadata: + name: cache + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 30Gi + storageClassName: cephrbd-fast-delete + volumeMode: Filesystem -- 2.47.3 From ceb21144672fe5488b1ff47ca288e79b19d7cd7b Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Tue, 11 Aug 2026 07:23:38 +1000 Subject: [PATCH 03/10] Enable Intel iGPU hardware transcode by default for jellyfin-ha The pod already requests gpu.intel.com/i915 and joins the render/video groups, but Jellyfin transcodes in software until hardware acceleration is turned on in its encoding config, which the fork does not template. Seed /config/config/encoding.xml from the init container so VAAPI on the injected Intel render node (/dev/dri/renderD128) is active on first boot: HardwareAccelerationType vaapi, EnableHardwareEncoding, h264/hevc decode, tonemapping left off. Rename the init container to inject-config and write each seed file only when absent, so admin changes persisted to the shared RWX /config are never clobbered on restart. --- apps/base/jellyfin/statefulset.yaml | 53 ++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/apps/base/jellyfin/statefulset.yaml b/apps/base/jellyfin/statefulset.yaml index 8bed69d..e9e4d33 100644 --- a/apps/base/jellyfin/statefulset.yaml +++ b/apps/base/jellyfin/statefulset.yaml @@ -44,10 +44,12 @@ spec: app: jellyfin topologyKey: kubernetes.io/hostname initContainers: - # Select the fork's experimental PostgreSQL provider by writing - # database.xml before Jellyfin starts. Runs as root to chown into the - # shared config volume; mirrors the fork Helm chart's inject-db-config. - - name: inject-db-config + # 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 @@ -56,15 +58,47 @@ spec: mkdir -p /config/config chown 1000:1000 /config/config chmod 775 /config/config - cat > /config/config/database.xml << 'DBEOF' + if [ ! -e /config/config/database.xml ]; then + cat > /config/config/database.xml << 'DBEOF' Jellyfin-PostgreSQL NoLock DBEOF - chown 1000:1000 /config/config/database.xml - chmod 664 /config/config/database.xml + 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' + + + -1 + vaapi + /dev/dri/renderD128 + true + true + false + false + false + false + false + + h264 + hevc + vc1 + vp9 + + + ENCEOF + chown 1000:1000 /config/config/encoding.xml + chmod 664 /config/config/encoding.xml + fi resources: requests: cpu: 10m @@ -156,8 +190,9 @@ spec: 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. Enable - # QSV/VA-API once in the Jellyfin admin UI; it persists to /config. + # 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 -- 2.47.3 From 648ff1694052e7e48d3796abb516e379db8b4f28 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Tue, 11 Aug 2026 07:36:23 +1000 Subject: [PATCH 04/10] jellyfin-ha: pull image from artifactapi docker-internal registry The build repo's release pipeline pushes the runtime image to the artifactapi local docker registry, not Gitea. Point the StatefulSet at artifactapi.k8s.syd1.au.unkin.net/docker-internal/jellyfin-ha:v0.1.0 so the deploy pulls the image the build actually produces. Pull is anonymous in-cluster (no imagePullSecret), matching every other docker-internal workload in the estate (encapi, pdbmux, bind-operator, cephrgw, age-api). --- apps/base/jellyfin/statefulset.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/base/jellyfin/statefulset.yaml b/apps/base/jellyfin/statefulset.yaml index e9e4d33..6c65143 100644 --- a/apps/base/jellyfin/statefulset.yaml +++ b/apps/base/jellyfin/statefulset.yaml @@ -111,7 +111,7 @@ spec: mountPath: /config containers: - name: jellyfin - image: git.unkin.net/unkin/jellyfin-ha:v0.1.0 + image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/jellyfin-ha:v0.1.0 imagePullPolicy: IfNotPresent ports: - name: http -- 2.47.3 From 6769b5291ed461873a0b85cfc5537df88bab476c Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 15 Aug 2026 11:55:55 +1000 Subject: [PATCH 05/10] jellyfin: split media PVC into tv/movies on cephfs-raid5-delete Separate tv and movies onto their own volumes so sonarr/radarr can each mount and manage their library individually later, and move media storage off cephfs-raid6-retain onto cephfs-raid5-delete. - Remove single jellyfin-media PVC (RWX, cephfs-raid6-retain) - Add jellyfin-media-tv and jellyfin-media-movies PVCs (RWX, cephfs-raid5-delete, 500Gi each, expandable) - Mount tv read-only at /media/tv and movies read-only at /media/movies in the statefulset - Update kustomization resources to reference the two new PVCs --- apps/base/jellyfin/kustomization.yaml | 3 ++- apps/base/jellyfin/pvc-media-movies.yaml | 17 +++++++++++++++++ apps/base/jellyfin/pvc-media-tv.yaml | 17 +++++++++++++++++ apps/base/jellyfin/pvc-media.yaml | 17 ----------------- apps/base/jellyfin/statefulset.yaml | 14 ++++++++++---- 5 files changed, 46 insertions(+), 22 deletions(-) create mode 100644 apps/base/jellyfin/pvc-media-movies.yaml create mode 100644 apps/base/jellyfin/pvc-media-tv.yaml delete mode 100644 apps/base/jellyfin/pvc-media.yaml diff --git a/apps/base/jellyfin/kustomization.yaml b/apps/base/jellyfin/kustomization.yaml index 2fdb41c..2c4117d 100644 --- a/apps/base/jellyfin/kustomization.yaml +++ b/apps/base/jellyfin/kustomization.yaml @@ -9,7 +9,8 @@ resources: - cnpg_backup.yaml - pvc-config.yaml - pvc-transcode.yaml - - pvc-media.yaml + - pvc-media-tv.yaml + - pvc-media-movies.yaml - statefulset.yaml - pdb.yaml - service.yaml diff --git a/apps/base/jellyfin/pvc-media-movies.yaml b/apps/base/jellyfin/pvc-media-movies.yaml new file mode 100644 index 0000000..4665bb1 --- /dev/null +++ b/apps/base/jellyfin/pvc-media-movies.yaml @@ -0,0 +1,17 @@ +--- +# Movie library, shared read-many across replicas. Split out from the old single +# media PVC so radarr can mount and manage movies independently. cephfs-raid5-delete +# supports RWX and allows volume expansion; size is a starting point. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: jellyfin-media-movies + namespace: jellyfin +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 500Gi + storageClassName: cephfs-raid5-delete + volumeMode: Filesystem diff --git a/apps/base/jellyfin/pvc-media-tv.yaml b/apps/base/jellyfin/pvc-media-tv.yaml new file mode 100644 index 0000000..6780291 --- /dev/null +++ b/apps/base/jellyfin/pvc-media-tv.yaml @@ -0,0 +1,17 @@ +--- +# TV library, shared read-many across replicas. Split out from the old single +# media PVC so sonarr can mount and manage TV independently. cephfs-raid5-delete +# supports RWX and allows volume expansion; size is a starting point. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: jellyfin-media-tv + namespace: jellyfin +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 500Gi + storageClassName: cephfs-raid5-delete + volumeMode: Filesystem diff --git a/apps/base/jellyfin/pvc-media.yaml b/apps/base/jellyfin/pvc-media.yaml deleted file mode 100644 index 8b60434..0000000 --- a/apps/base/jellyfin/pvc-media.yaml +++ /dev/null @@ -1,17 +0,0 @@ ---- -# Media library, shared read-many across replicas. Retain — this holds the -# actual media and must survive PVC deletion. Empty on first deploy; populating -# it is out of scope for this app. -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: jellyfin-media - namespace: jellyfin -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Ti - storageClassName: cephfs-raid6-retain - volumeMode: Filesystem diff --git a/apps/base/jellyfin/statefulset.yaml b/apps/base/jellyfin/statefulset.yaml index 6c65143..8a6ad9e 100644 --- a/apps/base/jellyfin/statefulset.yaml +++ b/apps/base/jellyfin/statefulset.yaml @@ -207,8 +207,11 @@ spec: mountPath: /config/transcodes - name: cache mountPath: /cache - - name: media - mountPath: /media + - name: media-tv + mountPath: /media/tv + readOnly: true + - name: media-movies + mountPath: /media/movies readOnly: true volumes: - name: config @@ -217,9 +220,12 @@ spec: - name: transcode persistentVolumeClaim: claimName: jellyfin-transcode - - name: media + - name: media-tv persistentVolumeClaim: - claimName: jellyfin-media + claimName: jellyfin-media-tv + - name: media-movies + persistentVolumeClaim: + claimName: jellyfin-media-movies volumeClaimTemplates: # Per-pod scratch cache — RWO, disposable, one PVC per replica. - metadata: -- 2.47.3 From 6b30958a24a432ca745a82b31aeb073d827485ab Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 15 Aug 2026 12:06:30 +1000 Subject: [PATCH 06/10] jellyfin: add cephrgw config-backup bucket for k8up Give the jellyfin backup user a second bucket so k8up can store restic backups of the jellyfin-config PVC alongside the existing CNPG barman bucket (one user, two buckets). - Add a jellyfin-config-backup Bucket owned by the existing cnpg-jellyfin-backup ObjectStoreUser - Add a read-write BucketAccess emitting jellyfin-config-backup-s3 with the S3 creds k8up consumes - Wire the new file into the jellyfin kustomization --- apps/base/jellyfin/cephrgw-config-backup.yaml | 32 +++++++++++++++++++ apps/base/jellyfin/kustomization.yaml | 1 + 2 files changed, 33 insertions(+) create mode 100644 apps/base/jellyfin/cephrgw-config-backup.yaml diff --git a/apps/base/jellyfin/cephrgw-config-backup.yaml b/apps/base/jellyfin/cephrgw-config-backup.yaml new file mode 100644 index 0000000..14ad7ed --- /dev/null +++ b/apps/base/jellyfin/cephrgw-config-backup.yaml @@ -0,0 +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: +# 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 +# Secret the k8up Schedule consumes. +apiVersion: ceph.unkin.net/v1alpha1 +kind: Bucket +metadata: + name: jellyfin-config-backup + namespace: jellyfin +spec: + placementTarget: ec + bucketName: jellyfin-config-backup + ownerRef: cnpg-jellyfin-backup + versioning: false + tags: + app: jellyfin + purpose: config-backup + retainOnDelete: true +--- +apiVersion: ceph.unkin.net/v1alpha1 +kind: BucketAccess +metadata: + name: jellyfin-config-backup + namespace: jellyfin +spec: + bucketRef: jellyfin-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 diff --git a/apps/base/jellyfin/kustomization.yaml b/apps/base/jellyfin/kustomization.yaml index 2c4117d..edab63d 100644 --- a/apps/base/jellyfin/kustomization.yaml +++ b/apps/base/jellyfin/kustomization.yaml @@ -7,6 +7,7 @@ resources: - cnpg_cluster.yaml - cnpg_pooler.yaml - cnpg_backup.yaml + - cephrgw-config-backup.yaml - pvc-config.yaml - pvc-transcode.yaml - pvc-media-tv.yaml -- 2.47.3 From 57ac95bdec7ba416e3ebbddb94ebb7767f22ff72 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 15 Aug 2026 12:08:50 +1000 Subject: [PATCH 07/10] jellyfin: back up the config PVC with a k8up Schedule Protect the jellyfin-config PVC (library metadata, plugins, config XML) with daily restic backups to the new Ceph RGW config-backup bucket. - Add a k8up.io Schedule: daily backup (02:00), weekly prune (Sun 03:00, keep 14 daily/8 weekly/12 monthly) and weekly check (Sun 04:00) - Source S3 creds from the cephrgw BucketAccess Secret and the restic repo password from Vault via a VaultStaticSecret (kv path kubernetes/namespace/jellyfin/default/k8up-restic) - Add the namespace VaultAuth (default role/SA) VSO needs to sync it - Mount the reflected vault-ca-cert into the restic pods so restic trusts the internal unkin.net CA on s3.ceph.unkin.net - Wire the new files into the jellyfin kustomization --- apps/base/jellyfin/kustomization.yaml | 3 ++ apps/base/jellyfin/schedule.yaml | 58 +++++++++++++++++++++++ apps/base/jellyfin/vaultauth.yaml | 20 ++++++++ apps/base/jellyfin/vaultstaticsecret.yaml | 24 ++++++++++ 4 files changed, 105 insertions(+) create mode 100644 apps/base/jellyfin/schedule.yaml create mode 100644 apps/base/jellyfin/vaultauth.yaml create mode 100644 apps/base/jellyfin/vaultstaticsecret.yaml diff --git a/apps/base/jellyfin/kustomization.yaml b/apps/base/jellyfin/kustomization.yaml index edab63d..33ded07 100644 --- a/apps/base/jellyfin/kustomization.yaml +++ b/apps/base/jellyfin/kustomization.yaml @@ -8,6 +8,9 @@ resources: - cnpg_pooler.yaml - cnpg_backup.yaml - cephrgw-config-backup.yaml + - vaultauth.yaml + - vaultstaticsecret.yaml + - schedule.yaml - pvc-config.yaml - pvc-transcode.yaml - pvc-media-tv.yaml diff --git a/apps/base/jellyfin/schedule.yaml b/apps/base/jellyfin/schedule.yaml new file mode 100644 index 0000000..6043a18 --- /dev/null +++ b/apps/base/jellyfin/schedule.yaml @@ -0,0 +1,58 @@ +--- +# k8up Schedule: restic backups of the jellyfin-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. +# +# 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: jellyfin-config + namespace: jellyfin +spec: + backend: + repoPasswordSecretRef: + name: jellyfin-k8up-restic + key: password + s3: + endpoint: https://s3.ceph.unkin.net + bucket: jellyfin-config-backup + accessKeyIDSecretRef: + name: jellyfin-config-backup-s3 + key: AWS_ACCESS_KEY_ID + secretAccessKeySecretRef: + name: jellyfin-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 diff --git a/apps/base/jellyfin/vaultauth.yaml b/apps/base/jellyfin/vaultauth.yaml new file mode 100644 index 0000000..9fbc2c2 --- /dev/null +++ b/apps/base/jellyfin/vaultauth.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultAuth +metadata: + name: default + namespace: jellyfin + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + allowedNamespaces: + - jellyfin + kubernetes: + audiences: + - vault + role: default + serviceAccount: default + tokenExpirationSeconds: 600 + method: kubernetes + mount: k8s/au/syd1 + vaultConnectionRef: vso-system/default diff --git a/apps/base/jellyfin/vaultstaticsecret.yaml b/apps/base/jellyfin/vaultstaticsecret.yaml new file mode 100644 index 0000000..9de7ef2 --- /dev/null +++ b/apps/base/jellyfin/vaultstaticsecret.yaml @@ -0,0 +1,24 @@ +--- +# restic repository password for the k8up jellyfin-config backups. Seeded at +# kv/kubernetes/namespace/jellyfin/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 +# Secret that the Schedule references via backend.repoPasswordSecretRef. +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: jellyfin-k8up-restic + namespace: jellyfin + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + destination: + create: true + name: jellyfin-k8up-restic + overwrite: true + hmacSecretData: true + mount: kv + path: kubernetes/namespace/jellyfin/default/k8up-restic + refreshAfter: 5m + type: kv-v2 + vaultAuthRef: default -- 2.47.3 From 066dc41b558b28bf29e329753a53030d00655324 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 15 Aug 2026 12:34:25 +1000 Subject: [PATCH 08/10] jellyfin: scope k8up backup to config PVC only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jellyfin-config Schedule had no PVC selector and the k8up chart runs skipWithoutAnnotation=false, so restic swept in every PVC in the namespace (media, transcode scratch, redis, per-pod cache, CNPG data) — RWO volumes also fail to mount while in use. k8up 4.10.0's Schedule CRD has no positive PVC selector, so exclude every non-config PVC by annotation. - Annotate media-tv, media-movies, transcode and redis-data PVCs with k8up.io/backup: "false". - Annotate the per-pod cache volumeClaimTemplate the same way. - Propagate the annotation to the CNPG data PVCs via inheritedMetadata (postgres has its own barmanObjectStore backup). - Leave jellyfin-config unannotated so it remains the only backup target. --- apps/base/jellyfin/cnpg_cluster.yaml | 7 +++++++ apps/base/jellyfin/pvc-media-movies.yaml | 5 +++++ apps/base/jellyfin/pvc-media-tv.yaml | 5 +++++ apps/base/jellyfin/pvc-transcode.yaml | 5 +++++ apps/base/jellyfin/redis-pvc.yaml | 5 +++++ apps/base/jellyfin/statefulset.yaml | 5 +++++ 6 files changed, 32 insertions(+) diff --git a/apps/base/jellyfin/cnpg_cluster.yaml b/apps/base/jellyfin/cnpg_cluster.yaml index 9acd6c4..46a2476 100644 --- a/apps/base/jellyfin/cnpg_cluster.yaml +++ b/apps/base/jellyfin/cnpg_cluster.yaml @@ -10,6 +10,13 @@ metadata: name: jellyfin-postgres namespace: jellyfin spec: + # Exclude the operator-managed data PVCs (jellyfin-postgres-N) from the + # jellyfin-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: diff --git a/apps/base/jellyfin/pvc-media-movies.yaml b/apps/base/jellyfin/pvc-media-movies.yaml index 4665bb1..5611832 100644 --- a/apps/base/jellyfin/pvc-media-movies.yaml +++ b/apps/base/jellyfin/pvc-media-movies.yaml @@ -7,6 +7,11 @@ kind: PersistentVolumeClaim metadata: name: jellyfin-media-movies namespace: jellyfin + annotations: + # Exclude from the jellyfin-config k8up Schedule (skipWithoutAnnotation is + # false cluster-wide, so unannotated PVCs are swept in). Only jellyfin-config + # is backed up; the media library is not restic-backup material. + k8up.io/backup: "false" spec: accessModes: - ReadWriteMany diff --git a/apps/base/jellyfin/pvc-media-tv.yaml b/apps/base/jellyfin/pvc-media-tv.yaml index 6780291..51bcc31 100644 --- a/apps/base/jellyfin/pvc-media-tv.yaml +++ b/apps/base/jellyfin/pvc-media-tv.yaml @@ -7,6 +7,11 @@ kind: PersistentVolumeClaim metadata: name: jellyfin-media-tv namespace: jellyfin + annotations: + # Exclude from the jellyfin-config k8up Schedule (skipWithoutAnnotation is + # false cluster-wide, so unannotated PVCs are swept in). Only jellyfin-config + # is backed up; the media library is not restic-backup material. + k8up.io/backup: "false" spec: accessModes: - ReadWriteMany diff --git a/apps/base/jellyfin/pvc-transcode.yaml b/apps/base/jellyfin/pvc-transcode.yaml index d23721a..01750ad 100644 --- a/apps/base/jellyfin/pvc-transcode.yaml +++ b/apps/base/jellyfin/pvc-transcode.yaml @@ -8,6 +8,11 @@ kind: PersistentVolumeClaim metadata: name: jellyfin-transcode namespace: jellyfin + annotations: + # Exclude from the jellyfin-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 diff --git a/apps/base/jellyfin/redis-pvc.yaml b/apps/base/jellyfin/redis-pvc.yaml index 36a4315..a6717ad 100644 --- a/apps/base/jellyfin/redis-pvc.yaml +++ b/apps/base/jellyfin/redis-pvc.yaml @@ -4,6 +4,11 @@ kind: PersistentVolumeClaim metadata: name: jellyfin-redis-data namespace: jellyfin + annotations: + # Exclude from the jellyfin-config k8up Schedule (skipWithoutAnnotation is + # false cluster-wide, so unannotated PVCs are swept in). Redis holds only + # ephemeral transcode-lease state; RWO would also fail to mount while in use. + k8up.io/backup: "false" spec: accessModes: - ReadWriteOnce diff --git a/apps/base/jellyfin/statefulset.yaml b/apps/base/jellyfin/statefulset.yaml index 8a6ad9e..11fdbd4 100644 --- a/apps/base/jellyfin/statefulset.yaml +++ b/apps/base/jellyfin/statefulset.yaml @@ -230,6 +230,11 @@ spec: # 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 + # (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 -- 2.47.3 From f5a721cd0bf1e33d3fd80d749dca26149b8a49f7 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 15 Aug 2026 13:03:49 +1000 Subject: [PATCH 09/10] jellyfin: back media PVCs with shared static CephFS PVs --- apps/base/jellyfin/kustomization.yaml | 2 ++ apps/base/jellyfin/pv-media-movies.yaml | 30 ++++++++++++++++++++++++ apps/base/jellyfin/pv-media-tv.yaml | 30 ++++++++++++++++++++++++ apps/base/jellyfin/pvc-media-movies.yaml | 12 ++++++---- apps/base/jellyfin/pvc-media-tv.yaml | 12 ++++++---- argocd/projects/media.yaml | 4 ++++ 6 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 apps/base/jellyfin/pv-media-movies.yaml create mode 100644 apps/base/jellyfin/pv-media-tv.yaml diff --git a/apps/base/jellyfin/kustomization.yaml b/apps/base/jellyfin/kustomization.yaml index 33ded07..1374887 100644 --- a/apps/base/jellyfin/kustomization.yaml +++ b/apps/base/jellyfin/kustomization.yaml @@ -13,6 +13,8 @@ resources: - 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 diff --git a/apps/base/jellyfin/pv-media-movies.yaml b/apps/base/jellyfin/pv-media-movies.yaml new file mode 100644 index 0000000..b9ddfcc --- /dev/null +++ b/apps/base/jellyfin/pv-media-movies.yaml @@ -0,0 +1,30 @@ +--- +# 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 +# namespace gets its own PV (unique name + volumeHandle) pinned by claimRef. +apiVersion: v1 +kind: PersistentVolume +metadata: + name: jellyfin-media-movies +spec: + capacity: + storage: 1Ti + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + volumeMode: Filesystem + claimRef: + namespace: jellyfin + name: jellyfin-media-movies + csi: + driver: cephfs.csi.ceph.com + volumeHandle: jellyfin-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 diff --git a/apps/base/jellyfin/pv-media-tv.yaml b/apps/base/jellyfin/pv-media-tv.yaml new file mode 100644 index 0000000..a3df747 --- /dev/null +++ b/apps/base/jellyfin/pv-media-tv.yaml @@ -0,0 +1,30 @@ +--- +# 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 +# namespace gets its own PV (unique name + volumeHandle) pinned by claimRef. +apiVersion: v1 +kind: PersistentVolume +metadata: + name: jellyfin-media-tv +spec: + capacity: + storage: 1Ti + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + volumeMode: Filesystem + claimRef: + namespace: jellyfin + name: jellyfin-media-tv + csi: + driver: cephfs.csi.ceph.com + volumeHandle: jellyfin-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 diff --git a/apps/base/jellyfin/pvc-media-movies.yaml b/apps/base/jellyfin/pvc-media-movies.yaml index 5611832..4fe15f6 100644 --- a/apps/base/jellyfin/pvc-media-movies.yaml +++ b/apps/base/jellyfin/pvc-media-movies.yaml @@ -1,7 +1,8 @@ --- -# Movie library, shared read-many across replicas. Split out from the old single -# media PVC so radarr can mount and manage movies independently. cephfs-raid5-delete -# supports RWX and allows volume expansion; size is a starting point. +# Movie library, shared read-many across replicas. Statically bound to the +# jellyfin-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: @@ -17,6 +18,7 @@ spec: - ReadWriteMany resources: requests: - storage: 500Gi - storageClassName: cephfs-raid5-delete + storage: 1Ti + storageClassName: "" + volumeName: jellyfin-media-movies volumeMode: Filesystem diff --git a/apps/base/jellyfin/pvc-media-tv.yaml b/apps/base/jellyfin/pvc-media-tv.yaml index 51bcc31..6593bc5 100644 --- a/apps/base/jellyfin/pvc-media-tv.yaml +++ b/apps/base/jellyfin/pvc-media-tv.yaml @@ -1,7 +1,8 @@ --- -# TV library, shared read-many across replicas. Split out from the old single -# media PVC so sonarr can mount and manage TV independently. cephfs-raid5-delete -# supports RWX and allows volume expansion; size is a starting point. +# TV library, shared read-many across replicas. Statically bound to the +# jellyfin-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: @@ -17,6 +18,7 @@ spec: - ReadWriteMany resources: requests: - storage: 500Gi - storageClassName: cephfs-raid5-delete + storage: 1Ti + storageClassName: "" + volumeName: jellyfin-media-tv volumeMode: Filesystem diff --git a/argocd/projects/media.yaml b/argocd/projects/media.yaml index 652c226..4b3f29f 100644 --- a/argocd/projects/media.yaml +++ b/argocd/projects/media.yaml @@ -11,9 +11,13 @@ spec: destinations: - namespace: 'jellyfin' server: https://kubernetes.default.svc + - namespace: 'arrstack' + server: https://kubernetes.default.svc clusterResourceWhitelist: - group: '' kind: Namespace + - group: '' + kind: PersistentVolume namespaceResourceWhitelist: - group: '*' kind: '*' -- 2.47.3 From 5c854e61fec8a95f27660c1e321e1cdbdfcd7b79 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 15 Aug 2026 13:12:09 +1000 Subject: [PATCH 10/10] arrstack: add sonarr/radarr/prowlarr/nzbget on shared CephFS media --- apps/base/arrstack/kustomization.yaml | 14 ++ apps/base/arrstack/namespace.yaml | 5 + apps/base/arrstack/nzbget/deployment.yaml | 137 ++++++++++++++++++ apps/base/arrstack/nzbget/gateway.yaml | 37 +++++ apps/base/arrstack/nzbget/httproute.yaml | 49 +++++++ apps/base/arrstack/nzbget/kustomization.yaml | 10 ++ apps/base/arrstack/nzbget/pvc-config.yaml | 16 ++ apps/base/arrstack/nzbget/service.yaml | 15 ++ apps/base/arrstack/prowlarr/deployment.yaml | 67 +++++++++ apps/base/arrstack/prowlarr/gateway.yaml | 37 +++++ apps/base/arrstack/prowlarr/httproute.yaml | 49 +++++++ .../base/arrstack/prowlarr/kustomization.yaml | 10 ++ apps/base/arrstack/prowlarr/pvc-config.yaml | 16 ++ apps/base/arrstack/prowlarr/service.yaml | 15 ++ apps/base/arrstack/pv-media-movies.yaml | 31 ++++ apps/base/arrstack/pv-media-tv.yaml | 30 ++++ apps/base/arrstack/pvc-media-movies.yaml | 22 +++ apps/base/arrstack/pvc-media-tv.yaml | 22 +++ apps/base/arrstack/radarr/deployment.yaml | 72 +++++++++ apps/base/arrstack/radarr/gateway.yaml | 37 +++++ apps/base/arrstack/radarr/httproute.yaml | 49 +++++++ apps/base/arrstack/radarr/kustomization.yaml | 10 ++ apps/base/arrstack/radarr/pvc-config.yaml | 16 ++ apps/base/arrstack/radarr/service.yaml | 15 ++ apps/base/arrstack/sonarr/deployment.yaml | 75 ++++++++++ apps/base/arrstack/sonarr/gateway.yaml | 37 +++++ apps/base/arrstack/sonarr/httproute.yaml | 49 +++++++ apps/base/arrstack/sonarr/kustomization.yaml | 10 ++ apps/base/arrstack/sonarr/pvc-config.yaml | 16 ++ apps/base/arrstack/sonarr/service.yaml | 15 ++ .../au-syd1/arrstack/kustomization.yaml | 6 + argocd/applicationsets/media.yaml | 1 + 32 files changed, 990 insertions(+) create mode 100644 apps/base/arrstack/kustomization.yaml create mode 100644 apps/base/arrstack/namespace.yaml create mode 100644 apps/base/arrstack/nzbget/deployment.yaml create mode 100644 apps/base/arrstack/nzbget/gateway.yaml create mode 100644 apps/base/arrstack/nzbget/httproute.yaml create mode 100644 apps/base/arrstack/nzbget/kustomization.yaml create mode 100644 apps/base/arrstack/nzbget/pvc-config.yaml create mode 100644 apps/base/arrstack/nzbget/service.yaml create mode 100644 apps/base/arrstack/prowlarr/deployment.yaml create mode 100644 apps/base/arrstack/prowlarr/gateway.yaml create mode 100644 apps/base/arrstack/prowlarr/httproute.yaml create mode 100644 apps/base/arrstack/prowlarr/kustomization.yaml create mode 100644 apps/base/arrstack/prowlarr/pvc-config.yaml create mode 100644 apps/base/arrstack/prowlarr/service.yaml create mode 100644 apps/base/arrstack/pv-media-movies.yaml create mode 100644 apps/base/arrstack/pv-media-tv.yaml create mode 100644 apps/base/arrstack/pvc-media-movies.yaml create mode 100644 apps/base/arrstack/pvc-media-tv.yaml create mode 100644 apps/base/arrstack/radarr/deployment.yaml create mode 100644 apps/base/arrstack/radarr/gateway.yaml create mode 100644 apps/base/arrstack/radarr/httproute.yaml create mode 100644 apps/base/arrstack/radarr/kustomization.yaml create mode 100644 apps/base/arrstack/radarr/pvc-config.yaml create mode 100644 apps/base/arrstack/radarr/service.yaml create mode 100644 apps/base/arrstack/sonarr/deployment.yaml create mode 100644 apps/base/arrstack/sonarr/gateway.yaml create mode 100644 apps/base/arrstack/sonarr/httproute.yaml create mode 100644 apps/base/arrstack/sonarr/kustomization.yaml create mode 100644 apps/base/arrstack/sonarr/pvc-config.yaml create mode 100644 apps/base/arrstack/sonarr/service.yaml create mode 100644 apps/overlays/au-syd1/arrstack/kustomization.yaml diff --git a/apps/base/arrstack/kustomization.yaml b/apps/base/arrstack/kustomization.yaml new file mode 100644 index 0000000..c68c8c5 --- /dev/null +++ b/apps/base/arrstack/kustomization.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace.yaml + - pv-media-tv.yaml + - pv-media-movies.yaml + - pvc-media-tv.yaml + - pvc-media-movies.yaml + - sonarr + - radarr + - prowlarr + - nzbget diff --git a/apps/base/arrstack/namespace.yaml b/apps/base/arrstack/namespace.yaml new file mode 100644 index 0000000..e57af7f --- /dev/null +++ b/apps/base/arrstack/namespace.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: arrstack diff --git a/apps/base/arrstack/nzbget/deployment.yaml b/apps/base/arrstack/nzbget/deployment.yaml new file mode 100644 index 0000000..acf6381 --- /dev/null +++ b/apps/base/arrstack/nzbget/deployment.yaml @@ -0,0 +1,137 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nzbget + namespace: arrstack +spec: + replicas: 1 + strategy: + # RWO config PVC + single queue state: never run two pods at once. + type: Recreate + selector: + matchLabels: + app: nzbget + template: + metadata: + labels: + app: nzbget + spec: + securityContext: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + initContainers: + # Seed download layout onto the shared media PVCs (not /config or an + # emptyDir) so completed downloads land beside the arr libraries and + # imports are same-filesystem hardlink moves. Reuses the image's own + # template (inherits correct WebDir/ConfigTemplate) and appends the + # path/category overrides once; nzbget honours the last value for a + # repeated option, and the grep guard keeps re-runs idempotent so admin + # UI edits to the persisted /config/nzbget.conf survive restarts. + - name: seed-config + image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/linuxserver/nzbget: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/radarr + # 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 + - name: media-movies + mountPath: /media/movies + containers: + - name: nzbget + image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/linuxserver/nzbget: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 + - name: media-movies + mountPath: /media/movies + volumes: + - name: config + persistentVolumeClaim: + claimName: nzbget-config + - name: media-tv + persistentVolumeClaim: + claimName: media-tv + - name: media-movies + persistentVolumeClaim: + claimName: media-movies diff --git a/apps/base/arrstack/nzbget/gateway.yaml b/apps/base/arrstack/nzbget/gateway.yaml new file mode 100644 index 0000000..a0e42e5 --- /dev/null +++ b/apps/base/arrstack/nzbget/gateway.yaml @@ -0,0 +1,37 @@ +--- +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: nzbget.k8s.syd1.au.unkin.net + cert-manager.io/private-key-size: "4096" + external-dns.alpha.kubernetes.io/hostname: nzbget.k8s.syd1.au.unkin.net + external-dns.alpha.kubernetes.io/target: 198.18.200.4 + name: nzbget + namespace: arrstack +spec: + gatewayClassName: traefik-internal + listeners: + - allowedRoutes: + namespaces: + from: Same + hostname: nzbget.k8s.syd1.au.unkin.net + name: http + port: 80 + protocol: HTTP + - allowedRoutes: + namespaces: + from: Same + hostname: nzbget.k8s.syd1.au.unkin.net + name: https + port: 443 + protocol: HTTPS + tls: + certificateRefs: + - group: "" + kind: Secret + name: nzbget-tls + mode: Terminate diff --git a/apps/base/arrstack/nzbget/httproute.yaml b/apps/base/arrstack/nzbget/httproute.yaml new file mode 100644 index 0000000..b4aa2fb --- /dev/null +++ b/apps/base/arrstack/nzbget/httproute.yaml @@ -0,0 +1,49 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: nzbget-http-redirect + namespace: arrstack +spec: + hostnames: + - nzbget.k8s.syd1.au.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: nzbget + 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: nzbget-route + namespace: arrstack +spec: + hostnames: + - nzbget.k8s.syd1.au.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: nzbget + sectionName: https + rules: + - backendRefs: + - group: "" + kind: Service + name: nzbget + port: 6789 + weight: 1 + matches: + - path: + type: PathPrefix + value: / diff --git a/apps/base/arrstack/nzbget/kustomization.yaml b/apps/base/arrstack/nzbget/kustomization.yaml new file mode 100644 index 0000000..a7f5633 --- /dev/null +++ b/apps/base/arrstack/nzbget/kustomization.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - pvc-config.yaml + - deployment.yaml + - service.yaml + - gateway.yaml + - httproute.yaml diff --git a/apps/base/arrstack/nzbget/pvc-config.yaml b/apps/base/arrstack/nzbget/pvc-config.yaml new file mode 100644 index 0000000..52c365b --- /dev/null +++ b/apps/base/arrstack/nzbget/pvc-config.yaml @@ -0,0 +1,16 @@ +--- +# NZBGet config + queue/temp state. RWO on cephrbd (block). Retain: this is +# state. The download data itself lives on the shared media PVCs, not here. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: nzbget-config + namespace: arrstack +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi + storageClassName: cephrbd-fast-retain + volumeMode: Filesystem diff --git a/apps/base/arrstack/nzbget/service.yaml b/apps/base/arrstack/nzbget/service.yaml new file mode 100644 index 0000000..43e29f4 --- /dev/null +++ b/apps/base/arrstack/nzbget/service.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: nzbget + namespace: arrstack +spec: + ports: + - name: http + port: 6789 + protocol: TCP + targetPort: http + selector: + app: nzbget + type: ClusterIP diff --git a/apps/base/arrstack/prowlarr/deployment.yaml b/apps/base/arrstack/prowlarr/deployment.yaml new file mode 100644 index 0000000..df9efde --- /dev/null +++ b/apps/base/arrstack/prowlarr/deployment.yaml @@ -0,0 +1,67 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prowlarr + namespace: arrstack +spec: + replicas: 1 + strategy: + # RWO config PVC + single stateful SQLite DB: never run two pods at once. + type: Recreate + selector: + matchLabels: + app: prowlarr + template: + metadata: + labels: + app: prowlarr + spec: + securityContext: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + containers: + - name: prowlarr + image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/linuxserver/prowlarr:2.5.2 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 9696 + protocol: TCP + env: + - name: PUID + value: "1000" + - name: PGID + value: "1000" + - name: TZ + value: Australia/Sydney + livenessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 512Mi + volumeMounts: + - name: config + mountPath: /config + volumes: + - name: config + persistentVolumeClaim: + claimName: prowlarr-config diff --git a/apps/base/arrstack/prowlarr/gateway.yaml b/apps/base/arrstack/prowlarr/gateway.yaml new file mode 100644 index 0000000..4d8a19e --- /dev/null +++ b/apps/base/arrstack/prowlarr/gateway.yaml @@ -0,0 +1,37 @@ +--- +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: prowlarr.k8s.syd1.au.unkin.net + cert-manager.io/private-key-size: "4096" + external-dns.alpha.kubernetes.io/hostname: prowlarr.k8s.syd1.au.unkin.net + external-dns.alpha.kubernetes.io/target: 198.18.200.4 + name: prowlarr + namespace: arrstack +spec: + gatewayClassName: traefik-internal + listeners: + - allowedRoutes: + namespaces: + from: Same + hostname: prowlarr.k8s.syd1.au.unkin.net + name: http + port: 80 + protocol: HTTP + - allowedRoutes: + namespaces: + from: Same + hostname: prowlarr.k8s.syd1.au.unkin.net + name: https + port: 443 + protocol: HTTPS + tls: + certificateRefs: + - group: "" + kind: Secret + name: prowlarr-tls + mode: Terminate diff --git a/apps/base/arrstack/prowlarr/httproute.yaml b/apps/base/arrstack/prowlarr/httproute.yaml new file mode 100644 index 0000000..9e43c60 --- /dev/null +++ b/apps/base/arrstack/prowlarr/httproute.yaml @@ -0,0 +1,49 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: prowlarr-http-redirect + namespace: arrstack +spec: + hostnames: + - prowlarr.k8s.syd1.au.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: prowlarr + 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: prowlarr-route + namespace: arrstack +spec: + hostnames: + - prowlarr.k8s.syd1.au.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: prowlarr + sectionName: https + rules: + - backendRefs: + - group: "" + kind: Service + name: prowlarr + port: 9696 + weight: 1 + matches: + - path: + type: PathPrefix + value: / diff --git a/apps/base/arrstack/prowlarr/kustomization.yaml b/apps/base/arrstack/prowlarr/kustomization.yaml new file mode 100644 index 0000000..a7f5633 --- /dev/null +++ b/apps/base/arrstack/prowlarr/kustomization.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - pvc-config.yaml + - deployment.yaml + - service.yaml + - gateway.yaml + - httproute.yaml diff --git a/apps/base/arrstack/prowlarr/pvc-config.yaml b/apps/base/arrstack/prowlarr/pvc-config.yaml new file mode 100644 index 0000000..208bd7c --- /dev/null +++ b/apps/base/arrstack/prowlarr/pvc-config.yaml @@ -0,0 +1,16 @@ +--- +# Prowlarr config + SQLite DB. RWO on cephrbd (block) — the arr apps' SQLite +# does not tolerate CephFS locking. Retain: this is state. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: prowlarr-config + namespace: arrstack +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi + storageClassName: cephrbd-fast-retain + volumeMode: Filesystem diff --git a/apps/base/arrstack/prowlarr/service.yaml b/apps/base/arrstack/prowlarr/service.yaml new file mode 100644 index 0000000..96da114 --- /dev/null +++ b/apps/base/arrstack/prowlarr/service.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: prowlarr + namespace: arrstack +spec: + ports: + - name: http + port: 9696 + protocol: TCP + targetPort: http + selector: + app: prowlarr + type: ClusterIP diff --git a/apps/base/arrstack/pv-media-movies.yaml b/apps/base/arrstack/pv-media-movies.yaml new file mode 100644 index 0000000..a70696c --- /dev/null +++ b/apps/base/arrstack/pv-media-movies.yaml @@ -0,0 +1,31 @@ +--- +# Static PV for the shared MOVIES CephFS subvolume. Same rootPath as jellyfin's +# movies PV so radarr/nzbget write and jellyfin reads the identical library +# tree; each namespace gets its own PV (unique name + volumeHandle) pinned by +# claimRef. +apiVersion: v1 +kind: PersistentVolume +metadata: + name: arrstack-media-movies +spec: + capacity: + storage: 1Ti + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + volumeMode: Filesystem + claimRef: + namespace: arrstack + name: media-movies + csi: + driver: cephfs.csi.ceph.com + volumeHandle: arrstack-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 diff --git a/apps/base/arrstack/pv-media-tv.yaml b/apps/base/arrstack/pv-media-tv.yaml new file mode 100644 index 0000000..86fd07d --- /dev/null +++ b/apps/base/arrstack/pv-media-tv.yaml @@ -0,0 +1,30 @@ +--- +# Static PV for the shared TV CephFS subvolume. Same rootPath as jellyfin's TV +# PV so sonarr/nzbget write and jellyfin reads the identical library tree; each +# namespace gets its own PV (unique name + volumeHandle) pinned by claimRef. +apiVersion: v1 +kind: PersistentVolume +metadata: + name: arrstack-media-tv +spec: + capacity: + storage: 1Ti + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + volumeMode: Filesystem + claimRef: + namespace: arrstack + name: media-tv + csi: + driver: cephfs.csi.ceph.com + volumeHandle: arrstack-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 diff --git a/apps/base/arrstack/pvc-media-movies.yaml b/apps/base/arrstack/pvc-media-movies.yaml new file mode 100644 index 0000000..1fc691a --- /dev/null +++ b/apps/base/arrstack/pvc-media-movies.yaml @@ -0,0 +1,22 @@ +--- +# Movies library + downloads, shared RWX across radarr and nzbget. Statically +# bound to the arrstack-media-movies PV (same CephFS subvolume jellyfin mounts +# read-only). storageClassName "" + volumeName disables dynamic provisioning and +# binds the pre-created static PV. Downloads and library live on one filesystem +# so import is an atomic hardlink move. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: media-movies + namespace: arrstack + annotations: + k8up.io/backup: "false" +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 1Ti + storageClassName: "" + volumeName: arrstack-media-movies + volumeMode: Filesystem diff --git a/apps/base/arrstack/pvc-media-tv.yaml b/apps/base/arrstack/pvc-media-tv.yaml new file mode 100644 index 0000000..8680637 --- /dev/null +++ b/apps/base/arrstack/pvc-media-tv.yaml @@ -0,0 +1,22 @@ +--- +# TV library + downloads, shared RWX across sonarr and nzbget. Statically bound +# to the arrstack-media-tv PV (same CephFS subvolume jellyfin mounts read-only). +# storageClassName "" + volumeName disables dynamic provisioning and binds the +# pre-created static PV. Downloads and library live on one filesystem so import +# is an atomic hardlink move. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: media-tv + namespace: arrstack + annotations: + k8up.io/backup: "false" +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 1Ti + storageClassName: "" + volumeName: arrstack-media-tv + volumeMode: Filesystem diff --git a/apps/base/arrstack/radarr/deployment.yaml b/apps/base/arrstack/radarr/deployment.yaml new file mode 100644 index 0000000..9de78e5 --- /dev/null +++ b/apps/base/arrstack/radarr/deployment.yaml @@ -0,0 +1,72 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: radarr + namespace: arrstack +spec: + replicas: 1 + strategy: + # RWO config PVC + single stateful SQLite DB: never run two pods at once. + type: Recreate + selector: + matchLabels: + app: radarr + template: + metadata: + labels: + app: radarr + spec: + securityContext: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + containers: + - name: radarr + image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/linuxserver/radarr:6.3.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 7878 + protocol: TCP + env: + - name: PUID + value: "1000" + - name: PGID + value: "1000" + - name: TZ + value: Australia/Sydney + livenessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /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 + - name: media-movies + mountPath: /media/movies + volumes: + - name: config + persistentVolumeClaim: + claimName: radarr-config + - name: media-movies + persistentVolumeClaim: + claimName: media-movies diff --git a/apps/base/arrstack/radarr/gateway.yaml b/apps/base/arrstack/radarr/gateway.yaml new file mode 100644 index 0000000..2fb50ca --- /dev/null +++ b/apps/base/arrstack/radarr/gateway.yaml @@ -0,0 +1,37 @@ +--- +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: radarr.k8s.syd1.au.unkin.net + cert-manager.io/private-key-size: "4096" + external-dns.alpha.kubernetes.io/hostname: radarr.k8s.syd1.au.unkin.net + external-dns.alpha.kubernetes.io/target: 198.18.200.4 + name: radarr + namespace: arrstack +spec: + gatewayClassName: traefik-internal + listeners: + - allowedRoutes: + namespaces: + from: Same + hostname: radarr.k8s.syd1.au.unkin.net + name: http + port: 80 + protocol: HTTP + - allowedRoutes: + namespaces: + from: Same + hostname: radarr.k8s.syd1.au.unkin.net + name: https + port: 443 + protocol: HTTPS + tls: + certificateRefs: + - group: "" + kind: Secret + name: radarr-tls + mode: Terminate diff --git a/apps/base/arrstack/radarr/httproute.yaml b/apps/base/arrstack/radarr/httproute.yaml new file mode 100644 index 0000000..002cc5b --- /dev/null +++ b/apps/base/arrstack/radarr/httproute.yaml @@ -0,0 +1,49 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: radarr-http-redirect + namespace: arrstack +spec: + hostnames: + - radarr.k8s.syd1.au.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: radarr + 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: radarr-route + namespace: arrstack +spec: + hostnames: + - radarr.k8s.syd1.au.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: radarr + sectionName: https + rules: + - backendRefs: + - group: "" + kind: Service + name: radarr + port: 7878 + weight: 1 + matches: + - path: + type: PathPrefix + value: / diff --git a/apps/base/arrstack/radarr/kustomization.yaml b/apps/base/arrstack/radarr/kustomization.yaml new file mode 100644 index 0000000..a7f5633 --- /dev/null +++ b/apps/base/arrstack/radarr/kustomization.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - pvc-config.yaml + - deployment.yaml + - service.yaml + - gateway.yaml + - httproute.yaml diff --git a/apps/base/arrstack/radarr/pvc-config.yaml b/apps/base/arrstack/radarr/pvc-config.yaml new file mode 100644 index 0000000..25fadad --- /dev/null +++ b/apps/base/arrstack/radarr/pvc-config.yaml @@ -0,0 +1,16 @@ +--- +# Radarr config + SQLite DB. RWO on cephrbd (block) — the arr apps' SQLite +# does not tolerate CephFS locking. Retain: this is state. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: radarr-config + namespace: arrstack +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi + storageClassName: cephrbd-fast-retain + volumeMode: Filesystem diff --git a/apps/base/arrstack/radarr/service.yaml b/apps/base/arrstack/radarr/service.yaml new file mode 100644 index 0000000..08d154f --- /dev/null +++ b/apps/base/arrstack/radarr/service.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: radarr + namespace: arrstack +spec: + ports: + - name: http + port: 7878 + protocol: TCP + targetPort: http + selector: + app: radarr + type: ClusterIP diff --git a/apps/base/arrstack/sonarr/deployment.yaml b/apps/base/arrstack/sonarr/deployment.yaml new file mode 100644 index 0000000..5352c71 --- /dev/null +++ b/apps/base/arrstack/sonarr/deployment.yaml @@ -0,0 +1,75 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sonarr + namespace: arrstack +spec: + replicas: 1 + strategy: + # RWO config PVC + single stateful SQLite DB: never run two pods at once. + type: Recreate + selector: + matchLabels: + app: sonarr + template: + metadata: + labels: + app: sonarr + spec: + securityContext: + # LinuxServer images init as root via s6 then step down to PUID/PGID. + # fsGroup makes the shared CephFS group-writable for that user; + # OnRootMismatch avoids a recursive chown of the whole media tree. + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + containers: + - name: sonarr + image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/linuxserver/sonarr:4.0.19 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8989 + protocol: TCP + env: + - name: PUID + value: "1000" + - name: PGID + value: "1000" + - name: TZ + value: Australia/Sydney + livenessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /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 + - name: media-tv + mountPath: /media/tv + volumes: + - name: config + persistentVolumeClaim: + claimName: sonarr-config + - name: media-tv + persistentVolumeClaim: + claimName: media-tv diff --git a/apps/base/arrstack/sonarr/gateway.yaml b/apps/base/arrstack/sonarr/gateway.yaml new file mode 100644 index 0000000..38ff6b3 --- /dev/null +++ b/apps/base/arrstack/sonarr/gateway.yaml @@ -0,0 +1,37 @@ +--- +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: sonarr.k8s.syd1.au.unkin.net + cert-manager.io/private-key-size: "4096" + external-dns.alpha.kubernetes.io/hostname: sonarr.k8s.syd1.au.unkin.net + external-dns.alpha.kubernetes.io/target: 198.18.200.4 + name: sonarr + namespace: arrstack +spec: + gatewayClassName: traefik-internal + listeners: + - allowedRoutes: + namespaces: + from: Same + hostname: sonarr.k8s.syd1.au.unkin.net + name: http + port: 80 + protocol: HTTP + - allowedRoutes: + namespaces: + from: Same + hostname: sonarr.k8s.syd1.au.unkin.net + name: https + port: 443 + protocol: HTTPS + tls: + certificateRefs: + - group: "" + kind: Secret + name: sonarr-tls + mode: Terminate diff --git a/apps/base/arrstack/sonarr/httproute.yaml b/apps/base/arrstack/sonarr/httproute.yaml new file mode 100644 index 0000000..5e11c73 --- /dev/null +++ b/apps/base/arrstack/sonarr/httproute.yaml @@ -0,0 +1,49 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: sonarr-http-redirect + namespace: arrstack +spec: + hostnames: + - sonarr.k8s.syd1.au.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: sonarr + 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: sonarr-route + namespace: arrstack +spec: + hostnames: + - sonarr.k8s.syd1.au.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: sonarr + sectionName: https + rules: + - backendRefs: + - group: "" + kind: Service + name: sonarr + port: 8989 + weight: 1 + matches: + - path: + type: PathPrefix + value: / diff --git a/apps/base/arrstack/sonarr/kustomization.yaml b/apps/base/arrstack/sonarr/kustomization.yaml new file mode 100644 index 0000000..a7f5633 --- /dev/null +++ b/apps/base/arrstack/sonarr/kustomization.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - pvc-config.yaml + - deployment.yaml + - service.yaml + - gateway.yaml + - httproute.yaml diff --git a/apps/base/arrstack/sonarr/pvc-config.yaml b/apps/base/arrstack/sonarr/pvc-config.yaml new file mode 100644 index 0000000..8bc132c --- /dev/null +++ b/apps/base/arrstack/sonarr/pvc-config.yaml @@ -0,0 +1,16 @@ +--- +# Sonarr config + SQLite DB. RWO on cephrbd (block) — the arr apps' SQLite +# does not tolerate CephFS locking. Retain: this is state. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: sonarr-config + namespace: arrstack +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi + storageClassName: cephrbd-fast-retain + volumeMode: Filesystem diff --git a/apps/base/arrstack/sonarr/service.yaml b/apps/base/arrstack/sonarr/service.yaml new file mode 100644 index 0000000..3ee669d --- /dev/null +++ b/apps/base/arrstack/sonarr/service.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: sonarr + namespace: arrstack +spec: + ports: + - name: http + port: 8989 + protocol: TCP + targetPort: http + selector: + app: sonarr + type: ClusterIP diff --git a/apps/overlays/au-syd1/arrstack/kustomization.yaml b/apps/overlays/au-syd1/arrstack/kustomization.yaml new file mode 100644 index 0000000..bb227cc --- /dev/null +++ b/apps/overlays/au-syd1/arrstack/kustomization.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../../base/arrstack diff --git a/argocd/applicationsets/media.yaml b/argocd/applicationsets/media.yaml index 9fe99a5..e5c1037 100644 --- a/argocd/applicationsets/media.yaml +++ b/argocd/applicationsets/media.yaml @@ -11,6 +11,7 @@ spec: revision: HEAD directories: - path: apps/overlays/*/jellyfin + - path: apps/overlays/*/arrstack template: metadata: name: 'media-{{path[3]}}' -- 2.47.3