From b71ed91e676ddcd176f78c9aee90e0dd00c99392 Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Sat, 20 Jun 2026 05:31:37 -0700 Subject: [PATCH] [milky-way][orion-system] Add sarr parr --- .../stage00/orion-system/main.jsonnet | 21 +++ milky-way/lib/images.libsonnet | 11 ++ milky-way/lib/prowlarr.libsonnet | 131 +++++++++++++++++ milky-way/lib/sonarr.libsonnet | 139 ++++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 milky-way/lib/prowlarr.libsonnet create mode 100644 milky-way/lib/sonarr.libsonnet diff --git a/milky-way/environments/stage00/orion-system/main.jsonnet b/milky-way/environments/stage00/orion-system/main.jsonnet index 774a186..93f0549 100644 --- a/milky-way/environments/stage00/orion-system/main.jsonnet +++ b/milky-way/environments/stage00/orion-system/main.jsonnet @@ -13,6 +13,8 @@ local testTailscaleIngress = import 'milky-way/lib/test-tailscale-operator-ingre local testTailscaleL3 = import 'milky-way/lib/test-tailscale-operator-network-L3.libsonnet'; local openclaw = import 'milky-way/lib/openclaw.libsonnet'; local qbittorrent = import 'milky-way/lib/qbittorrent.libsonnet'; +local sonarr = import 'milky-way/lib/sonarr.libsonnet'; +local prowlarr = import 'milky-way/lib/prowlarr.libsonnet'; local wgConf = import 'milky-way/lib/wireguard-conf.libsonnet'; local sftp = import 'milky-way/lib/sftp.libsonnet'; local grandCentral = import 'milky-way/lib/grand-central.libsonnet'; @@ -149,6 +151,25 @@ local secrets = import 'milky-way/secrets/k8s-secret-values.jsonnet'; downloadsSubdir = "downloads/qbittorrent", ), + // Sonarr: monitors/grabs TV episodes, hands torrents to qbittorrent + // (qbittorrent.default.svc.cluster.local:8080), then imports completed downloads by hardlinking + // them out of /data/downloads/qbittorrent into a library tree (e.g. /data/library/tv) on the + // SHARED mdata volume -- same PVC, same /data mount path as qbittorrent, so hardlinks/atomic + // moves stay on one filesystem. WebUI via Tailscale L7 ingress; SQLite config on its own iSCSI + // RWO PVC. The download-client/indexer links are entered in the UI post-deploy (they need API + // keys each app generates on first boot). + sonarr: sonarr.new( + tailscaleHostname = "sonarr", + mediaVolumeClaimName = this.mdataPvc.metadata.name, + ), + + // Prowlarr: indexer manager. No media volume -- it pushes indexer configs to Sonarr + // (sonarr.default.svc.cluster.local:8989, and later Radarr) over ClusterIP DNS. WebUI via + // Tailscale L7 ingress; SQLite config on its own iSCSI RWO PVC. + prowlarr: prowlarr.new( + tailscaleHostname = "prowlarr", + ), + // Public-key-only SFTP front door onto the shared mdata volume (read-write), reached over the // tailnet (mdata-sftp.tail4c9a.ts.net:22) and over the LAN via methanol's mDNS alias // (mdata-methanol.local:30022 -- alias + firewall port live in venus methanol.nix). Authorized diff --git a/milky-way/lib/images.libsonnet b/milky-way/lib/images.libsonnet index 0036c7d..796df56 100644 --- a/milky-way/lib/images.libsonnet +++ b/milky-way/lib/images.libsonnet @@ -53,6 +53,17 @@ local images = { fullyQualifiedRepository: "lscr.io/linuxserver/qbittorrent", defaultDigest: { hash: "sha256:1784d5a65d08d01de308c7d87ff2c1dba328379e180eeca41cc6b96bdf6a0ffc", tagHint: "5.2.1" }, }, + // *arr media-management apps (LinuxServer.io). The hash is the multi-arch INDEX digest (k3s + // resolves the per-node arch), matching the qbittorrent pin above; tagHint is the readable + // LinuxServer version. Re-resolve with `docker buildx imagetools inspect :latest`. + sonarr: { + fullyQualifiedRepository: "lscr.io/linuxserver/sonarr", + defaultDigest: { hash: "sha256:02bc962946fef994e67a38152446df25c10a52f8583aefeeb6467f9dd44cab99", tagHint: "4.0.17.2952-ls314" }, + }, + prowlarr: { + fullyQualifiedRepository: "lscr.io/linuxserver/prowlarr", + defaultDigest: { hash: "sha256:7ab5769616c1929247c8e7944453253f0b777fac2724c3bc9976ae2ff4023257", tagHint: "2.4.0.5397-ls150" }, + }, // Minimal OpenSSH SFTP-only server. The :alpine tag is a single-arch (linux/amd64) manifest -- // matches methanol -- so the digest below is that manifest, not a multi-arch index. "atmoz-sftp": { diff --git a/milky-way/lib/prowlarr.libsonnet b/milky-way/lib/prowlarr.libsonnet new file mode 100644 index 0000000..ae1f580 --- /dev/null +++ b/milky-way/lib/prowlarr.libsonnet @@ -0,0 +1,131 @@ +local utils = import 'milky-way/lib/utils.libsonnet'; +local images = import 'milky-way/lib/images.libsonnet'; + +// Prowlarr (LinuxServer.io): indexer manager. Configure indexers (trackers/usenet) once here and +// Prowlarr pushes them to the *arr apps (Sonarr now, Radarr later) over HTTP + API key. It manages +// no media files, so -- unlike Sonarr -- it mounts NO shared media volume; the only persistent +// state is its own /config. +// +// No VPN sidecar and no config seed (Prowlarr writes config.xml itself on first boot). /config +// holds a SQLite DB + config.xml rewritten at runtime; SQLite over NFS is unsafe, so config lives +// on iSCSI (RWO), and an RWO PVC forces strategy: Recreate (old pod releases before new mounts). +// WebUI exposed over the tailnet via Tailscale L7 ingress. +{ + new( + tailscaleHostname, // required, unique tailnet-wide -> https://..ts.net + name='prowlarr', + namespace='default', + image=images.prowlarr.fullyQualifiedImageReferencePinned, + port=9696, // Prowlarr's WebUI/API port + timezone='America/Los_Angeles', + configStorageClassName='my-custom-zfs-generic-iscsi', // RWO; SQLite must not be on NFS + configStorageSize='5Gi', + ):: { + local this = self, + + configPvc: { + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { name: name + '-config', namespace: namespace }, + spec: { + accessModes: ['ReadWriteOncePod'], + storageClassName: configStorageClassName, + resources: { requests: { storage: configStorageSize } }, + }, + }, + + deployment: { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: name, namespace: namespace }, + spec: { + replicas: 1, + strategy: { type: 'Recreate' }, // RWO config PVC: old pod must release before new mounts + selector: { matchLabels: { app: name } }, + template: { + metadata: { labels: {} + this.deployment.spec.selector.matchLabels }, + spec: { + tolerations: [ + { key: 'ephemeral', operator: 'Exists', effect: 'NoSchedule' }, + ], + containers: [ + { + name: name, + image: image, + env: [ + { name: 'PUID', value: '1000' }, + { name: 'PGID', value: '1000' }, + { name: 'TZ', value: timezone }, + ], + ports: [{ name: 'webui', containerPort: port }], + volumeMounts: [ + { name: 'config', mountPath: '/config' }, + ], + // /ping is Prowlarr's unauthenticated health endpoint -- safe readiness signal even + // before first-run setup. + readinessProbe: { + httpGet: { path: '/ping', port: 'webui' }, + initialDelaySeconds: 15, + periodSeconds: 15, + }, + resources: { + requests: { memory: '256Mi', cpu: '100m' }, + limits: { memory: '1Gi', cpu: '1' }, + }, + }, + ], + volumes: [ + { name: 'config', persistentVolumeClaim: { claimName: this.configPvc.metadata.name } }, + ], + }, + }, + }, + }, + + service: { + apiVersion: 'v1', + kind: 'Service', + metadata: { name: name, namespace: namespace }, + spec: { + selector: {} + this.deployment.spec.template.metadata.labels, + ports: [ + { + name: 'webui', + port: port, + targetPort: utils.assertEqualAndReturn(this.deployment.spec.template.spec.containers[0].ports[0].name, 'webui'), + }, + ], + type: 'ClusterIP', + }, + }, + + // Tailnet-only L7 ingress (no funnel), mirroring qbittorrent/openclaw. + ingress: { + apiVersion: 'networking.k8s.io/v1', + kind: 'Ingress', + metadata: { + name: name, + namespace: namespace, + annotations: { 'tailscale.com/funnel': 'false' }, + }, + spec: { + ingressClassName: 'tailscale', + tls: [{ hosts: [tailscaleHostname] }], + rules: [{ + http: { + paths: [{ + path: '/', + pathType: 'Prefix', + backend: { + service: { + name: this.service.metadata.name, + port: { number: utils.assertEqualAndReturn(this.service.spec.ports[0].port, port) }, + }, + }, + }], + }, + }], + }, + }, + }, +} diff --git a/milky-way/lib/sonarr.libsonnet b/milky-way/lib/sonarr.libsonnet new file mode 100644 index 0000000..5394b95 --- /dev/null +++ b/milky-way/lib/sonarr.libsonnet @@ -0,0 +1,139 @@ +local utils = import 'milky-way/lib/utils.libsonnet'; +local images = import 'milky-way/lib/images.libsonnet'; + +// Sonarr (LinuxServer.io): monitors/grabs TV episodes, hands torrents to qbittorrent, then imports +// completed downloads by hardlinking them out of the qbittorrent save dir into a library tree. +// +// No VPN sidecar (Sonarr is not a torrent client -- it talks to qbittorrent's WebUI, which is the +// thing behind gluetun) and no config seed (Sonarr writes config.xml itself on first boot, so +// there's nothing to seed -- contrast qbittorrent, which seeds qBittorrent.conf only-if-empty). +// +// Storage: /config holds a SQLite DB + config.xml that Sonarr rewrites at runtime. SQLite over NFS +// is unsafe (locking/corruption), so config lives on iSCSI (RWO) -- and an RWO PVC means the old +// pod must release the volume before a new one mounts it, hence strategy: Recreate. Media lives on +// the SHARED `mdata` RWX-NFS PVC (the same one qbittorrent mounts), mounted here at the same path +// so that downloads (/downloads/qbittorrent) and the library tree +// (/library/tv) are one filesystem -- hardlinks and atomic moves require that. +{ + new( + tailscaleHostname, // required, unique tailnet-wide -> https://..ts.net + mediaVolumeClaimName, // required -> external shared RWX PVC (the `mdata` PVC in main.jsonnet) + name='sonarr', + namespace='default', + image=images.sonarr.fullyQualifiedImageReferencePinned, + port=8989, // Sonarr's WebUI/API port + timezone='America/Los_Angeles', + configStorageClassName='my-custom-zfs-generic-iscsi', // RWO; SQLite must not be on NFS + configStorageSize='5Gi', + mediaMountPath='/data', // whole shared volume mounted here (matches qbittorrent's /data) + ):: { + local this = self, + + configPvc: { + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { name: name + '-config', namespace: namespace }, + spec: { + accessModes: ['ReadWriteOncePod'], + storageClassName: configStorageClassName, + resources: { requests: { storage: configStorageSize } }, + }, + }, + + deployment: { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: name, namespace: namespace }, + spec: { + replicas: 1, + strategy: { type: 'Recreate' }, // RWO config PVC: old pod must release before new mounts + selector: { matchLabels: { app: name } }, + template: { + metadata: { labels: {} + this.deployment.spec.selector.matchLabels }, + spec: { + tolerations: [ + { key: 'ephemeral', operator: 'Exists', effect: 'NoSchedule' }, + ], + containers: [ + { + name: name, + image: image, + env: [ + { name: 'PUID', value: '1000' }, + { name: 'PGID', value: '1000' }, + { name: 'TZ', value: timezone }, + ], + ports: [{ name: 'webui', containerPort: port }], + volumeMounts: [ + { name: 'config', mountPath: '/config' }, + { name: 'media', mountPath: mediaMountPath }, + ], + // /ping is Sonarr's unauthenticated health endpoint (returns 200 regardless of the + // login/auth config), so it's a safe readiness signal even before first-run setup. + readinessProbe: { + httpGet: { path: '/ping', port: 'webui' }, + initialDelaySeconds: 15, + periodSeconds: 15, + }, + resources: { + requests: { memory: '256Mi', cpu: '100m' }, + limits: { memory: '1Gi', cpu: '1' }, + }, + }, + ], + volumes: [ + { name: 'config', persistentVolumeClaim: { claimName: this.configPvc.metadata.name } }, + { name: 'media', persistentVolumeClaim: { claimName: mediaVolumeClaimName } }, + ], + }, + }, + }, + }, + + service: { + apiVersion: 'v1', + kind: 'Service', + metadata: { name: name, namespace: namespace }, + spec: { + selector: {} + this.deployment.spec.template.metadata.labels, + ports: [ + { + name: 'webui', + port: port, + targetPort: utils.assertEqualAndReturn(this.deployment.spec.template.spec.containers[0].ports[0].name, 'webui'), + }, + ], + type: 'ClusterIP', + }, + }, + + // Tailnet-only L7 ingress (no funnel), mirroring qbittorrent/openclaw. + ingress: { + apiVersion: 'networking.k8s.io/v1', + kind: 'Ingress', + metadata: { + name: name, + namespace: namespace, + annotations: { 'tailscale.com/funnel': 'false' }, + }, + spec: { + ingressClassName: 'tailscale', + tls: [{ hosts: [tailscaleHostname] }], + rules: [{ + http: { + paths: [{ + path: '/', + pathType: 'Prefix', + backend: { + service: { + name: this.service.metadata.name, + port: { number: utils.assertEqualAndReturn(this.service.spec.ports[0].port, port) }, + }, + }, + }], + }, + }], + }, + }, + }, +} -- 2.51.2