Skip to content

feat(gatus): add isolated external probe - #4086

Merged
Tanguille merged 3 commits into
mainfrom
omos/gatus-stable-egress
Jul 20, 2026
Merged

feat(gatus): add isolated external probe#4086
Tanguille merged 3 commits into
mainfrom
omos/gatus-stable-egress

Conversation

@Tanguille

Copy link
Copy Markdown
Owner

Summary

  • add a dedicated Cloudflare-tunneled Gatus health route outside the CrowdSec-protected application gateway
  • restrict the probe Gateway to the Gatus hostname and observability namespace
  • replace generated external application probes with the dedicated health check

Validation

  • [x]
  • YAML parsing and final review
  • Full rendered validation (blocked locally: mise, flate, and kustomize unavailable)

@tanguille-cluster

Copy link
Copy Markdown
@@ data @@
# v1/ConfigMap/actions-runner-system/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/ai/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/cert-manager/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/database/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/default/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/flux-system/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/kopiur-system/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/kube-system/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/media/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/network/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/observability/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/openebs-system/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/rook-ceph/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/security/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/system-upgrade/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ data @@
# v1/ConfigMap/web3/cluster-settings
! + one map entry added:
+ ENVOY_GATUS_PROBE_IP: "192.168.0.9"

@@ spec.endpoints @@
# externaldns.k8s.io/v1alpha1/DNSEndpoint/network/cloudflared
! + one list entry added:
+ - dnsName: gatus-probe...PLACEHOLDER_SECRET_DOMAIN..
+   recordType: CNAME
+   targets:
+   - 2a03cc15-eff3-4c5c-a7c5-97877e5b8231.cfargotunnel.com

@@ data.config.yaml @@
# v1/ConfigMap/network/cloudflared-configmap
! ± value change in multiline text (one insert, no deletions)
  ---
  ingress:
    - hostname: ..PLACEHOLDER_SECRET_DOMAIN..
      originRequest: &originRequest
  
  [one line unchanged)]
  
        keepAliveTimeout: 3600s
        originServerName: external...PLACEHOLDER_SECRET_DOMAIN..
        tcpKeepAlive: 7200s
      service: &svc https://envoy-external.network.svc.cluster.local:443
+   - hostname: gatus-probe...PLACEHOLDER_SECRET_DOMAIN..
+     originRequest:
+       originServerName: gatus-probe...PLACEHOLDER_SECRET_DOMAIN..
+       http2Origin: true
+     service: https://envoy-external-probe.network.svc.cluster.local:443
    - hostname: "*...PLACEHOLDER_SECRET_DOMAIN.."
      originRequest: *originRequest
      service: *svc
    - service: http_status:404

@@ spec.values.sidecar.gatewayNames @@
# helm.toolkit.fluxcd.io/v2/HelmRelease/observability/gatus
! - one list entry removed:
- - envoy-external

@@ data.config.yaml @@
# v1/ConfigMap/observability/gatus-configmap
! ± value change in multiline text (one insert, no deletions)
  ---
  web:
    port: ${GATUS_WEB_PORT}
  storage:
  
  [17 lines unchanged)]
  
      target: 1.1.1.1:53
      interval: 1m
  
  endpoints:
+   - name: Gatus external probe
+     group: external
+     url: https://gatus-probe...PLACEHOLDER_SECRET_DOMAIN../health
+     interval: 1m
+     client:
+       dns-resolver: tcp://1.1.1.1:53
+     conditions:
+       - "[STATUS] == 200"
+     alerts:
+       - type: discord
+         enabled: true
+         send-on-resolved: true
+ 
    - name: Cloudflare
      group: connectivity
      url: icmp://1.1.1.1
      interval: 1m
  
  [83 lines unchanged)]
  
        timeout: 30s
      conditions:
        - "[STATUS] == 200"
        - "[RESPONSE_TIME] < 30000"

