[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:
parent
0c4fe98d75
commit
66d2d9916b
5 changed files with 209 additions and 49 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue