Compare commits

..

5 Commits

Author SHA1 Message Date
unkin-agent 0cd5446d6b Replace legacy jellyfin app with fafflix (adult, cheeztv pattern)
ci/woodpecker/pr/vector-test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/kubeconform Pipeline was successful
Rebuild the adult media instance as 'fafflix' following the same HA pattern
as the kids instance (cheeztv): Postgres-backed jellyfin-ha fork, Valkey
transcode-lease store, shared-RWX transcode, CNPG + k8up/restic backups,
static CephFS media PVs.

- Add apps/base/fafflix (namespace/labels/secrets/buckets/Vault path fafflix-*)
- fafflix mounts the shared movies/tv subvolumes' adult subtree at
  /media/{movies,tv} plus the kids subtree at /media/{movies,tv}-kids so it can
  resume kids content started on cheeztv; new unique static PV volumeHandles
- Keep serving the legacy hostname jellyfin.k8s.syd1.au.unkin.net (fafflix-tls);
  dedicated fafflix domain deferred, no new public host
- config PVC on cephfs-raid5-delete
- Remove apps/base/jellyfin + overlay; swap jellyfin->fafflix in the media
  ApplicationSet glob and AppProject namespace destination
- No data currently on the adult instance, so the wipe/replace is sanctioned
2026-08-24 22:50:15 +10:00
unkin-agent f272a1dccb Fix storage class: cephfs-raid5-retain → cephfs-raid5-delete (cheeztv-config)
ci/woodpecker/pr/vector-test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/kubeconform Pipeline was successful
2026-08-24 22:42:40 +10:00
unkin-agent b4f3491b18 Fix stale artifactapi comment in redis_exporter config
ci/woodpecker/pr/vector-test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/kubeconform Pipeline was successful
2026-08-24 22:30:17 +10:00
unkin-agent a7a96bd879 fix: convert third-party image URLs from artifactapi proxies to upstream registries
ci/woodpecker/pr/vector-test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/kubeconform Pipeline was successful
- valkey: docker.io/valkey/valkey (was artifactapi/dockerhub)
- redis_exporter: docker.io/oliver006/redis_exporter (was artifactapi/dockerhub)

Kept docker-internal images unchanged per policy.
2026-08-24 22:25:59 +10:00
unkin-agent a4253f09cd Add cheeztv kids Jellyfin instance and fafflix kids mounts
ci/woodpecker/pr/vector-test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/kubeconform Pipeline was successful
cheeztv is a second, kids-only Jellyfin instance in the media project. Kids
titles need a separate, safe library front (cheeztv.unkin.net) while staying
resumable in the existing adult instance (fafflix).

- apps/base/cheeztv: mirror the jellyfin (fafflix) HA stack 1:1 - same
  jellyfin-ha:v0.1.3 fork image, 2-replica StatefulSet, RWX transcode PVC,
  Intel iGPU transcode, CNPG Postgres + PgBouncer, Valkey transcode store,
  k8up config backup, VMPodScrape. Media mounts subPath kids on the shared
  movies/tv CephFS subvolumes so cheeztv sees only the kids trees.
- Separate state: own namespace, config PVC, cheeztv-postgres cluster,
  cheeztv-valkey, and cnpg-cheeztv / cheeztv-config-backup buckets - nothing
  shared with fafflix.
- Ingress/DNS: cheeztv.unkin.net (internal Traefik + external-dns at
  198.18.200.4, cert-manager cheeztv-tls) plus the cluster hostname variant
  cheeztv.k8s.syd1.au.unkin.net, matching how fafflix and logviewer.unkin.net
  are wired.
- fafflix: add movies/kids and tvshows/kids subPath mounts alongside its
  existing media mounts so kids libraries are browsable/resumable there; its
  existing mounts, hostname and ingress are untouched.
- Register cheeztv in the media ApplicationSet generator and AppProject
  destinations.
2026-08-24 22:08:04 +10:00
257 changed files with 3837 additions and 5947 deletions
+29
View File
@@ -0,0 +1,29 @@
when:
- event: pull_request
steps:
- name: vector-test
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/timberio/vector:0.57.0-debian
commands:
# Dummy creds + writable dirs so the full topologies build; the unit tests
# only exercise the transforms (sources are not started).
- export CLICKHOUSE_USER=ci CLICKHOUSE_PASSWORD=ci
- export NATS_PRODUCER_PASSWORD=ci NATS_CONSUMER_PASSWORD=ci
- mkdir -p /vector-data-dir /etc/vault-ca
- cp /etc/ssl/certs/ca-certificates.crt /etc/vault-ca/ca.crt
# Transform tier + VM ingest: unit-tested transforms.
- vector test apps/base/logging/vector/aggregator.yaml apps/base/logging/vector/aggregator-tests.yaml
- vector test apps/base/logging/vector/vm-ingest.yaml apps/base/logging/vector/vm-ingest-tests.yaml
# Agent has no transforms to unit-test; validate it builds. (The archiver
# leg is now the logarchiver service, not a Vector pipeline.)
- vector validate --no-environment apps/base/logging/vector/agent.yaml
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 256Mi
cpu: 250m
limits:
memory: 1Gi
cpu: 1
+15 -38
View File
@@ -5,12 +5,9 @@ metadata:
name: arrproxy-api name: arrproxy-api
namespace: arrstack namespace: arrstack
annotations: annotations:
# Wave 2: start only after the wave-0 CNPG Cluster and VSO-synced Secrets # Wave 2: serve only after the wave-1 migrate Job completes.
# 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" 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" secret.reloader.stakater.com/reload: "arrproxy-pepper,arrproxy-admin-token,arrproxy-db-app,sonarr-apikey,radarr-apikey,prowlarr-apikey"
configmap.reloader.stakater.com/reload: "arrproxy-tiers"
spec: spec:
replicas: 2 replicas: 2
selector: selector:
@@ -36,7 +33,7 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: api - name: api
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-api:v0.6.1 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-api:v0.3.1
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 8080 - containerPort: 8080
@@ -52,18 +49,11 @@ spec:
# reaches this upstream) so group-based authorization works. # reaches this upstream) so group-based authorization works.
- name: ARRPROXY_GROUPS_HEADER - name: ARRPROXY_GROUPS_HEADER
value: X-Forwarded-Groups value: X-Forwarded-Groups
# Real per-app *arr keys, projected one file per app under a per-tier # Real per-app *arr keys, projected one file per app under this dir
# subdir (<keyDir>/<app>) matching the tiers file: adult/{sonarr,radarr} # (sourced from the existing <app>-apikey Secrets). The api injects
# and kids/{sonarr,radarr}. Sourced from the same <instance>-apikey # them server-side and redacts them from every proxied response.
# Secrets the *arr Deployments consume. The api injects them server-side
# and redacts them from every proxied response.
- name: ARRPROXY_KEYS_DIR - name: ARRPROXY_KEYS_DIR
value: /etc/arrproxy/keys 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 - name: ARRPROXY_PEPPER
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -96,9 +86,6 @@ spec:
- name: arr-keys - name: arr-keys
mountPath: /etc/arrproxy/keys mountPath: /etc/arrproxy/keys
readOnly: true readOnly: true
- name: tiers
mountPath: /etc/arrproxy/tiers
readOnly: true
- name: tmp - name: tmp
mountPath: /tmp mountPath: /tmp
livenessProbe: livenessProbe:
@@ -131,37 +118,27 @@ spec:
cpu: "1" cpu: "1"
memory: 512Mi memory: 512Mi
volumes: volumes:
# Real per-tier *arr API keys, projected as <keyDir>/<app> so the api # Real *arr API keys, one file per app named exactly <app> so the api
# reads /etc/arrproxy/keys/{adult,kids}/{sonarr,radarr} (matching the # reads /etc/arrproxy/keys/{sonarr,radarr,prowlarr}. Reuses the same
# tiers file keyDir). Reuses the same <instance>-apikey Secrets the *arr # <app>-apikey Secrets the *arr Deployments already consume.
# Deployments already consume (seeded by their VaultStaticSecrets).
- name: arr-keys - name: arr-keys
projected: projected:
sources: sources:
- secret: - secret:
name: sonarr-adult-apikey name: sonarr-apikey
items: items:
- key: apitoken - key: apitoken
path: adult/sonarr path: sonarr
- secret: - secret:
name: radarr-adult-apikey name: radarr-apikey
items: items:
- key: apitoken - key: apitoken
path: adult/radarr path: radarr
- secret: - secret:
name: sonarr-kids-apikey name: prowlarr-apikey
items: items:
- key: apitoken - key: apitoken
path: kids/sonarr path: prowlarr
- 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 - name: tmp
emptyDir: emptyDir:
sizeLimit: 64Mi sizeLimit: 64Mi
+8 -7
View File
@@ -1,11 +1,9 @@
--- ---
# External (DMZ) front for the arrstack, served on arrstack.unkin.net via the # 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 # external Traefik (LB VIP 198.18.199.0). cert-manager mints arrproxy-gateway-tls
# lives in the bind-operator unkin.net zone (bind-internal/authoritative), NOT # (CN arrstack.unkin.net) off the internal Vault-PKI CA. The apex arrstack.unkin.net
# external-dns, so no external-dns annotation here. Public TLS is terminated with # A record lives in the bind-operator unkin.net zone (bind-internal/authoritative),
# the real Let's Encrypt *.unkin.net wildcard, centrally minted once in the # NOT external-dns, so no external-dns annotation here.
# 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 apiVersion: gateway.networking.k8s.io/v1
kind: Gateway kind: Gateway
metadata: metadata:
@@ -13,6 +11,9 @@ metadata:
traefik.io/instance: external traefik.io/instance: external
annotations: annotations:
argocd.argoproj.io/sync-wave: "2" argocd.argoproj.io/sync-wave: "2"
cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: arrstack.unkin.net
cert-manager.io/private-key-size: "4096"
name: arrproxy name: arrproxy
namespace: arrstack namespace: arrstack
spec: spec:
@@ -37,4 +38,4 @@ spec:
certificateRefs: certificateRefs:
- group: "" - group: ""
kind: Secret kind: Secret
name: wildcard-unkin-net-tls name: arrproxy-gateway-tls
@@ -5,8 +5,9 @@ kind: Kustomization
resources: resources:
- cnpg_cluster.yaml - cnpg_cluster.yaml
- cnpg_backup.yaml - cnpg_backup.yaml
- migrations-configmap.yaml
- migrate-job.yaml
- vaultstaticsecret.yaml - vaultstaticsecret.yaml
- tiers-configmap.yaml
- oauth2-proxy-configmap.yaml - oauth2-proxy-configmap.yaml
- oauth2-proxy-deployment.yaml - oauth2-proxy-deployment.yaml
- api-deployment.yaml - api-deployment.yaml
@@ -0,0 +1,92 @@
---
# Applies the arrproxy schema once per sync, before the api rolls, so the serve
# replicas never race migrations (arrproxy-api does not self-migrate). Runs as the
# CNPG-minted app user so the tokens table is owned by that role.
#
# Sync-phase hook at wave 1 (NOT PreSync): the CNPG Cluster + generated
# arrproxy-db-app Secret apply at wave 0 and ArgoCD waits for the Cluster to be
# Healthy before starting wave 1, so Postgres exists before migrate connects.
apiVersion: batch/v1
kind: Job
metadata:
name: arrproxy-migrate
namespace: arrstack
annotations:
argocd.argoproj.io/hook: Sync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
argocd.argoproj.io/sync-wave: "1"
spec:
backoffLimit: 6
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app: arrproxy-migrate
spec:
serviceAccountName: default
automountServiceAccountToken: false
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: migrate
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/library/postgres:18-alpine
imagePullPolicy: IfNotPresent
env:
- name: HOME
value: /tmp
- name: PGUSER
valueFrom:
secretKeyRef:
name: arrproxy-db-app
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: arrproxy-db-app
key: password
- name: PGHOST
value: arrproxy-db-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: arrproxy
- name: PGSSLMODE
value: require
command:
- psql
- -v
- ON_ERROR_STOP=1
- -f
- /migrations/0001_init.sql
volumeMounts:
- name: migrations
mountPath: /migrations
readOnly: true
- name: tmp
mountPath: /tmp
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
volumes:
- name: migrations
configMap:
name: arrproxy-migrations
- name: tmp
emptyDir:
sizeLimit: 64Mi
@@ -0,0 +1,29 @@
---
# arrproxy schema, mirrored from the arrproxy repo migrations/0001_init.sql
# (v0.1.0). arrproxy-api does NOT self-migrate, so the wave-1 migrate Job applies
# this once per sync as the app user. Keep in sync with the repo on schema bumps.
apiVersion: v1
kind: ConfigMap
metadata:
name: arrproxy-migrations
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
data:
0001_init.sql: |
-- arrproxy token store. Only token hashes are persisted; plaintext is shown
-- once at mint time and never recoverable.
CREATE TABLE IF NOT EXISTS tokens (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
token_hash TEXT NOT NULL UNIQUE,
apps TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
disabled BOOLEAN NOT NULL DEFAULT false,
last_used_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS tokens_subject_idx ON tokens (subject);
CREATE INDEX IF NOT EXISTS tokens_token_hash_idx ON tokens (token_hash);
@@ -21,11 +21,9 @@ data:
# application (terraform-authentik PR #18). # application (terraform-authentik PR #18).
OAUTH2_PROXY_OIDC_ISSUER_URL: "https://identity.unkin.net/application/o/arrstack/" OAUTH2_PROXY_OIDC_ISSUER_URL: "https://identity.unkin.net/application/o/arrstack/"
OAUTH2_PROXY_REDIRECT_URL: "https://arrstack.unkin.net/oauth2/callback" OAUTH2_PROXY_REDIRECT_URL: "https://arrstack.unkin.net/oauth2/callback"
# Longest-prefix wins: /api and the per-tier hash routes (/3aa168, /3df803) go # Longest-prefix wins: /api and /<app> go to arrproxy-api, everything else
# to arrproxy-api, everything else (the SPA + static assets) to arrproxy-ui. # (the SPA + static assets) to arrproxy-ui.
# Tier routes carry /<hash>/<app>/...; the bare /sonarr /radarr /prowlarr OAUTH2_PROXY_UPSTREAMS: "http://arrproxy-ui.arrstack.svc.cluster.local:8080/,http://arrproxy-api.arrstack.svc.cluster.local:8080/api/,http://arrproxy-api.arrstack.svc.cluster.local:8080/sonarr/,http://arrproxy-api.arrstack.svc.cluster.local:8080/radarr/,http://arrproxy-api.arrstack.svc.cluster.local:8080/prowlarr/"
# 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" OAUTH2_PROXY_SCOPE: "openid email profile ak_groups"
# Populate session.Groups from the Authentik ak_groups claim; pass-user-headers # Populate session.Groups from the Authentik ak_groups claim; pass-user-headers
# then emits it as a single comma-joined X-Forwarded-Groups header. # then emits it as a single comma-joined X-Forwarded-Groups header.
@@ -35,16 +33,14 @@ data:
# set-xauthrequest is intentionally NOT set -- it only populates auth_request # set-xauthrequest is intentionally NOT set -- it only populates auth_request
# *response* headers, which never reach an --upstreams-proxied backend. # *response* headers, which never reach an --upstreams-proxied backend.
OAUTH2_PROXY_PASS_USER_HEADERS: "true" OAUTH2_PROXY_PASS_USER_HEADERS: "true"
# Bypass auth for the tier *arr proxy API (/<hash>/<app>/api...) and the # Bypass auth for the *arr proxy API (/<app>/api...) and the machine-mint
# machine-mint admin route (/api/admin/...). The first matches # admin route (/api/admin/...). The first matches /sonarr/api; the second
# /3aa168/sonarr/api (two path segments before /api, since tier routes are # matches /api/admin/ only -- both routed to the arrproxy-api upstream by the
# hash-prefixed); the second matches /api/admin/ only -- both routed to the # catch-all /api/ prefix above. /api/admin/ is protected by arrproxy's OWN
# arrproxy-api upstream by the tier and catch-all /api/ prefixes above. # ARRPROXY_ADMIN_TOKEN bearer (OpenBao on the VMs reaches it via the ingress),
# /api/admin/ is protected by arrproxy's OWN ARRPROXY_ADMIN_TOKEN bearer # so it is intentionally oauth-skipped. /api/tokens and /api/me are NOT
# (OpenBao on the VMs reaches it via the ingress), so it is intentionally # matched and stay oauth-authenticated.
# oauth-skipped. /api/tokens and /api/me are NOT matched and stay OAUTH2_PROXY_SKIP_AUTH_REGEX: "^/[^/]+/api,^/api/admin/"
# oauth-authenticated.
OAUTH2_PROXY_SKIP_AUTH_REGEX: "^/[^/]+/[^/]+/api,^/api/admin/"
OAUTH2_PROXY_EMAIL_DOMAINS: "*" OAUTH2_PROXY_EMAIL_DOMAINS: "*"
# Authentik hardcodes email_verified=false in the id_token; without this # Authentik hardcodes email_verified=false in the id_token; without this
# oauth2-proxy rejects the session ("email ... isn't verified") -> 500 on # oauth2-proxy rejects the session ("email ... isn't verified") -> 500 on
@@ -35,7 +35,7 @@ spec:
# identity.unkin.net serves a Vault-PKI cert; combine the system roots # 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. # with the internal CA so oauth2-proxy's OIDC HTTP client trusts it.
- name: combine-certs - name: combine-certs
image: docker.io/library/alpine:3 image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/library/alpine:3
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- sh - sh
@@ -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" }
]
}
]
}
@@ -31,7 +31,7 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: ui - name: ui
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-ui:v0.6.1 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-ui:v0.3.1
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 8080 - containerPort: 8080
+3 -9
View File
@@ -8,22 +8,16 @@ resources:
- pv-media-tv.yaml - pv-media-tv.yaml
- pv-media-movies.yaml - pv-media-movies.yaml
- pv-mediafs.yaml - pv-mediafs.yaml
- pv-mediastore.yaml
- pvc-media-tv.yaml - pvc-media-tv.yaml
- pvc-media-movies.yaml - pvc-media-movies.yaml
- pvc-mediafs.yaml - pvc-mediafs.yaml
- pvc-mediastore.yaml
- mediastore-bootstrap-job.yaml
- media-bucket.yaml - media-bucket.yaml
- backups-bucket.yaml - backups-bucket.yaml
- postgres - postgres
- valkey - valkey
- sonarr
- radarr
- prowlarr - prowlarr
- sonarr-kids - nzbget
- radarr-kids
- nzbget-kids
- sonarr-adult
- radarr-adult
- nzbget-adult
- arrproxy - arrproxy
- mediamover - mediamover
@@ -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
@@ -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,16 +0,0 @@
---
# NZBGet (kids) 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-kids-config
namespace: arrstack
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: cephrbd-fast-delete
volumeMode: Filesystem
@@ -2,7 +2,7 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: nzbget-kids name: nzbget
namespace: arrstack namespace: arrstack
spec: spec:
replicas: 1 replicas: 1
@@ -11,25 +11,25 @@ spec:
type: Recreate type: Recreate
selector: selector:
matchLabels: matchLabels:
app: nzbget-kids app: nzbget
template: template:
metadata: metadata:
labels: labels:
app: nzbget-kids app: nzbget
spec: spec:
securityContext: securityContext:
fsGroup: 1000 fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch fsGroupChangePolicy: OnRootMismatch
initContainers: initContainers:
# Seed download layout onto the kids media subtrees (not /config or an # Seed download layout onto the shared media PVCs (not /config or an
# emptyDir) so completed kids downloads land beside the kids arr libraries # emptyDir) so completed downloads land beside the arr libraries and
# and imports are same-filesystem hardlink moves. The media mounts use the # imports are same-filesystem hardlink moves. Reuses the image's own
# tvshows/kids and movies/kids subPaths, so the in-container paths match # template (inherits correct WebDir/ConfigTemplate) and appends the
# the adult nzbget while the data stays scoped to the kids subtree. Reuses # path/category overrides once; nzbget honours the last value for a
# the image's own template and appends the path/category overrides once; # repeated option, and the grep guard keeps re-runs idempotent so admin
# the grep guard keeps re-runs idempotent so admin UI edits survive. # UI edits to the persisted /config/nzbget.conf survive restarts.
- name: seed-config - name: seed-config
image: docker.io/linuxserver/nzbget:version-v26.2 image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/linuxserver/nzbget:version-v26.2
command: command:
- sh - sh
- -c - -c
@@ -42,12 +42,11 @@ spec:
cat >> /config/nzbget.conf << 'CONF' cat >> /config/nzbget.conf << 'CONF'
# arrstack-managed download layout (appended once; last value wins). # arrstack-managed download layout (appended once; last value wins).
# Downloads land on the shared media PVCs by category so sonarr-kids/ # Downloads land on the shared media PVCs by category so sonarr/radarr
# radarr-kids import with atomic hardlink moves (download dir + # import with atomic hardlink moves (download dir + library share one
# library share one filesystem per media type). InterDir is empty: # filesystem per media type). InterDir is empty: nzbget writes each
# nzbget writes each download straight into its category DestDir, so # download straight into its category DestDir, so BOTH tv and movies
# BOTH tv and movies stay on their own PVC with no cross-filesystem # stay on their own PVC with no cross-filesystem intermediate copy.
# intermediate copy.
MainDir=/media/tv MainDir=/media/tv
InterDir= InterDir=
DestDir=/media/tv/downloads DestDir=/media/tv/downloads
@@ -78,13 +77,11 @@ spec:
mountPath: /config mountPath: /config
- name: media-tv - name: media-tv
mountPath: /media/tv mountPath: /media/tv
subPath: tvshows/kids
- name: media-movies - name: media-movies
mountPath: /media/movies mountPath: /media/movies
subPath: movies/kids
containers: containers:
- name: nzbget - name: nzbget
image: docker.io/linuxserver/nzbget:version-v26.2 image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/linuxserver/nzbget:version-v26.2
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- name: http - name: http
@@ -126,14 +123,12 @@ spec:
mountPath: /config mountPath: /config
- name: media-tv - name: media-tv
mountPath: /media/tv mountPath: /media/tv
subPath: tvshows/kids
- name: media-movies - name: media-movies
mountPath: /media/movies mountPath: /media/movies
subPath: movies/kids
volumes: volumes:
- name: config - name: config
persistentVolumeClaim: persistentVolumeClaim:
claimName: nzbget-kids-config claimName: nzbget-config
- name: media-tv - name: media-tv
persistentVolumeClaim: persistentVolumeClaim:
claimName: media-tv claimName: media-tv
@@ -1,5 +1,4 @@
--- ---
# Internal front for mediamark (cf. watchstate).
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
kind: Gateway kind: Gateway
metadata: metadata:
@@ -7,26 +6,26 @@ metadata:
traefik.io/instance: internal traefik.io/instance: internal
annotations: annotations:
cert-manager.io/cluster-issuer: vault-issuer cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: mediamark.k8s.syd1.au.unkin.net cert-manager.io/common-name: nzbget.k8s.syd1.au.unkin.net
cert-manager.io/private-key-size: "4096" cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: mediamark.k8s.syd1.au.unkin.net external-dns.alpha.kubernetes.io/hostname: nzbget.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4 external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: mediamark name: nzbget
namespace: mediamark namespace: arrstack
spec: spec:
gatewayClassName: traefik-internal gatewayClassName: traefik-internal
listeners: listeners:
- allowedRoutes: - allowedRoutes:
namespaces: namespaces:
from: Same from: Same
hostname: mediamark.k8s.syd1.au.unkin.net hostname: nzbget.k8s.syd1.au.unkin.net
name: http name: http
port: 80 port: 80
protocol: HTTP protocol: HTTP
- allowedRoutes: - allowedRoutes:
namespaces: namespaces:
from: Same from: Same
hostname: mediamark.k8s.syd1.au.unkin.net hostname: nzbget.k8s.syd1.au.unkin.net
name: https name: https
port: 443 port: 443
protocol: HTTPS protocol: HTTPS
@@ -34,5 +33,5 @@ spec:
certificateRefs: certificateRefs:
- group: "" - group: ""
kind: Secret kind: Secret
name: mediamark-tls name: nzbget-tls
mode: Terminate mode: Terminate
@@ -2,15 +2,15 @@
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute kind: HTTPRoute
metadata: metadata:
name: mediamark-http-redirect name: nzbget-http-redirect
namespace: mediamark namespace: arrstack
spec: spec:
hostnames: hostnames:
- mediamark.k8s.syd1.au.unkin.net - nzbget.k8s.syd1.au.unkin.net
parentRefs: parentRefs:
- group: gateway.networking.k8s.io - group: gateway.networking.k8s.io
kind: Gateway kind: Gateway
name: mediamark name: nzbget
sectionName: http sectionName: http
rules: rules:
- filters: - filters:
@@ -26,22 +26,22 @@ spec:
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute kind: HTTPRoute
metadata: metadata:
name: mediamark name: nzbget-route
namespace: mediamark namespace: arrstack
spec: spec:
hostnames: hostnames:
- mediamark.k8s.syd1.au.unkin.net - nzbget.k8s.syd1.au.unkin.net
parentRefs: parentRefs:
- group: gateway.networking.k8s.io - group: gateway.networking.k8s.io
kind: Gateway kind: Gateway
name: mediamark name: nzbget
sectionName: https sectionName: https
rules: rules:
- backendRefs: - backendRefs:
- group: "" - group: ""
kind: Service kind: Service
name: mediamark-oauth2 name: nzbget
port: 4180 port: 6789
weight: 1 weight: 1
matches: matches:
- path: - path:
@@ -6,3 +6,5 @@ resources:
- pvc-config.yaml - pvc-config.yaml
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- gateway.yaml
- httproute.yaml
+16
View File
@@ -0,0 +1,16 @@
---
# NZBGet config + queue/temp state. RWO on cephrbd (block). Retain: this is
# state. The download data itself lives on the shared media PVCs, not here.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nzbget-config
namespace: arrstack
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: cephrbd-fast-retain
volumeMode: Filesystem
@@ -2,7 +2,7 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: nzbget-kids name: nzbget
namespace: arrstack namespace: arrstack
spec: spec:
ports: ports:
@@ -11,5 +11,5 @@ spec:
protocol: TCP protocol: TCP
targetPort: http targetPort: http
selector: selector:
app: nzbget-kids app: nzbget
type: ClusterIP type: ClusterIP
+24 -48
View File
@@ -50,6 +50,30 @@ spec:
owner: app owner: app
managed: managed:
roles: roles:
- name: sonarr
ensure: present
comment: Sonarr application role (owns sonarr-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: sonarr-db
- name: radarr
ensure: present
comment: Radarr application role (owns radarr-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: radarr-db
- name: prowlarr - name: prowlarr
ensure: present ensure: present
comment: Prowlarr application role (owns prowlarr-main) comment: Prowlarr application role (owns prowlarr-main)
@@ -62,54 +86,6 @@ spec:
connectionLimit: -1 connectionLimit: -1
passwordSecret: passwordSecret:
name: prowlarr-db 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 enablePDB: true
enableSuperuserAccess: false enableSuperuserAccess: false
failoverDelay: 0 failoverDelay: 0
@@ -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
@@ -0,0 +1,15 @@
---
# Per-app database owned by the radarr managed role. The fork's provider runs its
# own schema migrations on first start (advisory-locked, so only one replica
# migrates). retain: the database survives a Database CRD delete.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: radarr-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: radarr-main
owner: radarr
databaseReclaimPolicy: retain
@@ -1,15 +0,0 @@
---
# Per-app database owned by the sonarr-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
@@ -0,0 +1,15 @@
---
# Per-app database owned by the sonarr managed role. The fork's provider runs its
# own schema migrations on first start (advisory-locked, so only one replica
# migrates). retain: the database survives a Database CRD delete.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: sonarr-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: sonarr-main
owner: sonarr
databaseReclaimPolicy: retain
@@ -6,8 +6,6 @@ resources:
- vaultstaticsecret.yaml - vaultstaticsecret.yaml
- cnpg_cluster.yaml - cnpg_cluster.yaml
- cnpg_backup.yaml - cnpg_backup.yaml
- database-sonarr.yaml
- database-radarr.yaml
- database-prowlarr.yaml - database-prowlarr.yaml
- database-sonarr-kids.yaml
- database-radarr-kids.yaml
- database-sonarr-adult.yaml
- database-radarr-adult.yaml
@@ -10,6 +10,40 @@
# (wave 1) reconciles the roles. # (wave 1) reconciles the roles.
apiVersion: secrets.hashicorp.com/v1beta1 apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret kind: VaultStaticSecret
metadata:
name: sonarr-db
namespace: arrstack
spec:
destination:
create: true
name: sonarr-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-db
namespace: arrstack
spec:
destination:
create: true
name: radarr-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata: metadata:
name: prowlarr-db name: prowlarr-db
namespace: arrstack namespace: arrstack
@@ -24,71 +58,3 @@ spec:
refreshAfter: 5m refreshAfter: 5m
type: kv-v2 type: kv-v2
vaultAuthRef: default 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
-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 @@
---
# 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,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,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
+33
View File
@@ -0,0 +1,33 @@
---
# Non-secret env for the -unkin2 fork. The fork reads Servarr config from
# Radarr__<Section>__<Key> env (no config.xml edits, no s6/PUID). Postgres wiring
# points every replica at the same shared DB (arrstack-postgres-rw / radarr-main);
# Auth__Method=External defers UI auth to arrproxy/oauth2-proxy; Server__UrlBase
# keeps the /radarr prefix so arrproxy path-routing works; App__InstanceName is
# identical across replicas (shared session-cookie name). User/Password/ApiKey
# come from Secrets (see deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: radarr-env
namespace: arrstack
data:
Radarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Radarr__Postgres__Port: "5432"
Radarr__Postgres__MainDb: radarr-main
Radarr__Log__DbEnabled: "false"
Radarr__Auth__Method: External
Radarr__Auth__Required: DisabledForLocalAddresses
Radarr__App__InstanceName: Radarr
Radarr__Server__Port: "7878"
Radarr__Server__UrlBase: /radarr
Radarr__Update__Mechanism: External
# Shared arrstack Valkey (valkey-operator). Setting Host is what activates the
# fork's #14 Redis features (SignalR backplane, cross-replica cache-invalidation
# bus, distributed rate limiter): RedisOptions.IsConfigured gates purely on a
# non-empty Host, so there is no separate Enabled flag. The operator leaves the
# default user passwordless (jellyfin parity), so no Password/Ssl is wired.
# Channels/keys are namespaced by this fork's radarr:ratelimit: prefix, so the
# one cluster is safe to share with sonarr/prowlarr.
Radarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Radarr__Redis__Port: "6379"
@@ -2,40 +2,43 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: radarr-adult name: radarr
namespace: arrstack namespace: arrstack
annotations: annotations:
# Reloader rolls the Deployment when radarr-adult-env changes (plain envFrom # radarr-env is a plain (unhashed) ConfigMap consumed by fixed-name envFrom,
# ConfigMap does not trigger a rollout on its own). # 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" configmap.reloader.stakater.com/auto: "true"
spec: spec:
# Active-active: the -unkin2 fork keeps all state in the shared Postgres # Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N # (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the radarr-adult Service. RollingUpdate is # replicas run concurrently behind the radarr Service. RollingUpdate is safe —
# safe — no SQLite, no RWO lock. # no SQLite, no RWO lock.
replicas: 3 replicas: 3
strategy: strategy:
type: RollingUpdate type: RollingUpdate
selector: selector:
matchLabels: matchLabels:
app: radarr-adult app: radarr
template: template:
metadata: metadata:
labels: labels:
app: radarr-adult app: radarr
spec: spec:
securityContext: securityContext:
# Fork image has no USER; pin it to a non-root UID and group-write the # Fork image has no USER (runs as root by default); pin it to a non-root
# shared RWX CephFS media subtree. OnRootMismatch avoids a recursive # UID and group-write the shared RWX CephFS /config (MediaCover etc.).
# chown of the whole media tree. # OnRootMismatch avoids a recursive chown of the whole media tree.
runAsUser: 1000 runAsUser: 1000
runAsGroup: 1000 runAsGroup: 1000
fsGroup: 1000 fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch fsGroupChangePolicy: OnRootMismatch
initContainers: initContainers:
# Gate the app on its own Postgres database+role being reachable. # Gate the app on its own Postgres database+role being reachable, instead
# waitfordb reads the PG* env as a libpq fallback, so the password never # of relying on ArgoCD sync-waves (which deadlock if apps aren't Healthy).
# lands in argv. # waitfordb reads the PG* env as a libpq fallback, so the password never lands in argv.
- name: wait-for-db - name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env: env:
@@ -48,16 +51,16 @@ spec:
- name: PGPORT - name: PGPORT
value: "5432" value: "5432"
- name: PGDATABASE - name: PGDATABASE
value: radarr-adult-main value: radarr-main
- name: PGUSER - name: PGUSER
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: radarr-adult-db name: radarr-db
key: username key: username
- name: PGPASSWORD - name: PGPASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: radarr-adult-db name: radarr-db
key: password key: password
resources: resources:
requests: requests:
@@ -75,8 +78,9 @@ spec:
args: args:
- -nobrowser - -nobrowser
- -data=/config - -data=/config
# Bypass the single-instance guard so multiple replicas can share one # Required: bypass the single-instance guard so multiple replicas
# /config. Cross-replica safety is the Postgres layer, not a lock file. # can share one /config. Cross-replica safety is the Postgres layer,
# not a local lock file.
- -nosingleinstancecheck - -nosingleinstancecheck
ports: ports:
- name: http - name: http
@@ -84,25 +88,27 @@ spec:
protocol: TCP protocol: TCP
envFrom: envFrom:
- configMapRef: - configMapRef:
name: radarr-adult-env name: radarr-env
env: env:
- name: Radarr__Postgres__User - name: Radarr__Postgres__User
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: radarr-adult-db name: radarr-db
key: username key: username
- name: Radarr__Postgres__Password - name: Radarr__Postgres__Password
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: radarr-adult-db name: radarr-db
key: password key: password
- name: Radarr__Auth__ApiKey - name: Radarr__Auth__ApiKey
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: radarr-adult-apikey name: radarr-apikey
key: apitoken key: apitoken
# MediaCover object store (shared arrstack-media Ceph RGW bucket, # MediaCover object store (shared Ceph RGW bucket). Serves posters/fanart
# partitioned by the radarr-adult key prefix). # from S3 so any replica can render them instead of the leader-local
# emptyDir /config. Consumed by the -unkin3+ image; older images ignore
# these unknown config keys. Creds Secret is minted by cephrgw-operator.
- name: Radarr__MediaCover__S3__Endpoint - name: Radarr__MediaCover__S3__Endpoint
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -121,13 +127,14 @@ spec:
- name: Radarr__MediaCover__S3__Bucket - name: Radarr__MediaCover__S3__Bucket
value: arrstack-media value: arrstack-media
- name: Radarr__MediaCover__S3__Prefix - name: Radarr__MediaCover__S3__Prefix
value: radarr-adult value: radarr
- name: Radarr__MediaCover__S3__ForcePathStyle - name: Radarr__MediaCover__S3__ForcePathStyle
value: "true" value: "true"
- name: Radarr__MediaCover__S3__CaCertPath - name: Radarr__MediaCover__S3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket, # Backup object store (shared arrstack-backups Ceph RGW bucket,
# partitioned by the radarr-adult key prefix). # per-app key prefix). Routes the periodic config+DB zip backups off
# the ephemeral /config so any replica can write and restore them.
- name: Radarr__BackupS3__Endpoint - name: Radarr__BackupS3__Endpoint
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -146,14 +153,14 @@ spec:
- name: Radarr__BackupS3__Bucket - name: Radarr__BackupS3__Bucket
value: arrstack-backups value: arrstack-backups
- name: Radarr__BackupS3__Prefix - name: Radarr__BackupS3__Prefix
value: radarr-adult value: radarr
- name: Radarr__BackupS3__ForcePathStyle - name: Radarr__BackupS3__ForcePathStyle
value: "true" value: "true"
- name: Radarr__BackupS3__CaCertPath - name: Radarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt value: /etc/ssl/vault-ca/ca.crt
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /3aa168/radarr/ping path: /radarr/ping
port: http port: http
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 30 periodSeconds: 30
@@ -161,7 +168,7 @@ spec:
failureThreshold: 3 failureThreshold: 3
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /3aa168/radarr/ping path: /radarr/ping
port: http port: http
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 10 periodSeconds: 10
@@ -177,31 +184,28 @@ spec:
volumeMounts: volumeMounts:
- name: config - name: config
mountPath: /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 - name: media-movies
mountPath: /media/movies mountPath: /media/movies
subPath: movies/adult
- name: vault-ca - name: vault-ca
mountPath: /etc/ssl/vault-ca mountPath: /etc/ssl/vault-ca
readOnly: true readOnly: true
# exportarr sidecar: polls the local replica's API and exposes Prometheus # exportarr sidecar: polls the local replica's API and exposes Prometheus
# metrics on :9708 (scraped by the radarr-adult-exportarr VMPodScrape). # metrics on :9708 (scraped by the radarr-exportarr VMPodScrape).
- name: exportarr - name: exportarr
image: ghcr.io/onedr0p/exportarr:v2.3.0 image: artifactapi.k8s.syd1.au.unkin.net/ghcr/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
args: args:
- radarr - radarr
env: env:
- name: PORT - name: PORT
value: "9708" value: "9708"
# URL includes the /3aa168/radarr UrlBase (Radarr__Server__UrlBase). # URL includes the /radarr UrlBase (Radarr__Server__UrlBase).
- name: URL - name: URL
value: http://localhost:7878/3aa168/radarr value: http://localhost:7878/radarr
- name: APIKEY - name: APIKEY
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: radarr-adult-apikey name: radarr-apikey
key: apitoken key: apitoken
ports: ports:
- name: metrics - name: metrics
@@ -1,5 +1,4 @@
--- ---
# Internal-only front for the WatchState admin UI (cf. pdbmux/logviewer).
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
kind: Gateway kind: Gateway
metadata: metadata:
@@ -7,26 +6,26 @@ metadata:
traefik.io/instance: internal traefik.io/instance: internal
annotations: annotations:
cert-manager.io/cluster-issuer: vault-issuer cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: watchstate.k8s.syd1.au.unkin.net cert-manager.io/common-name: radarr.k8s.syd1.au.unkin.net
cert-manager.io/private-key-size: "4096" cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: watchstate.k8s.syd1.au.unkin.net external-dns.alpha.kubernetes.io/hostname: radarr.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4 external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: watchstate name: radarr
namespace: watchstate namespace: arrstack
spec: spec:
gatewayClassName: traefik-internal gatewayClassName: traefik-internal
listeners: listeners:
- allowedRoutes: - allowedRoutes:
namespaces: namespaces:
from: Same from: Same
hostname: watchstate.k8s.syd1.au.unkin.net hostname: radarr.k8s.syd1.au.unkin.net
name: http name: http
port: 80 port: 80
protocol: HTTP protocol: HTTP
- allowedRoutes: - allowedRoutes:
namespaces: namespaces:
from: Same from: Same
hostname: watchstate.k8s.syd1.au.unkin.net hostname: radarr.k8s.syd1.au.unkin.net
name: https name: https
port: 443 port: 443
protocol: HTTPS protocol: HTTPS
@@ -34,5 +33,5 @@ spec:
certificateRefs: certificateRefs:
- group: "" - group: ""
kind: Secret kind: Secret
name: watchstate-tls name: radarr-tls
mode: Terminate mode: Terminate
@@ -2,15 +2,15 @@
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute kind: HTTPRoute
metadata: metadata:
name: mediamark-external-http-redirect name: radarr-http-redirect
namespace: mediamark namespace: arrstack
spec: spec:
hostnames: hostnames:
- mediamark.unkin.net - radarr.k8s.syd1.au.unkin.net
parentRefs: parentRefs:
- group: gateway.networking.k8s.io - group: gateway.networking.k8s.io
kind: Gateway kind: Gateway
name: mediamark-external name: radarr
sectionName: http sectionName: http
rules: rules:
- filters: - filters:
@@ -26,22 +26,22 @@ spec:
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute kind: HTTPRoute
metadata: metadata:
name: mediamark-external name: radarr-route
namespace: mediamark namespace: arrstack
spec: spec:
hostnames: hostnames:
- mediamark.unkin.net - radarr.k8s.syd1.au.unkin.net
parentRefs: parentRefs:
- group: gateway.networking.k8s.io - group: gateway.networking.k8s.io
kind: Gateway kind: Gateway
name: mediamark-external name: radarr
sectionName: https sectionName: https
rules: rules:
- backendRefs: - backendRefs:
- group: "" - group: ""
kind: Service kind: Service
name: mediamark-oauth2 name: radarr
port: 4180 port: 7878
weight: 1 weight: 1
matches: matches:
- path: - path:
@@ -7,4 +7,6 @@ resources:
- configmap.yaml - configmap.yaml
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- gateway.yaml
- httproute.yaml
- vmpodscrape.yaml - vmpodscrape.yaml
+17
View File
@@ -0,0 +1,17 @@
---
# Radarr /config. RWX on CephFS so all replicas share it (the -unkin2 fork keeps
# the database in Postgres; /config now holds only config.xml + MediaCover, which
# tolerate — and want — shared access). Retain: this is state.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: radarr-config
namespace: arrstack
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 5Gi
storageClassName: cephfs-raid5-retain
volumeMode: Filesystem
@@ -2,7 +2,7 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: radarr-kids name: radarr
namespace: arrstack namespace: arrstack
spec: spec:
ports: ports:
@@ -11,5 +11,5 @@ spec:
protocol: TCP protocol: TCP
targetPort: http targetPort: http
selector: selector:
app: radarr-kids app: radarr
type: ClusterIP type: ClusterIP
@@ -0,0 +1,25 @@
---
# radarr API key. Seeded at kv/kubernetes/namespace/arrstack/default/radarr
# (key: apitoken); the default k8s role's templated policy already grants read
# on kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs it into the radarr-apikey Secret that the apikey-init initContainer reads
# to enforce <ApiKey> in /config/config.xml (Vault is source of truth).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: radarr-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -1,16 +1,16 @@
--- ---
# Scrape the exportarr sidecar (:9708) on every radarr-kids pod. Picked up by the # Scrape the exportarr sidecar (:9708) on every radarr pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than # observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the radarr-kids Service doesn't expose the metrics port. # VMServiceScrape because the radarr Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1 apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape kind: VMPodScrape
metadata: metadata:
name: radarr-kids-exportarr name: radarr-exportarr
namespace: arrstack namespace: arrstack
spec: spec:
selector: selector:
matchLabels: matchLabels:
app: radarr-kids app: radarr
podMetricsEndpoints: podMetricsEndpoints:
- port: metrics - port: metrics
path: /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,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,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
+33
View File
@@ -0,0 +1,33 @@
---
# Non-secret env for the -unkin2 fork. The fork reads Servarr config from
# Sonarr__<Section>__<Key> env (no config.xml edits, no s6/PUID). Postgres wiring
# points every replica at the same shared DB (arrstack-postgres-rw / sonarr-main);
# Auth__Method=External defers UI auth to arrproxy/oauth2-proxy; Server__UrlBase
# keeps the /sonarr prefix so arrproxy path-routing works; App__InstanceName is
# identical across replicas (shared session-cookie name). User/Password/ApiKey
# come from Secrets (see deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: sonarr-env
namespace: arrstack
data:
Sonarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Sonarr__Postgres__Port: "5432"
Sonarr__Postgres__MainDb: sonarr-main
Sonarr__Log__DbEnabled: "false"
Sonarr__Auth__Method: External
Sonarr__Auth__Required: DisabledForLocalAddresses
Sonarr__App__InstanceName: Sonarr
Sonarr__Server__Port: "8989"
Sonarr__Server__UrlBase: /sonarr
Sonarr__Update__Mechanism: External
# Shared arrstack Valkey (valkey-operator). Setting Host is what activates the
# fork's #14 Redis features (SignalR backplane, cross-replica cache-invalidation
# bus, distributed rate limiter): RedisOptions.IsConfigured gates purely on a
# non-empty Host, so there is no separate Enabled flag. The operator leaves the
# default user passwordless (jellyfin parity), so no Password/Ssl is wired.
# Channels/keys are namespaced by this fork's sonarr:ratelimit: prefix, so the
# one cluster is safe to share with radarr/prowlarr.
Sonarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Sonarr__Redis__Port: "6379"
@@ -2,40 +2,43 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: sonarr-adult name: sonarr
namespace: arrstack namespace: arrstack
annotations: annotations:
# Reloader rolls the Deployment when sonarr-adult-env changes (plain envFrom # sonarr-env is a plain (unhashed) ConfigMap consumed by fixed-name envFrom,
# ConfigMap does not trigger a rollout on its own). # 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" configmap.reloader.stakater.com/auto: "true"
spec: spec:
# Active-active: the -unkin2 fork keeps all state in the shared Postgres # Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N # (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the sonarr-adult Service. RollingUpdate is # replicas run concurrently behind the sonarr Service. RollingUpdate is safe —
# safe — no SQLite, no RWO lock. # no SQLite, no RWO lock.
replicas: 3 replicas: 3
strategy: strategy:
type: RollingUpdate type: RollingUpdate
selector: selector:
matchLabels: matchLabels:
app: sonarr-adult app: sonarr
template: template:
metadata: metadata:
labels: labels:
app: sonarr-adult app: sonarr
spec: spec:
securityContext: securityContext:
# Fork image has no USER; pin it to a non-root UID and group-write the # Fork image has no USER (runs as root by default); pin it to a non-root
# shared RWX CephFS media subtree. OnRootMismatch avoids a recursive # UID and group-write the shared RWX CephFS /config (MediaCover etc.).
# chown of the whole media tree. # OnRootMismatch avoids a recursive chown of the whole media tree.
runAsUser: 1000 runAsUser: 1000
runAsGroup: 1000 runAsGroup: 1000
fsGroup: 1000 fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch fsGroupChangePolicy: OnRootMismatch
initContainers: initContainers:
# Gate the app on its own Postgres database+role being reachable. # Gate the app on its own Postgres database+role being reachable, instead
# waitfordb reads the PG* env as a libpq fallback, so the password never # of relying on ArgoCD sync-waves (which deadlock if apps aren't Healthy).
# lands in argv. # waitfordb reads the PG* env as a libpq fallback, so the password never lands in argv.
- name: wait-for-db - name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env: env:
@@ -48,16 +51,16 @@ spec:
- name: PGPORT - name: PGPORT
value: "5432" value: "5432"
- name: PGDATABASE - name: PGDATABASE
value: sonarr-adult-main value: sonarr-main
- name: PGUSER - name: PGUSER
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: sonarr-adult-db name: sonarr-db
key: username key: username
- name: PGPASSWORD - name: PGPASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: sonarr-adult-db name: sonarr-db
key: password key: password
resources: resources:
requests: requests:
@@ -75,8 +78,9 @@ spec:
args: args:
- -nobrowser - -nobrowser
- -data=/config - -data=/config
# Bypass the single-instance guard so multiple replicas can share one # Required: bypass the single-instance guard so multiple replicas
# /config. Cross-replica safety is the Postgres layer, not a lock file. # can share one /config. Cross-replica safety is the Postgres layer,
# not a local lock file.
- -nosingleinstancecheck - -nosingleinstancecheck
ports: ports:
- name: http - name: http
@@ -84,25 +88,29 @@ spec:
protocol: TCP protocol: TCP
envFrom: envFrom:
- configMapRef: - configMapRef:
name: sonarr-adult-env name: sonarr-env
env: env:
- name: Sonarr__Postgres__User - name: Sonarr__Postgres__User
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: sonarr-adult-db name: sonarr-db
key: username key: username
- name: Sonarr__Postgres__Password - name: Sonarr__Postgres__Password
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: sonarr-adult-db name: sonarr-db
key: password key: password
- name: Sonarr__Auth__ApiKey - name: Sonarr__Auth__ApiKey
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: sonarr-adult-apikey name: sonarr-apikey
key: apitoken key: apitoken
# MediaCover object store (shared arrstack-media Ceph RGW bucket, # MediaCover object store (shared arrstack-media Ceph RGW bucket,
# partitioned by the sonarr-adult key prefix). # partitioned from radarr by the sonarr key prefix). Serves
# posters/fanart from S3 so any replica renders them instead of the
# leader-local emptyDir /config. Consumed by the -unkin3+ image;
# older images ignore these unknown config keys. Creds Secret minted
# by cephrgw-operator.
- name: Sonarr__MediaCoverS3__Endpoint - name: Sonarr__MediaCoverS3__Endpoint
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -121,13 +129,14 @@ spec:
- name: Sonarr__MediaCoverS3__Bucket - name: Sonarr__MediaCoverS3__Bucket
value: arrstack-media value: arrstack-media
- name: Sonarr__MediaCoverS3__Prefix - name: Sonarr__MediaCoverS3__Prefix
value: sonarr-adult value: sonarr
- name: Sonarr__MediaCoverS3__ForcePathStyle - name: Sonarr__MediaCoverS3__ForcePathStyle
value: "true" value: "true"
- name: Sonarr__MediaCoverS3__CaCertPath - name: Sonarr__MediaCoverS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket, # Backup object store (shared arrstack-backups Ceph RGW bucket,
# partitioned by the sonarr-adult key prefix). # per-app key prefix). Routes the periodic config+DB zip backups off
# the ephemeral /config so any replica can write and restore them.
- name: Sonarr__BackupS3__Endpoint - name: Sonarr__BackupS3__Endpoint
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -146,14 +155,14 @@ spec:
- name: Sonarr__BackupS3__Bucket - name: Sonarr__BackupS3__Bucket
value: arrstack-backups value: arrstack-backups
- name: Sonarr__BackupS3__Prefix - name: Sonarr__BackupS3__Prefix
value: sonarr-adult value: sonarr
- name: Sonarr__BackupS3__ForcePathStyle - name: Sonarr__BackupS3__ForcePathStyle
value: "true" value: "true"
- name: Sonarr__BackupS3__CaCertPath - name: Sonarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt value: /etc/ssl/vault-ca/ca.crt
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /3aa168/sonarr/ping path: /sonarr/ping
port: http port: http
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 30 periodSeconds: 30
@@ -161,7 +170,7 @@ spec:
failureThreshold: 3 failureThreshold: 3
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /3aa168/sonarr/ping path: /sonarr/ping
port: http port: http
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 10 periodSeconds: 10
@@ -177,31 +186,28 @@ spec:
volumeMounts: volumeMounts:
- name: config - name: config
mountPath: /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 - name: media-tv
mountPath: /media/tv mountPath: /media/tv
subPath: tvshows/adult
- name: vault-ca - name: vault-ca
mountPath: /etc/ssl/vault-ca mountPath: /etc/ssl/vault-ca
readOnly: true readOnly: true
# exportarr sidecar: polls the local replica's API and exposes Prometheus # exportarr sidecar: polls the local replica's API and exposes Prometheus
# metrics on :9707 (scraped by the sonarr-adult-exportarr VMPodScrape). # metrics on :9707 (scraped by the sonarr-exportarr VMPodScrape).
- name: exportarr - name: exportarr
image: ghcr.io/onedr0p/exportarr:v2.3.0 image: artifactapi.k8s.syd1.au.unkin.net/ghcr/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
args: args:
- sonarr - sonarr
env: env:
- name: PORT - name: PORT
value: "9707" value: "9707"
# URL includes the /3aa168/sonarr UrlBase (Sonarr__Server__UrlBase). # URL includes the /sonarr UrlBase (Sonarr__Server__UrlBase).
- name: URL - name: URL
value: http://localhost:8989/3aa168/sonarr value: http://localhost:8989/sonarr
- name: APIKEY - name: APIKEY
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: sonarr-adult-apikey name: sonarr-apikey
key: apitoken key: apitoken
ports: ports:
- name: metrics - name: metrics
+37
View File
@@ -0,0 +1,37 @@
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
labels:
traefik.io/instance: internal
annotations:
cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: sonarr.k8s.syd1.au.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: sonarr.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: sonarr
namespace: arrstack
spec:
gatewayClassName: traefik-internal
listeners:
- allowedRoutes:
namespaces:
from: Same
hostname: sonarr.k8s.syd1.au.unkin.net
name: http
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: sonarr.k8s.syd1.au.unkin.net
name: https
port: 443
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
name: sonarr-tls
mode: Terminate
@@ -2,15 +2,15 @@
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute kind: HTTPRoute
metadata: metadata:
name: repospawner-http-redirect name: sonarr-http-redirect
namespace: repospawner namespace: arrstack
spec: spec:
hostnames: hostnames:
- repospawner.k8s.syd1.au.unkin.net - sonarr.k8s.syd1.au.unkin.net
parentRefs: parentRefs:
- group: gateway.networking.k8s.io - group: gateway.networking.k8s.io
kind: Gateway kind: Gateway
name: repospawner name: sonarr
sectionName: http sectionName: http
rules: rules:
- filters: - filters:
@@ -26,22 +26,22 @@ spec:
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute kind: HTTPRoute
metadata: metadata:
name: repospawner name: sonarr-route
namespace: repospawner namespace: arrstack
spec: spec:
hostnames: hostnames:
- repospawner.k8s.syd1.au.unkin.net - sonarr.k8s.syd1.au.unkin.net
parentRefs: parentRefs:
- group: gateway.networking.k8s.io - group: gateway.networking.k8s.io
kind: Gateway kind: Gateway
name: repospawner name: sonarr
sectionName: https sectionName: https
rules: rules:
- backendRefs: - backendRefs:
- group: "" - group: ""
kind: Service kind: Service
name: repospawner-oauth2 name: sonarr
port: 4180 port: 8989
weight: 1 weight: 1
matches: matches:
- path: - path:
@@ -7,4 +7,6 @@ resources:
- configmap.yaml - configmap.yaml
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- gateway.yaml
- httproute.yaml
- vmpodscrape.yaml - vmpodscrape.yaml
+17
View File
@@ -0,0 +1,17 @@
---
# Sonarr /config. RWX on CephFS so all replicas share it (the -unkin2 fork keeps
# the database in Postgres; /config now holds only config.xml + MediaCover, which
# tolerate — and want — shared access). Retain: this is state.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: sonarr-config
namespace: arrstack
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 5Gi
storageClassName: cephfs-raid5-retain
volumeMode: Filesystem
@@ -2,7 +2,7 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: sonarr-kids name: sonarr
namespace: arrstack namespace: arrstack
spec: spec:
ports: ports:
@@ -11,5 +11,5 @@ spec:
protocol: TCP protocol: TCP
targetPort: http targetPort: http
selector: selector:
app: sonarr-kids app: sonarr
type: ClusterIP type: ClusterIP
@@ -0,0 +1,25 @@
---
# sonarr API key. Seeded at kv/kubernetes/namespace/arrstack/default/sonarr
# (key: apitoken); the default k8s role's templated policy already grants read
# on kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs it into the sonarr-apikey Secret that the apikey-init initContainer reads
# to enforce <ApiKey> in /config/config.xml (Vault is source of truth).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: sonarr-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -1,16 +1,16 @@
--- ---
# Scrape the exportarr sidecar (:9707) on every sonarr-kids pod. Picked up by the # Scrape the exportarr sidecar (:9707) on every sonarr pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than # observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the sonarr-kids Service doesn't expose the metrics port. # VMServiceScrape because the sonarr Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1 apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape kind: VMPodScrape
metadata: metadata:
name: sonarr-kids-exportarr name: sonarr-exportarr
namespace: arrstack namespace: arrstack
spec: spec:
selector: selector:
matchLabels: matchLabels:
app: sonarr-kids app: sonarr
podMetricsEndpoints: podMetricsEndpoints:
- port: metrics - port: metrics
path: /metrics path: /metrics
+1 -1
View File
@@ -28,7 +28,7 @@ metadata:
spec: spec:
shards: 1 shards: 1
replicas: 2 replicas: 2
image: docker.io/valkey/valkey:9.0.0 image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/valkey/valkey:9.0.0
exporter: exporter:
enabled: false enabled: false
scheduling: scheduling:
+1 -1
View File
@@ -36,7 +36,7 @@ spec:
mountPath: /combined-certs mountPath: /combined-certs
containers: containers:
- name: api - name: api
image: git.unkin.net/unkin/artifactapi:v3.11.2 image: git.unkin.net/unkin/artifactapi:v3.11.1
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 8000 - containerPort: 8000
+1 -28
View File
@@ -1,21 +1,4 @@
--- ---
# Path split between the authenticated UI and the unauthenticated machine API.
# Longest matching prefix wins, so the two UI rules take precedence over "/".
#
# AUTHENTICATED (oauth2 Service -> oauth2-proxy -> ui Service):
# /oauth2 oauth2-proxy sign_in / start / callback / sign_out
# /ui the human-facing SPA
#
# NOT AUTHENTICATED (artifactapi Service, unchanged):
# /api/v1/{remote,local,virtual}/* package proxy reads (yum/dnf, pip, ...)
# /api/v2/remotes|virtuals|locals/* management API + the UI's own XHR calls
# /api/v2/remotes/{name}/files/* CI publish uploads (PUT) and downloads
# /v2/* Docker Registry V2 (containerd, buildah)
# /terraform/v1/providers/* Terraform provider registry
# /.well-known/terraform.json Terraform service discovery
# /health, /version, / probes and the redirect to /ui/
# Those clients cannot complete a browser OIDC flow, so they must never be
# routed through oauth2-proxy.
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute kind: HTTPRoute
metadata: metadata:
@@ -39,17 +22,7 @@ spec:
- backendRefs: - backendRefs:
- group: "" - group: ""
kind: Service kind: Service
name: oauth2 name: ui
port: 80
weight: 1
matches:
- path:
type: PathPrefix
value: /oauth2
- backendRefs:
- group: ""
kind: Service
name: oauth2
port: 80 port: 80
weight: 1 weight: 1
matches: matches:
-2
View File
@@ -12,8 +12,6 @@ resources:
- gateway.yaml - gateway.yaml
- httproute.yaml - httproute.yaml
- namespace.yaml - namespace.yaml
- oauth2-proxy-configmap.yaml
- oauth2-proxy-deployment.yaml
- redis-deployment.yaml - redis-deployment.yaml
- services.yaml - services.yaml
- ui-deployment.yaml - ui-deployment.yaml
@@ -1,46 +0,0 @@
---
# Non-secret oauth2-proxy configuration (client_id/secret/cookie_secret come
# from the oauth-credentials Secret).
#
# SCOPE: this proxy fronts the artifactapi web UI ONLY. The HTTPRoute sends just
# /ui and /oauth2 here; every machine surface (/api/v1, /api/v2, /v2 docker
# registry, /terraform, /.well-known/terraform.json, /health, /version, /) goes
# straight to the api Service and is NOT authenticated. yum/dnf, containerd
# registry mirrors, docker/buildah, terraform init and Woodpecker publish steps
# cannot complete a browser OIDC flow, so they must never reach this container.
# Its only upstream is the ui Service -- there is deliberately no api upstream.
apiVersion: v1
kind: ConfigMap
metadata:
name: artifactapi-oauth2-env
namespace: artifactapi
data:
OAUTH2_PROXY_HTTP_ADDRESS: "0.0.0.0:4180"
OAUTH2_PROXY_METRICS_ADDRESS: "0.0.0.0:44180"
OAUTH2_PROXY_PROVIDER: "oidc"
# Publicly-trusted Authentik host: the authorize step is a browser redirect,
# so the issuer must present a cert every user's browser already trusts (the
# k8s host serves an internal-CA cert). Slug from terraform-authentik.
OAUTH2_PROXY_OIDC_ISSUER_URL: "https://identity.unkin.net/application/o/artifactapi/"
OAUTH2_PROXY_REDIRECT_URL: "https://artifactapi.k8s.syd1.au.unkin.net/oauth2/callback"
OAUTH2_PROXY_UPSTREAMS: "http://ui.artifactapi.svc.cluster.local:80/"
OAUTH2_PROXY_SCOPE: "openid email profile ak_groups"
# Populate session.Groups from the Authentik hierarchical ak_groups claim.
OAUTH2_PROXY_OIDC_GROUPS_CLAIM: "ak_groups"
OAUTH2_PROXY_ALLOWED_GROUPS: "akP-artifactapi-admin"
OAUTH2_PROXY_PASS_USER_HEADERS: "true"
OAUTH2_PROXY_EMAIL_DOMAINS: "*"
# Authentik hardcodes email_verified=false in the id_token; authorization is
# enforced via ak_groups, so accepting the unverified email is safe.
OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL: "true"
OAUTH2_PROXY_COOKIE_SECURE: "true"
OAUTH2_PROXY_COOKIE_DOMAINS: "artifactapi.k8s.syd1.au.unkin.net"
OAUTH2_PROXY_WHITELIST_DOMAINS: "artifactapi.k8s.syd1.au.unkin.net"
OAUTH2_PROXY_REVERSE_PROXY: "true"
OAUTH2_PROXY_CODE_CHALLENGE_METHOD: "S256"
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true"
# Back-channel discovery/token calls resolve the issuer inside the cluster,
# where it is served under the internal unkin.net CA rather than the publicly
# trusted cert the browser sees. Trust the bundle the combine-certs init
# container assembles, as every other oauth2-proxy in the estate does.
OAUTH2_PROXY_PROVIDER_CA_FILES: "/etc/ssl/combined/ca-certificates.crt"
@@ -1,136 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: oauth2
namespace: artifactapi
annotations:
configmap.reloader.stakater.com/auto: "true"
secret.reloader.stakater.com/reload: "oauth-credentials,vault-ca-cert"
spec:
replicas: 2
selector:
matchLabels:
app: oauth2
strategy:
rollingUpdate:
maxUnavailable: 1
type: RollingUpdate
template:
metadata:
labels:
app: oauth2
spec:
serviceAccountName: default
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
initContainers:
# The Authentik issuer is served behind the internal unkin.net CA;
# combine the system roots with it so oauth2-proxy's OIDC HTTP client
# trusts the discovery endpoint.
- 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
- containerPort: 44180
name: metrics
protocol: TCP
envFrom:
- configMapRef:
name: artifactapi-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
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
volumeMounts:
- name: combined-certs
mountPath: /etc/ssl/combined
readOnly: true
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
+1 -1
View File
@@ -54,7 +54,7 @@ spec:
successThreshold: 1 successThreshold: 1
timeoutSeconds: 5 timeoutSeconds: 5
- name: metrics-exporter - name: metrics-exporter
image: docker.io/oliver006/redis_exporter:v1.89.0 image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 9121 - containerPort: 9121
-20
View File
@@ -16,26 +16,6 @@ spec:
sessionAffinity: None sessionAffinity: None
type: ClusterIP type: ClusterIP
--- ---
# Authenticated front door for the web UI only: api-route sends /ui and /oauth2
# here, oauth2-proxy authenticates and forwards to the ui Service. Every other
# path reaches the api Service above directly and stays unauthenticated.
apiVersion: v1
kind: Service
metadata:
name: oauth2
namespace: artifactapi
spec:
internalTrafficPolicy: Cluster
ports:
- name: http
port: 80
protocol: TCP
targetPort: http
selector:
app: oauth2
sessionAffinity: None
type: ClusterIP
---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
automountServiceAccountToken: true automountServiceAccountToken: true
containers: containers:
- name: ui - name: ui
image: git.unkin.net/unkin/artifactapi-ui:v3.11.2 image: git.unkin.net/unkin/artifactapi-ui:v3.11.1
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 80 - containerPort: 80
@@ -32,26 +32,3 @@ spec:
refreshAfter: 5m refreshAfter: 5m
type: kv-v2 type: kv-v2
vaultAuthRef: default vaultAuthRef: default
---
# Authentik OIDC client for the artifactapi UI front door (client_id,
# client_secret, cookie_secret). Seeded out of band at
# kv/kubernetes/namespace/artifactapi/default/oauth-credentials; the default
# k8s auth role already grants the artifactapi/default ServiceAccount read on
# kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/*, so no
# terraform-vault change is needed. Consumed by the oauth2 Deployment.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: oauth-credentials
namespace: artifactapi
spec:
destination:
create: true
name: oauth-credentials
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/artifactapi/default/oauth-credentials
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
-14
View File
@@ -14,17 +14,3 @@ spec:
podMetricsEndpoints: podMetricsEndpoints:
- port: metrics - port: metrics
path: /metrics path: /metrics
---
# Scrape the UI oauth2-proxy (:44180), which exposes sign-in/authz counters.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: oauth2
namespace: artifactapi
spec:
selector:
matchLabels:
app: oauth2
podMetricsEndpoints:
- port: metrics
path: /metrics
+6 -21
View File
@@ -64,12 +64,8 @@ spec:
archive_mode: "on" archive_mode: "on"
archive_timeout: 5min archive_timeout: 5min
dynamic_shared_memory_type: posix dynamic_shared_memory_type: posix
effective_cache_size: 1536MB effective_cache_size: 256MB
full_page_writes: "on" full_page_writes: "on"
# Replicas report their oldest xmin to the primary, so multi-second reads on
# a hot standby stop exhausting max_standby_streaming_delay and being
# cancelled. Retained-dead-tuple cost is negligible on a ~155MB database.
hot_standby_feedback: "on"
log_destination: csvlog log_destination: csvlog
log_directory: /controller/log log_directory: /controller/log
log_filename: postgres log_filename: postgres
@@ -81,12 +77,7 @@ spec:
max_parallel_workers: "16" max_parallel_workers: "16"
max_replication_slots: "16" max_replication_slots: "16"
max_worker_processes: "16" max_worker_processes: "16"
# A pg_stat_statements.* parameter is what makes CNPG treat the extension as shared_buffers: 128MB
# managed and run CREATE EXTENSION in every database; preloading alone does
# not create it.
pg_stat_statements.max: "10000"
pg_stat_statements.track: top
shared_buffers: 512MB
shared_memory_type: mmap shared_memory_type: mmap
ssl_max_protocol_version: TLSv1.3 ssl_max_protocol_version: TLSv1.3
ssl_min_protocol_version: TLSv1.3 ssl_min_protocol_version: TLSv1.3
@@ -95,9 +86,6 @@ spec:
wal_log_hints: "on" wal_log_hints: "on"
wal_receiver_timeout: 5s wal_receiver_timeout: 5s
wal_sender_timeout: 5s wal_sender_timeout: 5s
# CNPG merges this with the libraries it manages itself.
shared_preload_libraries:
- pg_stat_statements
syncReplicaElectionConstraint: syncReplicaElectionConstraint:
enabled: false enabled: false
primaryUpdateMethod: restart primaryUpdateMethod: restart
@@ -117,16 +105,13 @@ spec:
updateInterval: 30 updateInterval: 30
resources: resources:
limits: limits:
# 500m is a 50ms CFS quota per 100ms period, exhausted by bursts even at cpu: 500m
# ~0.01 cores average, so every query pays throttle latency.
cpu: "2"
# 512Mi OOMKilled replicas under load (shared_buffers 128MB + # 512Mi OOMKilled replicas under load (shared_buffers 128MB +
# max_connections 200 leave no headroom) — see incident 2026-07-28. # max_connections 200 leave no headroom) — see incident 2026-07-28.
# shared_buffers 512MB needs the same headroom multiple, hence 2Gi.
memory: 2Gi
requests:
cpu: 500m
memory: 1Gi memory: 1Gi
requests:
cpu: 50m
memory: 512Mi
smartShutdownTimeout: 180 smartShutdownTimeout: 180
startDelay: 3600 startDelay: 3600
stopDelay: 1800 stopDelay: 1800
+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 apiVersion: gateway.networking.k8s.io/v1
kind: Gateway kind: Gateway
metadata: metadata:
@@ -49,11 +6,11 @@ metadata:
traefik.io/instance: internal traefik.io/instance: internal
annotations: annotations:
cert-manager.io/cluster-issuer: vault-issuer 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" 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 external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: authentik-internal name: authentik
namespace: authentik namespace: authentik
spec: spec:
gatewayClassName: traefik-internal gatewayClassName: traefik-internal
@@ -61,14 +18,14 @@ spec:
- allowedRoutes: - allowedRoutes:
namespaces: namespaces:
from: Same from: Same
hostname: identity.k8s.syd1.au.unkin.net hostname: identity.unkin.net
name: http name: http
port: 80 port: 80
protocol: HTTP protocol: HTTP
- allowedRoutes: - allowedRoutes:
namespaces: namespaces:
from: Same from: Same
hostname: identity.k8s.syd1.au.unkin.net hostname: identity.unkin.net
name: https name: https
port: 443 port: 443
protocol: HTTPS protocol: HTTPS
@@ -78,3 +35,23 @@ spec:
kind: Secret kind: Secret
name: authentik-tls name: authentik-tls
mode: Terminate 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
+8 -79
View File
@@ -7,11 +7,16 @@ metadata:
spec: spec:
hostnames: hostnames:
- identity.unkin.net - identity.unkin.net
- identity.k8s.syd1.au.unkin.net
parentRefs: parentRefs:
- group: gateway.networking.k8s.io - group: gateway.networking.k8s.io
kind: Gateway kind: Gateway
name: authentik name: authentik
sectionName: http sectionName: http
- group: gateway.networking.k8s.io
kind: Gateway
name: authentik
sectionName: http-internal
rules: rules:
- filters: - filters:
- type: RequestRedirect - type: RequestRedirect
@@ -31,93 +36,17 @@ metadata:
spec: spec:
hostnames: hostnames:
- identity.unkin.net - identity.unkin.net
- identity.k8s.syd1.au.unkin.net
parentRefs: parentRefs:
- group: gateway.networking.k8s.io - group: gateway.networking.k8s.io
kind: Gateway kind: Gateway
name: authentik name: authentik
sectionName: https sectionName: https
rules:
- backendRefs:
- group: ""
kind: Service
name: authentik-server
port: 80
weight: 1
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplaceFullPath
replaceFullPath: /application/o/token/
matches:
- path:
type: Exact
value: /application/o/token
- 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 - group: gateway.networking.k8s.io
kind: Gateway kind: Gateway
name: authentik-internal name: authentik
sectionName: http sectionName: https-internal
rules: 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
rules:
- backendRefs:
- group: ""
kind: Service
name: authentik-server
port: 80
weight: 1
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplaceFullPath
replaceFullPath: /application/o/token/
matches:
- path:
type: Exact
value: /application/o/token
- backendRefs: - backendRefs:
- group: "" - group: ""
kind: Service kind: Service
-4
View File
@@ -10,16 +10,12 @@ resources:
- httproute.yaml - httproute.yaml
- ldap-gateway.yaml - ldap-gateway.yaml
- ldap-httproute.yaml - ldap-httproute.yaml
- ldap-outpost-deployment.yaml
- ldap-outpost-vaultstaticsecret.yaml
- ldap-outpost-vmpodscrape.yaml
- ldap-service.yaml - ldap-service.yaml
- ldap-tlsroute.yaml - ldap-tlsroute.yaml
- namespace.yaml - namespace.yaml
- redis-deployment.yaml - redis-deployment.yaml
- redis-pvc.yaml - redis-pvc.yaml
- redis-service.yaml - redis-service.yaml
- server-vmpodscrape.yaml
- vaultauth.yaml - vaultauth.yaml
- vaultstaticsecret.yaml - vaultstaticsecret.yaml
- vmpodscrape.yaml - vmpodscrape.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: spec:
internalTrafficPolicy: Cluster internalTrafficPolicy: Cluster
ports: ports:
- name: ldap
port: 3389
protocol: TCP
targetPort: 3389
- name: ldaps - name: ldaps
port: 6636 port: 6636
protocol: TCP protocol: TCP
+1 -1
View File
@@ -53,7 +53,7 @@ spec:
- mountPath: /data - mountPath: /data
name: redis-data name: redis-data
- name: metrics-exporter - name: metrics-exporter
image: docker.io/oliver006/redis_exporter:v1.89.0 image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 9121 - containerPort: 9121
@@ -1,16 +0,0 @@
---
# Scrape the authentik server's django_prometheus endpoint (:9300). Picked up
# by the observability VMAgent (selectAllByDefault).
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: authentik-server
namespace: authentik
spec:
selector:
matchLabels:
app.kubernetes.io/name: authentik
app.kubernetes.io/component: server
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -1,6 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ns1.yaml
@@ -1,16 +0,0 @@
---
apiVersion: bind.unkin.net/v1alpha1
kind: DNSRecord
metadata:
name: acme-ns1-a
namespace: bind-external
spec:
zoneRef: acme-unkin-net
name: ns1
type: A
ttl: 3600
values:
# Public address of this cluster's external BIND, same target as
# acme-ns1.unkin.net. Resolvers that cached the seeded ns1.acme.unkin.net
# NS name must still reach the zone.
- 103.216.191.185
@@ -1,11 +0,0 @@
---
# Authoritative delegation records for acme.unkin.net. Without these the zone
# only holds the operator's seed apex (NS ns1.acme.unkin.net glued to the
# primary pod IP), which is unroutable off-cluster and goes stale on
# reschedule. DNSRecords must live in the same namespace as their BindZone.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ns
- a
@@ -1,16 +0,0 @@
---
apiVersion: bind.unkin.net/v1alpha1
kind: DNSRecord
metadata:
name: acme-apex-ns
namespace: bind-external
spec:
zoneRef: acme-unkin-net
# "@" is the zone apex.
name: "@"
type: NS
ttl: 3600
values:
# Matches the parent delegation in Google Cloud DNS. Out of zone, so the
# child needs no glue of its own.
- acme-ns1.unkin.net.
@@ -1,6 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- apex.yaml
@@ -7,5 +7,4 @@ resources:
- cluster.yaml - cluster.yaml
- tsigkey.yaml - tsigkey.yaml
- zones.yaml - zones.yaml
- acme-unkin-net
- agent-dns-rolebinding.yaml - agent-dns-rolebinding.yaml
-11
View File
@@ -17,14 +17,3 @@ spec:
updateKeyRef: certmanager updateKeyRef: certmanager
allowTransfer: allowTransfer:
- key certmanager - key certmanager
# Published apex NS. acme-ns1 is what the parent delegates to and glues; ns1 is
# in-zone, so its address is declared below or a reseed would glue it to the
# primary pod IP.
nameservers:
- acme-ns1.unkin.net.
- ns1.acme.unkin.net.
records:
- name: ns1
type: A
ttl: 3600
values: ["103.216.191.185"]
@@ -1,15 +0,0 @@
---
apiVersion: bind.unkin.net/v1alpha1
kind: DNSRecord
metadata:
name: dashboard-ceph-cname
namespace: bind-internal
spec:
zoneRef: ceph-unkin-net
name: dashboard
type: CNAME
ttl: 600
values:
# Ceph mgr dashboard, reached via lb1. Lets in-cluster clients (the
# cephrgw-operator) resolve dashboard.ceph.unkin.net.
- lb1.unkin.net.
@@ -1,7 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- dashboard.yaml
- s3.yaml
@@ -1,15 +0,0 @@
---
apiVersion: bind.unkin.net/v1alpha1
kind: DNSRecord
metadata:
name: s3-ceph-cname
namespace: bind-internal
spec:
zoneRef: ceph-unkin-net
name: s3
type: CNAME
ttl: 600
values:
# radosgw S3 endpoint. Points at the Consul service for now; the real
# target will be changed later.
- radosgw.service.consul.
@@ -1,6 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- cname

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