[infra] Per-ingress external-monitor annotation + actualbudget plan-time fix [ci skip]

## Context
Two operational gaps surfaced during a healthcheck sweep today:

1. **External monitoring coverage**: Only ~13 hostnames (via `cloudflare_proxied_names`
   in `config.tfvars`) had `[External]` monitors in Uptime Kuma. Any service deployed via
   `ingress_factory` with `dns_type = "proxied"` auto-created its DNS record but was NOT
   registered for external probing — so outages like Immich going down externally were
   invisible until a user complained. 99 of ~125 public ingresses had no external
   monitor.

2. **actualbudget stack unplannable**: `count = var.budget_encryption_password != null
   ? 1 : 0` in `factory/main.tf:152` failed with "Invalid count argument" because the
   value flows from a `data.kubernetes_secret` whose contents are `(known after apply)`
   at plan time. Blocked CI applies and drift reconciliation.

## This change

### Per-ingress external-monitor annotation (ingress_factory + reverse_proxy/factory)
- New variables `external_monitor` (bool, nullable) + `external_monitor_name` (string,
  nullable). Default is "follow dns_type" — enabled for any public DNS record
  (`dns_type != "none"`, covers both proxied and non-proxied so Immich and other
  direct-A records are also monitored).
- Emits two annotations on the Ingress:
  - `uptime.viktorbarzin.me/external-monitor = "true"`
  - `uptime.viktorbarzin.me/external-monitor-name = "<label>"` (optional override)

### external-monitor-sync CronJob (uptime-kuma stack)
- Discovers targets from live Ingress objects via the K8s API first (filter by
  annotation), falls back to the legacy `external-monitor-targets` ConfigMap on any
  API error (zero rollout risk).
- New `ServiceAccount` + cluster-wide `ClusterRole`/`ClusterRoleBinding` giving
  `list`/`get` on `networking.k8s.io/ingresses`.
- `API_SERVER` now uses the `KUBERNETES_SERVICE_HOST` env var (always injected by K8s)
  instead of `kubernetes.default.svc` — the search-domain expansion failed in the
  CronJob pod's DNS config. Verified working: CronJob now logs
  `Loaded N external monitor targets (source=k8s-api)`.

### actualbudget count-on-unknown refactor
- Replaced `count = var.budget_encryption_password != null ? 1 : 0` with two explicit
  plan-time booleans: `enable_http_api` and `enable_bank_sync`. Values are known at
  plan; no `-target` workaround needed.
- Callers (`stacks/actualbudget/main.tf`) pass `true` explicitly. Runtime behaviour is
  unchanged — the secret is still consumed via env var.
- Also aligned the factory with live state (the 3 budget-* PVCs had been migrated
  `proxmox-lvm` → `proxmox-lvm-encrypted` outside Terraform): PVC resource renamed
  `data_proxmox` → `data_encrypted`, storage class updated, orphaned `nfs_data` module
  removed. State was rm'd + re-imported with matching UIDs, so no data was moved.

## Rollout status (already partially applied in this session)
- `stacks/uptime-kuma` applied — SA + RBAC + CronJob changes live; FQDN fix verified
- `stacks/actualbudget` applied — budget-{viktor,anca,emo} all 200 OK externally
- `stacks/mailserver` + 21 other ingress_factory consumers applied — annotations live
- CronJob `external-monitor-sync` latest run: `source=k8s-api`, 26 monitors active
  (was 13 on the central list)

## Deferred (separate work)
- 4 stacks show pre-existing DESTRUCTIVE drift in plan (metallb namespace, claude-memory,
  rbac, redis) — NOT triggered by this commit but will be by CI's global-file cascade.
  `[ci skip]` here so those don't auto-apply; they will be fixed manually before the
  next CI push.
- Cleanup of `cloudflare_proxied_names` list once Helm-managed ingresses (authentik,
  grafana, vault, forgejo) are annotated — separate PR.

## Test plan

### Automated
\`\`\`
\$ kubectl -n uptime-kuma logs \$(kubectl -n uptime-kuma get pods -l job-name -o name | tail -1)
Loaded 26 external monitor targets (source=k8s-api)
Sync complete: 7 created, 0 deleted, 17 unchanged

\$ curl -sk -o /dev/null -w "%{http_code}\n" -H "Accept: text/html" \\
    https://dawarich.viktorbarzin.me/ https://nextcloud.viktorbarzin.me/ \\
    https://budget-viktor.viktorbarzin.me/
200 302 200

\$ kubectl -n actualbudget get deploy,pvc -l app=budget-viktor
deployment.apps/budget-viktor     1/1 1 1 Ready
persistentvolumeclaim/budget-viktor-data-encrypted  Bound  10Gi  RWO  proxmox-lvm-encrypted
\`\`\`

### Manual Verification
1. Confirm the annotation is present on an ingress_factory ingress:
   \`\`\`
   kubectl -n dawarich get ingress dawarich -o \\
     jsonpath='{.metadata.annotations.uptime\.viktorbarzin\.me/external-monitor}'
   # Expected: "true"
   \`\`\`
2. Confirm the new `[External] <name>` monitor appears in Uptime Kuma within 10 min
   (CronJob interval). For Immich specifically, it will appear after the immich stack
   is re-applied.
3. Verify actualbudget plan is clean:
   \`\`\`
   cd stacks/actualbudget && scripts/tg plan --non-interactive
   # Expected: no "Invalid count argument" errors
   \`\`\`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-04-17 10:34:32 +00:00
parent 0c4fe98d75
commit 66d2d9916b
5 changed files with 209 additions and 49 deletions

View file

@ -252,9 +252,50 @@ module "ingress" {
# =============================================================================
# External Monitor Sync
# Ensures Uptime Kuma has external HTTPS monitors for all Cloudflare-proxied services.
# Reads targets from a Terraform-generated ConfigMap, creates/deletes monitors to match.
# Ensures Uptime Kuma has external HTTPS monitors for every ingress annotated
# with `uptime.viktorbarzin.me/external-monitor=true`. Falls back to a
# Terraform-generated ConfigMap when API discovery is unavailable.
#
# Discovery modes (the script tries them in order):
# 1. K8s API list ingresses cluster-wide, filter by annotation
# 2. ConfigMap fallback read /config/targets.json (legacy list from
# cloudflare_proxied_names)
# =============================================================================
resource "kubernetes_service_account_v1" "external_monitor_sync" {
metadata {
name = "external-monitor-sync"
namespace = kubernetes_namespace.uptime-kuma.metadata[0].name
}
}
resource "kubernetes_cluster_role_v1" "external_monitor_sync" {
metadata {
name = "external-monitor-sync"
}
rule {
api_groups = ["networking.k8s.io"]
resources = ["ingresses"]
verbs = ["list", "get"]
}
}
resource "kubernetes_cluster_role_binding_v1" "external_monitor_sync" {
metadata {
name = "external-monitor-sync"
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "ClusterRole"
name = kubernetes_cluster_role_v1.external_monitor_sync.metadata[0].name
}
subject {
kind = "ServiceAccount"
name = kubernetes_service_account_v1.external_monitor_sync.metadata[0].name
namespace = kubernetes_namespace.uptime-kuma.metadata[0].name
}
}
resource "kubernetes_config_map_v1" "external_monitor_targets" {
metadata {
name = "external-monitor-targets"
@ -283,24 +324,79 @@ resource "kubernetes_cron_job_v1" "external_monitor_sync" {
template {
metadata {}
spec {
service_account_name = kubernetes_service_account_v1.external_monitor_sync.metadata[0].name
container {
name = "sync"
image = "docker.io/library/python:3.12-alpine"
command = ["/bin/sh", "-c", <<-EOT
pip install --quiet --disable-pip-version-check uptime-kuma-api
python3 << 'PYEOF'
import os, json, time
import os, json, ssl, time, urllib.request, urllib.error
from uptime_kuma_api import UptimeKumaApi, MonitorType
UPTIME_KUMA_URL = "http://uptime-kuma.uptime-kuma.svc.cluster.local"
UPTIME_KUMA_PASS = os.environ["UPTIME_KUMA_PASSWORD"]
TARGETS_FILE = "/config/targets.json"
FALLBACK_FILE = "/config/targets.json"
PREFIX = "[External] "
ANNOTATION_ENABLE = "uptime.viktorbarzin.me/external-monitor"
ANNOTATION_NAME = "uptime.viktorbarzin.me/external-monitor-name"
SA_DIR = "/var/run/secrets/kubernetes.io/serviceaccount"
API_SERVER = f"https://{os.environ.get('KUBERNETES_SERVICE_HOST', 'kubernetes.default.svc.cluster.local')}:{os.environ.get('KUBERNETES_SERVICE_PORT', '443')}"
with open(TARGETS_FILE) as f:
targets = json.load(f)
print(f"Loaded {len(targets)} external monitor targets")
def load_from_api():
"""List ingresses via in-cluster API, filter by annotation, derive targets."""
with open(f"{SA_DIR}/token") as f:
token = f.read().strip()
ctx = ssl.create_default_context(cafile=f"{SA_DIR}/ca.crt")
url = f"{API_SERVER}/apis/networking.k8s.io/v1/ingresses"
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
body = json.loads(resp.read())
targets = []
for ing in body.get("items", []):
anns = (ing.get("metadata") or {}).get("annotations") or {}
if anns.get(ANNOTATION_ENABLE, "").lower() != "true":
continue
tls = (ing.get("spec") or {}).get("tls") or []
host = None
if tls and tls[0].get("hosts"):
host = tls[0]["hosts"][0]
else:
rules = (ing.get("spec") or {}).get("rules") or []
if rules:
host = rules[0].get("host")
if not host:
ns = ing["metadata"]["namespace"]
nm = ing["metadata"]["name"]
print(f"WARN: ingress {ns}/{nm} annotated but has no host; skipping")
continue
label = anns.get(ANNOTATION_NAME) or host.split(".")[0]
targets.append({"name": label, "url": f"https://{host}"})
return targets
def load_from_configmap():
"""Legacy fallback: read the ConfigMap list."""
with open(FALLBACK_FILE) as f:
raw = json.load(f)
return [{"name": t["name"], "url": t["url"]} for t in raw]
try:
targets = load_from_api()
source = "k8s-api"
if not targets:
print("WARN: k8s-api returned 0 targets; falling back to ConfigMap")
targets = load_from_configmap()
source = "configmap"
except (urllib.error.URLError, OSError, KeyError, ValueError) as e:
print(f"WARN: k8s-api discovery failed ({e!r}); falling back to ConfigMap")
targets = load_from_configmap()
source = "configmap"
print(f"Loaded {len(targets)} external monitor targets (source={source})")
api = UptimeKumaApi(UPTIME_KUMA_URL, timeout=120, wait_events=0.2)
api.login("admin", UPTIME_KUMA_PASS)