truenas deprecation: migrate all non-immich storage to proxmox NFS
- Migrate 7 backup CronJobs to Proxmox host NFS (192.168.1.127) (etcd, mysql, postgresql, nextcloud, redis, vaultwarden, plotting-book) - Migrate headscale backup, ebook2audiobook, osm_routing to Proxmox NFS - Migrate servarr (lidarr, readarr, soulseek) NFS refs to Proxmox - Remove 79 orphaned TrueNAS NFS module declarations from 49 stacks - Delete stacks/platform/modules/ (27 dead module copies, 65MB) - Update nfs-truenas StorageClass to point to Proxmox (192.168.1.127) - Remove iscsi DNS record from config.tfvars - Fix woodpecker persistence config and alertmanager PV Only Immich (8 PVCs, ~1.4TB) remains on TrueNAS.
This commit is contained in:
parent
3246c4d112
commit
82b0f6c4cb
193 changed files with 825 additions and 177172 deletions
382
stacks/status-page/main.tf
Normal file
382
stacks/status-page/main.tf
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
variable "tls_secret_name" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
variable "nfs_server" { type = string }
|
||||
|
||||
data "vault_kv_secret_v2" "viktor" {
|
||||
mount = "secret"
|
||||
name = "viktor"
|
||||
}
|
||||
|
||||
locals {
|
||||
index_html = file("${path.module}/index.html")
|
||||
}
|
||||
|
||||
resource "kubernetes_namespace_v1" "status_page" {
|
||||
metadata {
|
||||
name = "status-page"
|
||||
labels = {
|
||||
tier = local.tiers.aux
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_config_map_v1" "status_page_template" {
|
||||
metadata {
|
||||
name = "status-page-template"
|
||||
namespace = kubernetes_namespace_v1.status_page.metadata[0].name
|
||||
}
|
||||
data = {
|
||||
"index.html" = local.index_html
|
||||
"CNAME" = "status.viktorbarzin.me"
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_service_account_v1" "status_page" {
|
||||
metadata {
|
||||
name = "status-page-pusher"
|
||||
namespace = kubernetes_namespace_v1.status_page.metadata[0].name
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_cluster_role_v1" "ingress_reader" {
|
||||
metadata {
|
||||
name = "status-page-ingress-reader"
|
||||
}
|
||||
rule {
|
||||
api_groups = ["networking.k8s.io"]
|
||||
resources = ["ingresses"]
|
||||
verbs = ["list"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_cluster_role_binding_v1" "ingress_reader" {
|
||||
metadata {
|
||||
name = "status-page-ingress-reader"
|
||||
}
|
||||
role_ref {
|
||||
api_group = "rbac.authorization.k8s.io"
|
||||
kind = "ClusterRole"
|
||||
name = kubernetes_cluster_role_v1.ingress_reader.metadata[0].name
|
||||
}
|
||||
subject {
|
||||
kind = "ServiceAccount"
|
||||
name = kubernetes_service_account_v1.status_page.metadata[0].name
|
||||
namespace = kubernetes_namespace_v1.status_page.metadata[0].name
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Status Page Pusher
|
||||
# Reads Uptime Kuma monitors, generates status.json, pushes to GitHub Pages
|
||||
# =============================================================================
|
||||
resource "kubernetes_cron_job_v1" "status_page_pusher" {
|
||||
metadata {
|
||||
name = "status-page-pusher"
|
||||
namespace = kubernetes_namespace_v1.status_page.metadata[0].name
|
||||
}
|
||||
spec {
|
||||
concurrency_policy = "Forbid"
|
||||
failed_jobs_history_limit = 3
|
||||
successful_jobs_history_limit = 3
|
||||
schedule = "*/5 * * * *"
|
||||
job_template {
|
||||
metadata {}
|
||||
spec {
|
||||
backoff_limit = 1
|
||||
ttl_seconds_after_finished = 300
|
||||
template {
|
||||
metadata {}
|
||||
spec {
|
||||
service_account_name = kubernetes_service_account_v1.status_page.metadata[0].name
|
||||
container {
|
||||
name = "status-pusher"
|
||||
image = "docker.io/library/python:3.12-alpine"
|
||||
command = ["/bin/sh", "-c", <<-EOT
|
||||
apk add --no-cache git >/dev/null 2>&1
|
||||
pip install --quiet --disable-pip-version-check uptime-kuma-api
|
||||
python3 << 'PYEOF'
|
||||
import os, sys, json, time, subprocess
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from uptime_kuma_api import UptimeKumaApi
|
||||
|
||||
UPTIME_KUMA_URL = "http://uptime-kuma.uptime-kuma.svc.cluster.local"
|
||||
UPTIME_KUMA_PASS = os.environ["UPTIME_KUMA_PASSWORD"]
|
||||
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
|
||||
REPO = "ViktorBarzin/status-page"
|
||||
REPO_URL = "https://" + GITHUB_TOKEN + "@github.com/" + REPO + ".git"
|
||||
|
||||
TYPE_NAMES = {
|
||||
"http": "HTTP",
|
||||
"port": "TCP Port",
|
||||
"ping": "Ping",
|
||||
"keyword": "HTTP Keyword",
|
||||
"grpc-keyword": "gRPC",
|
||||
"dns": "DNS",
|
||||
"docker": "Docker",
|
||||
"push": "Push",
|
||||
"steam": "Steam",
|
||||
"gamedig": "GameDig",
|
||||
"mqtt": "MQTT",
|
||||
"sqlserver": "SQL Server",
|
||||
"postgres": "PostgreSQL",
|
||||
"mysql": "MySQL",
|
||||
"mongodb": "MongoDB",
|
||||
"radius": "RADIUS",
|
||||
"redis": "Redis",
|
||||
"tailscale-ping": "Tailscale Ping",
|
||||
"real-browser": "Real Browser",
|
||||
"group": "Group",
|
||||
"snmp": "SNMP",
|
||||
"json-query": "JSON Query",
|
||||
}
|
||||
|
||||
def beat_status_is_up(status_val):
|
||||
"""Handle both enum and int status values."""
|
||||
if hasattr(status_val, "value"):
|
||||
return status_val.value == 1
|
||||
return status_val == 1
|
||||
|
||||
# Build namespace -> external URL map from K8s ingresses
|
||||
ingress_map = {}
|
||||
try:
|
||||
import ssl, urllib.request
|
||||
token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
ca_path = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||
if os.path.exists(token_path):
|
||||
with open(token_path) as f:
|
||||
token = f.read().strip()
|
||||
ctx = ssl.create_default_context(cafile=ca_path)
|
||||
k8s_host = os.environ.get("KUBERNETES_SERVICE_HOST", "kubernetes.default.svc")
|
||||
k8s_port = os.environ.get("KUBERNETES_SERVICE_PORT", "443")
|
||||
req = urllib.request.Request(
|
||||
"https://" + k8s_host + ":" + k8s_port + "/apis/networking.k8s.io/v1/ingresses",
|
||||
headers={"Authorization": "Bearer " + token}
|
||||
)
|
||||
resp = urllib.request.urlopen(req, context=ctx, timeout=10)
|
||||
ing_data = json.loads(resp.read())
|
||||
for item in ing_data.get("items", []):
|
||||
ns = item["metadata"]["namespace"]
|
||||
rules = item.get("spec", {}).get("rules", [])
|
||||
if rules and rules[0].get("host"):
|
||||
host = rules[0]["host"]
|
||||
if ns not in ingress_map:
|
||||
ingress_map[ns] = "https://" + host
|
||||
print(f"Built ingress map: {len(ingress_map)} namespaces")
|
||||
except Exception as e:
|
||||
print(f"Warning: could not build ingress map: {e}")
|
||||
|
||||
print("Connecting to Uptime Kuma...")
|
||||
api = UptimeKumaApi(UPTIME_KUMA_URL, timeout=30)
|
||||
api.login("admin", UPTIME_KUMA_PASS)
|
||||
|
||||
monitors = api.get_monitors()
|
||||
print(f"Fetched {len(monitors)} monitors")
|
||||
|
||||
# Get current heartbeats for live status
|
||||
heartbeats = api.get_heartbeats()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
def calc_uptime(beat_list, hours):
|
||||
cutoff = now - timedelta(hours=hours)
|
||||
relevant = []
|
||||
for b in beat_list:
|
||||
t = str(b["time"])
|
||||
try:
|
||||
bt = datetime.fromisoformat(t.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if bt.tzinfo is None:
|
||||
bt = bt.replace(tzinfo=timezone.utc)
|
||||
if bt > cutoff:
|
||||
relevant.append(b)
|
||||
if not relevant:
|
||||
return None
|
||||
up_count = sum(1 for b in relevant if beat_status_is_up(b.get("status", 0)))
|
||||
return round(up_count / len(relevant) * 100, 1)
|
||||
|
||||
groups = {}
|
||||
for m in monitors:
|
||||
raw_type = m.get("type", "unknown")
|
||||
monitor_type = raw_type.value if hasattr(raw_type, "value") else str(raw_type)
|
||||
monitor_type = monitor_type.lower().replace("monitortype.", "")
|
||||
group_name = TYPE_NAMES.get(monitor_type, monitor_type.upper())
|
||||
|
||||
if not m.get("active", True):
|
||||
continue
|
||||
else:
|
||||
# Get latest heartbeat for current status
|
||||
mid = m["id"]
|
||||
mon_beats = heartbeats.get(mid, [])
|
||||
if mon_beats:
|
||||
# Flatten if nested lists
|
||||
if mon_beats and isinstance(mon_beats[0], list):
|
||||
mon_beats = [b for sublist in mon_beats for b in sublist]
|
||||
latest = mon_beats[-1] if mon_beats else None
|
||||
if latest and beat_status_is_up(latest.get("status", 0)):
|
||||
status = "up"
|
||||
else:
|
||||
status = "down"
|
||||
else:
|
||||
status = "pending"
|
||||
|
||||
uptime_24h = None
|
||||
uptime_7d = None
|
||||
uptime_30d = None
|
||||
try:
|
||||
beats = api.get_monitor_beats(m["id"], 720)
|
||||
if beats:
|
||||
uptime_24h = calc_uptime(beats, 24)
|
||||
uptime_7d = calc_uptime(beats, 168)
|
||||
uptime_30d = calc_uptime(beats, 720)
|
||||
except Exception as e:
|
||||
print(f" Warning: could not get beats for {m['name']}: {e}")
|
||||
|
||||
if group_name not in groups:
|
||||
groups[group_name] = []
|
||||
|
||||
# Extract external URL for HTTP monitors
|
||||
monitor_url = None
|
||||
raw_url = m.get("url", "") or ""
|
||||
if monitor_type == "http" and raw_url:
|
||||
if ".svc.cluster.local" not in raw_url and raw_url.startswith("http"):
|
||||
monitor_url = raw_url.rstrip("/")
|
||||
else:
|
||||
# Internal URL — derive external from namespace
|
||||
import re as _re
|
||||
ns_match = _re.search(r"//[^.]+\.([^.]+)\.svc\.cluster\.local", raw_url)
|
||||
if ns_match:
|
||||
ns = ns_match.group(1)
|
||||
if ns in ingress_map:
|
||||
monitor_url = ingress_map[ns]
|
||||
|
||||
entry = {
|
||||
"name": m["name"],
|
||||
"status": status,
|
||||
"uptime_24h": uptime_24h,
|
||||
"uptime_7d": uptime_7d,
|
||||
"uptime_30d": uptime_30d,
|
||||
}
|
||||
if monitor_url:
|
||||
entry["url"] = monitor_url
|
||||
|
||||
groups[group_name].append(entry)
|
||||
|
||||
api.disconnect()
|
||||
print(f"Generated {len(groups)} groups")
|
||||
|
||||
status_data = {
|
||||
"last_updated": now.isoformat(),
|
||||
"groups": groups,
|
||||
}
|
||||
|
||||
work_dir = "/tmp/status-page"
|
||||
subprocess.run(["rm", "-rf", work_dir], check=True)
|
||||
subprocess.run(["git", "clone", "--depth=1", REPO_URL, work_dir], check=True, capture_output=True)
|
||||
|
||||
# Sync template files from ConfigMap mount
|
||||
import shutil
|
||||
for tpl in ["index.html", "CNAME"]:
|
||||
src = os.path.join("/template", tpl)
|
||||
dst = os.path.join(work_dir, tpl)
|
||||
if os.path.exists(src):
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
# Ensure .nojekyll exists
|
||||
open(os.path.join(work_dir, ".nojekyll"), "a").close()
|
||||
|
||||
with open(os.path.join(work_dir, "status.json"), "w") as f:
|
||||
json.dump(status_data, f, indent=2)
|
||||
|
||||
history_dir = os.path.join(work_dir, "history")
|
||||
os.makedirs(history_dir, exist_ok=True)
|
||||
today_file = os.path.join(history_dir, now.strftime("%Y-%m-%d") + ".json")
|
||||
history = []
|
||||
if os.path.exists(today_file):
|
||||
with open(today_file) as f:
|
||||
try:
|
||||
history = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
history = []
|
||||
|
||||
snapshot = {"timestamp": now.isoformat(), "monitors": {}}
|
||||
for gname, gmonitors in groups.items():
|
||||
for mon in gmonitors:
|
||||
snapshot["monitors"][mon["name"]] = mon["status"]
|
||||
history.append(snapshot)
|
||||
with open(today_file, "w") as f:
|
||||
json.dump(history, f)
|
||||
|
||||
cutoff_date = (now - timedelta(days=30)).strftime("%Y-%m-%d")
|
||||
for fname in os.listdir(history_dir):
|
||||
if fname.endswith(".json") and fname < cutoff_date + ".json":
|
||||
os.remove(os.path.join(history_dir, fname))
|
||||
print(f" Deleted old history: {fname}")
|
||||
|
||||
os.chdir(work_dir)
|
||||
subprocess.run(["git", "config", "user.email", "status-bot@viktorbarzin.me"], check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Status Bot"], check=True)
|
||||
subprocess.run(["git", "add", "-A"], check=True)
|
||||
|
||||
result = subprocess.run(["git", "diff", "--cached", "--quiet"])
|
||||
if result.returncode == 0:
|
||||
print("No changes to push")
|
||||
sys.exit(0)
|
||||
|
||||
commit_msg = "status update " + now.strftime("%Y-%m-%d %H:%M UTC")
|
||||
subprocess.run(["git", "commit", "-m", commit_msg], check=True)
|
||||
push_result = subprocess.run(["git", "push"], capture_output=True, text=True)
|
||||
if push_result.returncode != 0:
|
||||
print(f"Push failed: {push_result.stderr}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Successfully pushed status update at {now.isoformat()}")
|
||||
PYEOF
|
||||
EOT
|
||||
]
|
||||
env {
|
||||
name = "UPTIME_KUMA_PASSWORD"
|
||||
value = data.vault_kv_secret_v2.viktor.data["uptime_kuma_admin_password"]
|
||||
}
|
||||
env {
|
||||
name = "GITHUB_TOKEN"
|
||||
value = data.vault_kv_secret_v2.viktor.data["github_pat"]
|
||||
}
|
||||
volume_mount {
|
||||
name = "template"
|
||||
mount_path = "/template"
|
||||
read_only = true
|
||||
}
|
||||
resources {
|
||||
requests = {
|
||||
memory = "128Mi"
|
||||
cpu = "10m"
|
||||
}
|
||||
limits = {
|
||||
memory = "256Mi"
|
||||
}
|
||||
}
|
||||
}
|
||||
volume {
|
||||
name = "template"
|
||||
config_map {
|
||||
name = kubernetes_config_map_v1.status_page_template.metadata[0].name
|
||||
}
|
||||
}
|
||||
dns_config {
|
||||
option {
|
||||
name = "ndots"
|
||||
value = "2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle {
|
||||
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue