fix: restore tree dropped by 6d224861; land stem95su gdrive-sync (10m) [ci skip]

6d224861 came from a --no-checkout worktree whose empty index made the
commit drop every file except two. This restores 05b50d2b's full tree and
correctly adds stacks/stem95su/gdrive-sync.tf + the service-catalog stem95su
entry. Forward-only (parent=6d224861, no force-push); [ci skip] since the
live infra was never applied from the broken commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-09 08:45:33 +00:00
parent 6d224861c4
commit fd0f4a0365
1166 changed files with 358546 additions and 0 deletions

140
stacks/servarr/.terraform.lock.hcl generated Normal file
View file

@ -0,0 +1,140 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/cloudflare/cloudflare" {
version = "4.52.7"
constraints = "~> 4.0"
hashes = [
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
]
}
provider "registry.terraform.io/gavinbunney/kubectl" {
version = "1.19.0"
constraints = "~> 1.14"
hashes = [
"h1:9QkxPjp0x5FZFfJbE+B7hBOoads9gmdfj9aYu5N4Sfc=",
]
}
provider "registry.terraform.io/goauthentik/authentik" {
version = "2024.12.1"
constraints = "~> 2024.10"
hashes = [
"h1:roBMd+gi+TGgikH/bMzEI8JfvJiMAQWt+8FmokCrQIs=",
"zh:090260dc7889ea822ec1d899344e1ee23eba5290461989c0796149c9511f2316",
"zh:13c2655ff824b0dc4b9bb832b5ca6d41dba97cb280330258c5fef4115e236209",
"zh:166a73c3a810c9c895d68a8ff968158f339f8a2c1c03e20ec9fc5ed99cc64e20",
"zh:203777eae1cdc711233315499643180604cff2324411b186b7cf07fdbe16f655",
"zh:3b2f18c9a8d28dac74dc6bbf168c946855ab9c68f053578d4630c50d5eaf30a0",
"zh:4822275985f6b74b6196c47112316a4252db22cf4ceaef7c9ab4c66d488abf2f",
"zh:53ea97562666c8a5a2f6d63d418a302a7f8ee4b7bb7da35dedaa89aa5708b7f0",
"zh:56b8a230901e3550c92a1d3f58ee9dafe9853f30fe4315af3ab28ae63262e15d",
"zh:6293ab7b1fd8206a0c853591f50186aca4a1eff117b2a773e10760a23a2c83e9",
"zh:9433970f79fb92d8aae3ee436db5630ab312c78b6dc9df9c1db3273a18f8aaa1",
"zh:95df406214f79b3b98222d7c7fe8fc319a3d90b7a9d53e1d5abbda5dfb8b9436",
"zh:a85880da0552a42c8f449390fbd7d8b03541d1a13e04bba9f1404fa658754260",
"zh:a95f6e9bd62c67e70eba1b1a14728856b9a6a28cd1e5e3be54a7718882c87e7f",
"zh:dd599b51c5beb34a4c6feece244fde07d2558d69929449ab1fd39a5ebe738781",
]
}
provider "registry.terraform.io/hashicorp/helm" {
version = "3.1.1"
hashes = [
"h1:5b2ojWKT0noujHiweCds37ZreRFRQLNaErdJLusJN88=",
"zh:1a6d5ce931708aec29d1f3d9e360c2a0c35ba5a54d03eeaff0ce3ca597cd0275",
"zh:3411919ba2a5941801e677f0fea08bdd0ae22ba3c9ce3309f55554699e06524a",
"zh:81b36138b8f2320dc7f877b50f9e38f4bc614affe68de885d322629dd0d16a29",
"zh:95a2a0a497a6082ee06f95b38bd0f0d6924a65722892a856cfd914c0d117f104",
"zh:9d3e78c2d1bb46508b972210ad706dd8c8b106f8b206ecf096cd211c54f46990",
"zh:a79139abf687387a6efdbbb04289a0a8e7eaca2bd91cdc0ce68ea4f3286c2c34",
"zh:aaa8784be125fbd50c48d84d6e171d3fb6ef84a221dbc5165c067ce05faab4c8",
"zh:afecd301f469975c9d8f350cc482fe656e082b6ab0f677d1a816c3c615837cc1",
"zh:c54c22b18d48ff9053d899d178d9ffef7d9d19785d9bf310a07d648b7aac075b",
"zh:db2eefd55aea48e73384a555c72bac3f7d428e24147bedb64e1a039398e5b903",
"zh:ee61666a233533fd2be971091cecc01650561f1585783c381b6f6e8a390198a4",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
]
}
provider "registry.terraform.io/hashicorp/kubernetes" {
version = "3.1.0"
hashes = [
"h1:oodIAuFMikXNmEtil5MQgP4dfSctUBYQiGJfjbsF3NY=",
"zh:0215c5c60be62028c09a2f22458e89cda3ef5830a632299f1d401eb3538874b0",
"zh:09ebb9f442431e278a310a9423f32caf467cb4b3cad3fe59573ca71fa7b14e20",
"zh:0c4e5912f83bb35846ae0a9ae54fc320706ee61894cd21cc6b4181b1c5a2fa5c",
"zh:1678c982853ad461e65ccb5e79d585e13ed109dd47dab2a66d3a7a304faeef65",
"zh:1c050a5c15e330457a9c18caacf61a923c59d663e13f2962e4b32f04fef523a0",
"zh:2c55bcec83be58ec132c7cb0a1ac644758b800d794fdc636d53a0eada0358a3a",
"zh:a062bb0aa316c08d8460c66a5d68da71da40de5d3bc3b31abcf3a1a9a19650f1",
"zh:a26fdea0afaa9b247c73c0b42843ca51ba7db0ac2571f9d3d50dcabd20ca1b98",
"zh:c872c9385a78d502bf5823d61cd3bb0f9a0585030e025eb12585c83451beeaa1",
"zh:f180879af931182beee4c8c0d9dab62b81d86f17ddcbe3786ef4c7cec9163a4e",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
"zh:f70f5789264069e0eef06f9b5d5fde955ef7206f7d446d1ce51a4c37a3f3e02f",
]
}
provider "registry.terraform.io/hashicorp/random" {
version = "3.8.1"
hashes = [
"h1:Eexl06+6J+s75uD46+WnZtpJZYRVUMB0AiuPBifK6Jc=",
"zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4",
"zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae",
"zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57",
"zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0",
"zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66",
"zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9",
"zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05",
"zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8",
"zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b",
"zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699",
]
}
provider "registry.terraform.io/hashicorp/vault" {
version = "4.8.0"
constraints = "~> 4.0"
hashes = [
"h1:aHqgWQhDBMeZO9iUKwJYMlh4q+xNMUlMIcjRbF4d02Y=",
"zh:269ab13433f67684012ae7e15876532b0312f5d0d2002a9cf9febb1279ce5ea6",
"zh:4babc95bf0c40eb85005db1dc2ca403c46be4a71dd3e409db3711a56f7a5ca0e",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:86e27c1c625ecc24446a11eeffc3ac319b36c2b4e51251db8579256a0dbcf136",
"zh:a32f31da94824009e26b077374440b52098aecb93c92ff55dc3d31dd37c4ea25",
"zh:be0a18c6c0425518bab4fbffd82078b82036a88503b5d76064de551c9f646cbf",
"zh:be5a77fdfd36863ebeec79cd12b1d13322ffad6821d157a0b279789fa06b5937",
"zh:be8317d142a3caad74c7d936039ae27076a1b2b8312ef5208e2871a5f525977c",
"zh:c94a84895a3d9954b80e983eed4603330a5cdbbd8eef5b3c99278c2d1402ef3c",
"zh:de1fb712784dd8415f011ca5346a34f87fab6046c730557615247e511dbc7d98",
"zh:e3eafae7da550f86cae395d6660b2a0e93ec8d2b0e0e5ef982ec762e961fc952",
"zh:ff35fb1ab6add288f0f368981e56f780b50405accd1937131cba1137999c8d83",
]
}
provider "registry.terraform.io/telmate/proxmox" {
version = "3.0.2-rc07"
constraints = "3.0.2-rc07"
hashes = [
"h1:zp5hpQJQ4t4zROSLqdltVpBO+Riy9VugtfFbpyTw1aM=",
]
}

View file

@ -0,0 +1,616 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "aiostreams_database_connection_string" { type = string }
variable "nfs_server" { type = string }
resource "kubernetes_namespace" "aiostreams" {
metadata {
name = "aiostreams"
labels = {
"istio-injection" : "disabled"
"keel.sh/enrolled" = "true"
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
}
}
resource "random_id" "secret_key" {
byte_length = 32 # 32 bytes × 2 hex chars = 64 hex characters
}
resource "kubernetes_persistent_volume_claim" "data_proxmox" {
wait_until_bound = false
metadata {
name = "aiostreams-data-proxmox"
namespace = kubernetes_namespace.aiostreams.metadata[0].name
annotations = {
"resize.topolvm.io/threshold" = "10%"
"resize.topolvm.io/increase" = "100%"
"resize.topolvm.io/storage_limit" = "5Gi"
}
}
spec {
access_modes = ["ReadWriteOnce"]
storage_class_name = "proxmox-lvm"
resources {
requests = {
storage = "1Gi"
}
}
}
lifecycle {
# The autoresizer expands requests.storage up to storage_limit and
# PVCs can't shrink. Without this, every TF apply tries to revert
# to the spec value, K8s rejects the shrink, and the PVC ends up
# in Terminating-but-in-use limbo.
ignore_changes = [spec[0].resources[0].requests]
}
}
resource "kubernetes_deployment" "aiostreams" {
metadata {
name = "aiostreams"
namespace = kubernetes_namespace.aiostreams.metadata[0].name
labels = {
app = "aiostreams"
tier = var.tier
}
}
spec {
replicas = 1
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "aiostreams"
}
}
template {
metadata {
labels = {
app = "aiostreams"
}
}
spec {
container {
image = "viren070/aiostreams:2026.05.14.1326-nightly"
name = "aiostreams"
port {
container_port = 3000
}
env {
name = "BASE_URL"
value = "https://aiostreams.viktorbarzin.me"
}
env {
name = "SECRET_KEY"
value = random_id.secret_key.hex
}
env {
name = "DATABASE_URI"
value = var.aiostreams_database_connection_string
}
env {
# Cache stream-response payloads for 1h. Default is -1 (disabled),
# which made every Stremio request hit all 5 upstream addons live
# slow, and contributed to the perceived empty-list issue when an
# upstream was slow/erroring. 1h is short enough that RD cache
# invalidations are picked up quickly.
name = "STREAM_CACHE_TTL"
value = "3600"
}
env {
# Whitelisted regex sync URLs. Vidhin's regexes.json contains release-group
# patterns (TRaSH Guides-aligned).
name = "WHITELISTED_REGEX_PATTERNS_URLS"
value = jsonencode([
"https://raw.githubusercontent.com/Vidhin05/Releases-Regex/main/English/regexes.json",
])
}
env {
# Whitelisted SEL (Stream Expression Language) sync URLs. Stream-expression
# files (Vidhin's ranked expressions + Tamtaro's ISE/PSE/ESE) go here, NOT
# in WHITELISTED_REGEX_PATTERNS_URLS AIOStreams validates each field
# against the correct whitelist.
name = "WHITELISTED_SEL_URLS"
value = jsonencode([
"https://raw.githubusercontent.com/Vidhin05/Releases-Regex/main/English/expressions.json",
"https://raw.githubusercontent.com/Tam-Taro/SEL-Filtering-and-Sorting/main/AIOStreams-SyncedURLs/Tamtaro-synced-ISEs.json",
"https://raw.githubusercontent.com/Tam-Taro/SEL-Filtering-and-Sorting/main/AIOStreams-SyncedURLs/Tamtaro-synced-PSEs.json",
"https://raw.githubusercontent.com/Tam-Taro/SEL-Filtering-and-Sorting/main/AIOStreams-SyncedURLs/Tamtaro-synced-ESEs-standard.json",
])
}
volume_mount {
name = "data"
mount_path = "/app/data"
}
resources {
requests = {
cpu = "25m"
memory = "768Mi"
}
limits = {
memory = "768Mi"
}
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.data_proxmox.metadata[0].name
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [
spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1
metadata[0].annotations["keel.sh/policy"],
metadata[0].annotations["keel.sh/trigger"],
metadata[0].annotations["keel.sh/pollSchedule"], # KYVERNO_LIFECYCLE_V2
metadata[0].annotations["keel.sh/match-tag"],
spec[0].template[0].spec[0].container[0].image, # KEEL_IGNORE_IMAGE Keel manages tag updates
metadata[0].annotations["kubernetes.io/change-cause"],
metadata[0].annotations["deployment.kubernetes.io/revision"],
spec[0].template[0].metadata[0].annotations["keel.sh/update-time"], # KEEL_LIFECYCLE_V1
]
}
}
resource "kubernetes_service" "aiostreams" {
metadata {
name = "aiostreams"
namespace = kubernetes_namespace.aiostreams.metadata[0].name
labels = {
"app" = "aiostreams"
}
}
spec {
selector = {
app = "aiostreams"
}
port {
name = "http"
port = 80
target_port = 3000
}
}
}
resource "kubernetes_manifest" "probe_secrets" {
manifest = {
apiVersion = "external-secrets.io/v1beta1"
kind = "ExternalSecret"
metadata = {
name = "aiostreams-probe-secrets"
namespace = kubernetes_namespace.aiostreams.metadata[0].name
}
spec = {
refreshInterval = "15m"
secretStoreRef = {
name = "vault-kv"
kind = "ClusterSecretStore"
}
target = { name = "aiostreams-probe-secrets" }
data = [
{ secretKey = "AIOSTREAMS_UUID", remoteRef = { key = "viktor", property = "aiostreams_uuid" } },
{ secretKey = "AIOSTREAMS_PASSWORD", remoteRef = { key = "viktor", property = "aiostreams_password" } },
{ secretKey = "STREMIO_EMAIL", remoteRef = { key = "viktor", property = "stremio_email" } },
{ secretKey = "STREMIO_PASSWORD", remoteRef = { key = "viktor", property = "stremio_password" } },
]
}
}
depends_on = [kubernetes_namespace.aiostreams]
}
resource "kubernetes_cron_job_v1" "stream_probe" {
metadata {
name = "aiostreams-stream-probe"
namespace = kubernetes_namespace.aiostreams.metadata[0].name
}
spec {
schedule = "*/5 * * * *"
concurrency_policy = "Replace"
successful_jobs_history_limit = 3
failed_jobs_history_limit = 3
job_template {
metadata {}
spec {
backoff_limit = 1
ttl_seconds_after_finished = 300
template {
metadata {}
spec {
restart_policy = "Never"
container {
name = "probe"
image = "docker.io/library/python:3.12-alpine"
command = ["/bin/sh", "-c", <<-EOT
pip install --quiet --disable-pip-version-check requests && python3 -c '
import requests, os, time, urllib.parse, sys, re
BASE = "http://aiostreams.aiostreams.svc.cluster.local"
PUSHGATEWAY = "http://prometheus-prometheus-pushgateway.monitoring:9091/metrics/job/aiostreams-stream-probe"
UUID = os.environ["AIOSTREAMS_UUID"]
PW = os.environ["AIOSTREAMS_PASSWORD"]
SERIES_ID = "tt0903747:1:1" # Breaking Bad S01E01 - stable, BluRay-rich series path
MOVIE_ID = "tt0133093" # The Matrix - stable, BluRay-rich movie path (symptom was films)
THRESHOLD = 50
series_count = 0
movie_count = 0
comet = torrentio = torz = knaben = errors = 0
success = 0
duration = 0
start = time.time()
def fetch(enc_url, kind, sid):
r = requests.get(
f"{BASE}/stremio/{UUID}/{enc_url}/stream/{kind}/{sid}.json",
headers={"User-Agent": "AIOStreams/probe"}, timeout=60,
)
r.raise_for_status()
return r.json().get("streams", [])
try:
r = requests.get(f"{BASE}/api/v1/user/", params={"uuid": UUID, "password": PW}, timeout=10)
r.raise_for_status()
enc = r.json()["data"]["encryptedPassword"]
enc_url = urllib.parse.quote(enc, safe="")
# Series path + per-source breakdown (so a dying source is visible, not masked by a healthy total)
series = fetch(enc_url, "series", SERIES_ID)
series_count = len(series)
for s in series:
name = s.get("name", "") or ""
desc = (s.get("description", "") or "") + (s.get("title", "") or "")
if "Comet" in name: comet += 1
elif "Torrentio" in name: torrentio += 1
elif "StremThru" in name: torz += 1
elif "Knaben" in name: knaben += 1
if re.search("Invalid|Internal Server Error|451|infring|Legal Reasons", desc, re.I):
errors += 1
# Movie path (the reported symptom was films, not series)
movie_count = len(fetch(enc_url, "movie", MOVIE_ID))
# Healthy = both paths return plenty AND the workhorse source (Comet) is alive
success = 1 if (series_count >= THRESHOLD and movie_count >= THRESHOLD and comet > 0) else 0
print(f"series={series_count} movie={movie_count} comet={comet} torz={torz} knaben={knaben} errors={errors} success={success}")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
success = 0
duration = time.time() - start
body = (
"# TYPE aiostreams_stream_count gauge\n"
f"aiostreams_stream_count {series_count}\n"
"# TYPE aiostreams_movie_stream_count gauge\n"
f"aiostreams_movie_stream_count {movie_count}\n"
"# TYPE aiostreams_probe_success gauge\n"
f"aiostreams_probe_success {success}\n"
"# TYPE aiostreams_error_streams gauge\n"
f"aiostreams_error_streams {errors}\n"
"# TYPE aiostreams_streams_comet gauge\n"
f"aiostreams_streams_comet {comet}\n"
"# TYPE aiostreams_streams_torrentio gauge\n"
f"aiostreams_streams_torrentio {torrentio}\n"
"# TYPE aiostreams_streams_stremthru_torz gauge\n"
f"aiostreams_streams_stremthru_torz {torz}\n"
"# TYPE aiostreams_streams_knaben gauge\n"
f"aiostreams_streams_knaben {knaben}\n"
"# TYPE aiostreams_probe_duration_seconds gauge\n"
f"aiostreams_probe_duration_seconds {duration:.3f}\n"
"# TYPE aiostreams_probe_last_run_timestamp gauge\n"
f"aiostreams_probe_last_run_timestamp {int(time.time())}\n"
)
try:
requests.post(PUSHGATEWAY, data=body, timeout=10).raise_for_status()
except Exception as e:
print(f"WARN: pushgateway POST failed: {e}", file=sys.stderr)
sys.exit(0 if success else 1)
'
EOT
]
env_from {
secret_ref { name = "aiostreams-probe-secrets" }
}
resources {
requests = { memory = "64Mi", cpu = "10m" }
limits = { memory = "128Mi" }
}
}
}
}
}
}
}
depends_on = [kubernetes_manifest.probe_secrets, kubernetes_deployment.aiostreams]
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
}
}
module "nfs_backup" {
source = "../../../modules/kubernetes/nfs_volume"
name = "aiostreams-backup"
namespace = kubernetes_namespace.aiostreams.metadata[0].name
nfs_server = var.nfs_server
nfs_path = "/srv/nfs/aiostreams-backup"
storage = "1Gi"
}
resource "kubernetes_cron_job_v1" "config_backup" {
metadata {
name = "aiostreams-config-backup"
namespace = kubernetes_namespace.aiostreams.metadata[0].name
}
spec {
schedule = "0 3 * * 0" # Sunday 03:00 weekly
concurrency_policy = "Forbid"
successful_jobs_history_limit = 3
failed_jobs_history_limit = 3
job_template {
metadata {}
spec {
backoff_limit = 2
ttl_seconds_after_finished = 600
template {
metadata {}
spec {
restart_policy = "Never"
container {
name = "backup"
image = "docker.io/library/python:3.12-alpine"
command = ["/bin/sh", "-c", <<-EOT
pip install --quiet --disable-pip-version-check requests && python3 -c '
import requests, os, time, json, sys, datetime, glob
BASE = "http://aiostreams.aiostreams.svc.cluster.local"
PUSHGATEWAY = "http://prometheus-prometheus-pushgateway.monitoring:9091/metrics/job/aiostreams-config-backup"
UUID = os.environ["AIOSTREAMS_UUID"]
PW = os.environ["AIOSTREAMS_PASSWORD"]
BACKUP_DIR = "/backup"
RETENTION_DAYS = 90
success = 0
bytes_written = 0
start = time.time()
try:
r = requests.get(f"{BASE}/api/v1/user/", params={"uuid": UUID, "password": PW, "raw": "true"}, timeout=30)
r.raise_for_status()
data = r.json()["data"]["userData"]
if not data:
raise RuntimeError("empty userData from API")
os.makedirs(BACKUP_DIR, exist_ok=True)
ts = datetime.datetime.utcnow().strftime("%Y-%m-%d_%H%M")
path = f"{BACKUP_DIR}/config-{ts}.json"
with open(path, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
bytes_written = os.path.getsize(path)
os.chmod(path, 0o600)
print(f"OK wrote {path} ({bytes_written} bytes)")
# Prune backups older than RETENTION_DAYS
cutoff = time.time() - (RETENTION_DAYS * 86400)
pruned = 0
for f in glob.glob(f"{BACKUP_DIR}/config-*.json"):
if os.path.getmtime(f) < cutoff:
os.unlink(f)
pruned += 1
if pruned:
print(f"Pruned {pruned} old backups")
success = 1
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
duration = time.time() - start
body = (
"# TYPE aiostreams_config_backup_success gauge\n"
f"aiostreams_config_backup_success {success}\n"
"# TYPE aiostreams_config_backup_bytes gauge\n"
f"aiostreams_config_backup_bytes {bytes_written}\n"
"# TYPE aiostreams_config_backup_duration_seconds gauge\n"
f"aiostreams_config_backup_duration_seconds {duration:.3f}\n"
"# TYPE aiostreams_config_backup_last_run_timestamp gauge\n"
f"aiostreams_config_backup_last_run_timestamp {int(time.time())}\n"
)
try:
requests.post(PUSHGATEWAY, data=body, timeout=10).raise_for_status()
except Exception as e:
print(f"WARN: pushgateway POST failed: {e}", file=sys.stderr)
sys.exit(0 if success else 1)
'
EOT
]
env_from {
secret_ref { name = "aiostreams-probe-secrets" }
}
volume_mount {
name = "backup"
mount_path = "/backup"
}
resources {
requests = { memory = "64Mi", cpu = "10m" }
limits = { memory = "128Mi" }
}
}
volume {
name = "backup"
persistent_volume_claim {
claim_name = module.nfs_backup.claim_name
}
}
}
}
}
}
}
depends_on = [kubernetes_manifest.probe_secrets, kubernetes_deployment.aiostreams, module.nfs_backup]
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
}
}
resource "kubernetes_cron_job_v1" "stremio_account_backup" {
metadata {
name = "stremio-account-backup"
namespace = kubernetes_namespace.aiostreams.metadata[0].name
}
spec {
schedule = "0 4 * * 0" # Sunday 04:00 weekly (1h after config-backup)
concurrency_policy = "Forbid"
successful_jobs_history_limit = 3
failed_jobs_history_limit = 3
job_template {
metadata {}
spec {
backoff_limit = 2
ttl_seconds_after_finished = 600
template {
metadata {}
spec {
restart_policy = "Never"
container {
name = "backup"
image = "docker.io/library/python:3.12-alpine"
command = ["/bin/sh", "-c", <<-EOT
pip install --quiet --disable-pip-version-check requests && python3 -c '
import requests, os, time, json, sys, datetime, glob
BASE = "https://api.strem.io/api"
PUSHGATEWAY = "http://prometheus-prometheus-pushgateway.monitoring:9091/metrics/job/stremio-account-backup"
EMAIL = os.environ["STREMIO_EMAIL"]
PASSWORD = os.environ["STREMIO_PASSWORD"]
BACKUP_DIR = "/backup"
RETENTION_DAYS = 90
success = 0
bytes_written = 0
addon_count = 0
start = time.time()
try:
r = requests.post(f"{BASE}/login", json={"type":"Login","email":EMAIL,"password":PASSWORD}, timeout=20)
r.raise_for_status()
auth = r.json()["result"]["authKey"]
r2 = requests.post(f"{BASE}/addonCollectionGet", json={"type":"AddonCollectionGet","authKey":auth,"update":True}, timeout=30)
r2.raise_for_status()
addons = r2.json()["result"]["addons"]
addon_count = len(addons)
os.makedirs(BACKUP_DIR, exist_ok=True)
ts = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d_%H%M")
path = f"{BACKUP_DIR}/stremio-collection-{ts}.json"
payload = {"capturedAt": ts, "email": EMAIL, "addonCount": addon_count, "addons": addons}
with open(path, "w") as f:
json.dump(payload, f, indent=2, sort_keys=True)
bytes_written = os.path.getsize(path)
os.chmod(path, 0o600)
print(f"OK wrote {path} ({bytes_written} bytes, {addon_count} addons)")
# Logout to invalidate the auth key
try:
requests.post(f"{BASE}/logout", json={"type":"Logout","authKey":auth}, timeout=10)
except Exception:
pass
# Prune older than RETENTION_DAYS
cutoff = time.time() - (RETENTION_DAYS * 86400)
pruned = 0
for f in glob.glob(f"{BACKUP_DIR}/stremio-collection-*.json"):
if os.path.getmtime(f) < cutoff:
os.unlink(f); pruned += 1
if pruned: print(f"Pruned {pruned} old backups")
success = 1
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
duration = time.time() - start
body = (
"# TYPE stremio_account_backup_success gauge\n"
f"stremio_account_backup_success {success}\n"
"# TYPE stremio_account_backup_bytes gauge\n"
f"stremio_account_backup_bytes {bytes_written}\n"
"# TYPE stremio_account_backup_addon_count gauge\n"
f"stremio_account_backup_addon_count {addon_count}\n"
"# TYPE stremio_account_backup_duration_seconds gauge\n"
f"stremio_account_backup_duration_seconds {duration:.3f}\n"
"# TYPE stremio_account_backup_last_run_timestamp gauge\n"
f"stremio_account_backup_last_run_timestamp {int(time.time())}\n"
)
try:
requests.post(PUSHGATEWAY, data=body, timeout=10).raise_for_status()
except Exception as e:
print(f"WARN: pushgateway POST failed: {e}", file=sys.stderr)
sys.exit(0 if success else 1)
'
EOT
]
env_from {
secret_ref { name = "aiostreams-probe-secrets" }
}
volume_mount {
name = "backup"
mount_path = "/backup"
}
resources {
requests = { memory = "64Mi", cpu = "10m" }
limits = { memory = "128Mi" }
}
}
volume {
name = "backup"
persistent_volume_claim {
claim_name = module.nfs_backup.claim_name
}
}
}
}
}
}
}
depends_on = [kubernetes_manifest.probe_secrets, module.nfs_backup]
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
}
}
module "ingress" {
source = "../../../modules/kubernetes/ingress_factory"
# auth = "app": AIOStreams enforces its own UUID + password gate on /configure
# and /api/*, and Stremio addon URLs (/stremio/{uuid}/{encryptedPassword}/...)
# use the encryptedPassword path segment as a bearer token. Authentik forward-auth
# broke Stremio clients (cannot follow OAuth 302) and is redundant with the app's
# own auth. UUIDs are 128-bit random; password attempts are rate-limited.
auth = "app"
dns_type = "proxied"
namespace = kubernetes_namespace.aiostreams.metadata[0].name
name = "aiostreams"
tls_secret_name = var.tls_secret_name
extra_annotations = {
"gethomepage.dev/enabled" = "true"
"gethomepage.dev/name" = "AIOStreams"
"gethomepage.dev/description" = "Streaming addon manager"
"gethomepage.dev/icon" = "stremio.png"
"gethomepage.dev/group" = "Media & Entertainment"
"gethomepage.dev/pod-selector" = ""
}
}

View file

@ -0,0 +1,102 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
resource "kubernetes_deployment" "flaresolverr" {
metadata {
name = "flaresolverr"
namespace = "servarr"
labels = {
app = "flaresolverr"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "flaresolverr"
}
}
template {
metadata {
labels = {
app = "flaresolverr"
}
}
spec {
container {
image = "ghcr.io/flaresolverr/flaresolverr:latest"
name = "flaresolverr"
resources {
requests = {
cpu = "10m"
memory = "512Mi"
}
limits = {
memory = "1Gi"
}
}
port {
container_port = 8191
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [
spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1
metadata[0].annotations["keel.sh/policy"],
metadata[0].annotations["keel.sh/trigger"],
metadata[0].annotations["keel.sh/pollSchedule"], # KYVERNO_LIFECYCLE_V2
metadata[0].annotations["keel.sh/match-tag"],
spec[0].template[0].spec[0].container[0].image, # KEEL_IGNORE_IMAGE Keel manages tag updates
metadata[0].annotations["kubernetes.io/change-cause"],
metadata[0].annotations["deployment.kubernetes.io/revision"],
spec[0].template[0].metadata[0].annotations["keel.sh/update-time"], # KEEL_LIFECYCLE_V1
]
}
}
resource "kubernetes_service" "flaresolverr" {
metadata {
name = "flaresolverr"
namespace = "servarr"
labels = {
app = "flaresolverr"
}
}
spec {
selector = {
app = "flaresolverr"
}
port {
name = "http"
target_port = 8191
port = 80
}
}
}
module "ingress" {
source = "../../../modules/kubernetes/ingress_factory"
dns_type = "proxied"
namespace = "servarr"
name = "flaresolverr"
tls_secret_name = var.tls_secret_name
auth = "required"
extra_annotations = {
"gethomepage.dev/enabled" = "true"
"gethomepage.dev/name" = "FlareSolverr"
"gethomepage.dev/description" = "Captcha solver proxy"
"gethomepage.dev/icon" = "flaresolverr.png"
"gethomepage.dev/group" = "Media & Entertainment"
"gethomepage.dev/pod-selector" = ""
}
}

View file

@ -0,0 +1,197 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "nfs_server" { type = string }
module "nfs_data_host" {
source = "../../../modules/kubernetes/nfs_volume"
name = "servarr-lidarr-data-host"
namespace = "servarr"
nfs_server = "192.168.1.127"
nfs_path = "/srv/nfs/servarr/lidarr"
}
module "nfs_downloads_host" {
source = "../../../modules/kubernetes/nfs_volume"
name = "servarr-lidarr-downloads-host"
namespace = "servarr"
nfs_server = "192.168.1.127"
nfs_path = "/srv/nfs/servarr/downloads"
}
resource "kubernetes_deployment" "lidarr" {
metadata {
name = "lidarr"
namespace = "servarr"
labels = {
app = "lidarr"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "lidarr"
}
}
template {
metadata {
labels = {
app = "lidarr"
}
annotations = {
"diun.enable" = "true"
"diun.include_tags" = "^\\d+\\.\\d+\\.\\d+$"
}
}
spec {
container {
image = "lscr.io/linuxserver/lidarr:2.9.4"
# image = "youegraillot/lidarr-on-steroids"
name = "lidarr"
port {
name = "lidarr"
container_port = 8686
}
port {
name = "deemix"
container_port = 6595
}
env {
name = "PUID"
value = 1000
}
env {
name = "PGID"
value = 1000
}
env {
name = "TZ"
value = "Etc/UTC"
}
volume_mount {
name = "data"
mount_path = "/config"
}
volume_mount {
name = "downloads"
mount_path = "/downloads"
}
volume_mount {
name = "data"
mount_path = "/music"
sub_path = "music"
}
volume_mount {
name = "deemix-config"
mount_path = "/config_deemix"
sub_path = "deemix"
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = module.nfs_data_host.claim_name
}
}
volume {
name = "downloads"
persistent_volume_claim {
claim_name = module.nfs_downloads_host.claim_name
}
}
volume {
name = "deemix-config"
persistent_volume_claim {
claim_name = module.nfs_data_host.claim_name
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [
spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1
metadata[0].annotations["keel.sh/policy"],
metadata[0].annotations["keel.sh/trigger"],
metadata[0].annotations["keel.sh/pollSchedule"], # KYVERNO_LIFECYCLE_V2
metadata[0].annotations["keel.sh/match-tag"],
spec[0].template[0].spec[0].container[0].image, # KEEL_IGNORE_IMAGE Keel manages tag updates
metadata[0].annotations["kubernetes.io/change-cause"],
metadata[0].annotations["deployment.kubernetes.io/revision"],
spec[0].template[0].metadata[0].annotations["keel.sh/update-time"], # KEEL_LIFECYCLE_V1
]
}
}
resource "kubernetes_service" "lidarr" {
metadata {
name = "lidarr"
namespace = "servarr"
labels = {
app = "lidarr"
}
}
spec {
selector = {
app = "lidarr"
}
port {
name = "http"
port = 80
target_port = 8686
}
}
}
resource "kubernetes_service" "deemix" {
metadata {
name = "deemix"
namespace = "servarr"
labels = {
app = "deemix"
}
}
spec {
selector = {
app = "lidarr"
}
port {
name = "deemix"
port = 80
target_port = 6595
}
}
}
module "ingress" {
source = "../../../modules/kubernetes/ingress_factory"
dns_type = "proxied"
namespace = "servarr"
name = "lidarr"
tls_secret_name = var.tls_secret_name
auth = "required"
# extra_annotations = {
# "nginx.ingress.kubernetes.io/proxy-body-size" : "1G" // allow uploading .torrent files
# }
}
module "ingress-deemix" {
source = "../../../modules/kubernetes/ingress_factory"
dns_type = "proxied"
namespace = "servarr"
name = "deemix"
tls_secret_name = var.tls_secret_name
auth = "required"
}

View file

@ -0,0 +1,161 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "nfs_server" { type = string }
resource "kubernetes_persistent_volume_claim" "data_proxmox" {
wait_until_bound = false
metadata {
name = "servarr-listenarr-data-proxmox"
namespace = "servarr"
annotations = {
"resize.topolvm.io/threshold" = "10%"
"resize.topolvm.io/increase" = "100%"
"resize.topolvm.io/storage_limit" = "5Gi"
}
}
spec {
access_modes = ["ReadWriteOnce"]
storage_class_name = "proxmox-lvm"
resources {
requests = {
storage = "1Gi"
}
}
}
lifecycle {
# The autoresizer expands requests.storage up to storage_limit and
# PVCs can't shrink. Without this, every TF apply tries to revert
# to the spec value, K8s rejects the shrink, and the PVC ends up
# in Terminating-but-in-use limbo.
ignore_changes = [spec[0].resources[0].requests]
}
}
module "nfs_downloads_host" {
source = "../../../modules/kubernetes/nfs_volume"
name = "servarr-listenarr-downloads-host"
namespace = "servarr"
nfs_server = "192.168.1.127"
nfs_path = "/srv/nfs/servarr/downloads"
}
resource "kubernetes_deployment" "listenarr" {
metadata {
name = "listenarr"
namespace = "servarr"
labels = {
app = "listenarr"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "listenarr"
}
}
template {
metadata {
labels = {
app = "listenarr"
}
}
spec {
container {
image = "ghcr.io/therobbiedavis/listenarr:canary"
name = "listenarr"
port {
container_port = 5000
}
volume_mount {
name = "data"
mount_path = "/app/config"
}
resources {
requests = {
cpu = "25m"
memory = "896Mi"
}
limits = {
memory = "896Mi"
}
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.data_proxmox.metadata[0].name
}
}
volume {
name = "downloads"
persistent_volume_claim {
claim_name = module.nfs_downloads_host.claim_name
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [
spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1
metadata[0].annotations["keel.sh/policy"],
metadata[0].annotations["keel.sh/trigger"],
metadata[0].annotations["keel.sh/pollSchedule"], # KYVERNO_LIFECYCLE_V2
metadata[0].annotations["keel.sh/match-tag"],
spec[0].template[0].spec[0].container[0].image, # KEEL_IGNORE_IMAGE Keel manages tag updates
metadata[0].annotations["kubernetes.io/change-cause"],
metadata[0].annotations["deployment.kubernetes.io/revision"],
spec[0].template[0].metadata[0].annotations["keel.sh/update-time"], # KEEL_LIFECYCLE_V1
]
}
}
resource "kubernetes_service" "listenarr" {
metadata {
name = "listenarr"
namespace = "servarr"
labels = {
app = "listenarr"
}
}
spec {
selector = {
app = "listenarr"
}
port {
name = "http"
port = 80
target_port = 5000
}
}
}
module "ingress" {
source = "../../../modules/kubernetes/ingress_factory"
dns_type = "proxied"
namespace = "servarr"
name = "listenarr"
tls_secret_name = var.tls_secret_name
auth = "required"
extra_annotations = {
"gethomepage.dev/enabled" = "true"
"gethomepage.dev/name" = "Listenarr"
"gethomepage.dev/description" = "Podcast manager"
"gethomepage.dev/icon" = "mdi-podcast"
"gethomepage.dev/group" = "Media & Entertainment"
"gethomepage.dev/pod-selector" = ""
}
}

133
stacks/servarr/main.tf Normal file
View file

@ -0,0 +1,133 @@
variable "tls_secret_name" {
type = string
sensitive = true
}
variable "nfs_server" { type = string }
resource "kubernetes_manifest" "external_secret" {
manifest = {
apiVersion = "external-secrets.io/v1beta1"
kind = "ExternalSecret"
metadata = {
name = "servarr-secrets"
namespace = "servarr"
}
spec = {
refreshInterval = "15m"
secretStoreRef = {
name = "vault-kv"
kind = "ClusterSecretStore"
}
target = {
name = "servarr-secrets"
}
dataFrom = [{
extract = {
key = "servarr"
}
}]
}
}
depends_on = [kubernetes_namespace.servarr]
}
data "kubernetes_secret" "eso_secrets" {
metadata {
name = "servarr-secrets"
namespace = kubernetes_namespace.servarr.metadata[0].name
}
depends_on = [kubernetes_manifest.external_secret]
}
locals {
homepage_credentials = jsondecode(data.kubernetes_secret.eso_secrets.data["homepage_credentials"])
}
resource "kubernetes_namespace" "servarr" {
metadata {
name = "servarr"
labels = {
tier = local.tiers.aux
"keel.sh/enrolled" = "true"
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
}
}
module "tls_secret" {
source = "../../modules/kubernetes/setup_tls_secret"
namespace = kubernetes_namespace.servarr.metadata[0].name
tls_secret_name = var.tls_secret_name
}
# module "readarr" {
# source = "./readarr"
# tls_secret_name = var.tls_secret_name
# tier = local.tiers.aux
# }
module "prowlarr" {
source = "./prowlarr"
tls_secret_name = var.tls_secret_name
tier = local.tiers.aux
nfs_server = var.nfs_server
homepage_credentials = local.homepage_credentials
}
module "qbittorrent" {
source = "./qbittorrent"
tls_secret_name = var.tls_secret_name
tier = local.tiers.aux
nfs_server = var.nfs_server
homepage_credentials = local.homepage_credentials
}
module "mam_farming" {
source = "./mam-farming"
namespace = kubernetes_namespace.servarr.metadata[0].name
depends_on = [
kubernetes_manifest.external_secret,
module.qbittorrent,
]
}
module "flaresolverr" {
source = "./flaresolverr"
tls_secret_name = var.tls_secret_name
tier = local.tiers.aux
}
# module "lidarr" {
# source = "./lidarr"
# tls_secret_name = var.tls_secret_name
# tier = local.tiers.aux
# }
# module "soulseek" {
# source = "./soulseek"
# tls_secret_name = var.tls_secret_name
# tier = local.tiers.aux
# }
module "listenarr" {
source = "./listenarr"
tls_secret_name = var.tls_secret_name
tier = local.tiers.aux
nfs_server = var.nfs_server
}
module "aiostreams" {
source = "./aiostreams"
tls_secret_name = var.tls_secret_name
aiostreams_database_connection_string = data.kubernetes_secret.eso_secrets.data["aiostreams_database_connection_string"]
tier = local.tiers.aux
nfs_server = var.nfs_server
}
# CI retrigger 2026-05-16T13:42:57+00:00 bulk enrollment apply (pipeline #689 killed)
# CI retrigger v2 2026-05-16T13:46:35+00:00

View file

@ -0,0 +1,163 @@
"""
MAM bonus-point spender tier-aware, pay-what-we-owe.
MAM's bonusBuy.php API enforces a hard 50 GiB minimum per purchase
("Automated spenders are limited to buying at least 50 GB... due to log
spam"). Valid API tiers are 50, 100, 200, 500 GiB (@ 500 BP/GiB). That
means the "pay exactly what we owe" approach from the recovery plan
rounds UP to 50 GiB for the first purchase small buys can only be done
via the web UI, not the API.
Logic: pick the smallest valid tier that both (a) satisfies the ratio
deficit and (b) we can afford without burning the BP reserve. Skip if
nothing fits; the cron will retry in 6 h once BP grows.
"""
import math
import os
import sys
import tempfile
import time
import requests
PUSHGW = "http://prometheus-prometheus-pushgateway.monitoring:9091"
COOKIE_FILE = "/data/mam_id"
TARGET_RATIO = float(os.environ.get("TARGET_RATIO", "2.0"))
RESERVE_BP = int(os.environ.get("RESERVE_BP", "500"))
BP_PER_GB = int(os.environ.get("BP_PER_GB", "500"))
# MAM-enforced minimum purchase for API callers: 50 GiB.
API_TIERS_GIB = (50, 100, 200, 500)
CLASS_CODES = {
"Mouse": 0,
"Vole": 1,
"User": 2,
"Power User": 3,
"Elite": 4,
"Torrent Master": 5,
"Power TM": 6,
"Elite TM": 7,
"VIP": 8,
}
def save_cookie(resp):
for c in resp.cookies:
if c.name == "mam_id":
fd, tmp = tempfile.mkstemp(dir="/data")
os.write(fd, c.value.encode())
os.close(fd)
os.rename(tmp, COOKIE_FILE)
return
def push(metrics):
try:
requests.post(
f"{PUSHGW}/metrics/job/mam-bp-spender", data=metrics, timeout=10
)
except Exception as e:
print(f"pushgateway error: {e}", file=sys.stderr)
def load_cookie():
if os.path.exists(COOKIE_FILE):
return open(COOKIE_FILE).read().strip()
return os.environ.get("MAM_ID", "")
def main():
mam_id = load_cookie()
if not mam_id:
print("No mam_id available", file=sys.stderr)
sys.exit(1)
s = requests.Session()
s.cookies.set("mam_id", mam_id, domain=".myanonamouse.net")
r = s.get("https://www.myanonamouse.net/jsonLoad.php", timeout=15)
if r.status_code != 200:
push("mam_farming_cookie_expired 1\n")
print(f"Cookie expired: {r.status_code}", file=sys.stderr)
sys.exit(1)
save_cookie(r)
profile = r.json()
ratio = float(profile.get("ratio", 0) or 0)
classname = profile.get("classname", "Mouse")
class_code = CLASS_CODES.get(classname, 0)
# MAM returns `downloaded`/`uploaded` as pretty strings ("715.55 MiB");
# `*_bytes` are the authoritative integer fields.
downloaded = int(profile.get("downloaded_bytes", 0) or 0)
uploaded = int(profile.get("uploaded_bytes", 0) or 0)
bp = int(float(profile.get("seedbonus", 0) or 0))
deficit_bytes = max(0, int(downloaded * TARGET_RATIO) - uploaded)
needed_gib = math.ceil(deficit_bytes / (1024**3)) + 1 if deficit_bytes > 0 else 0
affordable_gib = max(0, (bp - RESERVE_BP) // BP_PER_GB)
# Pick the smallest API tier that satisfies the deficit AND fits the
# budget. If even the smallest tier is too expensive, skip — the cron
# will retry in 6 h once BP has grown.
buy_gib = 0
for tier in API_TIERS_GIB:
if tier >= needed_gib and tier <= affordable_gib:
buy_gib = tier
break
if buy_gib == 0 and needed_gib > 0 and affordable_gib >= API_TIERS_GIB[0]:
# Deficit exceeds all tiers we can afford — buy the largest
# tier that fits to make progress.
for tier in reversed(API_TIERS_GIB):
if tier <= affordable_gib:
buy_gib = tier
break
print(
f"Profile: ratio={ratio} class={classname} "
f"DL={downloaded / 1024**3:.2f} GiB UL={uploaded / 1024**3:.2f} GiB "
f"BP={bp} | deficit={deficit_bytes / 1024**3:.2f} GiB "
f"needed={needed_gib} affordable={affordable_gib} buy={buy_gib}"
)
spent_gib = 0
if buy_gib >= API_TIERS_GIB[0]:
time.sleep(3)
url = (
"https://www.myanonamouse.net/json/bonusBuy.php"
f"?spendtype=upload&amount={buy_gib}"
)
r2 = s.get(url, timeout=15)
save_cookie(r2)
try:
body = r2.json()
except ValueError:
body = {}
ok = r2.status_code == 200 and body.get("success") is True
print(
f"Buy {buy_gib} GiB -> {r2.status_code} "
f"success={body.get('success')} {r2.text[:160]}"
)
if ok:
spent_gib = buy_gib
metrics = (
"mam_farming_cookie_expired 0\n"
f"mam_ratio {ratio}\n"
f'mam_class_code{{classname="{classname}"}} {class_code}\n'
f"mam_downloaded_bytes {downloaded}\n"
f"mam_uploaded_bytes {uploaded}\n"
f"mam_bp_balance {bp}\n"
f"mam_bp_spent_gb {spent_gib}\n"
f"mam_bp_needed_gib {needed_gib}\n"
f"mam_bp_affordable_gib {affordable_gib}\n"
)
push(metrics)
print(
f"Done: BP={bp}, spent={spent_gib} GiB (needed={needed_gib}, "
f"affordable={affordable_gib})"
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,273 @@
"""
MAM freeleech grabber demand-first, ratio-guarded.
Selects small-but-popular freeleech titles to grow the account's upload
credit. Refuses to grab while the account is in Mouse class or ratio is
below 1.2, because MAM rejects peer-list announces under those conditions
and new grabs only deepen the ratio hole.
Cleanup is handled by `mam-farming-janitor.py`, which runs unconditionally.
"""
import json
import math
import os
import random
import sys
import tempfile
import time
import requests
QB_URL = "http://qbittorrent.servarr.svc.cluster.local"
PUSHGW = "http://prometheus-prometheus-pushgateway.monitoring:9091"
COOKIE_FILE = "/data/mam_id"
GRABBED_IDS_FILE = "/data/grabbed_ids.txt"
MIN_MB = int(os.environ.get("MIN_MB", "50"))
MAX_MB = int(os.environ.get("MAX_MB", "1024"))
LEECHER_FLOOR = int(os.environ.get("LEECHER_FLOOR", "1"))
# MAM's catalogue is well-seeded by design — a ceiling of 50 rejected ~99%
# of candidates in live testing. 200 still filters out truly oversupplied
# swarms while keeping enough working-set to grab 3-5 titles per run.
SEEDER_CEILING = int(os.environ.get("SEEDER_CEILING", "200"))
GRAB_PER_RUN = int(os.environ.get("GRAB_PER_RUN", "5"))
MAX_TORRENTS = int(os.environ.get("MAX_TORRENTS", "500"))
# The guard's real job is to prevent the Mouse-class death spiral (see RC1
# in the original recovery plan). Once class > Mouse, MAM serves peer
# lists normally and demand-first filtering (leechers>=1) keeps new grabs
# upload-positive. Keep a low floor as a tripwire for catastrophic dips
# rather than a steady-state block.
RATIO_FLOOR = float(os.environ.get("RATIO_FLOOR", "0.5"))
REQUEST_SLEEP = float(os.environ.get("REQUEST_SLEEP", "3"))
CLASS_CODES = {
"Mouse": 0,
"Vole": 1,
"User": 2,
"Power User": 3,
"Elite": 4,
"Torrent Master": 5,
"Power TM": 6,
"Elite TM": 7,
"VIP": 8,
}
def parse_size(s):
# MAM pretty-prints sizes with thousands separators (e.g. "1,002.9 MiB").
units = {"B": 1, "KiB": 1024, "MiB": 1024**2, "GiB": 1024**3, "TiB": 1024**4}
parts = s.replace(",", "").split()
if len(parts) != 2:
return 0
return int(float(parts[0]) * units.get(parts[1], 1))
def save_cookie(resp):
for c in resp.cookies:
if c.name == "mam_id":
fd, tmp = tempfile.mkstemp(dir="/data")
os.write(fd, c.value.encode())
os.close(fd)
os.rename(tmp, COOKIE_FILE)
return
def push(metrics):
try:
requests.post(
f"{PUSHGW}/metrics/job/mam-freeleech-grabber", data=metrics, timeout=10
)
except Exception as e:
print(f"pushgateway error: {e}", file=sys.stderr)
def load_cookie():
if os.path.exists(COOKIE_FILE):
return open(COOKIE_FILE).read().strip()
return os.environ.get("MAM_ID", "")
def exit_cookie_expired(status):
push("mam_farming_cookie_expired 1\n")
print(f"Cookie expired: {status}", file=sys.stderr)
sys.exit(1)
def main():
mam_id = load_cookie()
if not mam_id:
print("No mam_id available", file=sys.stderr)
sys.exit(1)
s = requests.Session()
s.cookies.set("mam_id", mam_id, domain=".myanonamouse.net")
r = s.get("https://www.myanonamouse.net/jsonLoad.php", timeout=15)
if r.status_code != 200:
exit_cookie_expired(r.status_code)
save_cookie(r)
profile = r.json()
ratio = float(profile.get("ratio", 0) or 0)
classname = profile.get("classname", "Mouse")
# `*_bytes` are authoritative integers; `downloaded`/`uploaded` are
# pretty strings like "715.55 MiB".
downloaded = int(profile.get("downloaded_bytes", 0) or 0)
uploaded = int(profile.get("uploaded_bytes", 0) or 0)
class_code = CLASS_CODES.get(classname, 0)
profile_metrics = (
f"mam_farming_cookie_expired 0\n"
f"mam_ratio {ratio}\n"
f'mam_class_code{{classname="{classname}"}} {class_code}\n'
f"mam_downloaded_bytes {downloaded}\n"
f"mam_uploaded_bytes {uploaded}\n"
)
if ratio < RATIO_FLOOR or classname == "Mouse":
reason = "mouse_class" if classname == "Mouse" else "low_ratio"
print(
f"Skip grab: ratio={ratio} class={classname} (floor={RATIO_FLOOR}) "
f"reason={reason}"
)
push(
profile_metrics
+ f'mam_grabber_skipped_reason{{reason="{reason}"}} 1\n'
+ f"mam_farming_grabbed 0\n"
)
return
time.sleep(REQUEST_SLEEP)
r = s.get("https://t.myanonamouse.net/json/dynamicSeedbox.php", timeout=15)
save_cookie(r)
print(f"Seedbox: {r.text[:80]}")
grabbed_ids = set()
if os.path.exists(GRABBED_IDS_FILE):
raw = open(GRABBED_IDS_FILE).read().strip()
grabbed_ids = set(raw.split("\n")) if raw else set()
try:
all_torrents = requests.get(
f"{QB_URL}/api/v2/torrents/info", timeout=10
).json()
except Exception as e:
print(f"qBittorrent unreachable: {e}", file=sys.stderr)
push(profile_metrics + "mam_farming_grabbed 0\n")
sys.exit(1)
farming = [t for t in all_torrents if t.get("category") == "mam-farming"]
all_names_lower = {t["name"].lower() for t in all_torrents}
total_size = sum(t.get("size", 0) for t in farming)
print(
f"Profile: ratio={ratio} class={classname} | "
f"Farming: {len(farming)}, {total_size / (1024**3):.1f} GiB, "
f"tracked IDs: {len(grabbed_ids)}"
)
grabbed = 0
if len(farming) >= MAX_TORRENTS:
print(f"At max torrents ({MAX_TORRENTS}), skipping grab")
else:
time.sleep(REQUEST_SLEEP)
offset = random.randint(0, 1400)
params = {
"tor[searchType]": "fl",
"tor[searchIn]": "torrents",
"tor[perpage]": "50",
"tor[startNumber]": str(offset),
}
r = s.get(
"https://www.myanonamouse.net/tor/js/loadSearchJSONbasic.php",
params=params,
timeout=15,
)
save_cookie(r)
data = r.json()
results = data.get("data", []) or []
print(
f"Search offset={offset}, found={data.get('found', 0)}, "
f"page_results={len(results)}"
)
candidates = []
for t in results:
tid = str(t.get("id", ""))
if tid in grabbed_ids:
continue
title = t.get("title", "")
if any(title.lower() in n for n in all_names_lower):
grabbed_ids.add(tid)
continue
size = parse_size(t.get("size", "0 B"))
if size < MIN_MB * 1024**2 or size > MAX_MB * 1024**2:
continue
seeders = int(t.get("seeders", 999) or 999)
leechers = int(t.get("leechers", 0) or 0)
if leechers < LEECHER_FLOOR:
continue
if seeders > SEEDER_CEILING:
continue
wedge_bonus = (
200 if (t.get("free") == 1 or t.get("personal_freeleech") == 1) else 0
)
score = leechers * 3 - seeders * 0.5 + wedge_bonus
candidates.append((score, t))
candidates.sort(key=lambda x: -x[0])
for score, t in candidates[:GRAB_PER_RUN]:
time.sleep(REQUEST_SLEEP)
tid = t["id"]
r = s.get(
f"https://www.myanonamouse.net/tor/download.php?tid={tid}", timeout=15
)
save_cookie(r)
if not r.content.startswith(b"d"):
print(f"Bad torrent body for tid={tid}")
grabbed_ids.add(str(tid))
continue
add_resp = requests.post(
f"{QB_URL}/api/v2/torrents/add",
files={
"torrents": (
f"{tid}.torrent",
r.content,
"application/x-bittorrent",
)
},
data={
"savepath": "/downloads/mam-farming",
"category": "mam-farming",
"tags": "mam,freeleech",
},
timeout=20,
)
ok = add_resp.status_code == 200 and add_resp.text.strip() != "Fails."
print(
f"{'Added' if ok else 'FAILED'} (score={score:.1f}): "
f"{t['title'][:60]} ({t['size']}, S:{t.get('seeders')} "
f"L:{t.get('leechers')}) -> {add_resp.status_code}"
)
grabbed_ids.add(str(tid))
if ok:
grabbed += 1
fd, tmp = tempfile.mkstemp(dir="/data")
os.write(fd, "\n".join(grabbed_ids).encode())
os.close(fd)
os.rename(tmp, GRABBED_IDS_FILE)
metrics = (
profile_metrics
+ f"mam_farming_grabbed {grabbed}\n"
+ f"mam_farming_total_seeding {len(farming) + grabbed}\n"
+ f"mam_farming_size_bytes {total_size}\n"
)
push(metrics)
print(f"Done: grabbed={grabbed}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,177 @@
"""
MAM farming janitor H&R-aware cleanup.
Runs every 15 minutes independently of the grabber's ratio guard: stuck
torrents accumulate fastest precisely when the grabber is skipping. Never
deletes a torrent that's inside MAM's 72-hour Hit-and-Run window.
Set DRY_RUN=1 to log candidates without deleting (used for the first
24 hours after rollout to sanity-check the rules against live state).
"""
import json
import os
import sys
import time
import requests
QB_URL = "http://qbittorrent.servarr.svc.cluster.local"
PUSHGW = "http://prometheus-prometheus-pushgateway.monitoring:9091"
DRY_RUN = os.environ.get("DRY_RUN", "0") == "1"
HNR_SEED_SECONDS = int(os.environ.get("HNR_SEED_SECONDS", str(72 * 3600)))
NEVER_STARTED_AGE = int(os.environ.get("NEVER_STARTED_AGE", str(24 * 3600)))
STALLED_AGE = int(os.environ.get("STALLED_AGE", str(3 * 86400)))
SATISFIED_SEED_AGE = int(os.environ.get("SATISFIED_SEED_AGE", str(3 * 86400)))
SATISFIED_SEEDER_FLOOR = int(os.environ.get("SATISFIED_SEEDER_FLOOR", "5"))
GRACEFUL_SEED_AGE = int(os.environ.get("GRACEFUL_SEED_AGE", str(14 * 86400)))
ZERO_DEMAND_AGE = int(os.environ.get("ZERO_DEMAND_AGE", str(7 * 86400)))
UNREG_KEYWORDS = ("unregistered", "torrent not found", "info hash not authorized")
REASONS = (
"never_started",
"stalled_old",
"satisfied_redundant",
"graceful_retire",
"zero_demand",
"unregistered",
)
def classify(t, now, tracker_msg):
age = now - int(t.get("added_on", 0) or 0)
progress = float(t.get("progress", 0) or 0)
downloaded = int(t.get("downloaded", 0) or 0)
uploaded = int(t.get("uploaded", 0) or 0)
seed_time = int(t.get("seeding_time", 0) or 0)
state = t.get("state", "")
num_complete = int(t.get("num_complete", 0) or 0)
if tracker_msg and any(k in tracker_msg.lower() for k in UNREG_KEYWORDS):
return "unregistered"
if progress < 1.0:
if age > NEVER_STARTED_AGE and downloaded == 0:
return "never_started"
if state == "stalledDL" and age > STALLED_AGE:
return "stalled_old"
return None
if seed_time < HNR_SEED_SECONDS:
return "hnr_window"
if seed_time > GRACEFUL_SEED_AGE:
return "graceful_retire"
if (
seed_time >= HNR_SEED_SECONDS
and uploaded == 0
and age > ZERO_DEMAND_AGE
):
return "zero_demand"
if seed_time > SATISFIED_SEED_AGE and num_complete > SATISFIED_SEEDER_FLOOR:
return "satisfied_redundant"
return None
def fetch_tracker_msg(hash_):
try:
resp = requests.get(
f"{QB_URL}/api/v2/torrents/trackers",
params={"hash": hash_},
timeout=10,
)
trackers = resp.json() or []
except Exception:
return ""
for tr in trackers:
url = tr.get("url", "")
if url.startswith("** ["):
continue
msg = tr.get("msg", "")
if msg:
return msg
return ""
def push(metrics):
try:
requests.post(
f"{PUSHGW}/metrics/job/mam-farming-janitor", data=metrics, timeout=10
)
except Exception as e:
print(f"pushgateway error: {e}", file=sys.stderr)
def main():
try:
all_torrents = requests.get(
f"{QB_URL}/api/v2/torrents/info", timeout=15
).json()
except Exception as e:
print(f"qBittorrent unreachable: {e}", file=sys.stderr)
sys.exit(1)
farming = [t for t in all_torrents if t.get("category") == "mam-farming"]
now = int(time.time())
deleted = {r: 0 for r in REASONS}
preserved_hnr = 0
skipped_active = 0
delete_hashes = []
# Only inspect tracker msg on torrents with a peer problem — avoids
# hundreds of extra API calls when things are healthy.
for t in farming:
state = t.get("state", "")
progress = float(t.get("progress", 0) or 0)
tracker_msg = ""
if progress < 1.0 and state in ("stalledDL", "metaDL", "missingFiles"):
tracker_msg = fetch_tracker_msg(t["hash"])
verdict = classify(t, now, tracker_msg)
if verdict is None:
skipped_active += 1
elif verdict == "hnr_window":
preserved_hnr += 1
else:
deleted[verdict] += 1
delete_hashes.append((t["hash"], verdict, t.get("name", "")[:60]))
for hash_, reason, name in delete_hashes:
if DRY_RUN:
print(f"[DRY_RUN] would delete ({reason}): {name}")
continue
try:
requests.post(
f"{QB_URL}/api/v2/torrents/delete",
data={"hashes": hash_, "deleteFiles": "true"},
timeout=20,
)
print(f"Deleted ({reason}): {name}")
except Exception as e:
print(f"Delete failed for {name}: {e}", file=sys.stderr)
for reason in REASONS:
push(
f'mam_janitor_deleted_per_run{{reason="{reason}"}} '
f"{deleted[reason] if not DRY_RUN else 0}\n"
f'mam_janitor_dry_run_candidates{{reason="{reason}"}} '
f"{deleted[reason] if DRY_RUN else 0}\n"
)
push(
f"mam_janitor_preserved_hnr {preserved_hnr}\n"
f"mam_janitor_skipped_active {skipped_active}\n"
f"mam_janitor_dry_run {1 if DRY_RUN else 0}\n"
f"mam_janitor_last_run_timestamp {now}\n"
)
total = sum(deleted.values())
print(
f"Done: deleted={total} preserved_hnr={preserved_hnr} "
f"skipped_active={skipped_active} dry_run={DRY_RUN}"
)
print(f" per reason: {deleted}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,270 @@
variable "namespace" {
type = string
default = "servarr"
}
locals {
python_image = "docker.io/library/python:3.12-alpine"
pip_prefix = "pip install -q requests > /dev/null 2>&1; python3 /tmp/script.py"
# Dry-run window was satisfied by a one-shot test on 2026-04-19 that
# produced 466 `never_started` candidates and 0 matches in any other
# reason bucket consistent with Phase B's expected 495 stuck torrents.
# Enforcing from here on.
janitor_dry_run = "0"
}
# ----------------------------- NFS data volume -----------------------
# Migrated off proxmox-lvm (2026-06-04): the cookie + grabbed-ID dedup list
# are two plain-text files (no embedded DB), so NFS is safe and removes this
# volume from the per-VM SCSI-LUN hotplug path entirely a stuck `query-pci`
# on a disk-heavy node VM used to wedge the grabber in ContainerCreating (the
# disk never enumerated, Forbid blocked every run MAMFarmingStuck). NFS
# mounts over the network, consumes zero LUN slots, and is RWX so the grabber
# and bp-spender can co-schedule on any node. See docs/architecture/storage.md
# "Per-VM SCSI-LUN cap" lever #1.
module "mam_data_nfs" {
source = "../../../modules/kubernetes/nfs_volume"
name = "servarr-mam-farming-data"
namespace = var.namespace
nfs_server = "192.168.1.127"
nfs_path = "/srv/nfs/servarr/mam-farming"
storage = "1Gi"
}
# --------------------------- Grabber ---------------------------------
# Every 30 minutes: skip while ratio < 1.2 or class == Mouse; otherwise
# grab up to 5 small-but-popular freeleech torrents. Existing ConfigMap
# + CronJob are adopted via imports in the parent stack.
resource "kubernetes_config_map" "grabber_script" {
metadata {
name = "mam-freeleech-grabber-script"
namespace = var.namespace
}
data = {
"script.py" = file("${path.module}/files/freeleech-grabber.py")
}
}
resource "kubernetes_cron_job_v1" "grabber" {
metadata {
name = "mam-freeleech-grabber"
namespace = var.namespace
}
spec {
schedule = "*/30 * * * *"
concurrency_policy = "Forbid"
successful_jobs_history_limit = 3
failed_jobs_history_limit = 3
job_template {
metadata {}
spec {
backoff_limit = 2
ttl_seconds_after_finished = 300
template {
metadata {}
spec {
restart_policy = "Never"
container {
name = "freeleech-grabber"
image = local.python_image
command = ["/bin/sh", "-c", local.pip_prefix]
env {
name = "MAM_ID"
value_from {
secret_key_ref {
name = "servarr-secrets"
key = "mam_id"
}
}
}
resources {
requests = { memory = "64Mi", cpu = "10m" }
limits = { memory = "128Mi" }
}
volume_mount {
name = "script"
mount_path = "/tmp/script.py"
sub_path = "script.py"
}
volume_mount {
name = "data"
mount_path = "/data"
}
}
volume {
name = "script"
config_map {
name = kubernetes_config_map.grabber_script.metadata[0].name
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = module.mam_data_nfs.claim_name
}
}
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
}
}
# --------------------------- BP Spender ------------------------------
# Every 6 hours: compute the upload deficit against TARGET_RATIO and buy
# exactly what we need (+1 GiB margin), capped by BP reserve. Existing
# ConfigMap + CronJob are adopted via imports in the parent stack.
resource "kubernetes_config_map" "bp_spender_script" {
metadata {
name = "mam-bp-spender-script"
namespace = var.namespace
}
data = {
"script.py" = file("${path.module}/files/bp-spender.py")
}
}
resource "kubernetes_cron_job_v1" "bp_spender" {
metadata {
name = "mam-bp-spender"
namespace = var.namespace
}
spec {
schedule = "0 */6 * * *"
concurrency_policy = "Forbid"
successful_jobs_history_limit = 3
failed_jobs_history_limit = 3
job_template {
metadata {}
spec {
backoff_limit = 2
ttl_seconds_after_finished = 300
template {
metadata {}
spec {
restart_policy = "Never"
container {
name = "bp-spender"
image = local.python_image
command = ["/bin/sh", "-c", local.pip_prefix]
env {
name = "MAM_ID"
value_from {
secret_key_ref {
name = "servarr-secrets"
key = "mam_id"
}
}
}
resources {
requests = { memory = "64Mi", cpu = "10m" }
limits = { memory = "128Mi" }
}
volume_mount {
name = "script"
mount_path = "/tmp/script.py"
sub_path = "script.py"
}
volume_mount {
name = "data"
mount_path = "/data"
}
}
volume {
name = "script"
config_map {
name = kubernetes_config_map.bp_spender_script.metadata[0].name
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = module.mam_data_nfs.claim_name
}
}
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
}
}
# ----------------------------- Janitor -------------------------------
# New: every 15 minutes, independent of grabber ratio guard. Deletes
# stuck/unregistered/redundant torrents in category=mam-farming while
# preserving torrents inside the 72h H&R window.
resource "kubernetes_config_map" "janitor_script" {
metadata {
name = "mam-farming-janitor-script"
namespace = var.namespace
}
data = {
"script.py" = file("${path.module}/files/mam-farming-janitor.py")
}
}
resource "kubernetes_cron_job_v1" "janitor" {
metadata {
name = "mam-farming-janitor"
namespace = var.namespace
}
spec {
schedule = "*/15 * * * *"
concurrency_policy = "Forbid"
successful_jobs_history_limit = 3
failed_jobs_history_limit = 3
job_template {
metadata {}
spec {
backoff_limit = 2
ttl_seconds_after_finished = 300
template {
metadata {}
spec {
restart_policy = "Never"
container {
name = "farming-janitor"
image = local.python_image
command = ["/bin/sh", "-c", local.pip_prefix]
env {
name = "DRY_RUN"
value = local.janitor_dry_run
}
resources {
requests = { memory = "64Mi", cpu = "10m" }
limits = { memory = "128Mi" }
}
volume_mount {
name = "script"
mount_path = "/tmp/script.py"
sub_path = "script.py"
}
}
volume {
name = "script"
config_map {
name = kubernetes_config_map.janitor_script.metadata[0].name
}
}
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
}
}

View file

@ -0,0 +1,53 @@
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
required_providers {
vault = {
source = "hashicorp/vault"
version = "~> 4.0"
}
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4"
}
authentik = {
source = "goauthentik/authentik"
version = "~> 2024.10"
}
# kubectl (gavinbunney) workaround for hashicorp/kubernetes
# `kubernetes_manifest` panics on Kyverno CRDs. See beads code-e2dp.
# Declared for all stacks but only used where opted-in.
kubectl = {
source = "gavinbunney/kubectl"
version = "~> 1.14"
}
proxmox = {
source = "telmate/proxmox"
version = "3.0.2-rc07"
}
}
}
variable "kube_config_path" {
type = string
default = "~/.kube/config"
}
provider "kubernetes" {
config_path = var.kube_config_path
}
provider "helm" {
kubernetes = {
config_path = var.kube_config_path
}
}
provider "vault" {
address = "https://vault.viktorbarzin.me"
skip_child_token = true
}
provider "kubectl" {
config_path = var.kube_config_path
load_config_file = true
}

View file

@ -0,0 +1,192 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "nfs_server" { type = string }
variable "homepage_credentials" {
type = map(any)
sensitive = true
}
resource "kubernetes_persistent_volume_claim" "data_proxmox" {
wait_until_bound = false
metadata {
name = "servarr-prowlarr-data-proxmox"
namespace = "servarr"
annotations = {
"resize.topolvm.io/threshold" = "10%"
"resize.topolvm.io/increase" = "100%"
"resize.topolvm.io/storage_limit" = "5Gi"
}
}
spec {
access_modes = ["ReadWriteOnce"]
storage_class_name = "proxmox-lvm"
resources {
requests = {
storage = "1Gi"
}
}
}
lifecycle {
# The autoresizer expands requests.storage up to storage_limit and
# PVCs can't shrink. Without this, every TF apply tries to revert
# to the spec value, K8s rejects the shrink, and the PVC ends up
# in Terminating-but-in-use limbo.
ignore_changes = [spec[0].resources[0].requests]
}
}
module "nfs_downloads_host" {
source = "../../../modules/kubernetes/nfs_volume"
name = "servarr-prowlarr-downloads-host"
namespace = "servarr"
nfs_server = "192.168.1.127"
nfs_path = "/srv/nfs/servarr/downloads"
}
resource "kubernetes_deployment" "prowlarr" {
metadata {
name = "prowlarr"
namespace = "servarr"
labels = {
app = "prowlarr"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "prowlarr"
}
}
template {
metadata {
labels = {
app = "prowlarr"
}
annotations = {
"diun.enable" = "true"
"diun.include_tags" = "^\\d+\\.\\d+\\.\\d+$"
}
}
spec {
container {
image = "lscr.io/linuxserver/prowlarr:2.3.5"
name = "prowlarr"
resources {
requests = {
cpu = "10m"
memory = "192Mi"
}
limits = {
memory = "384Mi"
}
}
port {
container_port = 9696
}
env {
name = "PUID"
value = 1000
}
env {
name = "PGID"
value = 1000
}
env {
name = "TZ"
value = "Etc/UTC"
}
volume_mount {
name = "data"
mount_path = "/config"
}
volume_mount {
name = "data"
mount_path = "/books"
}
volume_mount {
name = "downloads"
mount_path = "/downloads"
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.data_proxmox.metadata[0].name
}
}
volume {
name = "downloads"
persistent_volume_claim {
claim_name = module.nfs_downloads_host.claim_name
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [
spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1
metadata[0].annotations["keel.sh/policy"],
metadata[0].annotations["keel.sh/trigger"],
metadata[0].annotations["keel.sh/pollSchedule"], # KYVERNO_LIFECYCLE_V2
metadata[0].annotations["keel.sh/match-tag"],
spec[0].template[0].spec[0].container[0].image, # KEEL_IGNORE_IMAGE Keel manages tag updates
metadata[0].annotations["kubernetes.io/change-cause"],
metadata[0].annotations["deployment.kubernetes.io/revision"],
spec[0].template[0].metadata[0].annotations["keel.sh/update-time"], # KEEL_LIFECYCLE_V1
]
}
}
resource "kubernetes_service" "prowlarr" {
metadata {
name = "prowlarr"
namespace = "servarr"
labels = {
app = "prowlarr"
}
}
spec {
selector = {
app = "prowlarr"
}
port {
name = "http"
port = 80
target_port = 9696
}
}
}
module "ingress" {
source = "../../../modules/kubernetes/ingress_factory"
dns_type = "proxied"
namespace = "servarr"
name = "prowlarr"
tls_secret_name = var.tls_secret_name
auth = "required"
extra_annotations = {
"gethomepage.dev/enabled" = "true"
"gethomepage.dev/name" = "Prowlarr"
"gethomepage.dev/description" = "Indexer manager"
"gethomepage.dev/icon" = "prowlarr.png"
"gethomepage.dev/group" = "Media & Entertainment"
"gethomepage.dev/pod-selector" = ""
"gethomepage.dev/widget.type" = "prowlarr"
"gethomepage.dev/widget.url" = "http://prowlarr.servarr.svc.cluster.local"
"gethomepage.dev/widget.key" = var.homepage_credentials["prowlarr"]["api_key"]
}
}

View file

@ -0,0 +1,440 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "nfs_server" { type = string }
variable "homepage_credentials" {
type = map(any)
sensitive = true
}
resource "kubernetes_persistent_volume_claim" "data_proxmox" {
wait_until_bound = false
metadata {
name = "servarr-qbittorrent-data-proxmox"
namespace = "servarr"
annotations = {
"resize.topolvm.io/threshold" = "10%"
"resize.topolvm.io/increase" = "100%"
"resize.topolvm.io/storage_limit" = "5Gi"
}
}
spec {
access_modes = ["ReadWriteOnce"]
storage_class_name = "proxmox-lvm"
resources {
requests = {
storage = "1Gi"
}
}
}
lifecycle {
# The autoresizer expands requests.storage up to storage_limit and
# PVCs can't shrink. Without this, every TF apply tries to revert
# to the spec value, K8s rejects the shrink, and the PVC ends up
# in Terminating-but-in-use limbo.
ignore_changes = [spec[0].resources[0].requests]
}
}
module "nfs_downloads_host" {
source = "../../../modules/kubernetes/nfs_volume"
name = "servarr-qbittorrent-downloads-host"
namespace = "servarr"
nfs_server = "192.168.1.127"
nfs_path = "/srv/nfs/servarr/downloads"
}
module "nfs_audiobooks_host" {
source = "../../../modules/kubernetes/nfs_volume"
name = "servarr-qbittorrent-audiobooks-host"
namespace = "servarr"
nfs_server = "192.168.1.127"
nfs_path = "/srv/nfs/audiobookshelf/audiobooks"
}
resource "kubernetes_deployment" "qbittorrent" {
metadata {
name = "qbittorrent"
namespace = "servarr"
labels = {
app = "qbittorrent"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "qbittorrent"
}
}
template {
metadata {
labels = {
app = "qbittorrent"
}
annotations = {
"diun.enable" = "true"
"diun.include_tags" = "^\\d+\\.\\d+\\.\\d+$"
}
}
spec {
container {
image = "lscr.io/linuxserver/qbittorrent:5.1.4"
name = "qbittorrent"
port {
container_port = 8080
}
env {
name = "PUID"
value = 1000
}
env {
name = "PGID"
value = 1000
}
env {
name = "WEBUI_PORT"
value = 8080
}
env {
name = "TORRENTING_PORT"
value = 50000
}
volume_mount {
name = "data"
mount_path = "/config"
}
volume_mount {
name = "downloads"
mount_path = "/downloads"
}
volume_mount {
name = "audiobooks"
mount_path = "/audiobooks"
}
resources {
requests = {
memory = "512Mi"
cpu = "50m"
}
limits = {
memory = "1Gi"
}
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.data_proxmox.metadata[0].name
}
}
volume {
name = "downloads"
persistent_volume_claim {
claim_name = module.nfs_downloads_host.claim_name
}
}
volume {
name = "audiobooks"
persistent_volume_claim {
claim_name = module.nfs_audiobooks_host.claim_name
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [
spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1
metadata[0].annotations["keel.sh/policy"],
metadata[0].annotations["keel.sh/trigger"],
metadata[0].annotations["keel.sh/pollSchedule"], # KYVERNO_LIFECYCLE_V2
metadata[0].annotations["keel.sh/match-tag"],
spec[0].template[0].spec[0].container[0].image, # KEEL_IGNORE_IMAGE Keel manages tag updates
metadata[0].annotations["kubernetes.io/change-cause"],
metadata[0].annotations["deployment.kubernetes.io/revision"],
spec[0].template[0].metadata[0].annotations["keel.sh/update-time"], # KEEL_LIFECYCLE_V1
]
}
}
resource "kubernetes_service" "qbittorrent" {
metadata {
name = "qbittorrent"
namespace = "servarr"
labels = {
app = "qbittorrent"
}
}
spec {
selector = {
app = "qbittorrent"
}
port {
name = "http"
port = 80
target_port = 8080
}
}
}
resource "kubernetes_service" "qbittorrent-torrenting" {
metadata {
name = "qbittorrent-torrenting"
namespace = "servarr"
labels = {
app = "qbittorrent-torrenting"
}
annotations = {
"metallb.io/loadBalancerIPs" = "10.0.20.200"
"metallb.io/allow-shared-ip" = "shared"
}
}
spec {
type = "LoadBalancer"
external_traffic_policy = "Cluster"
selector = {
app = "qbittorrent"
}
port {
name = "torrenting"
port = 50000
target_port = 50000
}
port {
name = "torrenting-udp"
port = 50000
protocol = "UDP"
target_port = 50000
}
}
}
resource "kubernetes_cron_job_v1" "qbittorrent_ratio_monitor" {
metadata {
name = "qbittorrent-ratio-monitor"
namespace = "servarr"
}
spec {
concurrency_policy = "Replace"
failed_jobs_history_limit = 3
successful_jobs_history_limit = 3
schedule = "*/5 * * * *"
job_template {
metadata {}
spec {
backoff_limit = 2
ttl_seconds_after_finished = 300
template {
metadata {}
spec {
container {
name = "ratio-monitor"
image = "docker.io/library/python:3.12-alpine"
command = ["/bin/sh", "-c", "set -euo pipefail; pip install -q requests > /dev/null 2>&1; python3 /tmp/monitor.py"]
volume_mount {
name = "script"
mount_path = "/tmp/monitor.py"
sub_path = "monitor.py"
}
resources {
requests = {
memory = "64Mi"
cpu = "10m"
}
limits = {
memory = "128Mi"
}
}
}
volume {
name = "script"
config_map {
name = kubernetes_config_map.ratio_monitor_script.metadata[0].name
}
}
dns_config {
option {
name = "ndots"
value = "2"
}
}
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
}
}
resource "kubernetes_config_map" "ratio_monitor_script" {
metadata {
name = "qbt-ratio-monitor-script"
namespace = "servarr"
}
data = {
"monitor.py" = <<-PYEOF
import requests, json, sys
from collections import defaultdict
from urllib.parse import urlparse
QB_URL = "http://qbittorrent.servarr.svc.cluster.local"
PUSHGW = "http://prometheus-prometheus-pushgateway.monitoring:9091"
try:
torrents = requests.get(f"{QB_URL}/api/v2/torrents/info", timeout=10).json()
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
try:
transfer = requests.get(f"{QB_URL}/api/v2/transfer/info", timeout=10).json()
except Exception:
transfer = {}
tracker_stats = defaultdict(lambda: {
"uploaded": 0, "downloaded": 0, "size": 0,
"count": 0, "seeding": 0, "downloading": 0,
"seed_time_total": 0, "unsatisfied": 0
})
for t in torrents:
category = (t.get("category") or "").lower()
tracker_url = t.get("tracker", "")
domain = ""
if tracker_url:
try:
domain = (urlparse(tracker_url).hostname or "").lower()
except Exception:
domain = ""
# Category is the only signal for queuedDL torrents whose announces
# haven't happened yet (tracker field is empty). Map those first so
# hundreds of MAM torrents don't collect under "unknown".
if category == "mam-farming" or "myanonamouse" in domain or "mam" in domain:
label = "mam"
elif category.startswith("abb") or "audiobookbay" in domain or "abb" in domain:
label = "audiobookbay"
elif domain:
label = domain.replace(".", "_")
else:
label = "unknown"
s = tracker_stats[label]
s["uploaded"] += t.get("uploaded", 0)
s["downloaded"] += t.get("downloaded", 0)
s["size"] += t.get("size", 0)
s["count"] += 1
s["seed_time_total"] += t.get("seeding_time", 0)
state = t.get("state", "")
if state in ("uploading", "stalledUP", "forcedUP", "queuedUP"):
s["seeding"] += 1
elif state in ("downloading", "stalledDL", "forcedDL", "queuedDL"):
s["downloading"] += 1
if t.get("seeding_time", 0) < 259200 and t.get("progress", 0) >= 1.0:
s["unsatisfied"] += 1
for tracker, stats in tracker_stats.items():
dl = stats["downloaded"]
ul = stats["uploaded"]
ratio = ul / dl if dl > 0 else 0.0
metrics = f"""# HELP qbt_tracker_uploaded_bytes Total bytes uploaded for tracker
# TYPE qbt_tracker_uploaded_bytes gauge
qbt_tracker_uploaded_bytes {ul}
# HELP qbt_tracker_downloaded_bytes Total bytes downloaded for tracker
# TYPE qbt_tracker_downloaded_bytes gauge
qbt_tracker_downloaded_bytes {dl}
# HELP qbt_tracker_ratio Upload/download ratio for tracker
# TYPE qbt_tracker_ratio gauge
qbt_tracker_ratio {ratio:.4f}
# HELP qbt_tracker_torrents_total Total torrents for tracker
# TYPE qbt_tracker_torrents_total gauge
qbt_tracker_torrents_total {stats['count']}
# HELP qbt_tracker_seeding Torrents currently seeding
# TYPE qbt_tracker_seeding gauge
qbt_tracker_seeding {stats['seeding']}
# HELP qbt_tracker_downloading Torrents currently downloading
# TYPE qbt_tracker_downloading gauge
qbt_tracker_downloading {stats['downloading']}
# HELP qbt_tracker_seed_time_total_seconds Total seed time across all torrents
# TYPE qbt_tracker_seed_time_total_seconds gauge
qbt_tracker_seed_time_total_seconds {stats['seed_time_total']}
# HELP qbt_tracker_unsatisfied Torrents not yet seeded 72h
# TYPE qbt_tracker_unsatisfied gauge
qbt_tracker_unsatisfied {stats['unsatisfied']}
# HELP qbt_tracker_size_bytes Total size of all torrents
# TYPE qbt_tracker_size_bytes gauge
qbt_tracker_size_bytes {stats['size']}
"""
resp = requests.post(
f"{PUSHGW}/metrics/job/qbt-ratio-monitor/tracker/{tracker}",
data=metrics, timeout=10
)
print(f"Tracker {tracker}: ratio={ratio:.3f} ul={ul} dl={dl} count={stats['count']} seeding={stats['seeding']} unsatisfied={stats['unsatisfied']} -> {resp.status_code}")
connected = 1 if transfer.get("connection_status") == "connected" else 0
dht = transfer.get("dht_nodes", 0)
dl_speed = transfer.get("dl_info_speed", 0)
ul_speed = transfer.get("up_info_speed", 0)
global_metrics = f"""# HELP qbt_connected Whether qBittorrent is connected
# TYPE qbt_connected gauge
qbt_connected {connected}
# HELP qbt_dht_nodes Number of DHT nodes
# TYPE qbt_dht_nodes gauge
qbt_dht_nodes {dht}
# HELP qbt_dl_speed_bytes Current download speed
# TYPE qbt_dl_speed_bytes gauge
qbt_dl_speed_bytes {dl_speed}
# HELP qbt_ul_speed_bytes Current upload speed
# TYPE qbt_ul_speed_bytes gauge
qbt_ul_speed_bytes {ul_speed}
"""
resp = requests.post(
f"{PUSHGW}/metrics/job/qbt-ratio-monitor/tracker/global",
data=global_metrics, timeout=10
)
print(f"Global: connected={connected} dht={dht} dl_speed={dl_speed} ul_speed={ul_speed} -> {resp.status_code}")
PYEOF
}
}
module "ingress" {
source = "../../../modules/kubernetes/ingress_factory"
dns_type = "non-proxied"
namespace = "servarr"
name = "qbittorrent"
tls_secret_name = var.tls_secret_name
auth = "required"
extra_annotations = {
"gethomepage.dev/enabled" = "true"
"gethomepage.dev/name" = "qBittorrent"
"gethomepage.dev/description" = "BitTorrent client"
"gethomepage.dev/icon" = "qbittorrent.png"
"gethomepage.dev/group" = "Media & Entertainment"
"gethomepage.dev/pod-selector" = ""
"gethomepage.dev/widget.type" = "qbittorrent"
"gethomepage.dev/widget.url" = "http://qbittorrent.servarr.svc.cluster.local"
"gethomepage.dev/widget.username" = var.homepage_credentials["qbittorrent"]["username"]
"gethomepage.dev/widget.password" = var.homepage_credentials["qbittorrent"]["password"]
}
}

View file

@ -0,0 +1,161 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "nfs_server" { type = string }
resource "kubernetes_namespace" "readarr" {
metadata {
name = "readarr"
# labels = {
# "istio-injection" : "enabled"
# }
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
}
}
module "tls_secret" {
source = "../../../modules/kubernetes/setup_tls_secret"
namespace = "readarr"
tls_secret_name = var.tls_secret_name
}
module "nfs_data_host" {
source = "../../../modules/kubernetes/nfs_volume"
name = "readarr-data-host"
namespace = "readarr"
nfs_server = "192.168.1.127"
nfs_path = "/srv/nfs/servarr/readarr"
}
module "nfs_qbittorrent_host" {
source = "../../../modules/kubernetes/nfs_volume"
name = "readarr-qbittorrent-host"
namespace = "readarr"
nfs_server = "192.168.1.127"
nfs_path = "/srv/nfs/servarr/qbittorrent"
}
resource "kubernetes_deployment" "readarr" {
metadata {
name = "readarr"
namespace = "readarr"
labels = {
app = "readarr"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "readarr"
}
}
template {
metadata {
labels = {
app = "readarr"
}
}
spec {
container {
image = "lscr.io/linuxserver/readarr:develop"
name = "readarr"
port {
container_port = 8787
}
env {
name = "PUID"
value = 1000
}
env {
name = "PGID"
value = 1000
}
env {
name = "TZ"
value = "Etc/UTC"
}
volume_mount {
name = "data"
mount_path = "/config"
}
volume_mount {
name = "data"
mount_path = "/books"
}
volume_mount {
name = "data"
mount_path = "/downloads"
}
volume_mount {
name = "qbittorrent"
mount_path = "/mnt"
read_only = true
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = module.nfs_data_host.claim_name
}
}
volume {
name = "qbittorrent"
persistent_volume_claim {
claim_name = module.nfs_qbittorrent_host.claim_name
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [
spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1
metadata[0].annotations["keel.sh/policy"],
metadata[0].annotations["keel.sh/trigger"],
metadata[0].annotations["keel.sh/pollSchedule"], # KYVERNO_LIFECYCLE_V2
metadata[0].annotations["keel.sh/match-tag"],
spec[0].template[0].spec[0].container[0].image, # KEEL_IGNORE_IMAGE Keel manages tag updates
metadata[0].annotations["kubernetes.io/change-cause"],
metadata[0].annotations["deployment.kubernetes.io/revision"],
spec[0].template[0].metadata[0].annotations["keel.sh/update-time"], # KEEL_LIFECYCLE_V1
]
}
}
resource "kubernetes_service" "readarr" {
metadata {
name = "readarr"
namespace = "readarr"
labels = {
app = "readarr"
}
}
spec {
selector = {
app = "readarr"
}
port {
name = "http"
port = 8787
}
}
}
module "ingress" {
source = "../../../modules/kubernetes/ingress_factory"
namespace = "readarr"
name = "readarr"
port = 8787
tls_secret_name = var.tls_secret_name
auth = "required"
}

1
stacks/servarr/secrets Symbolic link
View file

@ -0,0 +1 @@
../../secrets

View file

@ -0,0 +1,127 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "nfs_server" { type = string }
module "nfs_lidarr_host" {
source = "../../../modules/kubernetes/nfs_volume"
name = "servarr-soulseek-lidarr-host"
namespace = "servarr"
nfs_server = "192.168.1.127"
nfs_path = "/srv/nfs/servarr/lidarr"
}
resource "kubernetes_deployment" "soulseek" {
metadata {
name = "soulseek"
namespace = "servarr"
labels = {
app = "soulseek"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "soulseek"
}
}
template {
metadata {
labels = {
app = "soulseek"
}
}
spec {
container {
image = "realies/soulseek"
name = "soulseek"
port {
name = "soulseek"
container_port = 6080
}
env {
name = "PUID"
value = 1000
}
env {
name = "PGID"
value = 1000
}
volume_mount {
name = "config"
mount_path = "/data/.SoulseekQt"
sub_path = "soulseek/config"
}
volume_mount {
name = "downloads"
mount_path = "/data/Soulseek Downloads"
sub_path = "soulseek/downloads"
}
}
volume {
name = "config"
persistent_volume_claim {
claim_name = module.nfs_lidarr_host.claim_name
}
}
volume {
name = "downloads"
persistent_volume_claim {
claim_name = module.nfs_lidarr_host.claim_name
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [
spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1
metadata[0].annotations["keel.sh/policy"],
metadata[0].annotations["keel.sh/trigger"],
metadata[0].annotations["keel.sh/pollSchedule"], # KYVERNO_LIFECYCLE_V2
metadata[0].annotations["keel.sh/match-tag"],
spec[0].template[0].spec[0].container[0].image, # KEEL_IGNORE_IMAGE Keel manages tag updates
metadata[0].annotations["kubernetes.io/change-cause"],
metadata[0].annotations["deployment.kubernetes.io/revision"],
spec[0].template[0].metadata[0].annotations["keel.sh/update-time"], # KEEL_LIFECYCLE_V1
]
}
}
resource "kubernetes_service" "soulseek" {
metadata {
name = "soulseek"
namespace = "servarr"
labels = {
app = "soulseek"
}
}
spec {
selector = {
app = "soulseek"
}
port {
name = "http"
port = 80
target_port = 6080
}
}
}
module "ingress" {
source = "../../../modules/kubernetes/ingress_factory"
dns_type = "non-proxied"
namespace = "servarr"
name = "soulseek"
tls_secret_name = var.tls_secret_name
auth = "required"
}

View file

@ -0,0 +1,13 @@
include "root" {
path = find_in_parent_folders()
}
dependency "platform" {
config_path = "../platform"
skip_outputs = true
}
dependency "vault" {
config_path = "../vault"
skip_outputs = true
}