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
292 changed files with 448 additions and 10774 deletions
@@ -1,168 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: arrproxy-api
namespace: arrstack
annotations:
# Wave 2: start only after the wave-0 CNPG Cluster and VSO-synced Secrets
# exist. The api self-migrates at startup under a Postgres advisory lock and
# holds /readyz until the schema is current, so no migration ordering is needed.
argocd.argoproj.io/sync-wave: "2"
secret.reloader.stakater.com/reload: "arrproxy-pepper,arrproxy-admin-token,arrproxy-db-app,sonarr-adult-apikey,radarr-adult-apikey,sonarr-kids-apikey,radarr-kids-apikey"
configmap.reloader.stakater.com/reload: "arrproxy-tiers"
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.6.0
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 a per-tier
# subdir (<keyDir>/<app>) matching the tiers file: adult/{sonarr,radarr}
# and kids/{sonarr,radarr}. Sourced from the same <instance>-apikey
# Secrets the *arr Deployments consume. The api injects them server-side
# and redacts them from every proxied response.
- name: ARRPROXY_KEYS_DIR
value: /etc/arrproxy/keys
# Tier topology (fafflix/cheeztv): upstreams, route hashes, key subdirs
# and group grants. Legacy unprefixed routes are retired in the file
# (legacyRoutes:false); see arrproxy-tiers ConfigMap.
- name: ARRPROXY_TIERS_FILE
value: /etc/arrproxy/tiers/tiers.json
- 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: tiers
mountPath: /etc/arrproxy/tiers
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 per-tier *arr API keys, projected as <keyDir>/<app> so the api
# reads /etc/arrproxy/keys/{adult,kids}/{sonarr,radarr} (matching the
# tiers file keyDir). Reuses the same <instance>-apikey Secrets the *arr
# Deployments already consume (seeded by their VaultStaticSecrets).
- name: arr-keys
projected:
sources:
- secret:
name: sonarr-adult-apikey
items:
- key: apitoken
path: adult/sonarr
- secret:
name: radarr-adult-apikey
items:
- key: apitoken
path: adult/radarr
- secret:
name: sonarr-kids-apikey
items:
- key: apitoken
path: kids/sonarr
- secret:
name: radarr-kids-apikey
items:
- key: apitoken
path: kids/radarr
# Tier topology JSON (ARRPROXY_TIERS_FILE).
- name: tiers
configMap:
name: arrproxy-tiers
- 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
-40
View File
@@ -1,40 +0,0 @@
---
# External (DMZ) front for the arrstack, served on arrstack.unkin.net via the
# external Traefik (LB VIP 198.18.199.0). 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. Public TLS is terminated with
# the real Let's Encrypt *.unkin.net wildcard, centrally minted once in the
# cert-manager namespace (Certificate wildcard-unkin-net) and reflected into this
# namespace by the emberstack reflector as wildcard-unkin-net-tls, not Vault PKI.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
labels:
traefik.io/instance: external
annotations:
argocd.argoproj.io/sync-wave: "2"
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: wildcard-unkin-net-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,16 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- cnpg_cluster.yaml
- cnpg_backup.yaml
- vaultstaticsecret.yaml
- tiers-configmap.yaml
- oauth2-proxy-configmap.yaml
- oauth2-proxy-deployment.yaml
- api-deployment.yaml
- ui-deployment.yaml
- services.yaml
- gateway.yaml
- httproute.yaml
@@ -1,60 +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 the per-tier hash routes (/3aa168, /3df803) go
# to arrproxy-api, everything else (the SPA + static assets) to arrproxy-ui.
# Tier routes carry /<hash>/<app>/...; the bare /sonarr /radarr /prowlarr
# upstreams are retired (legacy routes off; prowlarr is served directly).
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/3aa168/,http://arrproxy-api.arrstack.svc.cluster.local:8080/3df803/"
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 tier *arr proxy API (/<hash>/<app>/api...) and the
# machine-mint admin route (/api/admin/...). The first matches
# /3aa168/sonarr/api (two path segments before /api, since tier routes are
# hash-prefixed); the second matches /api/admin/ only -- both routed to the
# arrproxy-api upstream by the tier and catch-all /api/ prefixes 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: docker.io/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,53 +0,0 @@
---
# arrproxy v0.4.0 tier topology (ARRPROXY_TIERS_FILE). Two isolated instance
# tiers, each with its own *arr backends, real-key subdir, and group grants:
# fafflix (adult, /3aa168/*) and cheeztv (kids, /3df803/*).
# arrproxy strips the /<hash> route prefix and proxies "/<app>/..." upstream, so
# each app's upstream URL carries the "/<hash>" path segment: the joined upstream
# path (/3aa168/sonarr/...) then matches the *arr's own UrlBase (/3aa168/sonarr).
# Access: the adult group reaches BOTH tiers with any method; the kids group
# reaches ONLY the cheeztv tier and only GET/HEAD. Groups arrive on the
# hierarchical Authentik ak_groups claim, forwarded by oauth2-proxy as
# X-Forwarded-Groups. Legacy unprefixed routes are retired (legacyRoutes:false);
# the old bare sonarr/radarr Services no longer exist.
apiVersion: v1
kind: ConfigMap
metadata:
name: arrproxy-tiers
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "2"
data:
tiers.json: |
{
"legacyTier": "fafflix",
"legacyRoutes": false,
"tiers": [
{
"name": "fafflix",
"hash": "3aa168",
"keyDir": "adult",
"grants": [
{ "group": "akP-media-fafflix" }
],
"apps": [
{ "name": "sonarr", "upstream": "http://sonarr-adult.arrstack.svc.cluster.local:8989/3aa168", "urlBase": "/3aa168/sonarr" },
{ "name": "radarr", "upstream": "http://radarr-adult.arrstack.svc.cluster.local:7878/3aa168", "urlBase": "/3aa168/radarr" }
]
},
{
"name": "cheeztv",
"hash": "3df803",
"keyDir": "kids",
"readOnly": true,
"grants": [
{ "group": "akP-media-fafflix" },
{ "group": "akP-media-cheeztv", "methods": ["GET", "HEAD"] }
],
"apps": [
{ "name": "sonarr", "upstream": "http://sonarr-kids.arrstack.svc.cluster.local:8989/3df803", "urlBase": "/3df803/sonarr" },
{ "name": "radarr", "upstream": "http://radarr-kids.arrstack.svc.cluster.local:7878/3df803", "urlBase": "/3df803/radarr" }
]
}
]
}
@@ -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.6.0
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
-29
View File
@@ -1,29 +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
- pv-mediastore.yaml
- pvc-media-tv.yaml
- pvc-media-movies.yaml
- pvc-mediafs.yaml
- pvc-mediastore.yaml
- mediastore-bootstrap-job.yaml
- media-bucket.yaml
- backups-bucket.yaml
- postgres
- valkey
- prowlarr
- sonarr-kids
- radarr-kids
- nzbget-kids
- sonarr-adult
- radarr-adult
- nzbget-adult
- 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
@@ -1,74 +0,0 @@
---
# Seeds the directory skeleton on the freshly created mediastore subvolume so
# the arrs, nzbget and both jellyfins mount subPaths that already exist and are
# owned by uid/gid 1000 (the uid every arrstack media pod runs as). mkdir -p is
# idempotent, so re-running it on every sync is harmless and self-heals a tree
# someone deleted by hand.
#
# Sync hook with BeforeHookCreation delete: ArgoCD replaces the completed Job
# each sync instead of failing on the immutable pod template. No sync-wave is
# needed -- the PVC applies in the same wave and the pod simply stays Pending
# until it binds.
apiVersion: batch/v1
kind: Job
metadata:
name: mediastore-bootstrap
namespace: arrstack
annotations:
argocd.argoproj.io/hook: Sync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
backoffLimit: 6
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app: mediastore-bootstrap
spec:
serviceAccountName: default
automountServiceAccountToken: false
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: mkdir
image: docker.io/library/alpine:3
imagePullPolicy: IfNotPresent
command:
- sh
- -c
- |
set -eu
mkdir -p \
/media/fafflix/tvseries \
/media/fafflix/movies \
/media/cheeztv/tvseries \
/media/cheeztv/movies \
/media/nzbget/downloads/complete
ls -la /media
volumeMounts:
- name: mediastore
mountPath: /media
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
volumes:
- name: mediastore
persistentVolumeClaim:
claimName: mediastore
-5
View File
@@ -1,5 +0,0 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: arrstack
@@ -1,142 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nzbget-adult
namespace: arrstack
spec:
replicas: 1
strategy:
# RWO config PVC + single queue state: never run two pods at once.
type: Recreate
selector:
matchLabels:
app: nzbget-adult
template:
metadata:
labels:
app: nzbget-adult
spec:
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Seed download layout onto the adult media subtrees (not /config or an
# emptyDir) so completed adult downloads land beside the adult arr libraries
# and imports are same-filesystem hardlink moves. The media mounts use the
# tvshows/adult and movies/adult subPaths, so the in-container paths match
# the kids nzbget while the data stays scoped to the adult subtree. Reuses
# the image's own template and appends the path/category overrides once;
# the grep guard keeps re-runs idempotent so admin UI edits survive.
- name: seed-config
image: docker.io/linuxserver/nzbget:version-v26.2
command:
- sh
- -c
- |
set -e
if [ ! -f /config/nzbget.conf ]; then
cp /app/nzbget/share/nzbget/nzbget.conf /config/nzbget.conf
fi
if ! grep -q '# arrstack-managed' /config/nzbget.conf; then
cat >> /config/nzbget.conf << 'CONF'
# arrstack-managed download layout (appended once; last value wins).
# Downloads land on the shared media PVCs by category so sonarr-adult/
# radarr-adult import with atomic hardlink moves (download dir +
# library share one filesystem per media type). InterDir is empty:
# nzbget writes each download straight into its category DestDir, so
# BOTH tv and movies stay on their own PVC with no cross-filesystem
# intermediate copy.
MainDir=/media/tv
InterDir=
DestDir=/media/tv/downloads
NzbDir=/config/nzb
QueueDir=/config/queue
TempDir=/config/tmp
ControlIP=0.0.0.0
ControlPort=6789
Category1.Name=tv
Category1.DestDir=/media/tv/downloads
Category2.Name=movies
Category2.DestDir=/media/movies/downloads
CONF
fi
mkdir -p /media/tv/series /media/tv/downloads /media/movies/films /media/movies/downloads
chown 1000:1000 /config/nzbget.conf \
/media/tv /media/tv/series /media/tv/downloads \
/media/movies /media/movies/films /media/movies/downloads
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
volumeMounts:
- name: config
mountPath: /config
- name: media-tv
mountPath: /media/tv
subPath: tvshows/adult
- name: media-movies
mountPath: /media/movies
subPath: movies/adult
containers:
- name: nzbget
image: docker.io/linuxserver/nzbget:version-v26.2
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 6789
protocol: TCP
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: Australia/Sydney
livenessProbe:
# nzbget's root path requires auth (401); a TCP check is the
# dependency-free liveness signal for the web/JSON-RPC server.
tcpSocket:
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
# Headroom for par2 repair + unpack of large downloads.
cpu: "2"
memory: 2Gi
volumeMounts:
- name: config
mountPath: /config
- name: media-tv
mountPath: /media/tv
subPath: tvshows/adult
- name: media-movies
mountPath: /media/movies
subPath: movies/adult
volumes:
- name: config
persistentVolumeClaim:
claimName: nzbget-adult-config
- name: media-tv
persistentVolumeClaim:
claimName: media-tv
- name: media-movies
persistentVolumeClaim:
claimName: media-movies
@@ -1,8 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pvc-config.yaml
- deployment.yaml
- service.yaml
@@ -1,16 +0,0 @@
---
# NZBGet (adult) config + queue/temp state. RWO on cephrbd (block, fast-delete).
# The download data itself lives on the shared media PVCs, not here.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nzbget-adult-config
namespace: arrstack
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: cephrbd-fast-delete
volumeMode: Filesystem
@@ -1,15 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: nzbget-adult
namespace: arrstack
spec:
ports:
- name: http
port: 6789
protocol: TCP
targetPort: http
selector:
app: nzbget-adult
type: ClusterIP
@@ -1,142 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nzbget-kids
namespace: arrstack
spec:
replicas: 1
strategy:
# RWO config PVC + single queue state: never run two pods at once.
type: Recreate
selector:
matchLabels:
app: nzbget-kids
template:
metadata:
labels:
app: nzbget-kids
spec:
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Seed download layout onto the kids media subtrees (not /config or an
# emptyDir) so completed kids downloads land beside the kids arr libraries
# and imports are same-filesystem hardlink moves. The media mounts use the
# tvshows/kids and movies/kids subPaths, so the in-container paths match
# the adult nzbget while the data stays scoped to the kids subtree. Reuses
# the image's own template and appends the path/category overrides once;
# the grep guard keeps re-runs idempotent so admin UI edits survive.
- name: seed-config
image: docker.io/linuxserver/nzbget:version-v26.2
command:
- sh
- -c
- |
set -e
if [ ! -f /config/nzbget.conf ]; then
cp /app/nzbget/share/nzbget/nzbget.conf /config/nzbget.conf
fi
if ! grep -q '# arrstack-managed' /config/nzbget.conf; then
cat >> /config/nzbget.conf << 'CONF'
# arrstack-managed download layout (appended once; last value wins).
# Downloads land on the shared media PVCs by category so sonarr-kids/
# radarr-kids import with atomic hardlink moves (download dir +
# library share one filesystem per media type). InterDir is empty:
# nzbget writes each download straight into its category DestDir, so
# BOTH tv and movies stay on their own PVC with no cross-filesystem
# intermediate copy.
MainDir=/media/tv
InterDir=
DestDir=/media/tv/downloads
NzbDir=/config/nzb
QueueDir=/config/queue
TempDir=/config/tmp
ControlIP=0.0.0.0
ControlPort=6789
Category1.Name=tv
Category1.DestDir=/media/tv/downloads
Category2.Name=movies
Category2.DestDir=/media/movies/downloads
CONF
fi
mkdir -p /media/tv/series /media/tv/downloads /media/movies/films /media/movies/downloads
chown 1000:1000 /config/nzbget.conf \
/media/tv /media/tv/series /media/tv/downloads \
/media/movies /media/movies/films /media/movies/downloads
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
volumeMounts:
- name: config
mountPath: /config
- name: media-tv
mountPath: /media/tv
subPath: tvshows/kids
- name: media-movies
mountPath: /media/movies
subPath: movies/kids
containers:
- name: nzbget
image: docker.io/linuxserver/nzbget:version-v26.2
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 6789
protocol: TCP
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: Australia/Sydney
livenessProbe:
# nzbget's root path requires auth (401); a TCP check is the
# dependency-free liveness signal for the web/JSON-RPC server.
tcpSocket:
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
# Headroom for par2 repair + unpack of large downloads.
cpu: "2"
memory: 2Gi
volumeMounts:
- name: config
mountPath: /config
- name: media-tv
mountPath: /media/tv
subPath: tvshows/kids
- name: media-movies
mountPath: /media/movies
subPath: movies/kids
volumes:
- name: config
persistentVolumeClaim:
claimName: nzbget-kids-config
- name: media-tv
persistentVolumeClaim:
claimName: media-tv
- name: media-movies
persistentVolumeClaim:
claimName: media-movies
@@ -1,8 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pvc-config.yaml
- deployment.yaml
- service.yaml
@@ -1,15 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: nzbget-kids
namespace: arrstack
spec:
ports:
- name: http
port: 6789
protocol: TCP
targetPort: http
selector:
app: nzbget-kids
type: ClusterIP
@@ -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,185 +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: 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
- name: sonarr-kids
ensure: present
comment: Sonarr (kids) application role (owns sonarr-kids-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: sonarr-kids-db
- name: radarr-kids
ensure: present
comment: Radarr (kids) application role (owns radarr-kids-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: radarr-kids-db
- name: sonarr-adult
ensure: present
comment: Sonarr (adult) application role (owns sonarr-adult-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: sonarr-adult-db
- name: radarr-adult
ensure: present
comment: Radarr (adult) application role (owns radarr-adult-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: radarr-adult-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-adult 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-adult-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: radarr-adult-main
owner: radarr-adult
databaseReclaimPolicy: retain
@@ -1,15 +0,0 @@
---
# Per-app database owned by the radarr-kids managed role. The fork's provider
# runs its own schema migrations on first start (advisory-locked, so only one
# replica migrates). retain: the database survives a Database CRD delete.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: radarr-kids-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: radarr-kids-main
owner: radarr-kids
databaseReclaimPolicy: retain
@@ -1,15 +0,0 @@
---
# Per-app database owned by the sonarr-adult 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-adult-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: sonarr-adult-main
owner: sonarr-adult
databaseReclaimPolicy: retain
@@ -1,15 +0,0 @@
---
# Per-app database owned by the sonarr-kids managed role. The fork's provider
# runs its own schema migrations on first start (advisory-locked, so only one
# replica migrates). retain: the database survives a Database CRD delete.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: sonarr-kids-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: sonarr-kids-main
owner: sonarr-kids
databaseReclaimPolicy: retain
@@ -1,13 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- cnpg_cluster.yaml
- cnpg_backup.yaml
- database-prowlarr.yaml
- database-sonarr-kids.yaml
- database-radarr-kids.yaml
- database-sonarr-adult.yaml
- database-radarr-adult.yaml
@@ -1,94 +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: 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
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-kids-db
namespace: arrstack
spec:
destination:
create: true
name: sonarr-kids-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr-kids-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-kids-db
namespace: arrstack
spec:
destination:
create: true
name: radarr-kids-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr-kids-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-adult-db
namespace: arrstack
spec:
destination:
create: true
name: sonarr-adult-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr-adult-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-adult-db
namespace: arrstack
spec:
destination:
create: true
name: radarr-adult-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr-adult-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: /
-32
View File
@@ -1,32 +0,0 @@
---
# Static PV for the shared MEDIASTORE CephFS subvolume: one 10Ti filesystem
# holding every library plus the nzbget download tree, so arr imports are
# same-filesystem hardlink moves across tv AND movies. Same rootPath as the
# fafflix/cheeztv mediastore PVs; each namespace gets its own PV (unique name +
# volumeHandle) pinned by claimRef.
apiVersion: v1
kind: PersistentVolume
metadata:
name: arrstack-mediastore
spec:
capacity:
storage: 10Ti
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
volumeMode: Filesystem
claimRef:
namespace: arrstack
name: mediastore
csi:
driver: cephfs.csi.ceph.com
volumeHandle: arrstack-mediastore-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/mediastore/a0152dac-a51b-4b95-ac5e-ecdd99bfe3f1
-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
-22
View File
@@ -1,22 +0,0 @@
---
# Whole media tree (/fafflix, /cheeztv, /nzbget) on one RWX filesystem, shared
# across the sonarr/radarr/nzbget pods. Statically bound to the
# arrstack-mediastore PV (the same CephFS subvolume fafflix and cheeztv mount).
# storageClassName "" + volumeName disables dynamic provisioning and binds the
# pre-created static PV.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mediastore
namespace: arrstack
annotations:
k8up.io/backup: "false"
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Ti
storageClassName: ""
volumeName: arrstack-mediastore
volumeMode: Filesystem
@@ -1,25 +0,0 @@
---
# Non-secret env for the -unkin2 fork (adult tier). Identical mechanism to the
# kids radarr, pointed at its own shared-Postgres database (radarr-adult-main)
# and its own UrlBase (/3aa168/radarr) so arrproxy path-routing reaches the adult
# instance separately. Shares the one arrstack Valkey (keys namespaced by the
# fork's radarr:ratelimit: prefix). User/Password/ApiKey come from Secrets (see
# deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: radarr-adult-env
namespace: arrstack
data:
Radarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Radarr__Postgres__Port: "5432"
Radarr__Postgres__MainDb: radarr-adult-main
Radarr__Log__DbEnabled: "false"
Radarr__Auth__Method: External
Radarr__Auth__Required: DisabledForLocalAddresses
Radarr__App__InstanceName: Radarr
Radarr__Server__Port: "7878"
Radarr__Server__UrlBase: /3aa168/radarr
Radarr__Update__Mechanism: External
Radarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Radarr__Redis__Port: "6379"
@@ -1,245 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: radarr-adult
namespace: arrstack
annotations:
# Reloader rolls the Deployment when radarr-adult-env changes (plain envFrom
# ConfigMap does not trigger a rollout on its own).
configmap.reloader.stakater.com/auto: "true"
spec:
# Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the radarr-adult Service. RollingUpdate is
# safe — no SQLite, no RWO lock.
replicas: 3
strategy:
type: RollingUpdate
selector:
matchLabels:
app: radarr-adult
template:
metadata:
labels:
app: radarr-adult
spec:
securityContext:
# Fork image has no USER; pin it to a non-root UID and group-write the
# shared RWX CephFS media subtree. OnRootMismatch avoids a recursive
# chown of the whole media tree.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Gate the app on its own Postgres database+role being reachable.
# waitfordb reads the PG* env as a libpq fallback, so the password never
# lands in argv.
- name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env:
- name: WAITFORDB_TIMEOUT
value: 5m
- name: WAITFORDB_SSLMODE
value: disable
- name: PGHOST
value: arrstack-postgres-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: radarr-adult-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: radarr-adult-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: radarr-adult-db
key: password
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
containers:
- name: radarr
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/radarr:v6.4.2-unkin7
imagePullPolicy: IfNotPresent
command:
- /app/Radarr
args:
- -nobrowser
- -data=/config
# Bypass the single-instance guard so multiple replicas can share one
# /config. Cross-replica safety is the Postgres layer, not a lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 7878
protocol: TCP
envFrom:
- configMapRef:
name: radarr-adult-env
env:
- name: Radarr__Postgres__User
valueFrom:
secretKeyRef:
name: radarr-adult-db
key: username
- name: Radarr__Postgres__Password
valueFrom:
secretKeyRef:
name: radarr-adult-db
key: password
- name: Radarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: radarr-adult-apikey
key: apitoken
# MediaCover object store (shared arrstack-media Ceph RGW bucket,
# partitioned by the radarr-adult key prefix).
- name: Radarr__MediaCover__S3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: S3_ENDPOINT
- name: Radarr__MediaCover__S3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_ACCESS_KEY_ID
- name: Radarr__MediaCover__S3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_SECRET_ACCESS_KEY
- name: Radarr__MediaCover__S3__Bucket
value: arrstack-media
- name: Radarr__MediaCover__S3__Prefix
value: radarr-adult
- name: Radarr__MediaCover__S3__ForcePathStyle
value: "true"
- name: Radarr__MediaCover__S3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket,
# partitioned by the radarr-adult key prefix).
- name: Radarr__BackupS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: S3_ENDPOINT
- name: Radarr__BackupS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_ACCESS_KEY_ID
- name: Radarr__BackupS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_SECRET_ACCESS_KEY
- name: Radarr__BackupS3__Bucket
value: arrstack-backups
- name: Radarr__BackupS3__Prefix
value: radarr-adult
- name: Radarr__BackupS3__ForcePathStyle
value: "true"
- name: Radarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /3aa168/radarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /3aa168/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
# Adult movies subtree of the shared media-movies PVC (same CephFS
# subvolume the kids radarr writes and jellyfin reads).
- name: media-movies
mountPath: /media/movies
subPath: movies/adult
- 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-adult-exportarr VMPodScrape).
- name: exportarr
image: ghcr.io/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent
args:
- radarr
env:
- name: PORT
value: "9708"
# URL includes the /3aa168/radarr UrlBase (Radarr__Server__UrlBase).
- name: URL
value: http://localhost:7878/3aa168/radarr
- name: APIKEY
valueFrom:
secretKeyRef:
name: radarr-adult-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
@@ -1,10 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- vmpodscrape.yaml
@@ -1,15 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: radarr-adult
namespace: arrstack
spec:
ports:
- name: http
port: 7878
protocol: TCP
targetPort: http
selector:
app: radarr-adult
type: ClusterIP
@@ -1,25 +0,0 @@
---
# radarr-adult API key. Seeded out-of-band at
# kv/kubernetes/namespace/arrstack/default/radarr-adult (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-adult-apikey Secret consumed by the Deployment.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-adult-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: radarr-adult-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr-adult
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -1,16 +0,0 @@
---
# Scrape the exportarr sidecar (:9708) on every radarr-adult pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the radarr-adult Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: radarr-adult-exportarr
namespace: arrstack
spec:
selector:
matchLabels:
app: radarr-adult
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -1,25 +0,0 @@
---
# Non-secret env for the -unkin2 fork (kids tier). Identical mechanism to the
# adult radarr, pointed at its own shared-Postgres database (radarr-kids-main)
# and its own UrlBase (/3df803/radarr) so arrproxy path-routing reaches the kids
# instance separately. Shares the one arrstack Valkey (keys namespaced by the
# fork's radarr:ratelimit: prefix). User/Password/ApiKey come from Secrets (see
# deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: radarr-kids-env
namespace: arrstack
data:
Radarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Radarr__Postgres__Port: "5432"
Radarr__Postgres__MainDb: radarr-kids-main
Radarr__Log__DbEnabled: "false"
Radarr__Auth__Method: External
Radarr__Auth__Required: DisabledForLocalAddresses
Radarr__App__InstanceName: Radarr
Radarr__Server__Port: "7878"
Radarr__Server__UrlBase: /3df803/radarr
Radarr__Update__Mechanism: External
Radarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Radarr__Redis__Port: "6379"
@@ -1,245 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: radarr-kids
namespace: arrstack
annotations:
# Reloader rolls the Deployment when radarr-kids-env changes (plain envFrom
# ConfigMap does not trigger a rollout on its own).
configmap.reloader.stakater.com/auto: "true"
spec:
# Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the radarr-kids Service. RollingUpdate is
# safe — no SQLite, no RWO lock.
replicas: 3
strategy:
type: RollingUpdate
selector:
matchLabels:
app: radarr-kids
template:
metadata:
labels:
app: radarr-kids
spec:
securityContext:
# Fork image has no USER; pin it to a non-root UID and group-write the
# shared RWX CephFS media subtree. OnRootMismatch avoids a recursive
# chown of the whole media tree.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Gate the app on its own Postgres database+role being reachable.
# waitfordb reads the PG* env as a libpq fallback, so the password never
# lands in argv.
- name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env:
- name: WAITFORDB_TIMEOUT
value: 5m
- name: WAITFORDB_SSLMODE
value: disable
- name: PGHOST
value: arrstack-postgres-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: radarr-kids-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: radarr-kids-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: radarr-kids-db
key: password
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
containers:
- name: radarr
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/radarr:v6.4.2-unkin7
imagePullPolicy: IfNotPresent
command:
- /app/Radarr
args:
- -nobrowser
- -data=/config
# Bypass the single-instance guard so multiple replicas can share one
# /config. Cross-replica safety is the Postgres layer, not a lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 7878
protocol: TCP
envFrom:
- configMapRef:
name: radarr-kids-env
env:
- name: Radarr__Postgres__User
valueFrom:
secretKeyRef:
name: radarr-kids-db
key: username
- name: Radarr__Postgres__Password
valueFrom:
secretKeyRef:
name: radarr-kids-db
key: password
- name: Radarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: radarr-kids-apikey
key: apitoken
# MediaCover object store (shared arrstack-media Ceph RGW bucket,
# partitioned by the radarr-kids key prefix).
- name: Radarr__MediaCover__S3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: S3_ENDPOINT
- name: Radarr__MediaCover__S3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_ACCESS_KEY_ID
- name: Radarr__MediaCover__S3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_SECRET_ACCESS_KEY
- name: Radarr__MediaCover__S3__Bucket
value: arrstack-media
- name: Radarr__MediaCover__S3__Prefix
value: radarr-kids
- name: Radarr__MediaCover__S3__ForcePathStyle
value: "true"
- name: Radarr__MediaCover__S3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket,
# partitioned by the radarr-kids key prefix).
- name: Radarr__BackupS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: S3_ENDPOINT
- name: Radarr__BackupS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_ACCESS_KEY_ID
- name: Radarr__BackupS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_SECRET_ACCESS_KEY
- name: Radarr__BackupS3__Bucket
value: arrstack-backups
- name: Radarr__BackupS3__Prefix
value: radarr-kids
- name: Radarr__BackupS3__ForcePathStyle
value: "true"
- name: Radarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /3df803/radarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /3df803/radarr/ping
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: config
mountPath: /config
# Kids movies subtree of the shared media-movies PVC (same CephFS
# subvolume the adult radarr writes and jellyfin reads).
- name: media-movies
mountPath: /media/movies
subPath: movies/kids
- name: vault-ca
mountPath: /etc/ssl/vault-ca
readOnly: true
# exportarr sidecar: polls the local replica's API and exposes Prometheus
# metrics on :9708 (scraped by the radarr-kids-exportarr VMPodScrape).
- name: exportarr
image: ghcr.io/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent
args:
- radarr
env:
- name: PORT
value: "9708"
# URL includes the /3df803/radarr UrlBase (Radarr__Server__UrlBase).
- name: URL
value: http://localhost:7878/3df803/radarr
- name: APIKEY
valueFrom:
secretKeyRef:
name: radarr-kids-apikey
key: apitoken
ports:
- name: metrics
containerPort: 9708
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
volumes:
- name: config
emptyDir: {}
- name: media-movies
persistentVolumeClaim:
claimName: media-movies
# Estate CA for validating the Ceph RGW (s3.ceph.unkin.net) TLS cert.
- name: vault-ca
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
@@ -1,10 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- vmpodscrape.yaml
@@ -1,15 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: radarr-kids
namespace: arrstack
spec:
ports:
- name: http
port: 7878
protocol: TCP
targetPort: http
selector:
app: radarr-kids
type: ClusterIP
@@ -1,25 +0,0 @@
---
# radarr-kids API key. Seeded out-of-band at
# kv/kubernetes/namespace/arrstack/default/radarr-kids (key: apitoken); the
# default k8s role's templated policy already grants read on
# kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs it into the radarr-kids-apikey Secret consumed by the Deployment.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-kids-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: radarr-kids-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr-kids
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -1,16 +0,0 @@
---
# Scrape the exportarr sidecar (:9708) on every radarr-kids pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the radarr-kids Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: radarr-kids-exportarr
namespace: arrstack
spec:
selector:
matchLabels:
app: radarr-kids
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -1,25 +0,0 @@
---
# Non-secret env for the -unkin2 fork (adult tier). Identical mechanism to the
# kids sonarr, pointed at its own shared-Postgres database (sonarr-adult-main)
# and its own UrlBase (/3aa168/sonarr) so arrproxy path-routing reaches the adult
# instance separately. Shares the one arrstack Valkey (keys namespaced by the
# fork's sonarr:ratelimit: prefix). User/Password/ApiKey come from Secrets (see
# deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: sonarr-adult-env
namespace: arrstack
data:
Sonarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Sonarr__Postgres__Port: "5432"
Sonarr__Postgres__MainDb: sonarr-adult-main
Sonarr__Log__DbEnabled: "false"
Sonarr__Auth__Method: External
Sonarr__Auth__Required: DisabledForLocalAddresses
Sonarr__App__InstanceName: Sonarr
Sonarr__Server__Port: "8989"
Sonarr__Server__UrlBase: /3aa168/sonarr
Sonarr__Update__Mechanism: External
Sonarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Sonarr__Redis__Port: "6379"
@@ -1,245 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sonarr-adult
namespace: arrstack
annotations:
# Reloader rolls the Deployment when sonarr-adult-env changes (plain envFrom
# ConfigMap does not trigger a rollout on its own).
configmap.reloader.stakater.com/auto: "true"
spec:
# Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the sonarr-adult Service. RollingUpdate is
# safe — no SQLite, no RWO lock.
replicas: 3
strategy:
type: RollingUpdate
selector:
matchLabels:
app: sonarr-adult
template:
metadata:
labels:
app: sonarr-adult
spec:
securityContext:
# Fork image has no USER; pin it to a non-root UID and group-write the
# shared RWX CephFS media subtree. OnRootMismatch avoids a recursive
# chown of the whole media tree.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Gate the app on its own Postgres database+role being reachable.
# waitfordb reads the PG* env as a libpq fallback, so the password never
# lands in argv.
- name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env:
- name: WAITFORDB_TIMEOUT
value: 5m
- name: WAITFORDB_SSLMODE
value: disable
- name: PGHOST
value: arrstack-postgres-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: sonarr-adult-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: sonarr-adult-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: sonarr-adult-db
key: password
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
containers:
- name: sonarr
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/sonarr:v5.0.0-unkin6
imagePullPolicy: IfNotPresent
command:
- /app/Sonarr
args:
- -nobrowser
- -data=/config
# Bypass the single-instance guard so multiple replicas can share one
# /config. Cross-replica safety is the Postgres layer, not a lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 8989
protocol: TCP
envFrom:
- configMapRef:
name: sonarr-adult-env
env:
- name: Sonarr__Postgres__User
valueFrom:
secretKeyRef:
name: sonarr-adult-db
key: username
- name: Sonarr__Postgres__Password
valueFrom:
secretKeyRef:
name: sonarr-adult-db
key: password
- name: Sonarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: sonarr-adult-apikey
key: apitoken
# MediaCover object store (shared arrstack-media Ceph RGW bucket,
# partitioned by the sonarr-adult key prefix).
- name: Sonarr__MediaCoverS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: S3_ENDPOINT
- name: Sonarr__MediaCoverS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_ACCESS_KEY_ID
- name: Sonarr__MediaCoverS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_SECRET_ACCESS_KEY
- name: Sonarr__MediaCoverS3__Bucket
value: arrstack-media
- name: Sonarr__MediaCoverS3__Prefix
value: sonarr-adult
- name: Sonarr__MediaCoverS3__ForcePathStyle
value: "true"
- name: Sonarr__MediaCoverS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket,
# partitioned by the sonarr-adult key prefix).
- name: Sonarr__BackupS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: S3_ENDPOINT
- name: Sonarr__BackupS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_ACCESS_KEY_ID
- name: Sonarr__BackupS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_SECRET_ACCESS_KEY
- name: Sonarr__BackupS3__Bucket
value: arrstack-backups
- name: Sonarr__BackupS3__Prefix
value: sonarr-adult
- name: Sonarr__BackupS3__ForcePathStyle
value: "true"
- name: Sonarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /3aa168/sonarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /3aa168/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
# Adult TV subtree of the shared media-tv PVC (same CephFS subvolume
# the kids sonarr writes and jellyfin reads).
- name: media-tv
mountPath: /media/tv
subPath: tvshows/adult
- 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-adult-exportarr VMPodScrape).
- name: exportarr
image: ghcr.io/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent
args:
- sonarr
env:
- name: PORT
value: "9707"
# URL includes the /3aa168/sonarr UrlBase (Sonarr__Server__UrlBase).
- name: URL
value: http://localhost:8989/3aa168/sonarr
- name: APIKEY
valueFrom:
secretKeyRef:
name: sonarr-adult-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
@@ -1,10 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- vmpodscrape.yaml
@@ -1,15 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: sonarr-adult
namespace: arrstack
spec:
ports:
- name: http
port: 8989
protocol: TCP
targetPort: http
selector:
app: sonarr-adult
type: ClusterIP
@@ -1,25 +0,0 @@
---
# sonarr-adult API key. Seeded out-of-band at
# kv/kubernetes/namespace/arrstack/default/sonarr-adult (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-adult-apikey Secret consumed by the Deployment.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-adult-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: sonarr-adult-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr-adult
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -1,16 +0,0 @@
---
# Scrape the exportarr sidecar (:9707) on every sonarr-adult pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the sonarr-adult Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: sonarr-adult-exportarr
namespace: arrstack
spec:
selector:
matchLabels:
app: sonarr-adult
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -1,25 +0,0 @@
---
# Non-secret env for the -unkin2 fork (kids tier). Identical mechanism to the
# adult sonarr, pointed at its own shared-Postgres database (sonarr-kids-main)
# and its own UrlBase (/3df803/sonarr) so arrproxy path-routing reaches the kids
# instance separately. Shares the one arrstack Valkey (keys namespaced by the
# fork's sonarr:ratelimit: prefix). User/Password/ApiKey come from Secrets (see
# deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: sonarr-kids-env
namespace: arrstack
data:
Sonarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Sonarr__Postgres__Port: "5432"
Sonarr__Postgres__MainDb: sonarr-kids-main
Sonarr__Log__DbEnabled: "false"
Sonarr__Auth__Method: External
Sonarr__Auth__Required: DisabledForLocalAddresses
Sonarr__App__InstanceName: Sonarr
Sonarr__Server__Port: "8989"
Sonarr__Server__UrlBase: /3df803/sonarr
Sonarr__Update__Mechanism: External
Sonarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Sonarr__Redis__Port: "6379"
@@ -1,245 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sonarr-kids
namespace: arrstack
annotations:
# Reloader rolls the Deployment when sonarr-kids-env changes (plain envFrom
# ConfigMap does not trigger a rollout on its own).
configmap.reloader.stakater.com/auto: "true"
spec:
# Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the sonarr-kids Service. RollingUpdate is
# safe — no SQLite, no RWO lock.
replicas: 3
strategy:
type: RollingUpdate
selector:
matchLabels:
app: sonarr-kids
template:
metadata:
labels:
app: sonarr-kids
spec:
securityContext:
# Fork image has no USER; pin it to a non-root UID and group-write the
# shared RWX CephFS media subtree. OnRootMismatch avoids a recursive
# chown of the whole media tree.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Gate the app on its own Postgres database+role being reachable.
# waitfordb reads the PG* env as a libpq fallback, so the password never
# lands in argv.
- name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env:
- name: WAITFORDB_TIMEOUT
value: 5m
- name: WAITFORDB_SSLMODE
value: disable
- name: PGHOST
value: arrstack-postgres-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: sonarr-kids-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: sonarr-kids-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: sonarr-kids-db
key: password
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
containers:
- name: sonarr
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/sonarr:v5.0.0-unkin6
imagePullPolicy: IfNotPresent
command:
- /app/Sonarr
args:
- -nobrowser
- -data=/config
# Bypass the single-instance guard so multiple replicas can share one
# /config. Cross-replica safety is the Postgres layer, not a lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 8989
protocol: TCP
envFrom:
- configMapRef:
name: sonarr-kids-env
env:
- name: Sonarr__Postgres__User
valueFrom:
secretKeyRef:
name: sonarr-kids-db
key: username
- name: Sonarr__Postgres__Password
valueFrom:
secretKeyRef:
name: sonarr-kids-db
key: password
- name: Sonarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: sonarr-kids-apikey
key: apitoken
# MediaCover object store (shared arrstack-media Ceph RGW bucket,
# partitioned by the sonarr-kids key prefix).
- name: Sonarr__MediaCoverS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: S3_ENDPOINT
- name: Sonarr__MediaCoverS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_ACCESS_KEY_ID
- name: Sonarr__MediaCoverS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_SECRET_ACCESS_KEY
- name: Sonarr__MediaCoverS3__Bucket
value: arrstack-media
- name: Sonarr__MediaCoverS3__Prefix
value: sonarr-kids
- name: Sonarr__MediaCoverS3__ForcePathStyle
value: "true"
- name: Sonarr__MediaCoverS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket,
# partitioned by the sonarr-kids key prefix).
- name: Sonarr__BackupS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: S3_ENDPOINT
- name: Sonarr__BackupS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_ACCESS_KEY_ID
- name: Sonarr__BackupS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_SECRET_ACCESS_KEY
- name: Sonarr__BackupS3__Bucket
value: arrstack-backups
- name: Sonarr__BackupS3__Prefix
value: sonarr-kids
- name: Sonarr__BackupS3__ForcePathStyle
value: "true"
- name: Sonarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /3df803/sonarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /3df803/sonarr/ping
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: config
mountPath: /config
# Kids TV subtree of the shared media-tv PVC (same CephFS subvolume
# the adult sonarr writes and jellyfin reads).
- name: media-tv
mountPath: /media/tv
subPath: tvshows/kids
- name: vault-ca
mountPath: /etc/ssl/vault-ca
readOnly: true
# exportarr sidecar: polls the local replica's API and exposes Prometheus
# metrics on :9707 (scraped by the sonarr-kids-exportarr VMPodScrape).
- name: exportarr
image: ghcr.io/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent
args:
- sonarr
env:
- name: PORT
value: "9707"
# URL includes the /3df803/sonarr UrlBase (Sonarr__Server__UrlBase).
- name: URL
value: http://localhost:8989/3df803/sonarr
- name: APIKEY
valueFrom:
secretKeyRef:
name: sonarr-kids-apikey
key: apitoken
ports:
- name: metrics
containerPort: 9707
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
volumes:
- name: config
emptyDir: {}
- name: media-tv
persistentVolumeClaim:
claimName: media-tv
# Estate CA for validating the Ceph RGW (s3.ceph.unkin.net) TLS cert.
- name: vault-ca
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
@@ -1,10 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- vmpodscrape.yaml
@@ -1,15 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: sonarr-kids
namespace: arrstack
spec:
ports:
- name: http
port: 8989
protocol: TCP
targetPort: http
selector:
app: sonarr-kids
type: ClusterIP
@@ -1,25 +0,0 @@
---
# sonarr-kids API key. Seeded out-of-band at
# kv/kubernetes/namespace/arrstack/default/sonarr-kids (key: apitoken); the
# default k8s role's templated policy already grants read on
# kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs it into the sonarr-kids-apikey Secret consumed by the Deployment.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-kids-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: sonarr-kids-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr-kids
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -1,16 +0,0 @@
---
# Scrape the exportarr sidecar (:9707) on every sonarr-kids pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the sonarr-kids Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: sonarr-kids-exportarr
namespace: arrstack
spec:
selector:
matchLabels:
app: sonarr-kids
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -1,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: docker.io/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: docker.io/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
+25 -48
View File
@@ -1,47 +1,4 @@
---
# External (DMZ) front for public identity.unkin.net, served via the external
# Traefik (LB VIP 198.18.199.0). The apex identity.unkin.net A record lives in
# the bind-operator unkin.net zone (bind-internal/authoritative), NOT
# external-dns, so no external-dns annotation here. Public TLS is terminated with
# the real Let's Encrypt *.unkin.net wildcard, centrally minted once in the
# cert-manager namespace (Certificate wildcard-unkin-net) and reflected into this
# namespace by the emberstack reflector as wildcard-unkin-net-tls, not Vault PKI.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
labels:
traefik.io/instance: external
annotations:
argocd.argoproj.io/sync-wave: "2"
name: authentik
namespace: authentik
spec:
gatewayClassName: traefik-external
listeners:
- allowedRoutes:
namespaces:
from: Same
hostname: identity.unkin.net
name: http
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: identity.unkin.net
name: https
port: 443
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
name: wildcard-unkin-net-tls
mode: Terminate
---
# Cluster hostname variant, identity.k8s.syd1.au.unkin.net. Internal Traefik,
# external-dns at 198.18.200.4. Own leaf from the Vault PKI issuer via the
# cert-manager gateway-shim; the common-name keys off this cluster host.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
@@ -49,11 +6,11 @@ metadata:
traefik.io/instance: internal
annotations:
cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: identity.k8s.syd1.au.unkin.net
cert-manager.io/common-name: identity.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: identity.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/hostname: identity.unkin.net,identity.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: authentik-internal
name: authentik
namespace: authentik
spec:
gatewayClassName: traefik-internal
@@ -61,14 +18,14 @@ spec:
- allowedRoutes:
namespaces:
from: Same
hostname: identity.k8s.syd1.au.unkin.net
hostname: identity.unkin.net
name: http
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: identity.k8s.syd1.au.unkin.net
hostname: identity.unkin.net
name: https
port: 443
protocol: HTTPS
@@ -78,3 +35,23 @@ spec:
kind: Secret
name: authentik-tls
mode: Terminate
- allowedRoutes:
namespaces:
from: Same
hostname: identity.k8s.syd1.au.unkin.net
name: http-internal
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: identity.k8s.syd1.au.unkin.net
name: https-internal
port: 443
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
name: authentik-tls
mode: Terminate
+10 -49
View File
@@ -7,11 +7,16 @@ metadata:
spec:
hostnames:
- identity.unkin.net
- identity.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: authentik
sectionName: http
- group: gateway.networking.k8s.io
kind: Gateway
name: authentik
sectionName: http-internal
rules:
- filters:
- type: RequestRedirect
@@ -31,60 +36,16 @@ metadata:
spec:
hostnames:
- identity.unkin.net
- identity.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: authentik
sectionName: https
rules:
- backendRefs:
- group: ""
kind: Service
name: authentik-server
port: 80
weight: 1
matches:
- path:
type: PathPrefix
value: /
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: authentik-http-redirect-internal
namespace: authentik
spec:
hostnames:
- identity.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: authentik-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: authentik-internal
namespace: authentik
spec:
hostnames:
- identity.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: authentik-internal
sectionName: https
- group: gateway.networking.k8s.io
kind: Gateway
name: authentik
sectionName: https-internal
rules:
- backendRefs:
- group: ""
-4
View File
@@ -10,9 +10,6 @@ resources:
- httproute.yaml
- ldap-gateway.yaml
- ldap-httproute.yaml
- ldap-outpost-deployment.yaml
- ldap-outpost-vaultstaticsecret.yaml
- ldap-outpost-vmpodscrape.yaml
- ldap-service.yaml
- ldap-tlsroute.yaml
- namespace.yaml
@@ -21,5 +18,4 @@ resources:
- redis-service.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
- vmpodscrape.yaml
- vpa.yaml
@@ -1,104 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-ldap-outpost
namespace: authentik
labels:
app.kubernetes.io/name: authentik
app.kubernetes.io/component: ldap
spec:
# Outposts are stateless; run two replicas for availability.
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: authentik
app.kubernetes.io/component: ldap
template:
metadata:
annotations:
secret.reloader.stakater.com/reload: "authentik-ldap-outpost-token,vault-ca-cert"
labels:
app.kubernetes.io/name: authentik
app.kubernetes.io/component: ldap
spec:
# The outpost validates the authentik core cert (identity.k8s.syd1.au.unkin.net,
# signed by the internal unkin.net CA). Combine the base image's public roots
# with the reflected vault-ca-cert into one bundle that SSL_CERT_FILE points at,
# so AUTHENTIK_INSECURE stays false.
initContainers:
- name: combine-certs
image: alpine:3
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
resources:
limits:
cpu: 100m
memory: 64Mi
requests:
cpu: 25m
memory: 32Mi
containers:
- name: ldap
image: ghcr.io/goauthentik/ldap:2026.5.3
imagePullPolicy: IfNotPresent
env:
- name: AUTHENTIK_HOST
value: https://identity.k8s.syd1.au.unkin.net
- name: AUTHENTIK_INSECURE
value: "false"
- name: SSL_CERT_FILE
value: /etc/ssl/combined/ca-certificates.crt
- name: AUTHENTIK_TOKEN
valueFrom:
secretKeyRef:
name: authentik-ldap-outpost-token
key: token
ports:
- containerPort: 3389
name: ldap
protocol: TCP
- containerPort: 6636
name: ldaps
protocol: TCP
- containerPort: 9300
name: metrics
protocol: TCP
livenessProbe:
tcpSocket:
port: ldap
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
tcpSocket:
port: ldap
initialDelaySeconds: 5
periodSeconds: 10
resources:
limits:
cpu: "1"
memory: 512Mi
requests:
cpu: 50m
memory: 128Mi
volumeMounts:
- name: combined-certs
mountPath: /etc/ssl/combined
readOnly: true
volumes:
- name: vault-ca-cert
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
- name: combined-certs
emptyDir: {}
@@ -1,20 +0,0 @@
---
# Outpost API token, issued by authentik for the LDAP outpost and seeded into
# Vault by the terraform-authentik apply. The KV value must exist at this path
# with a `token` key before the outpost can connect.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: authentik-ldap-outpost-token
namespace: authentik
spec:
destination:
create: true
name: authentik-ldap-outpost-token
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/authentik/default/outpost-token
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -1,16 +0,0 @@
---
# Scrape the LDAP outpost's Prometheus endpoint (:9300). Picked up by the
# observability VMAgent (selectAllByDefault).
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: authentik-ldap-outpost
namespace: authentik
spec:
selector:
matchLabels:
app.kubernetes.io/name: authentik
app.kubernetes.io/component: ldap
podMetricsEndpoints:
- port: metrics
path: /metrics
-4
View File
@@ -7,10 +7,6 @@ metadata:
spec:
internalTrafficPolicy: Cluster
ports:
- name: ldap
port: 3389
protocol: TCP
targetPort: 3389
- name: ldaps
port: 6636
protocol: TCP
-14
View File
@@ -52,20 +52,6 @@ spec:
volumeMounts:
- mountPath: /data
name: redis-data
- name: metrics-exporter
image: docker.io/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
@@ -16,9 +16,9 @@ spec:
type: A
ttl: 600
values:
# traefik-EXTERNAL (DMZ) gateway VIP; the authentik Gateway serves the
# traefik-internal gateway VIP; the authentik Gateway serves the
# identity.unkin.net hostname there.
- 198.18.199.0
- 198.18.200.4
---
# PRODUCTION CUTOVER RECORD — intentionally commented out.
# git.unkin.net currently resolves to the LIVE VM forge (HAProxy VRRP VIP
@@ -88,77 +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
---
apiVersion: bind.unkin.net/v1alpha1
kind: DNSRecord
metadata:
name: cheeztv-dns-internal
namespace: bind-internal
spec:
zoneRef: unkin-net
name: cheeztv
type: A
ttl: 600
values:
# traefik-internal gateway VIP; the cheeztv Gateway serves cheeztv.unkin.net
# there.
- 198.18.200.4
---
apiVersion: bind.unkin.net/v1alpha1
kind: DNSRecord
metadata:
name: watchstate-dns-internal
namespace: bind-internal
spec:
zoneRef: unkin-net
name: watchstate
type: A
ttl: 600
values:
# traefik-EXTERNAL (DMZ) gateway VIP; the watchstate-external Gateway serves
# the watchstate.unkin.net front door (oauth2-proxy) there.
- 198.18.199.0
@@ -1,27 +0,0 @@
---
# Single, centrally-managed Let's Encrypt *.unkin.net wildcard. DNS-01 validates
# once against the delegated _acme-challenge.unkin.net CNAME, so consumers get a
# real public leaf without per-host challenge records. The minted secret carries
# emberstack reflector annotations (via secretTemplate) so it is auto-reflected
# into consumer namespaces; append future consumers to the *-namespaces lists.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-unkin-net
namespace: cert-manager
spec:
secretName: wildcard-unkin-net-tls
secretTemplate:
annotations:
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "cheeztv,arrstack,authentik,gitea,watchstate,mediamark,repospawner"
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "cheeztv,arrstack,authentik,gitea,watchstate,mediamark,repospawner"
privateKey:
size: 4096
dnsNames:
- "*.unkin.net"
issuerRef:
name: letsencrypt
kind: ClusterIssuer
group: cert-manager.io
@@ -11,4 +11,3 @@ resources:
- vmservicescrape.yaml
- clusterissuer_letsencrypt.yaml
- clusterissuer_letsencrypt-staging.yaml
- certificate_wildcard-unkin-net.yaml
@@ -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

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