@@ (root level) @@
# gateway.networking.k8s.io/v1/Gateway/network/envoy-external-probe
! + one document added:
+ apiVersion: gateway.networking.k8s.io/v1
+ kind: Gateway
+ metadata:
+   name: envoy-external-probe
+   namespace: network
+   annotations:
+     external-dns.alpha.kubernetes.io/target: gatus-probe...PLACEHOLDER_SECRET_DOMAIN..
+   labels:
+     kustomize.toolkit.fluxcd.io/name: envoy-gateway
+     kustomize.toolkit.fluxcd.io/namespace: network
+ spec:
+   gatewayClassName: envoy
+   infrastructure:
+     annotations:
+       external-dns.alpha.kubernetes.io/hostname: gatus-probe...PLACEHOLDER_SECRET_DOMAIN..
+       lbipam.cilium.io/ips: "192.168.0.9"
+   listeners:
+   - name: https
+     hostname: gatus-probe...PLACEHOLDER_SECRET_DOMAIN..
+     port: 443
+     protocol: HTTPS
+     tls:
+       certificateRefs:
+       - name: "-.PLACEHOLDER_SECRET_DOMAIN..-production-tls"
+         kind: Secret
+     allowedRoutes:
+       namespaces:
+         from: Selector
+         selector:
+           matchLabels:
+             kubernetes.io/metadata.name: observability

@@ (root level) @@
# gateway.networking.k8s.io/v1/HTTPRoute/observability/gatus-probe
! + one document added:
+ apiVersion: gateway.networking.k8s.io/v1
+ kind: HTTPRoute
+ metadata:
+   name: gatus-probe
+   namespace: observability
+   labels:
+     kustomize.toolkit.fluxcd.io/name: gatus
+     kustomize.toolkit.fluxcd.io/namespace: observability
+ spec:
+   hostnames:
+   - gatus-probe...PLACEHOLDER_SECRET_DOMAIN..
+   parentRefs:
+   - name: envoy-external-probe
+     namespace: network
+     sectionName: https
+   rules:
+   - backendRefs:
+     - name: gatus
+       port: 80
+     matches:
+     - method: GET
+       path:
+         type: Exact
+         value: /health

@tanguille-cluster

Copy link
Copy Markdown
@@ spec.template.spec.initContainers.gatus-sidecar.args @@
# apps/v1/Deployment/observability/gatus
! - one list entry removed:
- - "--gateway-name=envoy-external"

@github-actions

Copy link
Copy Markdown

AI Automated Review

Analysis engine: qwen-3.6-fast@http://litellm.ai.svc.cluster.local/v1 (openai)

Recommendation: Approve with minor notes. The PR correctly implements an isolated, Cloudflare-tunneled health check route for Gatus, separating it from the CrowdSec-protected main gateway. The manifests are internally consistent and follow repository conventions.

Change-by-Change Findings

  1. Cloudflare Tunnel Configuration (kubernetes/apps/network/cloudflared/app/dnsendpoint.yaml, .../resources/config.yaml)

    • Added DNS endpoint gatus-probe.${SECRET_DOMAIN} pointing to the tunnel CNAME.
    • Added ingress rule in Cloudflare config routing gatus-probe.${SECRET_DOMAIN} to https://envoy-external-probe.network.svc.cluster.local:443 with HTTP/2 origin support.
    • Verification: The service name matches the new Gateway's expected backend target.
  2. Envoy Gateway Configuration (kubernetes/apps/network/envoy-gateway/app/envoy.yaml)

    • Added new Gateway resource envoy-external-probe.
    • Configured listener on port 443 for hostname gatus-probe.${SECRET_DOMAIN}.
    • Restricted allowedRoutes to namespace observability via label selector kubernetes.io/metadata.name: observability.
    • Assigned static IP ${ENVOY_GATUS_PROBE_IP} via Cilium LB IPAM annotation.
    • Verification: The TLS certificate ref uses the standard pattern ${SECRET_DOMAIN/./-}-production-tls. The namespace restriction aligns with the PR goal of isolation.
  3. Gatus Application Configuration (kubernetes/apps/observability/gatus/app/helmrelease.yaml, .../httproute.yaml, .../resources/config.yaml)

    • Removed envoy-external from gatewayNames in HelmRelease values, preventing Gatus from registering routes on the main external gateway.
    • Added new HTTPRoute gatus-probe targeting the new envoy-external-probe gateway for /health path only.
    • Added new endpoint in Gatus config pointing to https://gatus-probe.${SECRET_DOMAIN}/health using external DNS resolver tcp://1.1.1.1:53.
    • Verification: The HTTPRoute parentRef correctly points to envoy-external-probe in the network namespace. The endpoint configuration is valid YAML.
  4. Cluster Settings (kubernetes/components/common/cluster-settings.yaml)

    • Added ENVOY_GATUS_PROBE_IP: 192.168.0.9.
    • Verification: This IP is not currently assigned to any other service in the provided cluster-settings.yaml (assigned IPs: .1, .3, .4, .5, .6, .7, .8, .11-.13, .16, .27, .45, .95, .106). No conflict detected.

Standards Compliance

  • Manifest Validation: All modified files include appropriate JSON Schema comments (yaml-language-server: $schema=...).
  • Security Contexts: No changes to security contexts; existing hardening (readOnlyRootFilesystem: true, allowPrivilegeEscalation: false) remains intact.
  • GitOps Patterns: Changes are made via Git repo files, following the "Make changes through this repository" convention.
  • Conventional Commits: Title feat(gatus): add isolated external probe follows Conventional Commit format.

Required Checks

  • Validate manifest against target cluster version:
    • Verified: The API versions used (gateway.networking.k8s.io/v1, helm.toolkit.fluxcd.io/v2) are stable and supported by modern Kubernetes clusters (1.25+). The Envoy Gateway CRDs are managed by the operator installed in the network namespace. No version-specific incompatibilities were found in the diff.
  • Check for resource quota / limit changes:
    • Verified: No resource requests or limits were changed for Gatus or Envoy. The new Gateway does not define resources directly (they are inherited from the EnvoyProxy custom resource or defaults), but no explicit quota changes were introduced in this PR.

Unknowns or Needs Verification

  • IP Availability: While 192.168.0.9 appears free in cluster-settings.yaml, ensure it is not statically assigned elsewhere outside of this file (e.g., DHCP reservations or other network devices) before applying.
  • Local Validation: The PR body notes that full rendered validation was blocked locally due to missing tools (mise, flate, kustomize). It is recommended to run bash .agents/skills/pr-review/scripts/validate-pr.sh in a properly configured environment to confirm final rendered manifests are valid.

@Tanguille
Tanguille merged commit eedf65b into main Jul 20, 2026
17 checks passed
@Tanguille
Tanguille deleted the omos/gatus-stable-egress branch July 20, 2026 19:54
Tanguille added a commit that referenced this pull request Jul 27, 2026
…d xmrig guard

#4086 added the isolated external probe and dropped envoy-external from the
sidecar's gatewayNames in the same change. Since 2026-07-20 22:00 that has
left all eight public hostnames undiscovered, and GatusEndpointDown filters
on group=~"external|services", so the only externally-alerting endpoint was
the probe itself. The probe is a genuine round trip through Cloudflare's edge
and back down the tunnel, but it terminates on the dedicated probe gateway,
not the envoy-external one that serves users.

The xmrig guard recovers only at or below 60C, which neither mining node
reaches, so a node that trips once stays gated off. Thresholds are left alone
on purpose: on every sustained-mining day control-3 peaked at 89-95C against
66-75C when idle, which is the range that previously forced a cold power
cycle. The gate being shut is the correct outcome. What was missing is a
signal for the case where it is shut without cause, so the new rule pairs the
gate state with the temperature actually sitting in the recovery band.
Tanguille added a commit that referenced this pull request Jul 27, 2026
…d xmrig guard

#4086 added the isolated external probe and dropped envoy-external from the
sidecar's gatewayNames in the same change. Since 2026-07-20 22:00 that has
left all eight public hostnames undiscovered, and GatusEndpointDown filters
on group=~"external|services", so the only externally-alerting endpoint was
the probe itself. The probe is a genuine round trip through Cloudflare's edge
and back down the tunnel, but it terminates on the dedicated probe gateway,
not the envoy-external one that serves users.

The xmrig guard recovers only at or below 60C, which neither mining node
reaches, so a node that trips once stays gated off. Thresholds are left alone
on purpose: on every sustained-mining day control-3 peaked at 89-95C against
66-75C when idle, which is the range that previously forced a cold power
cycle. The gate being shut is the correct outcome. What was missing is a
signal for the case where it is shut without cause, so the new rule pairs the
gate state with the temperature actually sitting in the recovery band.
Tanguille added a commit that referenced this pull request Jul 27, 2026
* fix(homeassistant): raise mcp memory limit to 512Mi

Both ha-mcp containers peak at ~255Mi against a 256Mi limit; the primary
server was OOMKilled 13 times in the last 30 days.

* fix(observability): defrag etcd daily, right-size VM stack, drop noisy rule

etcd-defrag reclaimed 302MB to 130MB per member on its last run but only
runs weekly; fragmentation crosses the 50% alert threshold within ~4 days,
so etcdDatabaseHighFragmentationRatio fires for roughly half of every week.
A full three-member pass costs ~95s.

vmsingle, vmagent, vlogs and the log collector all requested less memory
than their observed peaks, leaving the entire metrics and logging path
burstable and first in line for eviction on a node that is already at 97%
of allocatable. vmsingle's 4Gi limit was within 80Mi of its 14d peak.

RecordingRulesNoData flags recording rules that are empty by design:
count:up0 is count(up == 0), which yields no samples precisely when every
scrape target is healthy.

* fix(toolhive): raise memory limits on OOMKilled mcp workloads

The ha VirtualMCPServer was OOMKilled twice at its 1Gi limit and the
observability one at the operator's 512Mi default; both swing by an order
of magnitude with session load. karakeep's mcp container peaked at 84% of
its 100Mi limit.

* fix(observability): unclamp cpu limits on bursty grafana and speedtest

Both sit at 100m and burst well past it: grafana throttles on 13% of CFS
periods rendering dashboards, and speedtest-exporter on 74%, which caps
the throughput it exists to measure. CPU is compressible, so the request
still does the isolation work.

* fix(observability): restore external gatus discovery, alert on latched xmrig guard

#4086 added the isolated external probe and dropped envoy-external from the
sidecar's gatewayNames in the same change. Since 2026-07-20 22:00 that has
left all eight public hostnames undiscovered, and GatusEndpointDown filters
on group=~"external|services", so the only externally-alerting endpoint was
the probe itself. The probe is a genuine round trip through Cloudflare's edge
and back down the tunnel, but it terminates on the dedicated probe gateway,
not the envoy-external one that serves users.

The xmrig guard recovers only at or below 60C, which neither mining node
reaches, so a node that trips once stays gated off. Thresholds are left alone
on purpose: on every sustained-mining day control-3 peaked at 89-95C against
66-75C when idle, which is the range that previously forced a cold power
cycle. The gate being shut is the correct outcome. What was missing is a
signal for the case where it is shut without cause, so the new rule pairs the
gate state with the temperature actually sitting in the recovery band.

* fix(xmrig-guard): gate on the Composite sensor and narrow the thermal band

The guard read max() across every NVMe hwmon sensor, but only temp1 is
Composite, which is the reading the drives' 70C rating specifies and the one
smartctl reports. temp2-temp4 are internal die sensors running ~9C hotter
with no comparable rating, so a Composite-spec threshold was being compared
against the wrong number and every measurement was ~9C pessimistic.

On Composite the drives are well inside their rating at idle: median 57.9C
and 58.9C, and 0.0% of the last 7d above 70C on either node. Mining is what
breaks the rating, peaking at 79.8C and 82.8C, so the gate still has to shut
during mining.

Trip moves 70C -> 65C and recovery stays at 60C. Idle Composite never reached
65C over 7d, so the trip does not false-fire, and it sits at or below 60C for
100% (control-2) and 90% (control-3) of the time, so recovery is reachable and
the permanent latch is gone. Mining raises Composite at up to 1.1C/min, giving
~4.5 minutes across the 65-70C band; the 180s scale-down stabilization did not
fit inside that with the 120s dwell and 60s poll, so it drops to 0.

Restricting to Composite also shrinks the audited set from 7 and 8 series to
2, which is what failed today when one missing sample tripped the identity
check and latched control-2 closed.

* fix(xmrig-guard): shed all miners at once and drop the trip to 64C

The scale-down policy capped removal at one pod per 60s, so draining three
replicas took a further 180s that the previous timing note did not count.
Full worst case was 60s to sample the crossing + 120s trip dwell + 60s KEDA
polling + 180s drain = 7 minutes, which at the observed 1.1C/min peak put the
drives at 72.7C, past their 70C rating.

Removing the per-pod cap brings the response to ~4.25 minutes. Pairing that
with a 64C trip gives a 6C band worth ~5.5 minutes and lands the peak near
68.7C. Idle Composite never exceeded 62C over 7d on either node, so the lower
trip does not false-fire and still leaves 4C of hysteresis above the 60C
recovery point. Gradual drain only ever made sense for tracking a falling
solar curve, never for a thermal trip, and mining is free to interrupt.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant