diff --git a/apps/base/cheeztv/cephrgw-config-backup.yaml b/apps/base/cheeztv/cephrgw-config-backup.yaml new file mode 100644 index 0000000..a8cf1f1 --- /dev/null +++ b/apps/base/cheeztv/cephrgw-config-backup.yaml @@ -0,0 +1,32 @@ +--- +# Second Ceph RGW (S3) bucket owned by the existing cheeztv backup user +# (cnpg-cheeztv-backup, defined in cnpg_backup.yaml) — one user, two buckets: +# the CNPG barman bucket plus this one, which k8up uses to hold restic backups +# of the cheeztv-config PVC. The BucketAccess emits read-write S3 creds into a +# Secret the k8up Schedule consumes. +apiVersion: ceph.unkin.net/v1alpha1 +kind: Bucket +metadata: + name: cheeztv-config-backup + namespace: cheeztv +spec: + placementTarget: ec + bucketName: cheeztv-config-backup + ownerRef: cnpg-cheeztv-backup + versioning: false + tags: + app: cheeztv + purpose: config-backup + retainOnDelete: true +--- +apiVersion: ceph.unkin.net/v1alpha1 +kind: BucketAccess +metadata: + name: cheeztv-config-backup + namespace: cheeztv +spec: + bucketRef: cheeztv-config-backup + level: read-write + # Operator writes AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (+ S3_ENDPOINT, + # BUCKET_NAME) into this Secret; the k8up Schedule reads the access keys. + secretName: cheeztv-config-backup-s3 diff --git a/apps/base/cheeztv/cnpg_backup.yaml b/apps/base/cheeztv/cnpg_backup.yaml new file mode 100644 index 0000000..d303b14 --- /dev/null +++ b/apps/base/cheeztv/cnpg_backup.yaml @@ -0,0 +1,45 @@ +--- +# Ceph RGW (S3) backup target for the cheeztv CNPG cluster, provisioned by the +# in-estate cephrgw-operator: one dedicated bucket + owner user. CNPG reads the +# S3 credential Secret from its own namespace. +apiVersion: ceph.unkin.net/v1alpha1 +kind: ObjectStoreUser +metadata: + name: cnpg-cheeztv-backup + namespace: cheeztv +spec: + displayName: "CNPG backup owner (cheeztv)" + uid: cnpg-cheeztv-backup + maxBuckets: 5 + secretName: cnpg-cheeztv-backup-s3 + retainOnDelete: true +--- +apiVersion: ceph.unkin.net/v1alpha1 +kind: Bucket +metadata: + name: cnpg-cheeztv + namespace: cheeztv +spec: + placementTarget: ec + bucketName: cnpg-cheeztv + ownerRef: cnpg-cheeztv-backup + versioning: false + tags: + app: cheeztv + 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-cheeztv-nightly + namespace: cheeztv +spec: + schedule: "0 35 3 * * *" + immediate: false + backupOwnerReference: self + method: barmanObjectStore + cluster: + name: cheeztv-postgres diff --git a/apps/base/cheeztv/cnpg_cluster.yaml b/apps/base/cheeztv/cnpg_cluster.yaml new file mode 100644 index 0000000..7f06440 --- /dev/null +++ b/apps/base/cheeztv/cnpg_cluster.yaml @@ -0,0 +1,126 @@ +--- +# Main Jellyfin database. The cheeztv-ha fork's experimental EF Core provider +# moves the entire Jellyfin DB (incl. library items) off SQLite into PostgreSQL, +# which is what makes a shared-nothing multi-replica deployment possible. No +# bootstrap secret is given, so CNPG generates the cheeztv-postgres-app secret +# (username/password/dbname) that the StatefulSet composes its DSN from. +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: cheeztv-postgres + namespace: cheeztv +spec: + # Exclude the operator-managed data PVCs (cheeztv-postgres-N) from the + # cheeztv-config k8up Schedule (skipWithoutAnnotation is false cluster-wide, + # so unannotated PVCs are swept in). Postgres has its own barmanObjectStore + # backup below; restic must not touch the raw RWO data volumes. + inheritedMetadata: + annotations: + k8up.io/backup: "false" + affinity: + podAntiAffinityType: preferred + backup: + retentionPolicy: 30d + barmanObjectStore: + # Dedicated per-cluster Ceph RGW bucket (cephrgw-operator provisions it). + destinationPath: s3://cnpg-cheeztv + endpointURL: https://s3.ceph.unkin.net + endpointCA: + name: vault-ca-cert + key: ca.crt + s3Credentials: + accessKeyId: + name: cnpg-cheeztv-backup-s3 + key: AWS_ACCESS_KEY_ID + secretAccessKey: + name: cnpg-cheeztv-backup-s3 + key: AWS_SECRET_ACCESS_KEY + serverName: cheeztv + data: + compression: bzip2 + jobs: 2 + wal: + compression: zstd + maxParallel: 2 + bootstrap: + initdb: + database: cheeztv + encoding: UTF8 + localeCType: C + localeCollate: C + owner: cheeztv + 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/cheeztv/cnpg_pooler.yaml b/apps/base/cheeztv/cnpg_pooler.yaml new file mode 100644 index 0000000..91f79c1 --- /dev/null +++ b/apps/base/cheeztv/cnpg_pooler.yaml @@ -0,0 +1,36 @@ +--- +# PgBouncer pooler in front of the cheeztv-postgres cluster. Jellyfin connects +# here (cheeztv-postgres-pooler:5432) rather than the -rw service so EF Core's +# connection churn is absorbed by the pool. +apiVersion: postgresql.cnpg.io/v1 +kind: Pooler +metadata: + name: cheeztv-postgres-pooler + namespace: cheeztv +spec: + cluster: + name: cheeztv-postgres + instances: 2 + pgbouncer: + parameters: + default_pool_size: "50" + max_client_conn: "200" + paused: false + poolMode: session + template: + metadata: + labels: + app: cheeztv-pooler + spec: + affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchExpressions: + - key: app + operator: In + values: + - cheeztv-pooler + topologyKey: kubernetes.io/hostname + containers: [] + type: rw diff --git a/apps/base/cheeztv/gateway.yaml b/apps/base/cheeztv/gateway.yaml new file mode 100644 index 0000000..9ceec36 --- /dev/null +++ b/apps/base/cheeztv/gateway.yaml @@ -0,0 +1,81 @@ +--- +# Friendly public hostname for the kids instance, cheeztv.unkin.net. Same +# internal-Traefik + external-dns pattern as logviewer.unkin.net: external-dns +# publishes the A record at the internal LB VIP (198.18.200.4) and cert-manager +# mints cheeztv-tls (CN cheeztv.unkin.net) off the Vault-PKI cluster issuer. +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + labels: + traefik.io/instance: internal + annotations: + cert-manager.io/cluster-issuer: vault-issuer + cert-manager.io/common-name: cheeztv.unkin.net + cert-manager.io/private-key-size: "4096" + external-dns.alpha.kubernetes.io/hostname: cheeztv.unkin.net + external-dns.alpha.kubernetes.io/target: 198.18.200.4 + name: cheeztv + namespace: cheeztv +spec: + gatewayClassName: traefik-internal + listeners: + - allowedRoutes: + namespaces: + from: Same + hostname: cheeztv.unkin.net + name: http + port: 80 + protocol: HTTP + - allowedRoutes: + namespaces: + from: Same + hostname: cheeztv.unkin.net + name: https + port: 443 + protocol: HTTPS + tls: + certificateRefs: + - group: "" + kind: Secret + name: cheeztv-tls + mode: Terminate +--- +# Cluster hostname variant, mirroring fafflix's jellyfin.k8s.syd1.au.unkin.net +# gateway 1:1 (internal Traefik, external-dns at 198.18.200.4). Own cert secret +# so each hostname gets its own leaf. +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + labels: + traefik.io/instance: internal + annotations: + cert-manager.io/cluster-issuer: vault-issuer + cert-manager.io/common-name: cheeztv.k8s.syd1.au.unkin.net + cert-manager.io/private-key-size: "4096" + external-dns.alpha.kubernetes.io/hostname: cheeztv.k8s.syd1.au.unkin.net + external-dns.alpha.kubernetes.io/target: 198.18.200.4 + name: cheeztv-internal + namespace: cheeztv +spec: + gatewayClassName: traefik-internal + listeners: + - allowedRoutes: + namespaces: + from: Same + hostname: cheeztv.k8s.syd1.au.unkin.net + name: http + port: 80 + protocol: HTTP + - allowedRoutes: + namespaces: + from: Same + hostname: cheeztv.k8s.syd1.au.unkin.net + name: https + port: 443 + protocol: HTTPS + tls: + certificateRefs: + - group: "" + kind: Secret + name: cheeztv-internal-tls + mode: Terminate diff --git a/apps/base/cheeztv/httproute.yaml b/apps/base/cheeztv/httproute.yaml new file mode 100644 index 0000000..325adba --- /dev/null +++ b/apps/base/cheeztv/httproute.yaml @@ -0,0 +1,98 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: http-redirect + namespace: cheeztv +spec: + hostnames: + - cheeztv.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: cheeztv + sectionName: http + rules: + - filters: + - type: RequestRedirect + requestRedirect: + scheme: https + statusCode: 301 + matches: + - path: + type: PathPrefix + value: / +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: cheeztv-route + namespace: cheeztv +spec: + hostnames: + - cheeztv.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: cheeztv + sectionName: https + rules: + - backendRefs: + - group: "" + kind: Service + name: cheeztv + port: 8096 + weight: 1 + matches: + - path: + type: PathPrefix + value: / +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: http-redirect-internal + namespace: cheeztv +spec: + hostnames: + - cheeztv.k8s.syd1.au.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: cheeztv-internal + sectionName: http + rules: + - filters: + - type: RequestRedirect + requestRedirect: + scheme: https + statusCode: 301 + matches: + - path: + type: PathPrefix + value: / +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: cheeztv-route-internal + namespace: cheeztv +spec: + hostnames: + - cheeztv.k8s.syd1.au.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: cheeztv-internal + sectionName: https + rules: + - backendRefs: + - group: "" + kind: Service + name: cheeztv + port: 8096 + weight: 1 + matches: + - path: + type: PathPrefix + value: / diff --git a/apps/base/cheeztv/kustomization.yaml b/apps/base/cheeztv/kustomization.yaml new file mode 100644 index 0000000..26efe34 --- /dev/null +++ b/apps/base/cheeztv/kustomization.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace.yaml + - cnpg_cluster.yaml + - cnpg_pooler.yaml + - cnpg_backup.yaml + - cephrgw-config-backup.yaml + - vaultauth.yaml + - vaultstaticsecret.yaml + - schedule.yaml + - pvc-config.yaml + - pvc-transcode.yaml + - pv-media-tv.yaml + - pv-media-movies.yaml + - pvc-media-tv.yaml + - pvc-media-movies.yaml + - statefulset.yaml + - pdb.yaml + - service.yaml + - valkey.yaml + - vmpodscrape.yaml + - gateway.yaml + - httproute.yaml diff --git a/apps/base/cheeztv/namespace.yaml b/apps/base/cheeztv/namespace.yaml new file mode 100644 index 0000000..e1c6a8c --- /dev/null +++ b/apps/base/cheeztv/namespace.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: cheeztv diff --git a/apps/base/cheeztv/pdb.yaml b/apps/base/cheeztv/pdb.yaml new file mode 100644 index 0000000..b42649e --- /dev/null +++ b/apps/base/cheeztv/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: cheeztv + namespace: cheeztv +spec: + minAvailable: 1 + selector: + matchLabels: + app: cheeztv diff --git a/apps/base/cheeztv/pv-media-movies.yaml b/apps/base/cheeztv/pv-media-movies.yaml new file mode 100644 index 0000000..d66d4bf --- /dev/null +++ b/apps/base/cheeztv/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 cheeztv reads the identical library tree; each +# namespace gets its own PV (unique name + volumeHandle) pinned by claimRef. +apiVersion: v1 +kind: PersistentVolume +metadata: + name: cheeztv-media-movies +spec: + capacity: + storage: 1Ti + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + volumeMode: Filesystem + claimRef: + namespace: cheeztv + name: cheeztv-media-movies + csi: + driver: cephfs.csi.ceph.com + volumeHandle: cheeztv-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/cheeztv/pv-media-tv.yaml b/apps/base/cheeztv/pv-media-tv.yaml new file mode 100644 index 0000000..a30a968 --- /dev/null +++ b/apps/base/cheeztv/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 cheeztv reads the identical library tree; each +# namespace gets its own PV (unique name + volumeHandle) pinned by claimRef. +apiVersion: v1 +kind: PersistentVolume +metadata: + name: cheeztv-media-tv +spec: + capacity: + storage: 1Ti + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + volumeMode: Filesystem + claimRef: + namespace: cheeztv + name: cheeztv-media-tv + csi: + driver: cephfs.csi.ceph.com + volumeHandle: cheeztv-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/cheeztv/pvc-config.yaml b/apps/base/cheeztv/pvc-config.yaml new file mode 100644 index 0000000..7b2e4bb --- /dev/null +++ b/apps/base/cheeztv/pvc-config.yaml @@ -0,0 +1,17 @@ +--- +# Jellyfin config: metadata images, plugins, subtitles and config XML. Shared +# ReadWriteMany across replicas (all pods read/write the same library metadata); +# the main library DB now lives in PostgreSQL, not here (on CephFS, raid5-delete). +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: cheeztv-config + namespace: cheeztv +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 20Gi + storageClassName: cephfs-raid5-delete + volumeMode: Filesystem diff --git a/apps/base/cheeztv/pvc-media-movies.yaml b/apps/base/cheeztv/pvc-media-movies.yaml new file mode 100644 index 0000000..d2ae938 --- /dev/null +++ b/apps/base/cheeztv/pvc-media-movies.yaml @@ -0,0 +1,24 @@ +--- +# Movie library, shared read-many across replicas. Statically bound to the +# cheeztv-media-movies PV (shared CephFS subvolume also used by arrstack/radarr). +# storageClassName "" + volumeName disables dynamic provisioning and binds the +# pre-created static PV. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: cheeztv-media-movies + namespace: cheeztv + annotations: + # Exclude from the cheeztv-config k8up Schedule (skipWithoutAnnotation is + # false cluster-wide, so unannotated PVCs are swept in). Only cheeztv-config + # is backed up; the media library is not restic-backup material. + k8up.io/backup: "false" +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 1Ti + storageClassName: "" + volumeName: cheeztv-media-movies + volumeMode: Filesystem diff --git a/apps/base/cheeztv/pvc-media-tv.yaml b/apps/base/cheeztv/pvc-media-tv.yaml new file mode 100644 index 0000000..4976bb1 --- /dev/null +++ b/apps/base/cheeztv/pvc-media-tv.yaml @@ -0,0 +1,24 @@ +--- +# TV library, shared read-many across replicas. Statically bound to the +# cheeztv-media-tv PV (shared CephFS subvolume also used by arrstack/sonarr). +# storageClassName "" + volumeName disables dynamic provisioning and binds the +# pre-created static PV. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: cheeztv-media-tv + namespace: cheeztv + annotations: + # Exclude from the cheeztv-config k8up Schedule (skipWithoutAnnotation is + # false cluster-wide, so unannotated PVCs are swept in). Only cheeztv-config + # is backed up; the media library is not restic-backup material. + k8up.io/backup: "false" +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 1Ti + storageClassName: "" + volumeName: cheeztv-media-tv + volumeMode: Filesystem diff --git a/apps/base/cheeztv/pvc-transcode.yaml b/apps/base/cheeztv/pvc-transcode.yaml new file mode 100644 index 0000000..bb47d13 --- /dev/null +++ b/apps/base/cheeztv/pvc-transcode.yaml @@ -0,0 +1,23 @@ +--- +# Shared transcode scratch. ReadWriteMany is the hard requirement for the HA +# fork: a taking-over pod must read the in-flight HLS segments written by the +# pod it replaces. Scratch data (delete reclaim); raid5 avoids the raid6 +# double-parity write penalty on the many small HLS segment writes. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: cheeztv-transcode + namespace: cheeztv + annotations: + # Exclude from the cheeztv-config k8up Schedule (skipWithoutAnnotation is + # false cluster-wide, so unannotated PVCs are swept in). Transcode is RWX + # scratch — nothing to back up. + k8up.io/backup: "false" +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 100Gi + storageClassName: cephfs-raid5-delete + volumeMode: Filesystem diff --git a/apps/base/cheeztv/schedule.yaml b/apps/base/cheeztv/schedule.yaml new file mode 100644 index 0000000..8c5a2b7 --- /dev/null +++ b/apps/base/cheeztv/schedule.yaml @@ -0,0 +1,58 @@ +--- +# k8up Schedule: restic backups of the cheeztv-config PVC (library metadata, +# plugins, config XML) to the dedicated Ceph RGW config-backup bucket. S3 creds +# come from the cephrgw BucketAccess Secret (cheeztv-config-backup-s3); the +# restic repo password comes from Vault via the cheeztv-k8up-restic Secret. +# +# s3.ceph.unkin.net presents the internal unkin.net CA, which the k8up/restic +# image does not trust by default, so the reflected vault-ca-cert Secret is +# mounted into every job pod and pointed at via backend.tlsOptions.caCert. +apiVersion: k8up.io/v1 +kind: Schedule +metadata: + name: cheeztv-config + namespace: cheeztv +spec: + backend: + repoPasswordSecretRef: + name: cheeztv-k8up-restic + key: password + s3: + endpoint: https://s3.ceph.unkin.net + bucket: cheeztv-config-backup + accessKeyIDSecretRef: + name: cheeztv-config-backup-s3 + key: AWS_ACCESS_KEY_ID + secretAccessKeySecretRef: + name: cheeztv-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/cheeztv/service.yaml b/apps/base/cheeztv/service.yaml new file mode 100644 index 0000000..625ece9 --- /dev/null +++ b/apps/base/cheeztv/service.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: cheeztv + namespace: cheeztv +spec: + internalTrafficPolicy: Cluster + ports: + - name: http + port: 8096 + protocol: TCP + targetPort: http + selector: + app: cheeztv + # Pin each client to one replica to reduce transcode-session churn/takeover. + sessionAffinity: ClientIP + type: ClusterIP diff --git a/apps/base/cheeztv/statefulset.yaml b/apps/base/cheeztv/statefulset.yaml new file mode 100644 index 0000000..9133dc0 --- /dev/null +++ b/apps/base/cheeztv/statefulset.yaml @@ -0,0 +1,253 @@ +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: cheeztv + namespace: cheeztv +spec: + # HA: two replicas coordinate transcode session ownership through Valkey and + # resume each other's HLS segments off the shared RWX transcode PVC. Stable + # pod names (cheeztv-0/1) are the lease owner identity, hence StatefulSet. + replicas: 2 + serviceName: cheeztv + podManagementPolicy: Parallel + updateStrategy: + type: RollingUpdate + selector: + matchLabels: + app: cheeztv + template: + metadata: + labels: + app: cheeztv + 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: cheeztv + topologyKey: kubernetes.io/hostname + initContainers: + # Seed the fork's PostgreSQL provider (database.xml) and Intel iGPU + # hardware transcode settings (encoding.xml) before Jellyfin starts. + # Runs as root to chown into the shared config volume; mirrors the fork + # Helm chart's inject-db-config. Each file is written only when absent so + # admin changes persisted to the shared RWX /config survive pod restarts. + - name: inject-config + image: busybox:1.37.0 + command: + - sh + - -c + - | + mkdir -p /config/config + chown 1000:1000 /config/config + chmod 775 /config/config + if [ ! -e /config/config/database.xml ]; then + cat > /config/config/database.xml << 'DBEOF' + + + Jellyfin-PostgreSQL + NoLock + + DBEOF + chown 1000:1000 /config/config/database.xml + chmod 664 /config/config/database.xml + fi + # VAAPI on the Intel render node the device plugin injects + # (/dev/dri/renderD128 — ffmpeg's default DRM node, reachable via + # the render/video supplementalGroups). Without this the attached + # iGPU is idle and every transcode runs in software. Omitted + # elements fall back to the fork's EncodingOptions defaults. + if [ ! -e /config/config/encoding.xml ]; then + cat > /config/config/encoding.xml << 'ENCEOF' + + + -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 + memory: 32Mi + limits: + cpu: 100m + memory: 64Mi + volumeMounts: + - name: config + mountPath: /config + containers: + - name: cheeztv + image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/jellyfin-ha:v0.1.3 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8096 + protocol: TCP + env: + # Pod identity for the Valkey transcode lease owner. The fork reads + # JELLYFIN_INSTANCE_ID (falling back to MachineName); the stable + # StatefulSet pod name gives each replica a unique lease identity so + # takeover can target a dead replica. JELLYFIN_HA_POD_NAME is set for + # parity with the fork Helm chart (nothing currently reads it). + - name: JELLYFIN_INSTANCE_ID + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: JELLYFIN_HA_POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + # Multiple replicas must not each answer UDP auto-discovery. + - name: JELLYFIN_Network__AutoDiscovery + value: "false" + # Config dir must differ from the data root (Jellyfin sanity check). + - name: JELLYFIN_CONFIG_DIR + value: /config/config + # Distributed transcode session store (cheeztv-ha additions). + - name: Jellyfin__TranscodeStore__RedisConnectionString + value: "valkey-cheeztv-valkey:6379,abortConnect=false" + - name: Jellyfin__TranscodeStore__LeaseDurationSeconds + value: "30" + # PostgreSQL main DB via the CNPG-generated app secret, routed through + # the PgBouncer pooler. Composed with $(VAR) expansion so the password + # is never rendered into the manifest; CNPG passwords are URL-safe. + - name: PGUSER + valueFrom: + secretKeyRef: + name: cheeztv-postgres-app + key: username + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: cheeztv-postgres-app + key: password + - name: PGDB + valueFrom: + secretKeyRef: + name: cheeztv-postgres-app + key: dbname + - name: POSTGRES_CONNECTION_STRING + value: "postgresql://$(PGUSER):$(PGPASSWORD)@cheeztv-postgres-pooler:5432/$(PGDB)" + - name: DATABASE_URL + value: "postgresql://$(PGUSER):$(PGPASSWORD)@cheeztv-postgres-pooler:5432/$(PGDB)" + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + requests: + cpu: "1" + memory: 1Gi + gpu.intel.com/i915: "1" + limits: + cpu: "4" + memory: 6Gi + # Intel iGPU (QSV/VA-API) slot. Requesting it pins the pod to a + # GPU-labelled node and injects /dev/dri/renderD* automatically, so + # no /dev/dri hostPath or privileged container is needed. VA-API is + # pre-enabled via the seeded encoding.xml (see inject-config), so + # transcodes use the iGPU on first boot with no manual UI step. + gpu.intel.com/i915: "1" + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + volumeMounts: + - name: config + mountPath: /config + - name: transcode + # Fork's real transcode temp path. RWX so a surviving pod reads the + # in-flight .ts/.m3u8 segments of the pod it takes over. A per-pod + # volume here silently breaks HA takeover. + mountPath: /config/transcodes + - name: cache + mountPath: /cache + - name: media-tv + # Kids-only instance: mount just the tvshows/kids subtree of the + # shared TV subvolume (subPath kids) so cheeztv's library never + # exposes the adult tree. Path matches fafflix's /media/tv-kids + # mount so the same episode resolves identically across instances. + mountPath: /media/tv + subPath: kids + readOnly: true + - name: media-movies + # Kids-only instance: mount just the movies/kids subtree of the + # shared movies subvolume (subPath kids). + mountPath: /media/movies + subPath: kids + readOnly: true + volumes: + - name: config + persistentVolumeClaim: + claimName: cheeztv-config + - name: transcode + persistentVolumeClaim: + claimName: cheeztv-transcode + - name: media-tv + persistentVolumeClaim: + claimName: cheeztv-media-tv + - name: media-movies + persistentVolumeClaim: + claimName: cheeztv-media-movies + volumeClaimTemplates: + # Per-pod scratch cache — RWO, disposable, one PVC per replica. + - metadata: + name: cache + annotations: + # Exclude the per-pod cache PVCs from the cheeztv-config k8up Schedule + # (skipWithoutAnnotation is false cluster-wide). Cache is disposable and + # RWO — it would also fail to mount into the backup pod while in use. + k8up.io/backup: "false" + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 30Gi + storageClassName: cephrbd-fast-delete + volumeMode: Filesystem diff --git a/apps/base/cheeztv/valkey.yaml b/apps/base/cheeztv/valkey.yaml new file mode 100644 index 0000000..e8716ce --- /dev/null +++ b/apps/base/cheeztv/valkey.yaml @@ -0,0 +1,47 @@ +--- +# HA Valkey managed by valkey-operator as Jellyfin's distributed transcode-session +# store. shards:1 + replicas:2 is one primary with two replicas in a single shard +# group (three ValkeyNodes total): losing the primary triggers an automatic +# failover to a replica, so a node/pod loss no longer drops the shared transcode +# lease state the two Jellyfin replicas coordinate through. The operator always +# runs Valkey cluster-mode-enabled with protected-mode off and leaves the built-in +# `default` user passwordless, so clients connect with no auth/TLS (parity with the +# old open redis); StackExchange.Redis seeds off the single headless service and +# auto-discovers the topology plus failovers, so the connection string is +# unchanged. scheduling.node.spread.shard:Required renders pod anti-affinity that +# keeps the three nodes on distinct hosts (kubernetes.io/hostname), so one host +# loss removes at most one node. podDisruptionBudget.mode:Cluster lets the operator +# manage a quorum-aware PDB. Persistence stays omitted so /data is an emptyDir: +# lease state is ephemeral (30s TTLs), replication+failover already provide +# redundancy and a replaced node re-syncs from the primary, and an operator-managed +# PVC cannot carry the k8up.io/backup:"false" annotation the namespace's +# cluster-wide k8up Schedule needs to skip in-use RWO volumes. +apiVersion: valkey.io/v1alpha1 +kind: ValkeyCluster +metadata: + name: cheeztv-valkey + namespace: cheeztv +spec: + shards: 1 + replicas: 2 + image: docker.io/valkey/valkey:9.0.0 + # redis_exporter sidecar (:9121, port name `metrics`) on every ValkeyNode pod; + # the operator manages a dedicated _exporter ACL user for it. Image version + # pinned; scraped by valkey-exporter VMPodScrape in vmpodscrape.yaml. + exporter: + enabled: true + image: docker.io/oliver006/redis_exporter:v1.89.0 + scheduling: + node: + spread: + shard: + mode: Required + podDisruptionBudget: + mode: Cluster + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/apps/base/cheeztv/vaultauth.yaml b/apps/base/cheeztv/vaultauth.yaml new file mode 100644 index 0000000..b263a01 --- /dev/null +++ b/apps/base/cheeztv/vaultauth.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultAuth +metadata: + name: default + namespace: cheeztv + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + allowedNamespaces: + - cheeztv + kubernetes: + audiences: + - vault + role: default + serviceAccount: default + tokenExpirationSeconds: 600 + method: kubernetes + mount: k8s/au/syd1 + vaultConnectionRef: vso-system/default diff --git a/apps/base/cheeztv/vaultstaticsecret.yaml b/apps/base/cheeztv/vaultstaticsecret.yaml new file mode 100644 index 0000000..1ea18fd --- /dev/null +++ b/apps/base/cheeztv/vaultstaticsecret.yaml @@ -0,0 +1,24 @@ +--- +# restic repository password for the k8up cheeztv-config backups. Seeded at +# kv/kubernetes/namespace/cheeztv/default/k8up-restic (key: password); the +# default k8s role's templated policy already grants read here, so no +# terraform-vault change is needed. VSO syncs it into the cheeztv-k8up-restic +# Secret that the Schedule references via backend.repoPasswordSecretRef. +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: cheeztv-k8up-restic + namespace: cheeztv + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + destination: + create: true + name: cheeztv-k8up-restic + overwrite: true + hmacSecretData: true + mount: kv + path: kubernetes/namespace/cheeztv/default/k8up-restic + refreshAfter: 5m + type: kv-v2 + vaultAuthRef: default diff --git a/apps/base/cheeztv/vmpodscrape.yaml b/apps/base/cheeztv/vmpodscrape.yaml new file mode 100644 index 0000000..e972007 --- /dev/null +++ b/apps/base/cheeztv/vmpodscrape.yaml @@ -0,0 +1,19 @@ +--- +# Scrape the operator-injected redis_exporter sidecar (:9121, port name +# `metrics`) on the cheeztv-valkey ValkeyNode pods. The valkey-operator gives +# its pods fixed labels only (no pod-label passthrough on the ValkeyCluster CR), +# so select on the operator-managed labels. Picked up by the observability +# VMAgent (selectAllByDefault). +apiVersion: operator.victoriametrics.com/v1beta1 +kind: VMPodScrape +metadata: + name: valkey-exporter + namespace: cheeztv +spec: + selector: + matchLabels: + app.kubernetes.io/managed-by: valkey-operator + app.kubernetes.io/component: valkey-node + podMetricsEndpoints: + - port: metrics + path: /metrics diff --git a/apps/base/jellyfin/statefulset.yaml b/apps/base/jellyfin/statefulset.yaml index e4120c3..8943d53 100644 --- a/apps/base/jellyfin/statefulset.yaml +++ b/apps/base/jellyfin/statefulset.yaml @@ -213,6 +213,18 @@ spec: - name: media-movies mountPath: /media/movies readOnly: true + # Kids subtrees surfaced as their own paths (same media PVCs, subPath + # kids) so a "Kids TV"/"Kids Movies" library can be added here and its + # titles are browsable and resume in this instance's own DB. The full + # /media/{tv,movies} mounts above are unchanged. + - name: media-tv + mountPath: /media/tv-kids + subPath: kids + readOnly: true + - name: media-movies + mountPath: /media/movies-kids + subPath: kids + readOnly: true volumes: - name: config persistentVolumeClaim: diff --git a/apps/overlays/au-syd1/cheeztv/kustomization.yaml b/apps/overlays/au-syd1/cheeztv/kustomization.yaml new file mode 100644 index 0000000..b2c0e39 --- /dev/null +++ b/apps/overlays/au-syd1/cheeztv/kustomization.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../../base/cheeztv diff --git a/argocd/applicationsets/media.yaml b/argocd/applicationsets/media.yaml index e5c1037..f958b85 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/*/cheeztv - path: apps/overlays/*/arrstack template: metadata: diff --git a/argocd/projects/media.yaml b/argocd/projects/media.yaml index 4b3f29f..00f179d 100644 --- a/argocd/projects/media.yaml +++ b/argocd/projects/media.yaml @@ -11,6 +11,8 @@ spec: destinations: - namespace: 'jellyfin' server: https://kubernetes.default.svc + - namespace: 'cheeztv' + server: https://kubernetes.default.svc - namespace: 'arrstack' server: https://kubernetes.default.svc clusterResourceWhitelist: