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:
parent
6d224861c4
commit
fd0f4a0365
1166 changed files with 358546 additions and 0 deletions
140
stacks/servarr/.terraform.lock.hcl
generated
Normal file
140
stacks/servarr/.terraform.lock.hcl
generated
Normal 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=",
|
||||
]
|
||||
}
|
||||
616
stacks/servarr/aiostreams/main.tf
Normal file
616
stacks/servarr/aiostreams/main.tf
Normal 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" = ""
|
||||
}
|
||||
}
|
||||
102
stacks/servarr/flaresolverr/main.tf
Normal file
102
stacks/servarr/flaresolverr/main.tf
Normal 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" = ""
|
||||
}
|
||||
}
|
||||
197
stacks/servarr/lidarr/main.tf
Normal file
197
stacks/servarr/lidarr/main.tf
Normal 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"
|
||||
}
|
||||
161
stacks/servarr/listenarr/main.tf
Normal file
161
stacks/servarr/listenarr/main.tf
Normal 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
133
stacks/servarr/main.tf
Normal 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
|
||||
163
stacks/servarr/mam-farming/files/bp-spender.py
Normal file
163
stacks/servarr/mam-farming/files/bp-spender.py
Normal 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()
|
||||
273
stacks/servarr/mam-farming/files/freeleech-grabber.py
Normal file
273
stacks/servarr/mam-farming/files/freeleech-grabber.py
Normal 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()
|
||||
177
stacks/servarr/mam-farming/files/mam-farming-janitor.py
Normal file
177
stacks/servarr/mam-farming/files/mam-farming-janitor.py
Normal 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()
|
||||
270
stacks/servarr/mam-farming/main.tf
Normal file
270
stacks/servarr/mam-farming/main.tf
Normal 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]
|
||||
}
|
||||
}
|
||||
53
stacks/servarr/providers.tf
Normal file
53
stacks/servarr/providers.tf
Normal 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
|
||||
}
|
||||
192
stacks/servarr/prowlarr/main.tf
Normal file
192
stacks/servarr/prowlarr/main.tf
Normal 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"]
|
||||
}
|
||||
}
|
||||
440
stacks/servarr/qbittorrent/main.tf
Normal file
440
stacks/servarr/qbittorrent/main.tf
Normal 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"]
|
||||
}
|
||||
}
|
||||
161
stacks/servarr/readarr/main.tf
Normal file
161
stacks/servarr/readarr/main.tf
Normal 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
1
stacks/servarr/secrets
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../secrets
|
||||
127
stacks/servarr/soulseek/main.tf
Normal file
127
stacks/servarr/soulseek/main.tf
Normal 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"
|
||||
}
|
||||
13
stacks/servarr/terragrunt.hcl
Normal file
13
stacks/servarr/terragrunt.hcl
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue