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
210 changed files with 358 additions and 7366 deletions
@@ -1,145 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: arrproxy-api
namespace: arrstack
annotations:
# Wave 2: serve only after the wave-1 migrate Job completes.
argocd.argoproj.io/sync-wave: "2"
secret.reloader.stakater.com/reload: "arrproxy-pepper,arrproxy-admin-token,arrproxy-db-app,sonarr-apikey,radarr-apikey,prowlarr-apikey"
spec:
replicas: 2
selector:
matchLabels:
app: arrproxy-api
strategy:
rollingUpdate:
maxUnavailable: 1
type: RollingUpdate
template:
metadata:
labels:
app: arrproxy-api
spec:
serviceAccountName: default
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: api
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-api:v0.3.1
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
protocol: TCP
env:
- name: ARRPROXY_ADDR
value: ":8080"
# oauth2-proxy --pass-user-headers forwards identity to the upstream as
# X-Forwarded-{User,Email,Groups} (a single comma-joined Groups value).
# Email/User already match the api defaults; override the groups header
# (default X-Auth-Request-Groups is auth_request-response-only and never
# reaches this upstream) so group-based authorization works.
- name: ARRPROXY_GROUPS_HEADER
value: X-Forwarded-Groups
# Real per-app *arr keys, projected one file per app under this dir
# (sourced from the existing <app>-apikey Secrets). The api injects
# them server-side and redacts them from every proxied response.
- name: ARRPROXY_KEYS_DIR
value: /etc/arrproxy/keys
- name: ARRPROXY_PEPPER
valueFrom:
secretKeyRef:
name: arrproxy-pepper
key: pepper
# Machine-mint admin bearer, synced from Vault KV via the
# arrproxy-admin-token VSO. Gates the /api/admin/ route that
# oauth2-proxy intentionally skip-auths so OpenBao on the VMs can
# mint tokens against arrproxy's own bearer.
- name: ARRPROXY_ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: arrproxy-admin-token
key: token
# DSN assembled from the CNPG-generated arrproxy-db-app Secret;
# $(VAR) expansion resolves the two env entries defined above it.
- name: ARRPROXY_DB_USER
valueFrom:
secretKeyRef:
name: arrproxy-db-app
key: username
- name: ARRPROXY_DB_PASSWORD
valueFrom:
secretKeyRef:
name: arrproxy-db-app
key: password
- name: DATABASE_URL
value: "postgres://$(ARRPROXY_DB_USER):$(ARRPROXY_DB_PASSWORD)@arrproxy-db-rw.arrstack.svc.cluster.local:5432/arrproxy?sslmode=require"
volumeMounts:
- name: arr-keys
mountPath: /etc/arrproxy/keys
readOnly: true
- name: tmp
mountPath: /tmp
livenessProbe:
httpGet:
path: /livez
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
volumes:
# Real *arr API keys, one file per app named exactly <app> so the api
# reads /etc/arrproxy/keys/{sonarr,radarr,prowlarr}. Reuses the same
# <app>-apikey Secrets the *arr Deployments already consume.
- name: arr-keys
projected:
sources:
- secret:
name: sonarr-apikey
items:
- key: apitoken
path: sonarr
- secret:
name: radarr-apikey
items:
- key: apitoken
path: radarr
- secret:
name: prowlarr-apikey
items:
- key: apitoken
path: prowlarr
- name: tmp
emptyDir:
sizeLimit: 64Mi
restartPolicy: Always
@@ -1,51 +0,0 @@
---
# Ceph RGW (S3) backup target for the arrproxy token-store CNPG cluster,
# provisioned by the in-estate cephrgw-operator. One dedicated bucket + owner
# user per cluster.
apiVersion: ceph.unkin.net/v1alpha1
kind: ObjectStoreUser
metadata:
name: cnpg-arrproxy-backup
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
displayName: "CNPG backup owner (arrproxy)"
uid: cnpg-arrproxy-backup
maxBuckets: 5
secretName: cnpg-arrproxy-backup-s3
retainOnDelete: true
---
apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket
metadata:
name: cnpg-arrproxy
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
placementTarget: ec
bucketName: cnpg-arrproxy
ownerRef: cnpg-arrproxy-backup
versioning: false
tags:
app: arrproxy
purpose: cnpg-backup
retainOnDelete: true
---
# Nightly base backup; continuous WAL archiving is always-on via the Cluster's
# spec.backup.barmanObjectStore. Staggered off other clusters' schedules.
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: cnpg-arrproxy-nightly
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
schedule: "0 20 2 * * *"
immediate: false
backupOwnerReference: self
method: barmanObjectStore
cluster:
name: arrproxy-db
@@ -1,117 +0,0 @@
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: arrproxy-db
namespace: arrstack
annotations:
# Wave 0: DB (and the generated arrproxy-db-app Secret) must be Healthy before
# the wave-1 migrate Job runs. ArgoCD gates on the Cluster's health status.
argocd.argoproj.io/sync-wave: "0"
spec:
affinity:
podAntiAffinityType: preferred
backup:
retentionPolicy: 30d
barmanObjectStore:
destinationPath: s3://cnpg-arrproxy
endpointURL: https://s3.ceph.unkin.net
endpointCA:
name: vault-ca-cert
key: ca.crt
s3Credentials:
accessKeyId:
name: cnpg-arrproxy-backup-s3
key: AWS_ACCESS_KEY_ID
secretAccessKey:
name: cnpg-arrproxy-backup-s3
key: AWS_SECRET_ACCESS_KEY
serverName: arrproxy
data:
compression: bzip2
jobs: 2
wal:
compression: zstd
maxParallel: 2
bootstrap:
initdb:
# No secret ref: CNPG mints the owner credentials and publishes them in the
# generated "arrproxy-db-app" Secret, which the api reads to build the DSN.
database: arrproxy
encoding: UTF8
localeCType: C
localeCollate: C
owner: arrproxy
enablePDB: true
enableSuperuserAccess: false
failoverDelay: 0
imageName: ghcr.io/cloudnative-pg/postgresql:18.1-system-trixie
instances: 2
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: 500m
memory: 512Mi
requests:
cpu: 50m
memory: 256Mi
smartShutdownTimeout: 180
startDelay: 3600
stopDelay: 1800
storage:
resizeInUseVolumes: true
size: 10Gi
storageClass: cephrbd-fast-delete
switchoverDelay: 3600
-41
View File
@@ -1,41 +0,0 @@
---
# External (DMZ) front for the arrstack, served on arrstack.unkin.net via the
# external Traefik (LB VIP 198.18.199.0). cert-manager mints arrproxy-gateway-tls
# (CN arrstack.unkin.net) off the internal Vault-PKI CA. The apex arrstack.unkin.net
# A record lives in the bind-operator unkin.net zone (bind-internal/authoritative),
# NOT external-dns, so no external-dns annotation here.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
labels:
traefik.io/instance: external
annotations:
argocd.argoproj.io/sync-wave: "2"
cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: arrstack.unkin.net
cert-manager.io/private-key-size: "4096"
name: arrproxy
namespace: arrstack
spec:
gatewayClassName: traefik-external
listeners:
- name: http
port: 80
protocol: HTTP
hostname: arrstack.unkin.net
allowedRoutes:
namespaces:
from: Same
- name: https
port: 443
protocol: HTTPS
hostname: arrstack.unkin.net
allowedRoutes:
namespaces:
from: Same
tls:
mode: Terminate
certificateRefs:
- group: ""
kind: Secret
name: arrproxy-gateway-tls
@@ -1,58 +0,0 @@
---
# Redirect plain HTTP to HTTPS.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: arrproxy-http-redirect
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
hostnames:
- arrstack.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: arrproxy
sectionName: http
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
matches:
- path:
type: PathPrefix
value: /
---
# All HTTPS traffic enters via oauth2-proxy (the arrproxy entry Service), which
# authenticates and path-routes to arrproxy-ui / arrproxy-api. The UI-vs-api and
# the /<app>/api oauth-bypass split is done inside oauth2-proxy (upstreams +
# skip-auth-regex), so a single backend here is sufficient.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: arrproxy-route
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
hostnames:
- arrstack.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: arrproxy
sectionName: https
rules:
- backendRefs:
- group: ""
kind: Service
name: arrproxy
port: 80
weight: 1
matches:
- path:
type: PathPrefix
value: /
@@ -1,17 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- cnpg_cluster.yaml
- cnpg_backup.yaml
- migrations-configmap.yaml
- migrate-job.yaml
- vaultstaticsecret.yaml
- oauth2-proxy-configmap.yaml
- oauth2-proxy-deployment.yaml
- api-deployment.yaml
- ui-deployment.yaml
- services.yaml
- gateway.yaml
- httproute.yaml
@@ -1,92 +0,0 @@
---
# Applies the arrproxy schema once per sync, before the api rolls, so the serve
# replicas never race migrations (arrproxy-api does not self-migrate). Runs as the
# CNPG-minted app user so the tokens table is owned by that role.
#
# Sync-phase hook at wave 1 (NOT PreSync): the CNPG Cluster + generated
# arrproxy-db-app Secret apply at wave 0 and ArgoCD waits for the Cluster to be
# Healthy before starting wave 1, so Postgres exists before migrate connects.
apiVersion: batch/v1
kind: Job
metadata:
name: arrproxy-migrate
namespace: arrstack
annotations:
argocd.argoproj.io/hook: Sync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
argocd.argoproj.io/sync-wave: "1"
spec:
backoffLimit: 6
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app: arrproxy-migrate
spec:
serviceAccountName: default
automountServiceAccountToken: false
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: migrate
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/library/postgres:18-alpine
imagePullPolicy: IfNotPresent
env:
- name: HOME
value: /tmp
- name: PGUSER
valueFrom:
secretKeyRef:
name: arrproxy-db-app
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: arrproxy-db-app
key: password
- name: PGHOST
value: arrproxy-db-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: arrproxy
- name: PGSSLMODE
value: require
command:
- psql
- -v
- ON_ERROR_STOP=1
- -f
- /migrations/0001_init.sql
volumeMounts:
- name: migrations
mountPath: /migrations
readOnly: true
- name: tmp
mountPath: /tmp
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
volumes:
- name: migrations
configMap:
name: arrproxy-migrations
- name: tmp
emptyDir:
sizeLimit: 64Mi
@@ -1,29 +0,0 @@
---
# arrproxy schema, mirrored from the arrproxy repo migrations/0001_init.sql
# (v0.1.0). arrproxy-api does NOT self-migrate, so the wave-1 migrate Job applies
# this once per sync as the app user. Keep in sync with the repo on schema bumps.
apiVersion: v1
kind: ConfigMap
metadata:
name: arrproxy-migrations
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
data:
0001_init.sql: |
-- arrproxy token store. Only token hashes are persisted; plaintext is shown
-- once at mint time and never recoverable.
CREATE TABLE IF NOT EXISTS tokens (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
token_hash TEXT NOT NULL UNIQUE,
apps TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
disabled BOOLEAN NOT NULL DEFAULT false,
last_used_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS tokens_subject_idx ON tokens (subject);
CREATE INDEX IF NOT EXISTS tokens_token_hash_idx ON tokens (token_hash);
@@ -1,56 +0,0 @@
---
# Non-secret oauth2-proxy configuration (client_id/secret/cookie_secret come from
# the oauth-credentials Secret). oauth2-proxy is the single auth front for the
# arrstack: it authenticates the UI and the token API against Authentik, and path-
# routes to the arrproxy-ui / arrproxy-api upstreams. /<app>/api is exempted from
# auth (SKIP_AUTH_REGEX) so *arr clients presenting a per-user token reach the api
# directly; the api then validates the token. Everything else requires the oauth
# session and receives identity via X-Forwarded-* / X-Auth-Request-* headers.
apiVersion: v1
kind: ConfigMap
metadata:
name: arrproxy-oauth2-env
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "2"
data:
OAUTH2_PROXY_HTTP_ADDRESS: "0.0.0.0:4180"
OAUTH2_PROXY_PROVIDER: "oidc"
# Authentik arrstack app discovery issuer (served by the internal unkin.net CA;
# trusted via PROVIDER_CA_FILES below). CONFIRM the slug matches the Authentik
# application (terraform-authentik PR #18).
OAUTH2_PROXY_OIDC_ISSUER_URL: "https://identity.unkin.net/application/o/arrstack/"
OAUTH2_PROXY_REDIRECT_URL: "https://arrstack.unkin.net/oauth2/callback"
# Longest-prefix wins: /api and /<app> go to arrproxy-api, everything else
# (the SPA + static assets) to arrproxy-ui.
OAUTH2_PROXY_UPSTREAMS: "http://arrproxy-ui.arrstack.svc.cluster.local:8080/,http://arrproxy-api.arrstack.svc.cluster.local:8080/api/,http://arrproxy-api.arrstack.svc.cluster.local:8080/sonarr/,http://arrproxy-api.arrstack.svc.cluster.local:8080/radarr/,http://arrproxy-api.arrstack.svc.cluster.local:8080/prowlarr/"
OAUTH2_PROXY_SCOPE: "openid email profile ak_groups"
# Populate session.Groups from the Authentik ak_groups claim; pass-user-headers
# then emits it as a single comma-joined X-Forwarded-Groups header.
OAUTH2_PROXY_OIDC_GROUPS_CLAIM: "ak_groups"
# Forward identity + groups to arrproxy-api as X-Forwarded-{User,Email,Groups}
# (the api reads these; ARRPROXY_GROUPS_HEADER=X-Forwarded-Groups). NOTE:
# set-xauthrequest is intentionally NOT set -- it only populates auth_request
# *response* headers, which never reach an --upstreams-proxied backend.
OAUTH2_PROXY_PASS_USER_HEADERS: "true"
# Bypass auth for the *arr proxy API (/<app>/api...) and the machine-mint
# admin route (/api/admin/...). The first matches /sonarr/api; the second
# matches /api/admin/ only -- both routed to the arrproxy-api upstream by the
# catch-all /api/ prefix above. /api/admin/ is protected by arrproxy's OWN
# ARRPROXY_ADMIN_TOKEN bearer (OpenBao on the VMs reaches it via the ingress),
# so it is intentionally oauth-skipped. /api/tokens and /api/me are NOT
# matched and stay oauth-authenticated.
OAUTH2_PROXY_SKIP_AUTH_REGEX: "^/[^/]+/api,^/api/admin/"
OAUTH2_PROXY_EMAIL_DOMAINS: "*"
# Authentik hardcodes email_verified=false in the id_token; without this
# oauth2-proxy rejects the session ("email ... isn't verified") -> 500 on
# /oauth2/callback. Authorization is enforced downstream via ak_groups, so
# accepting the unverified email here is safe.
OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL: "true"
OAUTH2_PROXY_COOKIE_SECURE: "true"
OAUTH2_PROXY_COOKIE_DOMAINS: "arrstack.unkin.net"
OAUTH2_PROXY_WHITELIST_DOMAINS: "arrstack.unkin.net"
OAUTH2_PROXY_REVERSE_PROXY: "true"
OAUTH2_PROXY_PROVIDER_CA_FILES: "/etc/ssl/combined/ca-certificates.crt"
OAUTH2_PROXY_CODE_CHALLENGE_METHOD: "S256"
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true"
@@ -1,133 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: arrproxy-oauth2
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "2"
configmap.reloader.stakater.com/auto: "true"
secret.reloader.stakater.com/reload: "oauth-credentials,vault-ca-cert"
spec:
replicas: 2
selector:
matchLabels:
app: arrproxy-oauth2
strategy:
rollingUpdate:
maxUnavailable: 1
type: RollingUpdate
template:
metadata:
labels:
app: arrproxy-oauth2
spec:
serviceAccountName: default
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
initContainers:
# identity.unkin.net serves a Vault-PKI cert; combine the system roots
# with the internal CA so oauth2-proxy's OIDC HTTP client trusts it.
- name: combine-certs
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/library/alpine:3
imagePullPolicy: IfNotPresent
command:
- sh
- -c
- cat /etc/ssl/certs/ca-certificates.crt /custom-ca/ca.crt > /combined-certs/ca-certificates.crt
volumeMounts:
- name: vault-ca-cert
mountPath: /custom-ca
readOnly: true
- name: combined-certs
mountPath: /combined-certs
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: 200m
memory: 64Mi
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.3
imagePullPolicy: IfNotPresent
ports:
- containerPort: 4180
name: http
protocol: TCP
envFrom:
- configMapRef:
name: arrproxy-oauth2-env
optional: false
env:
- name: OAUTH2_PROXY_CLIENT_ID
valueFrom:
secretKeyRef:
name: oauth-credentials
key: client_id
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: oauth-credentials
key: client_secret
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef:
name: oauth-credentials
key: cookie_secret
volumeMounts:
- name: combined-certs
mountPath: /etc/ssl/combined
readOnly: true
livenessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
volumes:
- name: vault-ca-cert
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
- name: combined-certs
emptyDir: {}
restartPolicy: Always
-59
View File
@@ -1,59 +0,0 @@
---
# Front-door entry Service: the HTTPRoute for arrstack.unkin.net targets this.
# All traffic (UI, token API, and the *arr proxy) enters via oauth2-proxy.
apiVersion: v1
kind: Service
metadata:
name: arrproxy
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
internalTrafficPolicy: Cluster
ports:
- name: http
port: 80
protocol: TCP
targetPort: http
selector:
app: arrproxy-oauth2
sessionAffinity: None
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: arrproxy-api
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
internalTrafficPolicy: Cluster
ports:
- name: http
port: 8080
protocol: TCP
targetPort: http
selector:
app: arrproxy-api
sessionAffinity: None
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: arrproxy-ui
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
internalTrafficPolicy: Cluster
ports:
- name: http
port: 8080
protocol: TCP
targetPort: http
selector:
app: arrproxy-ui
sessionAffinity: None
type: ClusterIP
@@ -1,72 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: arrproxy-ui
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
replicas: 2
selector:
matchLabels:
app: arrproxy-ui
strategy:
rollingUpdate:
maxUnavailable: 1
type: RollingUpdate
template:
metadata:
labels:
app: arrproxy-ui
spec:
serviceAccountName: default
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: ui
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-ui:v0.3.1
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
protocol: TCP
env:
- name: ARRPROXY_UI_ADDR
value: ":8080"
livenessProbe:
httpGet:
path: /livez
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
restartPolicy: Always
@@ -1,76 +0,0 @@
---
# Per-deployment token-hash pepper. Seeded (openssl rand) at
# kv/kubernetes/namespace/arrstack/default/arrproxy-pepper (key: pepper); the
# default k8s role's templated policy already grants read on
# kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs it into the arrproxy-pepper Secret consumed by arrproxy-api as
# ARRPROXY_PEPPER.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: arrproxy-pepper
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: arrproxy-pepper
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/arrproxy-pepper
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
# Machine-mint admin bearer token. Seeded (openssl rand) at
# kv/kubernetes/namespace/arrstack/default/arrproxy-admin-token (key: token) and
# shared as the source of truth with the future Vault engine. The default k8s
# role's templated policy already grants read on
# kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs it into the arrproxy-admin-token Secret consumed by arrproxy-api as
# ARRPROXY_ADMIN_TOKEN to gate the bearer-protected /api/admin/ route.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: arrproxy-admin-token
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: arrproxy-admin-token
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/arrproxy-admin-token
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
# Authentik OIDC client for the arrstack front door (client_id, client_secret,
# cookie_secret), created by terraform-authentik at
# kv/kubernetes/namespace/arrstack/default/oauth-credentials. VSO syncs it into
# the oauth-credentials Secret consumed by the oauth2-proxy Deployment.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: oauth-credentials
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: oauth-credentials
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/oauth-credentials
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
-42
View File
@@ -1,42 +0,0 @@
---
# Shared Ceph RGW (S3) bucket for arrstack application backups (the periodic
# radarr/sonarr/prowlarr config+database zip backups), provisioned by the
# in-estate cephrgw-operator. Backups otherwise land on each replica's local
# /config (an emptyDir) and are lost when that pod is rescheduled; routing them
# to S3 lets any replica write and restore them.
#
# Backups are kept in their own bucket (not the arrstack-media one) because they
# carry secrets (Config.xml holds API keys, the DB has all app state) and thus
# warrant separate credentials, lifecycle and retention from the public-ish
# poster art. The three apps share this one bucket, isolated by a per-app key
# prefix (radarr/, sonarr/, prowlarr/) set via <App>__BackupS3__Prefix.
#
# The operator mints the S3 credential Secret (arrstack-backups-s3) in this
# namespace with keys AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and S3_ENDPOINT,
# so no Vault KV seeding is required.
apiVersion: ceph.unkin.net/v1alpha1
kind: ObjectStoreUser
metadata:
name: arrstack-backups
namespace: arrstack
spec:
displayName: "arrstack backups owner"
uid: arrstack-backups
maxBuckets: 5
secretName: arrstack-backups-s3
retainOnDelete: true
---
apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket
metadata:
name: arrstack-backups
namespace: arrstack
spec:
placementTarget: ec
bucketName: arrstack-backups
ownerRef: arrstack-backups
versioning: false
tags:
app: arrstack
purpose: backups
retainOnDelete: true
-23
View File
@@ -1,23 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- vaultauth.yaml
- pv-media-tv.yaml
- pv-media-movies.yaml
- pv-mediafs.yaml
- pvc-media-tv.yaml
- pvc-media-movies.yaml
- pvc-mediafs.yaml
- media-bucket.yaml
- backups-bucket.yaml
- postgres
- valkey
- sonarr
- radarr
- prowlarr
- nzbget
- arrproxy
- mediamover
-34
View File
@@ -1,34 +0,0 @@
---
# Shared Ceph RGW (S3) bucket for arrstack media assets (Servarr MediaCover
# posters/fanart and, later, application backups), provisioned by the in-estate
# cephrgw-operator. Moving these off each replica's local /config (an emptyDir)
# lets any radarr/sonarr/prowlarr replica serve covers and store backups without
# leader-local state. The operator mints the S3 credential Secret
# (arrstack-media-s3) in this namespace with keys AWS_ACCESS_KEY_ID,
# AWS_SECRET_ACCESS_KEY and S3_ENDPOINT, so no Vault KV seeding is required.
apiVersion: ceph.unkin.net/v1alpha1
kind: ObjectStoreUser
metadata:
name: arrstack-media
namespace: arrstack
spec:
displayName: "arrstack media assets owner"
uid: arrstack-media
maxBuckets: 5
secretName: arrstack-media-s3
retainOnDelete: true
---
apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket
metadata:
name: arrstack-media
namespace: arrstack
spec:
placementTarget: ec
bucketName: arrstack-media
ownerRef: arrstack-media
versioning: false
tags:
app: arrstack
purpose: media-assets
retainOnDelete: true
@@ -1,95 +0,0 @@
---
# mediamover server: REST API + UI on :8080. Spawns one worker Job per queued
# file using this same image with `worker` args (Job spec lives in code; workers
# run as the default ServiceAccount and mount the same PVCs). The queue is
# in-memory, so keep a single replica; a restart just loses queued entries.
apiVersion: apps/v1
kind: Deployment
metadata:
name: mediamover
namespace: arrstack
spec:
replicas: 1
selector:
matchLabels:
app: mediamover
strategy:
type: Recreate
template:
metadata:
labels:
app: mediamover
spec:
serviceAccountName: mediamover
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: server
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/mediamover:v0.1.0
imagePullPolicy: IfNotPresent
args:
- server
- --src-root=/srv/src
- --src-pvc=mediafs
- --dst-roots=movies=/srv/dst/movies,tv=/srv/dst/tv
- --dst-pvc=movies=media-movies,tv=media-tv
- --namespace=arrstack
- --image=artifactapi.k8s.syd1.au.unkin.net/docker-internal/mediamover:v0.1.0
ports:
- containerPort: 8080
name: http
protocol: TCP
volumeMounts:
# RW: move mode deletes the source file after a successful copy.
- name: src
mountPath: /srv/src
- name: dst-movies
mountPath: /srv/dst/movies
- name: dst-tv
mountPath: /srv/dst/tv
livenessProbe:
httpGet:
path: /api/limit
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/limit
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: src
persistentVolumeClaim:
claimName: mediafs
- name: dst-movies
persistentVolumeClaim:
claimName: media-movies
- name: dst-tv
persistentVolumeClaim:
claimName: media-tv
restartPolicy: Always
@@ -1,40 +0,0 @@
---
# Internal front for mediamover. The existing arrstack (arrproxy) Gateway is
# external and hostname-locked to arrstack.unkin.net, so this tool gets its own
# internal Gateway following the cluster convention (cf. pdbmux).
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: mediamover.k8s.syd1.au.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: mediamover.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: mediamover
namespace: arrstack
spec:
gatewayClassName: traefik-internal
listeners:
- allowedRoutes:
namespaces:
from: Same
hostname: mediamover.k8s.syd1.au.unkin.net
name: http
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: mediamover.k8s.syd1.au.unkin.net
name: https
port: 443
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
name: mediamover-tls
mode: Terminate
@@ -1,49 +0,0 @@
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: mediamover-http-redirect
namespace: arrstack
spec:
hostnames:
- mediamover.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: mediamover
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: mediamover
namespace: arrstack
spec:
hostnames:
- mediamover.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: mediamover
sectionName: https
rules:
- backendRefs:
- group: ""
kind: Service
name: mediamover
port: 8080
weight: 1
matches:
- path:
type: PathPrefix
value: /
@@ -1,11 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- serviceaccount.yaml
- rbac.yaml
- deployment.yaml
- service.yaml
- gateway.yaml
- httproute.yaml
-48
View File
@@ -1,48 +0,0 @@
---
# The server creates one worker Job per queued file and polls Job/Pod state to
# track progress and clean up.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: mediamover
namespace: arrstack
rules:
- apiGroups:
- batch
resources:
- jobs
verbs:
- create
- get
- list
- watch
- delete
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- watch
- apiGroups:
- ""
resources:
- pods/log
verbs:
- get
- list
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: mediamover
namespace: arrstack
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: mediamover
subjects:
- kind: ServiceAccount
name: mediamover
namespace: arrstack
@@ -1,19 +0,0 @@
---
# Must stay named "mediamover" on port 8080: worker Jobs default their progress
# callback URL to http://mediamover.<namespace>.svc:8080.
apiVersion: v1
kind: Service
metadata:
name: mediamover
namespace: arrstack
spec:
internalTrafficPolicy: Cluster
ports:
- name: http
port: 8080
protocol: TCP
targetPort: http
selector:
app: mediamover
sessionAffinity: None
type: ClusterIP
@@ -1,6 +0,0 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: mediamover
namespace: arrstack
-5
View File
@@ -1,5 +0,0 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: arrstack
-137
View File
@@ -1,137 +0,0 @@
---
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:version-v26.2
command:
- sh
- -c
- |
set -e
if [ ! -f /config/nzbget.conf ]; then
cp /app/nzbget/share/nzbget/nzbget.conf /config/nzbget.conf
fi
if ! grep -q '# arrstack-managed' /config/nzbget.conf; then
cat >> /config/nzbget.conf << 'CONF'
# arrstack-managed download layout (appended once; last value wins).
# Downloads land on the shared media PVCs by category so sonarr/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:version-v26.2
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 6789
protocol: TCP
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: Australia/Sydney
livenessProbe:
# nzbget's root path requires auth (401); a TCP check is the
# dependency-free liveness signal for the web/JSON-RPC server.
tcpSocket:
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
# Headroom for par2 repair + unpack of large downloads.
cpu: "2"
memory: 2Gi
volumeMounts:
- name: config
mountPath: /config
- name: media-tv
mountPath: /media/tv
- 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
-37
View File
@@ -1,37 +0,0 @@
---
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
-49
View File
@@ -1,49 +0,0 @@
---
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: /
@@ -1,10 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pvc-config.yaml
- deployment.yaml
- service.yaml
- gateway.yaml
- httproute.yaml
-16
View File
@@ -1,16 +0,0 @@
---
# 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
-15
View File
@@ -1,15 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: nzbget
namespace: arrstack
spec:
ports:
- name: http
port: 6789
protocol: TCP
targetPort: http
selector:
app: nzbget
type: ClusterIP
@@ -1,45 +0,0 @@
---
# Ceph RGW (S3) backup target for the shared arrstack CNPG cluster, provisioned
# by the in-estate cephrgw-operator: one dedicated bucket + owner user. CNPG
# reads the S3 credential Secret (cnpg-arrstack-backup-s3) from this namespace.
apiVersion: ceph.unkin.net/v1alpha1
kind: ObjectStoreUser
metadata:
name: cnpg-arrstack-backup
namespace: arrstack
spec:
displayName: "CNPG backup owner (arrstack)"
uid: cnpg-arrstack-backup
maxBuckets: 5
secretName: cnpg-arrstack-backup-s3
retainOnDelete: true
---
apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket
metadata:
name: cnpg-arrstack
namespace: arrstack
spec:
placementTarget: ec
bucketName: cnpg-arrstack
ownerRef: cnpg-arrstack-backup
versioning: false
tags:
app: arrstack
purpose: cnpg-backup
retainOnDelete: true
---
# Nightly base backup on top of always-on WAL archiving. Staggered from the
# other CNPG clusters (6-field cron, seconds first).
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: cnpg-arrstack-nightly
namespace: arrstack
spec:
schedule: "0 45 3 * * *"
immediate: false
backupOwnerReference: self
method: barmanObjectStore
cluster:
name: arrstack-postgres
@@ -1,161 +0,0 @@
---
# Shared PostgreSQL backend for the -unkin2 fork sonarr/radarr/prowlarr, whose
# Npgsql/EF Core provider moves each *arr off SQLite into Postgres and makes the
# shared-nothing, active-active multi-replica deployment possible. One cluster,
# one throwaway initdb owner ("app"), and three managed login roles — one per
# app — each with its own per-app database (see database-*.yaml). Role passwords
# come from the VSO-synced <app>-db Secrets (vaultstaticsecret.yaml), so no
# credential is rendered into git.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: arrstack-postgres
namespace: arrstack
spec:
inheritedMetadata:
annotations:
k8up.io/backup: "false"
affinity:
podAntiAffinityType: preferred
backup:
retentionPolicy: 30d
barmanObjectStore:
destinationPath: s3://cnpg-arrstack
endpointURL: https://s3.ceph.unkin.net
endpointCA:
name: vault-ca-cert
key: ca.crt
s3Credentials:
accessKeyId:
name: cnpg-arrstack-backup-s3
key: AWS_ACCESS_KEY_ID
secretAccessKey:
name: cnpg-arrstack-backup-s3
key: AWS_SECRET_ACCESS_KEY
serverName: arrstack
data:
compression: bzip2
jobs: 2
wal:
compression: zstd
maxParallel: 2
bootstrap:
initdb:
# Throwaway owner + database: the real per-app databases are provisioned by
# the CNPG Database CRDs, owned by the managed roles below.
database: app
encoding: UTF8
localeCType: C
localeCollate: C
owner: app
managed:
roles:
- name: sonarr
ensure: present
comment: Sonarr application role (owns sonarr-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: sonarr-db
- name: radarr
ensure: present
comment: Radarr application role (owns radarr-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: radarr-db
- name: prowlarr
ensure: present
comment: Prowlarr application role (owns prowlarr-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: prowlarr-db
enablePDB: true
enableSuperuserAccess: false
failoverDelay: 0
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: 2Gi
requests:
cpu: 250m
memory: 1Gi
smartShutdownTimeout: 180
startDelay: 3600
stopDelay: 1800
storage:
resizeInUseVolumes: true
size: 10Gi
storageClass: cephrbd-fast-delete
switchoverDelay: 3600
@@ -1,15 +0,0 @@
---
# Per-app database owned by the prowlarr managed role. The fork's provider runs
# its own schema migrations on first start (advisory-locked, so only one replica
# migrates). retain: the database survives a Database CRD delete.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: prowlarr-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: prowlarr-main
owner: prowlarr
databaseReclaimPolicy: retain
@@ -1,15 +0,0 @@
---
# Per-app database owned by the radarr managed role. The fork's provider runs its
# own schema migrations on first start (advisory-locked, so only one replica
# migrates). retain: the database survives a Database CRD delete.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: radarr-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: radarr-main
owner: radarr
databaseReclaimPolicy: retain
@@ -1,15 +0,0 @@
---
# Per-app database owned by the sonarr managed role. The fork's provider runs its
# own schema migrations on first start (advisory-locked, so only one replica
# migrates). retain: the database survives a Database CRD delete.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: sonarr-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: sonarr-main
owner: sonarr
databaseReclaimPolicy: retain
@@ -1,11 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- cnpg_cluster.yaml
- cnpg_backup.yaml
- database-sonarr.yaml
- database-radarr.yaml
- database-prowlarr.yaml
@@ -1,60 +0,0 @@
---
# Per-app Postgres role credentials. Each is seeded out-of-band at
# kv/kubernetes/namespace/arrstack/default/<app>-db (keys: username, password);
# the default k8s role's templated policy already grants read on
# kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs each into the <app>-db Secret, which is both the CNPG managed role's
# passwordSecret (cnpg_cluster.yaml) and the source of the app Deployment's
# <App>__Postgres__User/__Password env. Wave 0: must exist before the Cluster
# (wave 1) reconciles the roles.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-db
namespace: arrstack
spec:
destination:
create: true
name: sonarr-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-db
namespace: arrstack
spec:
destination:
create: true
name: radarr-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: prowlarr-db
namespace: arrstack
spec:
destination:
create: true
name: prowlarr-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/prowlarr-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -1,33 +0,0 @@
---
# Non-secret env for the -unkin2 fork. The fork reads Servarr config from
# Prowlarr__<Section>__<Key> env (no config.xml edits, no s6/PUID). Postgres
# wiring points every replica at the same shared DB (arrstack-postgres-rw /
# prowlarr-main); Auth__Method=External defers UI auth to arrproxy/oauth2-proxy;
# Server__UrlBase keeps the /prowlarr prefix so arrproxy path-routing works;
# App__InstanceName is identical across replicas (shared session-cookie name).
# User/Password/ApiKey come from Secrets (see deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: prowlarr-env
namespace: arrstack
data:
Prowlarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Prowlarr__Postgres__Port: "5432"
Prowlarr__Postgres__MainDb: prowlarr-main
Prowlarr__Log__DbEnabled: "false"
Prowlarr__Auth__Method: External
Prowlarr__Auth__Required: DisabledForLocalAddresses
Prowlarr__App__InstanceName: Prowlarr
Prowlarr__Server__Port: "9696"
Prowlarr__Server__UrlBase: /prowlarr
Prowlarr__Update__Mechanism: External
# Shared arrstack Valkey (valkey-operator). Setting Host is what activates the
# fork's #14 Redis features (SignalR backplane, cross-replica cache-invalidation
# bus, distributed rate limiter): RedisOptions.IsConfigured gates purely on a
# non-empty Host, so there is no separate Enabled flag. The operator leaves the
# default user passwordless (jellyfin parity), so no Password/Ssl is wired.
# Channels/keys are namespaced by this fork's prowlarr:ratelimit: prefix, so the
# one cluster is safe to share with sonarr/radarr.
Prowlarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Prowlarr__Redis__Port: "6379"
-174
View File
@@ -1,174 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prowlarr
namespace: arrstack
annotations:
# prowlarr-env is a plain (unhashed) ConfigMap consumed by fixed-name envFrom,
# so editing it does not roll the Deployment on its own. Reloader watches the
# referenced ConfigMap and triggers a rolling restart on change, so adding the
# Redis env activates the #14 features on the next ArgoCD sync without a manual
# `rollout restart`.
configmap.reloader.stakater.com/auto: "true"
spec:
# Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the prowlarr Service. RollingUpdate is safe
# — no SQLite, no RWO lock.
replicas: 3
strategy:
type: RollingUpdate
selector:
matchLabels:
app: prowlarr
template:
metadata:
labels:
app: prowlarr
spec:
securityContext:
# Fork image has no USER (runs as root by default); pin it to a non-root
# UID and group-write the shared RWX CephFS /config. OnRootMismatch
# avoids a recursive chown of the whole volume.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Gate the app on its own Postgres database+role being reachable, instead
# of relying on ArgoCD sync-waves (which deadlock if apps aren't Healthy).
# waitfordb reads the PG* env as a libpq fallback, so the password never lands in argv.
- name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env:
- name: WAITFORDB_TIMEOUT
value: 5m
- name: WAITFORDB_SSLMODE
value: disable
- name: PGHOST
value: arrstack-postgres-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: prowlarr-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: prowlarr-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: prowlarr-db
key: password
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
containers:
- name: prowlarr
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/prowlarr:v2.6.2-unkin6
imagePullPolicy: IfNotPresent
command:
- /app/Prowlarr
args:
- -nobrowser
- -data=/config
# Required: bypass the single-instance guard so multiple replicas
# can share one /config. Cross-replica safety is the Postgres layer,
# not a local lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 9696
protocol: TCP
envFrom:
- configMapRef:
name: prowlarr-env
env:
- name: Prowlarr__Postgres__User
valueFrom:
secretKeyRef:
name: prowlarr-db
key: username
- name: Prowlarr__Postgres__Password
valueFrom:
secretKeyRef:
name: prowlarr-db
key: password
- name: Prowlarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: prowlarr-apikey
key: apitoken
# Backup object store (shared arrstack-backups Ceph RGW bucket,
# per-app key prefix). Routes the periodic config+DB zip backups off
# the ephemeral /config so any replica can write and restore them.
# Consumed by the -unkin3+ image; older images ignore these unknown
# config keys. Creds Secret minted by cephrgw-operator.
- name: Prowlarr__BackupS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: S3_ENDPOINT
- name: Prowlarr__BackupS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_ACCESS_KEY_ID
- name: Prowlarr__BackupS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_SECRET_ACCESS_KEY
- name: Prowlarr__BackupS3__Bucket
value: arrstack-backups
- name: Prowlarr__BackupS3__Prefix
value: prowlarr
- name: Prowlarr__BackupS3__ForcePathStyle
value: "true"
- name: Prowlarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /prowlarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /prowlarr/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: vault-ca
mountPath: /etc/ssl/vault-ca
readOnly: true
volumes:
- name: config
emptyDir: {}
# Estate CA for validating the Ceph RGW (s3.ceph.unkin.net) TLS cert.
- name: vault-ca
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
@@ -1,49 +0,0 @@
---
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: /
@@ -1,11 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- gateway.yaml
- httproute.yaml
@@ -1,17 +0,0 @@
---
# Prowlarr /config. RWX on CephFS so all replicas share it (the -unkin2 fork
# keeps the database in Postgres; /config now holds only config.xml + assets,
# which tolerate — and want — shared access). Retain: this is state.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: prowlarr-config
namespace: arrstack
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 5Gi
storageClassName: cephfs-raid5-retain
volumeMode: Filesystem
-15
View File
@@ -1,15 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: prowlarr
namespace: arrstack
spec:
ports:
- name: http
port: 9696
protocol: TCP
targetPort: http
selector:
app: prowlarr
type: ClusterIP
@@ -1,25 +0,0 @@
---
# prowlarr API key. Seeded at kv/kubernetes/namespace/arrstack/default/prowlarr
# (key: apitoken); the default k8s role's templated policy already grants read
# on kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs it into the prowlarr-apikey Secret that the apikey-init initContainer reads
# to enforce <ApiKey> in /config/config.xml (Vault is source of truth).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: prowlarr-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: prowlarr-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/prowlarr
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
-31
View File
@@ -1,31 +0,0 @@
---
# 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
-30
View File
@@ -1,30 +0,0 @@
---
# 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
-31
View File
@@ -1,31 +0,0 @@
---
# Static PV exposing the legacy mediafs CephFS filesystem root for the upcoming
# mediamover tool. clusterID only selects the monitor set from the csi config;
# fsName selects the actual filesystem, so the ssd_ec clusterID still reaches
# mediafs on the same cluster. Staged with the dedicated ceph-mediafs client.
apiVersion: v1
kind: PersistentVolume
metadata:
name: arrstack-mediafs
spec:
capacity:
storage: 10Ti
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
volumeMode: Filesystem
claimRef:
namespace: arrstack
name: mediafs
csi:
driver: cephfs.csi.ceph.com
volumeHandle: arrstack-mediafs-static
nodeStageSecretRef:
name: ceph-mediafs-secret
namespace: csi-cephfs
volumeAttributes:
staticVolume: "true"
clusterID: cephfs_csi_ssd_ec_4_1
fsName: mediafs
rootPath: /
-22
View File
@@ -1,22 +0,0 @@
---
# 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
-22
View File
@@ -1,22 +0,0 @@
---
# 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
-20
View File
@@ -1,20 +0,0 @@
---
# Legacy mediafs filesystem root, statically bound to the arrstack-mediafs PV
# for the upcoming mediamover tool. storageClassName "" + volumeName disables
# dynamic provisioning and binds the pre-created static PV.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mediafs
namespace: arrstack
annotations:
k8up.io/backup: "false"
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Ti
storageClassName: ""
volumeName: arrstack-mediafs
volumeMode: Filesystem
-33
View File
@@ -1,33 +0,0 @@
---
# Non-secret env for the -unkin2 fork. The fork reads Servarr config from
# Radarr__<Section>__<Key> env (no config.xml edits, no s6/PUID). Postgres wiring
# points every replica at the same shared DB (arrstack-postgres-rw / radarr-main);
# Auth__Method=External defers UI auth to arrproxy/oauth2-proxy; Server__UrlBase
# keeps the /radarr prefix so arrproxy path-routing works; App__InstanceName is
# identical across replicas (shared session-cookie name). User/Password/ApiKey
# come from Secrets (see deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: radarr-env
namespace: arrstack
data:
Radarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Radarr__Postgres__Port: "5432"
Radarr__Postgres__MainDb: radarr-main
Radarr__Log__DbEnabled: "false"
Radarr__Auth__Method: External
Radarr__Auth__Required: DisabledForLocalAddresses
Radarr__App__InstanceName: Radarr
Radarr__Server__Port: "7878"
Radarr__Server__UrlBase: /radarr
Radarr__Update__Mechanism: External
# Shared arrstack Valkey (valkey-operator). Setting Host is what activates the
# fork's #14 Redis features (SignalR backplane, cross-replica cache-invalidation
# bus, distributed rate limiter): RedisOptions.IsConfigured gates purely on a
# non-empty Host, so there is no separate Enabled flag. The operator leaves the
# default user passwordless (jellyfin parity), so no Password/Ssl is wired.
# Channels/keys are namespaced by this fork's radarr:ratelimit: prefix, so the
# one cluster is safe to share with sonarr/prowlarr.
Radarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Radarr__Redis__Port: "6379"
-249
View File
@@ -1,249 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: radarr
namespace: arrstack
annotations:
# radarr-env is a plain (unhashed) ConfigMap consumed by fixed-name envFrom,
# so editing it does not roll the Deployment on its own. Reloader watches the
# referenced ConfigMap and triggers a rolling restart on change, so adding the
# Redis env activates the #14 features on the next ArgoCD sync without a manual
# `rollout restart`.
configmap.reloader.stakater.com/auto: "true"
spec:
# Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the radarr Service. RollingUpdate is safe —
# no SQLite, no RWO lock.
replicas: 3
strategy:
type: RollingUpdate
selector:
matchLabels:
app: radarr
template:
metadata:
labels:
app: radarr
spec:
securityContext:
# Fork image has no USER (runs as root by default); pin it to a non-root
# UID and group-write the shared RWX CephFS /config (MediaCover etc.).
# OnRootMismatch avoids a recursive chown of the whole media tree.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Gate the app on its own Postgres database+role being reachable, instead
# of relying on ArgoCD sync-waves (which deadlock if apps aren't Healthy).
# waitfordb reads the PG* env as a libpq fallback, so the password never lands in argv.
- name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env:
- name: WAITFORDB_TIMEOUT
value: 5m
- name: WAITFORDB_SSLMODE
value: disable
- name: PGHOST
value: arrstack-postgres-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: radarr-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: radarr-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: radarr-db
key: password
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
containers:
- name: radarr
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/radarr:v6.4.2-unkin7
imagePullPolicy: IfNotPresent
command:
- /app/Radarr
args:
- -nobrowser
- -data=/config
# Required: bypass the single-instance guard so multiple replicas
# can share one /config. Cross-replica safety is the Postgres layer,
# not a local lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 7878
protocol: TCP
envFrom:
- configMapRef:
name: radarr-env
env:
- name: Radarr__Postgres__User
valueFrom:
secretKeyRef:
name: radarr-db
key: username
- name: Radarr__Postgres__Password
valueFrom:
secretKeyRef:
name: radarr-db
key: password
- name: Radarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: radarr-apikey
key: apitoken
# MediaCover object store (shared Ceph RGW bucket). Serves posters/fanart
# from S3 so any replica can render them instead of the leader-local
# emptyDir /config. Consumed by the -unkin3+ image; older images ignore
# these unknown config keys. Creds Secret is minted by cephrgw-operator.
- name: Radarr__MediaCover__S3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: S3_ENDPOINT
- name: Radarr__MediaCover__S3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_ACCESS_KEY_ID
- name: Radarr__MediaCover__S3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_SECRET_ACCESS_KEY
- name: Radarr__MediaCover__S3__Bucket
value: arrstack-media
- name: Radarr__MediaCover__S3__Prefix
value: radarr
- name: Radarr__MediaCover__S3__ForcePathStyle
value: "true"
- name: Radarr__MediaCover__S3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket,
# per-app key prefix). Routes the periodic config+DB zip backups off
# the ephemeral /config so any replica can write and restore them.
- name: Radarr__BackupS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: S3_ENDPOINT
- name: Radarr__BackupS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_ACCESS_KEY_ID
- name: Radarr__BackupS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_SECRET_ACCESS_KEY
- name: Radarr__BackupS3__Bucket
value: arrstack-backups
- name: Radarr__BackupS3__Prefix
value: radarr
- name: Radarr__BackupS3__ForcePathStyle
value: "true"
- name: Radarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /radarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /radarr/ping
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: config
mountPath: /config
- name: media-movies
mountPath: /media/movies
- name: vault-ca
mountPath: /etc/ssl/vault-ca
readOnly: true
# exportarr sidecar: polls the local replica's API and exposes Prometheus
# metrics on :9708 (scraped by the radarr-exportarr VMPodScrape).
- name: exportarr
image: artifactapi.k8s.syd1.au.unkin.net/ghcr/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent
args:
- radarr
env:
- name: PORT
value: "9708"
# URL includes the /radarr UrlBase (Radarr__Server__UrlBase).
- name: URL
value: http://localhost:7878/radarr
- name: APIKEY
valueFrom:
secretKeyRef:
name: radarr-apikey
key: apitoken
ports:
- name: metrics
containerPort: 9708
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
volumes:
- name: config
emptyDir: {}
- name: media-movies
persistentVolumeClaim:
claimName: media-movies
# Estate CA for validating the Ceph RGW (s3.ceph.unkin.net) TLS cert.
- name: vault-ca
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
-37
View File
@@ -1,37 +0,0 @@
---
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
-49
View File
@@ -1,49 +0,0 @@
---
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: /
@@ -1,12 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- gateway.yaml
- httproute.yaml
- vmpodscrape.yaml
-17
View File
@@ -1,17 +0,0 @@
---
# Radarr /config. RWX on CephFS so all replicas share it (the -unkin2 fork keeps
# the database in Postgres; /config now holds only config.xml + MediaCover, which
# tolerate — and want — shared access). Retain: this is state.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: radarr-config
namespace: arrstack
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 5Gi
storageClassName: cephfs-raid5-retain
volumeMode: Filesystem
-15
View File
@@ -1,15 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: radarr
namespace: arrstack
spec:
ports:
- name: http
port: 7878
protocol: TCP
targetPort: http
selector:
app: radarr
type: ClusterIP
@@ -1,25 +0,0 @@
---
# radarr API key. Seeded at kv/kubernetes/namespace/arrstack/default/radarr
# (key: apitoken); the default k8s role's templated policy already grants read
# on kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs it into the radarr-apikey Secret that the apikey-init initContainer reads
# to enforce <ApiKey> in /config/config.xml (Vault is source of truth).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: radarr-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -1,16 +0,0 @@
---
# Scrape the exportarr sidecar (:9708) on every radarr pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the radarr Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: radarr-exportarr
namespace: arrstack
spec:
selector:
matchLabels:
app: radarr
podMetricsEndpoints:
- port: metrics
path: /metrics
-33
View File
@@ -1,33 +0,0 @@
---
# Non-secret env for the -unkin2 fork. The fork reads Servarr config from
# Sonarr__<Section>__<Key> env (no config.xml edits, no s6/PUID). Postgres wiring
# points every replica at the same shared DB (arrstack-postgres-rw / sonarr-main);
# Auth__Method=External defers UI auth to arrproxy/oauth2-proxy; Server__UrlBase
# keeps the /sonarr prefix so arrproxy path-routing works; App__InstanceName is
# identical across replicas (shared session-cookie name). User/Password/ApiKey
# come from Secrets (see deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: sonarr-env
namespace: arrstack
data:
Sonarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Sonarr__Postgres__Port: "5432"
Sonarr__Postgres__MainDb: sonarr-main
Sonarr__Log__DbEnabled: "false"
Sonarr__Auth__Method: External
Sonarr__Auth__Required: DisabledForLocalAddresses
Sonarr__App__InstanceName: Sonarr
Sonarr__Server__Port: "8989"
Sonarr__Server__UrlBase: /sonarr
Sonarr__Update__Mechanism: External
# Shared arrstack Valkey (valkey-operator). Setting Host is what activates the
# fork's #14 Redis features (SignalR backplane, cross-replica cache-invalidation
# bus, distributed rate limiter): RedisOptions.IsConfigured gates purely on a
# non-empty Host, so there is no separate Enabled flag. The operator leaves the
# default user passwordless (jellyfin parity), so no Password/Ssl is wired.
# Channels/keys are namespaced by this fork's sonarr:ratelimit: prefix, so the
# one cluster is safe to share with radarr/prowlarr.
Sonarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Sonarr__Redis__Port: "6379"
-251
View File
@@ -1,251 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sonarr
namespace: arrstack
annotations:
# sonarr-env is a plain (unhashed) ConfigMap consumed by fixed-name envFrom,
# so editing it does not roll the Deployment on its own. Reloader watches the
# referenced ConfigMap and triggers a rolling restart on change, so adding the
# Redis env activates the #14 features on the next ArgoCD sync without a manual
# `rollout restart`.
configmap.reloader.stakater.com/auto: "true"
spec:
# Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the sonarr Service. RollingUpdate is safe —
# no SQLite, no RWO lock.
replicas: 3
strategy:
type: RollingUpdate
selector:
matchLabels:
app: sonarr
template:
metadata:
labels:
app: sonarr
spec:
securityContext:
# Fork image has no USER (runs as root by default); pin it to a non-root
# UID and group-write the shared RWX CephFS /config (MediaCover etc.).
# OnRootMismatch avoids a recursive chown of the whole media tree.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Gate the app on its own Postgres database+role being reachable, instead
# of relying on ArgoCD sync-waves (which deadlock if apps aren't Healthy).
# waitfordb reads the PG* env as a libpq fallback, so the password never lands in argv.
- name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env:
- name: WAITFORDB_TIMEOUT
value: 5m
- name: WAITFORDB_SSLMODE
value: disable
- name: PGHOST
value: arrstack-postgres-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: sonarr-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: sonarr-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: sonarr-db
key: password
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
containers:
- name: sonarr
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/sonarr:v5.0.0-unkin6
imagePullPolicy: IfNotPresent
command:
- /app/Sonarr
args:
- -nobrowser
- -data=/config
# Required: bypass the single-instance guard so multiple replicas
# can share one /config. Cross-replica safety is the Postgres layer,
# not a local lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 8989
protocol: TCP
envFrom:
- configMapRef:
name: sonarr-env
env:
- name: Sonarr__Postgres__User
valueFrom:
secretKeyRef:
name: sonarr-db
key: username
- name: Sonarr__Postgres__Password
valueFrom:
secretKeyRef:
name: sonarr-db
key: password
- name: Sonarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: sonarr-apikey
key: apitoken
# MediaCover object store (shared arrstack-media Ceph RGW bucket,
# partitioned from radarr by the sonarr key prefix). Serves
# posters/fanart from S3 so any replica renders them instead of the
# leader-local emptyDir /config. Consumed by the -unkin3+ image;
# older images ignore these unknown config keys. Creds Secret minted
# by cephrgw-operator.
- name: Sonarr__MediaCoverS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: S3_ENDPOINT
- name: Sonarr__MediaCoverS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_ACCESS_KEY_ID
- name: Sonarr__MediaCoverS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_SECRET_ACCESS_KEY
- name: Sonarr__MediaCoverS3__Bucket
value: arrstack-media
- name: Sonarr__MediaCoverS3__Prefix
value: sonarr
- name: Sonarr__MediaCoverS3__ForcePathStyle
value: "true"
- name: Sonarr__MediaCoverS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket,
# per-app key prefix). Routes the periodic config+DB zip backups off
# the ephemeral /config so any replica can write and restore them.
- name: Sonarr__BackupS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: S3_ENDPOINT
- name: Sonarr__BackupS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_ACCESS_KEY_ID
- name: Sonarr__BackupS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_SECRET_ACCESS_KEY
- name: Sonarr__BackupS3__Bucket
value: arrstack-backups
- name: Sonarr__BackupS3__Prefix
value: sonarr
- name: Sonarr__BackupS3__ForcePathStyle
value: "true"
- name: Sonarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /sonarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /sonarr/ping
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: config
mountPath: /config
- name: media-tv
mountPath: /media/tv
- name: vault-ca
mountPath: /etc/ssl/vault-ca
readOnly: true
# exportarr sidecar: polls the local replica's API and exposes Prometheus
# metrics on :9707 (scraped by the sonarr-exportarr VMPodScrape).
- name: exportarr
image: artifactapi.k8s.syd1.au.unkin.net/ghcr/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent
args:
- sonarr
env:
- name: PORT
value: "9707"
# URL includes the /sonarr UrlBase (Sonarr__Server__UrlBase).
- name: URL
value: http://localhost:8989/sonarr
- name: APIKEY
valueFrom:
secretKeyRef:
name: sonarr-apikey
key: apitoken
ports:
- name: metrics
containerPort: 9707
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
volumes:
- name: config
emptyDir: {}
- name: media-tv
persistentVolumeClaim:
claimName: media-tv
# Estate CA for validating the Ceph RGW (s3.ceph.unkin.net) TLS cert.
- name: vault-ca
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
-37
View File
@@ -1,37 +0,0 @@
---
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
-49
View File
@@ -1,49 +0,0 @@
---
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: /
@@ -1,12 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- gateway.yaml
- httproute.yaml
- vmpodscrape.yaml
-17
View File
@@ -1,17 +0,0 @@
---
# Sonarr /config. RWX on CephFS so all replicas share it (the -unkin2 fork keeps
# the database in Postgres; /config now holds only config.xml + MediaCover, which
# tolerate — and want — shared access). Retain: this is state.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: sonarr-config
namespace: arrstack
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 5Gi
storageClassName: cephfs-raid5-retain
volumeMode: Filesystem
-15
View File
@@ -1,15 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: sonarr
namespace: arrstack
spec:
ports:
- name: http
port: 8989
protocol: TCP
targetPort: http
selector:
app: sonarr
type: ClusterIP
@@ -1,25 +0,0 @@
---
# sonarr API key. Seeded at kv/kubernetes/namespace/arrstack/default/sonarr
# (key: apitoken); the default k8s role's templated policy already grants read
# on kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs it into the sonarr-apikey Secret that the apikey-init initContainer reads
# to enforce <ApiKey> in /config/config.xml (Vault is source of truth).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: sonarr-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -1,16 +0,0 @@
---
# Scrape the exportarr sidecar (:9707) on every sonarr pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the sonarr Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: sonarr-exportarr
namespace: arrstack
spec:
selector:
matchLabels:
app: sonarr
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -1,6 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- valkeycluster.yaml
@@ -1,47 +0,0 @@
---
# Single shared HA Valkey for the arr apps (sonarr/radarr/prowlarr), managed by
# valkey-operator. It activates the fork's #14 Redis features: the SignalR
# backplane, the cross-replica cache-invalidation bus, and the distributed rate
# limiter. One cluster is safe for all three because each fork namespaces its
# keys and pub/sub channels by a per-app prefix (sonarr:ratelimit: /
# radarr:ratelimit: / prowlarr:ratelimit:), so their state never collides.
#
# Modeled on jellyfin-valkey: 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 so a node/pod loss no longer drops the shared
# state the app replicas coordinate through. The operator runs Valkey
# cluster-mode-enabled with protected-mode off and leaves the built-in `default`
# user passwordless, so clients connect with no auth/TLS; StackExchange.Redis
# seeds off the single service and auto-discovers topology plus failovers.
# scheduling.node.spread.shard:Required keeps the three nodes on distinct hosts,
# so one host loss removes at most one node; podDisruptionBudget.mode:Cluster
# lets the operator manage a quorum-aware PDB. Persistence is omitted (/data is an
# emptyDir): the coordination state is ephemeral (short TTLs / transient pub/sub),
# replication+failover already provide redundancy, and an operator-managed PVC
# cannot carry the k8up.io/backup:"false" annotation the namespace k8up Schedule
# needs to skip in-use RWO volumes.
apiVersion: valkey.io/v1alpha1
kind: ValkeyCluster
metadata:
name: arrstack-valkey
namespace: arrstack
spec:
shards: 1
replicas: 2
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/valkey/valkey:9.0.0
exporter:
enabled: false
scheduling:
node:
spread:
shard:
mode: Required
podDisruptionBudget:
mode: Cluster
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
-20
View File
@@ -1,20 +0,0 @@
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: default
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
allowedNamespaces:
- arrstack
kubernetes:
audiences:
- vault
role: default
serviceAccount: default
tokenExpirationSeconds: 600
method: kubernetes
mount: k8s/au/syd1
vaultConnectionRef: vso-system/default
+1 -1
View File
@@ -36,7 +36,7 @@ spec:
mountPath: /combined-certs
containers:
- name: api
image: git.unkin.net/unkin/artifactapi:v3.11.1
image: git.unkin.net/unkin/artifactapi:v3.7.7
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8000
-1
View File
@@ -18,5 +18,4 @@ resources:
- ui-hpa.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
- vmpodscrape.yaml
- vpa.yaml
@@ -53,18 +53,4 @@ spec:
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
- name: metrics-exporter
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9121
name: metrics
protocol: TCP
resources:
limits:
cpu: 100m
memory: 64Mi
requests:
cpu: 25m
memory: 32Mi
restartPolicy: Always
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
automountServiceAccountToken: true
containers:
- name: ui
image: git.unkin.net/unkin/artifactapi-ui:v3.11.1
image: git.unkin.net/unkin/artifactapi-ui:v3.7.7
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
-16
View File
@@ -1,16 +0,0 @@
---
# Scrape the redis_exporter sidecar (:9121) on the redis pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the redis Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: redis-exporter
namespace: artifactapi
spec:
selector:
matchLabels:
app: redis
podMetricsEndpoints:
- port: metrics
path: /metrics
-1
View File
@@ -18,5 +18,4 @@ resources:
- redis-service.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
- vmpodscrape.yaml
- vpa.yaml
-14
View File
@@ -52,20 +52,6 @@ spec:
volumeMounts:
- mountPath: /data
name: redis-data
- name: metrics-exporter
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9121
name: metrics
protocol: TCP
resources:
limits:
cpu: 100m
memory: 64Mi
requests:
cpu: 25m
memory: 32Mi
volumes:
- name: redis-data
persistentVolumeClaim:
-16
View File
@@ -1,16 +0,0 @@
---
# Scrape the redis_exporter sidecar (:9121) on the redis pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the redis Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: redis-exporter
namespace: authentik
spec:
selector:
matchLabels:
app: redis
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -88,47 +88,3 @@ spec:
ttl: 600
values:
- 103.216.191.185
---
apiVersion: bind.unkin.net/v1alpha1
kind: DNSRecord
metadata:
name: ghp-dns-internal
namespace: bind-internal
spec:
zoneRef: unkin-net
name: ghp
type: A
ttl: 600
values:
# traefik-internal gateway VIP; the ghp Gateway serves ghp.unkin.net there.
- 198.18.200.4
---
apiVersion: bind.unkin.net/v1alpha1
kind: DNSRecord
metadata:
name: arrstack-dns-internal
namespace: bind-internal
spec:
zoneRef: unkin-net
name: arrstack
type: A
ttl: 600
values:
# traefik-EXTERNAL (DMZ) gateway VIP; the arrproxy Gateway serves the
# arrstack.unkin.net front door (oauth2-proxy) there.
- 198.18.199.0
---
apiVersion: bind.unkin.net/v1alpha1
kind: DNSRecord
metadata:
name: logviewer-dns-internal
namespace: bind-internal
spec:
zoneRef: unkin-net
name: logviewer
type: A
ttl: 600
values:
# traefik-internal gateway VIP; the logviewer Gateway serves
# logviewer.unkin.net there.
- 198.18.200.4
@@ -1,32 +0,0 @@
---
# 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
-45
View File
@@ -1,45 +0,0 @@
---
# 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
-126
View File
@@ -1,126 +0,0 @@
---
# 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
-36
View File
@@ -1,36 +0,0 @@
---
# 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
-81
View File
@@ -1,81 +0,0 @@
---
# Friendly public hostname for the kids instance, cheeztv.unkin.net. Same
# internal-Traefik + external-dns pattern as logviewer.unkin.net: external-dns
# publishes the A record at the internal LB VIP (198.18.200.4) and cert-manager
# mints cheeztv-tls (CN cheeztv.unkin.net) off the Vault-PKI cluster issuer.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
labels:
traefik.io/instance: internal
annotations:
cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: cheeztv.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: cheeztv.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: cheeztv
namespace: cheeztv
spec:
gatewayClassName: traefik-internal
listeners:
- allowedRoutes:
namespaces:
from: Same
hostname: cheeztv.unkin.net
name: http
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: cheeztv.unkin.net
name: https
port: 443
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
name: cheeztv-tls
mode: Terminate
---
# Cluster hostname variant, mirroring fafflix's jellyfin.k8s.syd1.au.unkin.net
# gateway 1:1 (internal Traefik, external-dns at 198.18.200.4). Own cert secret
# so each hostname gets its own leaf.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
labels:
traefik.io/instance: internal
annotations:
cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: cheeztv.k8s.syd1.au.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: cheeztv.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: cheeztv-internal
namespace: cheeztv
spec:
gatewayClassName: traefik-internal
listeners:
- allowedRoutes:
namespaces:
from: Same
hostname: cheeztv.k8s.syd1.au.unkin.net
name: http
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: cheeztv.k8s.syd1.au.unkin.net
name: https
port: 443
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
name: cheeztv-internal-tls
mode: Terminate
-98
View File
@@ -1,98 +0,0 @@
---
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: /
-26
View File
@@ -1,26 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- cnpg_cluster.yaml
- cnpg_pooler.yaml
- cnpg_backup.yaml
- cephrgw-config-backup.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
- schedule.yaml
- pvc-config.yaml
- pvc-transcode.yaml
- pv-media-tv.yaml
- pv-media-movies.yaml
- pvc-media-tv.yaml
- pvc-media-movies.yaml
- statefulset.yaml
- pdb.yaml
- service.yaml
- valkey.yaml
- vmpodscrape.yaml
- gateway.yaml
- httproute.yaml
-5
View File
@@ -1,5 +0,0 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: cheeztv
-13
View File
@@ -1,13 +0,0 @@
---
# 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
-30
View File
@@ -1,30 +0,0 @@
---
# 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
-30
View File
@@ -1,30 +0,0 @@
---
# 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
-17
View File
@@ -1,17 +0,0 @@
---
# 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
-24
View File
@@ -1,24 +0,0 @@
---
# 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
-24
View File
@@ -1,24 +0,0 @@
---
# 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
-23
View File
@@ -1,23 +0,0 @@
---
# 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
-58
View File
@@ -1,58 +0,0 @@
---
# 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
-253
View File
@@ -1,253 +0,0 @@
---
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'
<?xml version="1.0" encoding="utf-8"?>
<DatabaseConfigurationOptions>
<DatabaseType>Jellyfin-PostgreSQL</DatabaseType>
<LockingBehavior>NoLock</LockingBehavior>
</DatabaseConfigurationOptions>
DBEOF
chown 1000:1000 /config/config/database.xml
chmod 664 /config/config/database.xml
fi
# VAAPI on the Intel render node the device plugin injects
# (/dev/dri/renderD128 — ffmpeg's default DRM node, reachable via
# the render/video supplementalGroups). Without this the attached
# iGPU is idle and every transcode runs in software. Omitted
# elements fall back to the fork's EncodingOptions defaults.
if [ ! -e /config/config/encoding.xml ]; then
cat > /config/config/encoding.xml << 'ENCEOF'
<?xml version="1.0" encoding="utf-8"?>
<EncodingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<EncodingThreadCount>-1</EncodingThreadCount>
<HardwareAccelerationType>vaapi</HardwareAccelerationType>
<VaapiDevice>/dev/dri/renderD128</VaapiDevice>
<EnableHardwareEncoding>true</EnableHardwareEncoding>
<AllowHevcEncoding>true</AllowHevcEncoding>
<AllowAv1Encoding>false</AllowAv1Encoding>
<EnableIntelLowPowerH264HwEncoder>false</EnableIntelLowPowerH264HwEncoder>
<EnableIntelLowPowerHevcHwEncoder>false</EnableIntelLowPowerHevcHwEncoder>
<EnableTonemapping>false</EnableTonemapping>
<EnableVppTonemapping>false</EnableVppTonemapping>
<HardwareDecodingCodecs>
<string>h264</string>
<string>hevc</string>
<string>vc1</string>
<string>vp9</string>
</HardwareDecodingCodecs>
</EncodingOptions>
ENCEOF
chown 1000:1000 /config/config/encoding.xml
chmod 664 /config/config/encoding.xml
fi
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
volumeMounts:
- name: config
mountPath: /config
containers:
- name: 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
-47
View File
@@ -1,47 +0,0 @@
---
# 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
-20
View File
@@ -1,20 +0,0 @@
---
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
-24
View File
@@ -1,24 +0,0 @@
---
# 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

Some files were not shown because too many files have changed in this diff Show More