Compare commits

...

3 Commits

Author SHA1 Message Date
Ben Vincent 54c25be828 Reduce media PR to jellyfin-only in its own namespace
ci/woodpecker/pr/vector-test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/kubeconform Pipeline was successful
Why:
Jellyfin ships and gets validated first, ahead of the rest of the media stack.
Scoping this PR to jellyfin alone keeps the initial rollout small and lets the
HA fork prove out against the real library before the download and manager apps
follow.

How:
- Drop sonarr, radarr, prowlarr, bazarr, nzbget, and jellyseerr and their shared
  media-apps foundation from this PR; they land in later PRs.
- Move jellyfin into its own jellyfin namespace and fold the namespace and the
  static mediafs PV plus its RWX claim into the jellyfin base.
- Keep the static CephFS PV bound to the in-use mediafs library with
  reclaimPolicy Retain and staticVolume true so nothing can reclaim it, mounted
  into jellyfin by the movies and tvseries subPaths; keep redis, the fresh RWX
  transcode scratch, the intel iGPU nodeSelector and i915 request, gateway, and
  httproute.
- Scope the media AppProject and ApplicationSet to the single jellyfin
  namespace and app, extensible as the remaining apps are added.
2026-08-09 21:08:12 +10:00
Ben Vincent a52a419dfd Point media apps at the real mediafs library via a static CephFS PV
ci/woodpecker/pr/vector-test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/kubeconform Pipeline was successful
Why:
The media apps must serve and manage the actual media library, not empty
volumes. That library already exists on the puppet-managed CephFS filesystem
mediafs (mounted by the VM/incus instances at /shared/media) and is in active
use, so the k8s apps must mount it in place rather than provision fresh storage.

How:
- Replace the two fresh movies/tvseries PVCs with one static CephFS
  PersistentVolume bound to mediafs and a single RWX media-library claim the
  whole stack shares.
- Set the PV reclaim policy to Retain and mark it staticVolume so ceph-csi only
  mounts the pre-existing storage and can never provision or reclaim it;
  deleting the PVC or PV cannot destroy the underlying library.
- Reuse the live csi-cephfs cluster parameters (clusterID cephfs_csi_ssd_ec_4_1
  for mon discovery, csi-cephfs/csi-cephfs-secret node-stage secret) with
  fsName mediafs and rootPath / (the mediafs root that maps to /shared/media).
- Mount the library into each app by subPath so the tree matches the VM
  layout: sonarr /mnt/tvseries (tvseries), radarr /mnt/movies (movies),
  jellyfin and nzbget both subtrees; prowlarr keeps no library mount. The
  jellyfin transcode PVC stays a fresh scratch volume.
- Whitelist PersistentVolume in the media AppProject so the cluster-scoped PV
  can sync.
2026-08-09 13:39:40 +10:00
Ben Vincent e03aeca101 Add media-apps stack to ArgoCD
ci/woodpecker/pr/vector-test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/kubeconform Pipeline was successful
Why:
The media stack (jellyfin plus the sonarr/radarr/prowlarr/bazarr/nzbget/
jellyseerr apps) runs in the media-apps namespace but is deployed out-of-band
by terraform-k8s rather than GitOps. Bringing it under ArgoCD makes the stack
declarative, self-healing, and consistent with every other cluster workload,
and prepares terraform-k8s to drop the media-apps config.

How:
- Add a media AppProject scoped to the media-apps namespace and a media-apps
  ApplicationSet that renders one Application per app plus a shared foundation.
- Add a shared media-apps foundation (namespace, media-apps-vault-reader
  ServiceAccount, default VaultAuth on k8s/au/syd1, and the RWX movies/tvseries
  library PVCs) that the whole stack mounts.
- Add per-app kustomize base and au-syd1 overlay for jellyfin and the six *arr
  apps, using plain resource names (jellyfin, sonarr, ...) with fresh PVCs.
- Deploy jellyfin from the jellyfin-ha fork (Redis transcode store, RWX
  transcode scratch) wired to the shared movies/tvseries library PVCs, keeping
  the intel iGPU nodeSelector and gpu.intel.com/i915 request.
- Source API keys and nzbget credentials through VSO VaultStaticSecrets from
  kv/service/media-apps/<app>; expose each app via a traefik-internal Gateway
  and HTTPRoute at <app>.k8s.syd1.au.unkin.net.
- Register the media project and applicationset in the argocd bootstrap
  kustomizations.
2026-08-09 13:25:25 +10:00
19 changed files with 486 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: jellyfin
namespace: jellyfin
spec:
# Single-replica for now. The jellyfin-ha fork adds the Redis transcode store
# and RWX transcode scratch that make scaling to true HA a follow-up.
replicas: 1
strategy:
# Config PVC is RWO; Recreate avoids two pods contending for it.
type: Recreate
selector:
matchLabels:
app: jellyfin
template:
metadata:
labels:
app: jellyfin
spec:
securityContext:
fsGroup: 1000
nodeSelector:
feature.node.kubernetes.io/pci-0300_8086.present: "true"
containers:
- name: jellyfin
image: git.unkin.net/unkin/jellyfin-ha:v0.1.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8096
protocol: TCP
env:
- name: TZ
value: Australia/Sydney
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: JELLYFIN_PublishedServerUrl
value: https://jellyfin.k8s.syd1.au.unkin.net
# Distributed transcode session store (jellyfin-ha additions).
- name: Jellyfin__TranscodeStore__RedisConnectionString
value: "jellyfin-redis:6379,abortConnect=false"
- name: Jellyfin__TranscodeStore__LeaseDurationSeconds
value: "30"
resources:
requests:
cpu: 100m
memory: 1Gi
limits:
cpu: "4"
memory: 8Gi
gpu.intel.com/i915: "1"
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
volumeMounts:
- name: config
mountPath: /config
- name: cache
mountPath: /cache
- name: transcode
mountPath: /transcode
- name: media-library
mountPath: /mnt/movies
subPath: movies
- name: media-library
mountPath: /mnt/tvseries
subPath: tvseries
volumes:
- name: config
persistentVolumeClaim:
claimName: jellyfin-config
- name: cache
persistentVolumeClaim:
claimName: jellyfin-cache
- name: transcode
persistentVolumeClaim:
claimName: jellyfin-transcode
- name: media-library
persistentVolumeClaim:
claimName: media-library
+37
View File
@@ -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
+49
View File
@@ -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
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: /
+18
View File
@@ -0,0 +1,18 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- pv_media-library.yaml
- pvc_media-library.yaml
- pvc_config.yaml
- pvc_cache.yaml
- pvc_transcode.yaml
- deployment.yaml
- service.yaml
- redis-deployment.yaml
- redis-service.yaml
- redis-pvc.yaml
- gateway.yaml
- httproute.yaml
+5
View File
@@ -0,0 +1,5 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: jellyfin
+41
View File
@@ -0,0 +1,41 @@
---
# Static CephFS PersistentVolume bound to the pre-existing, ACTIVELY-USED
# puppet media library (ceph filesystem "mediafs", mounted by the VM/incus
# instances at /shared/media). ceph-csi only mounts this volume; staticVolume
# tells it the storage pre-exists and it must never provision or delete it.
#
# reclaimPolicy MUST stay Retain: deleting this PV or its PVC must NEVER be able
# to reclaim or destroy the underlying CephFS data that the VM instances use.
apiVersion: v1
kind: PersistentVolume
metadata:
name: jellyfin-media-library
spec:
accessModes:
- ReadWriteMany
capacity:
storage: 10Ti
# Load-bearing safety control. Do not change to Delete.
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
volumeMode: Filesystem
# Pre-bind to the media-library claim so nothing else can grab this PV.
claimRef:
apiVersion: v1
kind: PersistentVolumeClaim
name: media-library
namespace: jellyfin
csi:
driver: cephfs.csi.ceph.com
volumeHandle: jellyfin-media-library-static
nodeStageSecretRef:
name: csi-cephfs-secret
namespace: csi-cephfs
volumeAttributes:
# clusterID maps (in the csi-cephfs ceph-csi-config) to the mon set that
# also serves mediafs; for a static volume only the mon lookup is used.
clusterID: cephfs_csi_ssd_ec_4_1
fsName: mediafs
staticVolume: "true"
# Filesystem-internal root of the library (mediafs root == /shared/media).
rootPath: /
+15
View File
@@ -0,0 +1,15 @@
---
# Local transcode/image cache. Scratch, delete reclaim policy.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jellyfin-cache
namespace: jellyfin
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 200Gi
storageClassName: cephrbd-fast-delete
volumeMode: Filesystem
+15
View File
@@ -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: 10Gi
storageClassName: cephrbd-fast-retain
volumeMode: Filesystem
+19
View File
@@ -0,0 +1,19 @@
---
# Claim bound to the static mediafs PV. RWX so every app in the stack shares the
# one library. storageClassName "" + volumeName pin it to the static PV (no
# dynamic provisioning). Deleting this claim cannot reclaim the data (PV is
# Retain).
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: media-library
namespace: jellyfin
spec:
accessModes:
- ReadWriteMany
storageClassName: ""
volumeName: jellyfin-media-library
resources:
requests:
storage: 10Ti
volumeMode: Filesystem
+17
View File
@@ -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-raid5-delete
volumeMode: Filesystem
+64
View File
@@ -0,0 +1,64 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: jellyfin-redis
namespace: jellyfin
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: jellyfin-redis
template:
metadata:
labels:
app: jellyfin-redis
spec:
restartPolicy: Always
containers:
- name: redis
image: redis:7-alpine
imagePullPolicy: IfNotPresent
command:
- redis-server
- --save
- "20"
- "1"
ports:
- name: redis
containerPort: 6379
protocol: TCP
livenessProbe:
exec:
command:
- redis-cli
- ping
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
exec:
command:
- redis-cli
- ping
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: jellyfin-redis-data
+14
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
---
apiVersion: v1
kind: Service
metadata:
name: jellyfin-redis
namespace: jellyfin
spec:
type: ClusterIP
internalTrafficPolicy: Cluster
sessionAffinity: None
selector:
app: jellyfin-redis
ports:
- name: redis
port: 6379
targetPort: redis
protocol: TCP
+17
View File
@@ -0,0 +1,17 @@
---
apiVersion: v1
kind: Service
metadata:
name: jellyfin
namespace: jellyfin
spec:
type: ClusterIP
internalTrafficPolicy: Cluster
sessionAffinity: None
selector:
app: jellyfin
ports:
- name: http
port: 8096
targetPort: http
protocol: TCP
@@ -0,0 +1,6 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../../base/jellyfin
@@ -5,6 +5,7 @@ kind: Kustomization
resources:
- aitooling.yaml
- logging.yaml
- media.yaml
- observability.yaml
- platform.yaml
- storage.yaml
+32
View File
@@ -0,0 +1,32 @@
---
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:
# jellyfin only for now; downloads/managers apps join in later PRs.
- 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: jellyfin
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- ServerSideApply=true
+1
View File
@@ -5,6 +5,7 @@ kind: Kustomization
resources:
- aitooling.yaml
- logging.yaml
- media.yaml
- observability.yaml
- platform.yaml
- storage.yaml
+21
View File
@@ -0,0 +1,21 @@
---
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: media
namespace: argocd
spec:
description: Media services (jellyfin; downloads/managers namespaces to follow)
sourceRepos:
- https://git.unkin.net/unkin/argocd-apps
destinations:
- namespace: jellyfin
server: https://kubernetes.default.svc
clusterResourceWhitelist:
- group: ''
kind: Namespace
- group: ''
kind: PersistentVolume
namespaceResourceWhitelist:
- group: '*'
kind: '*'