Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d61c19d41f | ||
|
|
e6fcf217b4 | ||
|
|
cb86ad8fd7 | ||
|
|
078b550bd8 | ||
|
|
765a8cb7de | ||
|
|
999c4fe440 | ||
|
|
2af2e6ac56 | ||
|
|
fbe54ca1ad | ||
|
|
f073b91033 | ||
|
|
425994515a | ||
|
|
c41384e03d | ||
|
|
127621bdd7 | ||
|
|
ccb805cf0b | ||
|
|
9eb53ef937 |
@@ -176,11 +176,12 @@ alertmanager.container`, loopback :9093) → host-side Signal relay
|
|||||||
(`scripts/signal-relay.py`, user unit, 192.168.0.143:18093 — the host LAN
|
(`scripts/signal-relay.py`, user unit, 192.168.0.143:18093 — the host LAN
|
||||||
IP, the one bind a container can reach as `host.containers.internal`;
|
IP, the one bind a container can reach as `host.containers.internal`;
|
||||||
iptables admits loopback, the host and the podman bridge subnet only) → signal-cli account 318311.
|
iptables admits loopback, the host and the podman bridge subnet only) → signal-cli account 318311.
|
||||||
Prometheus targets read down for structural reasons (HTML-scraped "probes",
|
The former structural target downs (HTML-scraped "probes", Gitea metrics off)
|
||||||
Gitea metrics off); the remedy is a blackbox exporter. `frank-zealot` was down for
|
are retired as of 2026-10-21: HTTP liveness rides a blackbox exporter
|
||||||
a different reason until 2026-10-18: llama.cpp will not serve `/metrics` at all
|
(`shared/blackbox/blackbox.yml`), Gitea serves `/metrics` from its quadlet, and
|
||||||
without `--metrics`. See
|
every Prometheus job reports up. `frank-zealot` was down for a different reason
|
||||||
`serverAppConfigs-docs/Infrastructure/Observability.md`.
|
until 2026-10-18: llama.cpp will not serve `/metrics` at all without
|
||||||
|
`--metrics`. See `serverAppConfigs-docs/Infrastructure/Observability.md`.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ Environment=DATABASE_PASSWD=${GITEA_DB_PASSWORD}
|
|||||||
Environment=DATABASE_PORT=5432
|
Environment=DATABASE_PORT=5432
|
||||||
Environment=USER_UID=1000
|
Environment=USER_UID=1000
|
||||||
Environment=USER_GID=1000
|
Environment=USER_GID=1000
|
||||||
|
# Prometheus scrapes /metrics over the published LAN port (job "gitea" in
|
||||||
|
# shared/prometheus/prometheus.yml). Off, /metrics is a 404 and the target is
|
||||||
|
# permanently red (the audit's "disabled metrics" class). 0.0.0.0 binds inside
|
||||||
|
# the container; the only route in is this host's published 3000, and the
|
||||||
|
# exposed surface is counters only — same posture as the LLM /metrics.
|
||||||
|
Environment=GITEA__metrics__ENABLED=true
|
||||||
|
Environment=GITEA__metrics__ENABLED_HOST=0.0.0.0
|
||||||
PublishPort=0.0.0.0:3000:3000
|
PublishPort=0.0.0.0:3000:3000
|
||||||
PublishPort=2222:22
|
PublishPort=2222:22
|
||||||
Volume=/home/dank/Containers/gitea/data:/data
|
Volume=/home/dank/Containers/gitea/data:/data
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Quadlet unit: blackbox exporter (rootless, boot-persistent).
|
||||||
|
#
|
||||||
|
# Install path on dankclaw:
|
||||||
|
# ~/.config/containers/systemd/blackbox-exporter.container
|
||||||
|
#
|
||||||
|
# WHY Network=host. The probes it runs are (a) the host's own LAN IP
|
||||||
|
# (192.168.0.143:<port>) and (b) container names on dankclaw_default (comfyui).
|
||||||
|
# A bridge container cannot reach the host's LAN IP at an unpublished port
|
||||||
|
# (netavark DNATs published ports only — see Container Host Reachability in the
|
||||||
|
# vault); a host-netns process reaches both. Prometheus (bridge) reaches THIS
|
||||||
|
# container the same way it reaches the signal relay: the quadlet-published
|
||||||
|
# host.containers.internal address (169.254.1.2), mapped via HostAddr= on the
|
||||||
|
# prometheus unit.
|
||||||
|
#
|
||||||
|
# The 9115 metrics port publishes on loopback only — it is scraped by the
|
||||||
|
# prometheus container through the gateway, and never opened on the LAN.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Blackbox exporter (HTTP probe prober)
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
ContainerName=blackbox-exporter
|
||||||
|
Exec=/bin/blackbox_exporter --config.file=/etc/blackbox_exporter/config.yml
|
||||||
|
Volume=/opt/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
|
||||||
|
HealthCmd=/bin/sh -c "wget -q -O /dev/null http://127.0.0.1:9115/-/healthy || exit 1"
|
||||||
|
HealthInterval=30s
|
||||||
|
HealthRetries=3
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -45,6 +45,11 @@ Wants=network-online.target
|
|||||||
ContainerName=prometheus
|
ContainerName=prometheus
|
||||||
Image=docker.io/prom/prometheus:latest
|
Image=docker.io/prom/prometheus:latest
|
||||||
Network=dankclaw_default
|
Network=dankclaw_default
|
||||||
|
# Name the podman gateway so scrape jobs can reach host-netns processes (the
|
||||||
|
# blackbox exporter listens on the host's loopback; a bridge container reaches
|
||||||
|
# it at this address, never at 127.0.0.1). Same mechanism the hermes Caddy
|
||||||
|
# upstream learned the hard way (Container Host Reachability in the vault).
|
||||||
|
HostAddr=host.containers.internal=169.254.1.2
|
||||||
User=0:0
|
User=0:0
|
||||||
Exec=--config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus --storage.tsdb.retention.time=30d --web.enable-lifecycle
|
Exec=--config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus --storage.tsdb.retention.time=30d --web.enable-lifecycle
|
||||||
PublishPort=127.0.0.1:9090:9090
|
PublishPort=127.0.0.1:9090:9090
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ SERVICE_ORDER = (
|
|||||||
"hermes",
|
"hermes",
|
||||||
"hermes-dashboard",
|
"hermes-dashboard",
|
||||||
"hermes-webui",
|
"hermes-webui",
|
||||||
"prometheus",
|
|
||||||
"grafana",
|
"grafana",
|
||||||
"loki",
|
"loki",
|
||||||
"alloy",
|
"alloy",
|
||||||
@@ -42,6 +41,9 @@ SERVICE_ORDER = (
|
|||||||
"cadvisor",
|
"cadvisor",
|
||||||
"gpu-exporter",
|
"gpu-exporter",
|
||||||
"alertmanager",
|
"alertmanager",
|
||||||
|
# The prober must answer before prometheus re-runs the services job.
|
||||||
|
"blackbox-exporter",
|
||||||
|
"prometheus",
|
||||||
"authentik-postgres",
|
"authentik-postgres",
|
||||||
"authentik-server",
|
"authentik-server",
|
||||||
"authentik-worker",
|
"authentik-worker",
|
||||||
@@ -97,6 +99,7 @@ DANKCLAW_SERVICES = {
|
|||||||
"cadvisor",
|
"cadvisor",
|
||||||
"gpu-exporter",
|
"gpu-exporter",
|
||||||
"alertmanager",
|
"alertmanager",
|
||||||
|
"blackbox-exporter",
|
||||||
"authentik-postgres",
|
"authentik-postgres",
|
||||||
"authentik-server",
|
"authentik-server",
|
||||||
"authentik-worker",
|
"authentik-worker",
|
||||||
@@ -277,6 +280,8 @@ def services_for_path(path: str, target: str) -> set[str]:
|
|||||||
# Provisioned datasources and dashboards: git is the source of
|
# Provisioned datasources and dashboards: git is the source of
|
||||||
# truth, so a change here must restart the service that reads them.
|
# truth, so a change here must restart the service that reads them.
|
||||||
_add(services, "grafana")
|
_add(services, "grafana")
|
||||||
|
elif group == "blackbox":
|
||||||
|
_add(services, "blackbox-exporter")
|
||||||
elif group == "homepage":
|
elif group == "homepage":
|
||||||
# Homepage is a Dankclaw-owned deployment. Shared homepage files
|
# Homepage is a Dankclaw-owned deployment. Shared homepage files
|
||||||
# are intentionally not copied during a Dankserver-only run.
|
# are intentionally not copied during a Dankserver-only run.
|
||||||
@@ -380,7 +385,7 @@ def services_for_path(path: str, target: str) -> set[str]:
|
|||||||
# consumer (prometheus before grafana, loki before alloy).
|
# consumer (prometheus before grafana, loki before alloy).
|
||||||
if len(parts) >= 4 and parts[2] == "quadlet":
|
if len(parts) >= 4 and parts[2] == "quadlet":
|
||||||
name = parts[3].removesuffix(".container")
|
name = parts[3].removesuffix(".container")
|
||||||
if name in {"prometheus", "grafana", "loki", "alloy", "node-exporter", "cadvisor", "alertmanager"}:
|
if name in {"prometheus", "grafana", "loki", "alloy", "node-exporter", "cadvisor", "alertmanager", "blackbox-exporter"}:
|
||||||
_add(services, name)
|
_add(services, name)
|
||||||
return services
|
return services
|
||||||
if len(parts) >= 2 and parts[1] == "gitea":
|
if len(parts) >= 2 and parts[1] == "gitea":
|
||||||
|
|||||||
@@ -311,8 +311,45 @@ copy_loki_config() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
copy_alloy_config() {
|
copy_alloy_config() {
|
||||||
run_privileged mkdir -p /opt/alloy/config
|
# The repo config carries the token GRAFANA_HOST_LABEL; the installed copy
|
||||||
run_privileged cp shared/alloy/config.alloy /opt/alloy/config/config.alloy
|
# carries the host's name, so Loki streams say WHICH machine's journal a
|
||||||
|
# line came from without a second config file per host.
|
||||||
|
# *_CONF_ROOT are test seams: the bats cases execute these copiers for
|
||||||
|
# real against a sandbox with a stubbed run_privileged.
|
||||||
|
local root="${ALLOY_CONF_ROOT:-/opt/alloy}" tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
sed "s/GRAFANA_HOST_LABEL/$TARGET/g" shared/alloy/config.alloy > "$tmp"
|
||||||
|
if ! grep -q "host = \"$TARGET\"," "$tmp" || grep -q "GRAFANA_HOST_LABEL" "$tmp"; then
|
||||||
|
echo "alloy config: host-label substitution failed" >&2
|
||||||
|
rm -f "$tmp"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# Syntax gate: alloy's config language has no schema — a misplaced
|
||||||
|
# attribute (e.g. `on_error` outside the json stage) parses only at RUN
|
||||||
|
# time, and the unit then crash-loops with a healthy-looking deploy.
|
||||||
|
# Validate against the very image that will run it, before installing.
|
||||||
|
# Skipped only in the bats sandbox (no image there).
|
||||||
|
if [ "$root" = "/opt/alloy" ] || podman image exists localhost/franksdank/alloy:latest 2>/dev/null; then
|
||||||
|
if ! podman run --rm -v "$tmp:/c.alloy:ro" \
|
||||||
|
--entrypoint /usr/bin/alloy localhost/franksdank/alloy:latest \
|
||||||
|
validate /c.alloy; then
|
||||||
|
echo "alloy config: validate FAILED against the pinned image" >&2
|
||||||
|
rm -f "$tmp"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
run_privileged mkdir -p "$root/config"
|
||||||
|
run_privileged cp "$tmp" "$root/config/config.alloy"
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_blackbox_config() {
|
||||||
|
# Single-file mount target: the unit binds this exact path into
|
||||||
|
# /etc/blackbox_exporter/config.yml. cp (not cp -r) keeps it one file.
|
||||||
|
# BLACKBOX_CONF_ROOT is the same test seam.
|
||||||
|
local root="${BLACKBOX_CONF_ROOT:-/opt/blackbox}"
|
||||||
|
run_privileged mkdir -p "$root"
|
||||||
|
run_privileged cp shared/blackbox/blackbox.yml "$root/blackbox.yml"
|
||||||
}
|
}
|
||||||
|
|
||||||
copy_grafana_provisioning() {
|
copy_grafana_provisioning() {
|
||||||
@@ -321,14 +358,25 @@ copy_grafana_provisioning() {
|
|||||||
# datasource would be invisible to review, lost with the data volume, and
|
# datasource would be invisible to review, lost with the data volume, and
|
||||||
# unknown to anyone reading the repository. The unit already mounts
|
# unknown to anyone reading the repository. The unit already mounts
|
||||||
# /opt/grafana/provisioning read-only, so this only has to populate it.
|
# /opt/grafana/provisioning read-only, so this only has to populate it.
|
||||||
run_privileged mkdir -p /opt/grafana/provisioning/datasources \
|
# GRAFANA_PROV_ROOT is the test seam: point it at a sandbox and the whole
|
||||||
/opt/grafana/provisioning/dashboards
|
# copy runs unprivileged (the bats case executes this function for real).
|
||||||
|
local root="${GRAFANA_PROV_ROOT:-/opt/grafana}"
|
||||||
|
run_privileged mkdir -p "$root/provisioning/datasources" \
|
||||||
|
"$root/provisioning/dashboards"
|
||||||
run_privileged cp shared/grafana/provisioning/datasources/prometheus.yml \
|
run_privileged cp shared/grafana/provisioning/datasources/prometheus.yml \
|
||||||
/opt/grafana/provisioning/datasources/prometheus.yml
|
"$root/provisioning/datasources/prometheus.yml"
|
||||||
run_privileged cp shared/grafana/provisioning/dashboards/provider.yml \
|
run_privileged cp shared/grafana/provisioning/dashboards/provider.yml \
|
||||||
/opt/grafana/provisioning/dashboards/provider.yml
|
"$root/provisioning/dashboards/provider.yml"
|
||||||
run_privileged cp shared/grafana/provisioning/dashboards/llm-fleet.json \
|
# EVERY dashboard JSON rides, not a hand-listed subset: the fleet provider
|
||||||
/opt/grafana/provisioning/dashboards/llm-fleet.json
|
# turns each file in the directory into a dashboard, so a copier that names
|
||||||
|
# files one by one silently drops new boards (measured 2026-10-21: the
|
||||||
|
# containers board existed in the repo since its commit and was NEVER
|
||||||
|
# provisioned — the panel-census browser check could not find it while the
|
||||||
|
# hand-placed older boards happened to be on disk). Same rule as the quadlet
|
||||||
|
# tier: the directory is the contract, the copy is generated state.
|
||||||
|
for dash in shared/grafana/provisioning/dashboards/*.json; do
|
||||||
|
run_privileged cp "$dash" "$root/provisioning/dashboards/$(basename "$dash")"
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
render_caddyfile() {
|
render_caddyfile() {
|
||||||
@@ -552,6 +600,9 @@ deploy_observability() {
|
|||||||
;;
|
;;
|
||||||
cadvisor)
|
cadvisor)
|
||||||
;;
|
;;
|
||||||
|
blackbox-exporter)
|
||||||
|
copy_blackbox_config
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "unknown observability service: $service" >&2
|
echo "unknown observability service: $service" >&2
|
||||||
return 2
|
return 2
|
||||||
@@ -566,6 +617,7 @@ deploy_observability() {
|
|||||||
loki) wait_for_health loki http://127.0.0.1:3100/ready 60 ;;
|
loki) wait_for_health loki http://127.0.0.1:3100/ready 60 ;;
|
||||||
node-exporter) wait_for_health node-exporter http://127.0.0.1:9100/metrics 60 ;;
|
node-exporter) wait_for_health node-exporter http://127.0.0.1:9100/metrics 60 ;;
|
||||||
cadvisor) wait_for_health cadvisor http://127.0.0.1:8080/healthz 90 ;;
|
cadvisor) wait_for_health cadvisor http://127.0.0.1:8080/healthz 90 ;;
|
||||||
|
blackbox-exporter) wait_for_health blackbox-exporter http://127.0.0.1:9115/-/healthy 60 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,9 +110,9 @@ only `models`, `input`, `output`, `custom_nodes` and the healthcheck file
|
|||||||
|
|
||||||
## Recommended additions
|
## Recommended additions
|
||||||
|
|
||||||
Quantized preferred, sized for the 8060S iGPU (GTT-backed — `/system_stats`
|
Quantized preferred. The iGPU's GTT aperture is not free RAM beside
|
||||||
reports 122.4 GB of device VRAM on `cuda:0`, `curl http://127.0.0.1:8188/system_stats`
|
frank-dragoon — size every pick against *What actually fits beside
|
||||||
2026-10-18; the RTX 3090 is frank-zealot's). All repos verified 2026-10-05 and
|
frank-dragoon* below (the RTX 3090 is frank-zealot's). All repos verified 2026-10-05 and
|
||||||
all four still resolve today (`huggingface.co/lightx2v/Qwen-Image-Lightning`
|
all four still resolve today (`huggingface.co/lightx2v/Qwen-Image-Lightning`
|
||||||
`huggingface.co/unsloth/Qwen-Image-Edit-2511-GGUF`
|
`huggingface.co/unsloth/Qwen-Image-Edit-2511-GGUF`
|
||||||
`huggingface.co/Comfy-Org/Qwen-Image-DiffSynth-ControlNets`
|
`huggingface.co/Comfy-Org/Qwen-Image-DiffSynth-ControlNets`
|
||||||
@@ -133,8 +133,34 @@ sha256 checks) or a plain `hf download` with the token in the environment —
|
|||||||
never on the command line.
|
never on the command line.
|
||||||
|
|
||||||
Skip list: the original `city96/Qwen-Image-gguf` conversions (superseded by
|
Skip list: the original `city96/Qwen-Image-gguf` conversions (superseded by
|
||||||
the 2.1 files already on disk), and any BF16 of #2/#4 (the iGPU does not need
|
the 2.1 files already on disk), and any BF16 of #2/#4 — they double the disk
|
||||||
them at GTT capacity, and they double the disk bill).
|
bill, and see *What actually fits beside frank-dragoon* for why raw GTT size
|
||||||
|
is not the real budget.
|
||||||
|
|
||||||
|
## What actually fits beside frank-dragoon
|
||||||
|
|
||||||
|
The 122 GB `/system_stats` figure is the GTT **aperture**, not free memory: on
|
||||||
|
an APU the aperture and `MemAvailable` are the same physical RAM, and
|
||||||
|
frank-dragoon pins tens of GiB of it. Measured 2026-10-21 with frank-dragoon
|
||||||
|
live: `MemAvailable` 15.3 GiB, iGPU busy 1.75 GiB — ComfyUI itself idles at
|
||||||
|
~10 MB, so the budget for a generation run is roughly what `free` says.
|
||||||
|
|
||||||
|
Weights must land inside that budget with slack for compute buffers and
|
||||||
|
activations (the mmproj-class lesson: price a model at ~1.5× its file set):
|
||||||
|
|
||||||
|
| Pipeline (weights) | Fits beside frank-dragoon? |
|
||||||
|
|---|---|
|
||||||
|
| Qwen-Image 2.1 int8_convrot + w4a8 + bf16 VAE (~14.2 GB) — the two proven workflows | **No** — thrash-or-fail; do not queue it while the endpoint serves |
|
||||||
|
| GGUF Q8_0 + w4a8 + small VAE (~13.3 GB) | No, same reason |
|
||||||
|
| Any BF16 combination | No |
|
||||||
|
| SDXL-Turbo / SD 1.5-class (~2–7 GB) | Yes, with real headroom |
|
||||||
|
|
||||||
|
**The law: one process owns the memory — same rule as the 3090 bench law.**
|
||||||
|
For a Qwen-Image render: stop and mask frank-dragoon
|
||||||
|
(`sudo systemctl stop frank-dragoon` + `mask` — the mask stops
|
||||||
|
`Restart=always` from firing mid-render), generate, unmask, verify
|
||||||
|
`curl 192.168.0.143:7770/health`. The proven workflows above then run with
|
||||||
|
the whole machine. Do not treat `/system_stats`'s 122 GB as an invitation.
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,12 @@ bind-mounted to `/usr/local/bin/comfyui-healthcheck.py` and run as `HealthCmd`
|
|||||||
- Do not let pip reinstall the torch family. The Containerfile filters it out on purpose.
|
- Do not let pip reinstall the torch family. The Containerfile filters it out on purpose.
|
||||||
- The base image is about 32 GiB (`podman image inspect localhost/comfyui-rocm:gfx1151` → 33,765,227,471 B ≈ 31.4 GiB, 2026-10-18). A code update means a rebuild, because the code is baked in.
|
- The base image is about 32 GiB (`podman image inspect localhost/comfyui-rocm:gfx1151` → 33,765,227,471 B ≈ 31.4 GiB, 2026-10-18). A code update means a rebuild, because the code is baked in.
|
||||||
- The empty-models note from the 2026-09-16 era is retired: `/home/dank/comfyui-data/models` now holds the Qwen-Image 2.1 payload — three `diffusion_models`, three `text_encoders`, two VAEs (~35 GB, `ls -l` 2026-10-18), listed in [[ComfyUI Qwen-Image Workflows]]. New model classes (LoRA, ControlNet) still need their directories created first: `models/lora` and `models/controlnet` do not exist yet.
|
- The empty-models note from the 2026-09-16 era is retired: `/home/dank/comfyui-data/models` now holds the Qwen-Image 2.1 payload — three `diffusion_models`, three `text_encoders`, two VAEs (~35 GB, `ls -l` 2026-10-18), listed in [[ComfyUI Qwen-Image Workflows]]. New model classes (LoRA, ControlNet) still need their directories created first: `models/lora` and `models/controlnet` do not exist yet.
|
||||||
|
- **The 122 GB `/system_stats` VRAM figure is the GTT aperture, not free
|
||||||
|
memory.** On the APU it is system RAM, and frank-dragoon pins most of it
|
||||||
|
(`MemAvailable` 15.3 GiB with the endpoint live, measured 2026-10-21). A
|
||||||
|
Qwen-Image-class render beside the running endpoint is thrash-or-fail; the
|
||||||
|
stop-mask-render-verify procedure and the fit table are in
|
||||||
|
[[ComfyUI Qwen-Image Workflows]] → What actually fits beside frank-dragoon.
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ committed.
|
|||||||
| `shared/prometheus/prometheus.yml` | scrape jobs (global + per-host) |
|
| `shared/prometheus/prometheus.yml` | scrape jobs (global + per-host) |
|
||||||
| `shared/prometheus/rules/llm-fleet.yml` | alert rules + recording rules |
|
| `shared/prometheus/rules/llm-fleet.yml` | alert rules + recording rules |
|
||||||
| `shared/grafana/provisioning/` | datasources + dashboards (provisioned tree) |
|
| `shared/grafana/provisioning/` | datasources + dashboards (provisioned tree) |
|
||||||
|
| `shared/alloy/config.alloy` | the log pipeline: journal source → label promotion → Loki push |
|
||||||
|
| `shared/blackbox/blackbox.yml` | prober modules (`http_2xx`) + the loopback bind |
|
||||||
|
|
||||||
Targets and datasources reference **container names** (`grafana:3000`
|
Targets and datasources reference **container names** (`grafana:3000`
|
||||||
`loki:3100` — `shared/prometheus/prometheus.yml:52-61`), never
|
`loki:3100` — `shared/prometheus/prometheus.yml:52-61`), never
|
||||||
@@ -31,8 +33,8 @@ scraped as `host.containers.internal:<port>` because a bridge container cannot
|
|||||||
reach a host-netns bind at the host's LAN IP (`prometheus.yml:89-99`), and only
|
reach a host-netns bind at the host's LAN IP (`prometheus.yml:89-99`), and only
|
||||||
the endpoint on another machine (`192.168.0.235:7770`) is addressed by LAN IP
|
the endpoint on another machine (`192.168.0.235:7770`) is addressed by LAN IP
|
||||||
(`prometheus.yml:128-131`). Prometheus 3 refuses HTML responses as metrics —
|
(`prometheus.yml:128-131`). Prometheus 3 refuses HTML responses as metrics —
|
||||||
HTML-scraped "probes" are structurally inert and need a blackbox exporter (see
|
HTTP liveness rides the **blackbox exporter** (`shared/blackbox/blackbox.yml`,
|
||||||
[[Stale Signal Audit]]).
|
deployed 2026-10-21; see [[Stale Signal Audit]]).
|
||||||
|
|
||||||
## The deploy path
|
## The deploy path
|
||||||
|
|
||||||
@@ -43,8 +45,9 @@ from `deploy_observability`:
|
|||||||
- `copy_alertmanager_config` → `/opt/alertmanager/config/alertmanager.yml` (`:232-235`)
|
- `copy_alertmanager_config` → `/opt/alertmanager/config/alertmanager.yml` (`:232-235`)
|
||||||
- `install_signal_relay` → the user unit + iptables + the health wait on `192.168.0.143:18093/health` (`:243-274`)
|
- `install_signal_relay` → the user unit + iptables + the health wait on `192.168.0.143:18093/health` (`:243-274`)
|
||||||
- `copy_loki_config` → `/opt/loki/config/loki-config.yaml` (`:276-279`)
|
- `copy_loki_config` → `/opt/loki/config/loki-config.yaml` (`:276-279`)
|
||||||
- `copy_alloy_config` → `/opt/alloy/config/config.alloy` (`:281-284`)
|
- `copy_alloy_config` → `/opt/alloy/config/config.alloy` — substitutes the `GRAFANA_HOST_LABEL` token with the deploy target's name and **refuses to install** if the token survives; `ALLOY_CONF_ROOT` is the bats sandbox seam
|
||||||
- `copy_grafana_provisioning` → `/opt/grafana/provisioning/` (`:286-300`)
|
- `copy_blackbox_config` → `/opt/blackbox/blackbox.yml` (single-file mount of the prober); `BLACKBOX_CONF_ROOT` is its sandbox seam
|
||||||
|
- `copy_grafana_provisioning` → `/opt/grafana/provisioning/` — datasources, provider, and **every** `dashboards/*.json` (a glob, not a hand-listed subset; `GRAFANA_PROV_ROOT` is the test seam the bats case uses to run the copier into a sandbox)
|
||||||
|
|
||||||
`scripts/deploy-plan.py` maps `shared/prometheus/**` to `prometheus` (plus
|
`scripts/deploy-plan.py` maps `shared/prometheus/**` to `prometheus` (plus
|
||||||
`alertmanager` when the changed file is `alertmanager.yml`, `:251-256`)
|
`alertmanager` when the changed file is `alertmanager.yml`, `:251-256`)
|
||||||
@@ -82,11 +85,14 @@ reports 4 alerting + 2 recording. Rules reload with the prometheus service.
|
|||||||
the backend (`http://prometheus:9090`), not loopback.
|
the backend (`http://prometheus:9090`), not loopback.
|
||||||
|
|
||||||
**Add a dashboard** — drop the JSON into
|
**Add a dashboard** — drop the JSON into
|
||||||
`shared/grafana/provisioning/dashboards/`. Provisioned dashboards live in
|
`shared/grafana/provisioning/dashboards/`; the copier globs the directory, so
|
||||||
unified storage, not (only) the SQLite table — grep the API, not the DB, when
|
the next `--service grafana` deploy provisions it. This is a repaired law, not
|
||||||
verifying (see the verify section of [[Fleet Audit and Posture]]). *(unverified
|
a given: until 2026-10-21 the copier named `llm-fleet.json` alone, and the
|
||||||
2026-10-18: the storage-backend claim needs an admin password to confirm
|
`containers` board — merged, tested, documented as "working" — had **never
|
||||||
against `grafana.db` and the API side by side)*
|
been provisioned**; the other boards existed on disk only because someone had
|
||||||
|
hand-copied them once. A repo JSON is not a live dashboard until the copier
|
||||||
|
carries it, and `deploy.sh` cannot see the gap because it only reports the
|
||||||
|
copy it performed.
|
||||||
|
|
||||||
## Verify through the APIs, not the filesystem
|
## Verify through the APIs, not the filesystem
|
||||||
|
|
||||||
@@ -95,17 +101,22 @@ against `grafana.db` and the API side by side)*
|
|||||||
curl -s http://127.0.0.1:9090/api/v1/rules | python3 -m json.tool | head
|
curl -s http://127.0.0.1:9090/api/v1/rules | python3 -m json.tool | head
|
||||||
curl -s "http://127.0.0.1:9090/api/v1/targets?state=active" | \
|
curl -s "http://127.0.0.1:9090/api/v1/targets?state=active" | \
|
||||||
python3 -c "import json,sys; [print(t['labels'].get('job'), t['health'], (t['lastError'] or '')[:60]) for t in json.load(sys.stdin)['data']['activeTargets']]"
|
python3 -c "import json,sys; [print(t['labels'].get('job'), t['health'], (t['lastError'] or '')[:60]) for t in json.load(sys.stdin)['data']['activeTargets']]"
|
||||||
# grafana: datasource + dashboard provisioned
|
# grafana 13: /api/health, /api/org, /login all 404 UNAUTHENTICATED, and there
|
||||||
curl -s -u admin:<pw> http://127.0.0.1:3002/api/datasources | python3 -m json.tool | grep -E '"name"|"url"'
|
# is NO local admin password (root .env sets no GF_SECURITY_ADMIN_*; the only
|
||||||
|
# account is the Authentik RP login). The proven surface is a real browser
|
||||||
|
# session: warm the Anubis challenge on the vhost root, then hit
|
||||||
|
# /login/generic_oauth with the e2e storage state (tests/e2e/.auth/qa-e2e-admin.json)
|
||||||
|
# — exactly the path specs/20-grafana-sso.spec.ts drives — and read the boards.
|
||||||
```
|
```
|
||||||
|
|
||||||
The two Prometheus queries were run on 2026-10-18 and are what every number on
|
The two Prometheus queries were run on 2026-10-18 and are what every number on
|
||||||
this page comes from: 4 alerting + 2 recording rules, and `up` for
|
this page comes from: 4 alerting + 2 recording rules, and `up` for
|
||||||
`prometheus`, `node-dankclaw`, `node-dankserver`, `grafana`, `loki`
|
`prometheus`, `node-dankclaw`, `node-dankserver`, `grafana`, `loki`
|
||||||
`frank-zealot`, `frank-dragoon`, `frank-archon` with the four structural downs
|
`frank-zealot`, `frank-dragoon`, `frank-archon`. The four structural downs
|
||||||
listed in [[Prometheus]]. The Grafana query needs the local admin password and
|
that census recorded are retired (2026-10-21, [[Prometheus]]). The Grafana proof is the browser session above, run
|
||||||
was not run here; the filesystem side (`ls /opt/grafana/provisioning/*`) is
|
2026-10-21: it found a provisioned-file gap the filesystem listing hid. The
|
||||||
proof of installation only — see the next paragraph.
|
filesystem side (`ls /opt/grafana/provisioning/*`) is proof of installation
|
||||||
|
only — see *Add a dashboard* for why a repo JSON is not a live board.
|
||||||
|
|
||||||
Health answers from a stale process and provisioned resources can hide in
|
Health answers from a stale process and provisioned resources can hide in
|
||||||
unified storage — a file existing on disk is not proof the running server
|
unified storage — a file existing on disk is not proof the running server
|
||||||
|
|||||||
@@ -95,13 +95,14 @@ health endpoint (`/prometheus`: `/-/healthy`, `/alertmanager`: `/-/healthy`
|
|||||||
[[Observability Provisioning]] for what is provisioned as code and how to
|
[[Observability Provisioning]] for what is provisioned as code and how to
|
||||||
verify it through the APIs.
|
verify it through the APIs.
|
||||||
|
|
||||||
## Current state (2026-10-20)
|
## Current state (2026-10-21)
|
||||||
|
|
||||||
* **Metrics: working.** `curl -s http://127.0.0.1:9090/api/v1/targets` reports
|
* **Metrics: working.** `curl -s http://127.0.0.1:9090/api/v1/targets` reports
|
||||||
eight up targets — the three LLM endpoints (frank-archon on `vllm:*`,
|
every job up — the three LLM endpoints (frank-archon on `vllm:*`,
|
||||||
frank-dragoon and frank-zealot on `llamacpp:*`), grafana, loki, prometheus
|
frank-dragoon and frank-zealot on `llamacpp:*`), grafana, loki, prometheus,
|
||||||
and both node exporters. The four downs are structural (the `comfyui` and
|
both node exporters, cadvisor, gitea (metrics enabled in its quadlet) and
|
||||||
`services` HTML probes, and Gitea's disabled metrics endpoint), not outages.
|
the blackbox-probed `services` (SearXNG, OpenWebUI, ComfyUI). The structural
|
||||||
|
downs are retired — see [[Prometheus]] → Targets.
|
||||||
Two rule groups in `shared/prometheus/rules/llm-fleet.yml`: `llm-fleet`
|
Two rule groups in `shared/prometheus/rules/llm-fleet.yml`: `llm-fleet`
|
||||||
(2 recording + 4 alert, `llamacpp:*`) and `vllm-fleet` (1 recording + 4
|
(2 recording + 4 alert, `llamacpp:*`) and `vllm-fleet` (1 recording + 4
|
||||||
alert, `vllm:*` — archon queueing, prefix collapse, preemption, p95
|
alert, `vllm:*` — archon queueing, prefix collapse, preemption, p95
|
||||||
@@ -109,12 +110,15 @@ verify it through the APIs.
|
|||||||
* **Delivery: working.** Alertmanager's single `signal` receiver webhooks the
|
* **Delivery: working.** Alertmanager's single `signal` receiver webhooks the
|
||||||
relay, and `journalctl --user -u signal-relay` records successful
|
relay, and `journalctl --user -u signal-relay` records successful
|
||||||
`sent to +12062476336:` lines for every rule in the set.
|
`sent to +12062476336:` lines for every rule in the set.
|
||||||
* **Dashboards: working.** Provisioned datasources — Prometheus (uid
|
* **Dashboards: working (after the 2026-10-21 copier fix).** Provisioned
|
||||||
`prometheus`) and Loki (uid `loki`) — and the `llm-fleet`, `host-system`,
|
datasources — Prometheus (uid `prometheus`) and Loki (uid `loki`) — and the
|
||||||
`containers`, `observability` and `logs-overview` dashboards, all
|
`llm-fleet`, `host-system`, `containers`, `observability` and
|
||||||
installed from `shared/grafana/provisioning/`. The Loki datasource is how
|
`logs-overview` boards. Until 2026-10-21 the copier carried only
|
||||||
you read logs: Loki has no web UI of its own, so Grafana Explore is the
|
`llm-fleet.json` by name and the `containers` board had never been
|
||||||
human surface (the guarded `loki.franksdank.cloud` vhost serves only the API).
|
provisioned; the deployer now globs the directory. The Loki datasource is
|
||||||
|
how you read logs: Loki has no web UI of its own, so Grafana Explore is the
|
||||||
|
human surface (the guarded `loki.franksdank.cloud` vhost serves only the
|
||||||
|
API). Verify method and the copier law: [[Observability Provisioning]].
|
||||||
* **Per-container metrics: the `containers` dashboard (added 2026-10-20; live
|
* **Per-container metrics: the `containers` dashboard (added 2026-10-20; live
|
||||||
after `deploy.sh <host> --service cadvisor`).** cadvisor is the only way to see
|
after `deploy.sh <host> --service cadvisor`).** cadvisor is the only way to see
|
||||||
per-container CPU/memory here, and on this rootless-Podman host it works on one
|
per-container CPU/memory here, and on this rootless-Podman host it works on one
|
||||||
@@ -139,26 +143,26 @@ verify it through the APIs.
|
|||||||
`power1_average`, so they carry no `gpu_power_watts` line at all (only NVIDIA and
|
`power1_average`, so they carry no `gpu_power_watts` line at all (only NVIDIA and
|
||||||
the Dankclaw 8060S iGPU report power); and it binds loopback + the host LAN, like
|
the Dankclaw 8060S iGPU report power); and it binds loopback + the host LAN, like
|
||||||
node-exporter, never the wildcard. The dashboard graphs util/VRAM/VRAM-%/temp/power.
|
node-exporter, never the wildcard. The dashboard graphs util/VRAM/VRAM-%/temp/power.
|
||||||
* **Logs: fixed in code, awaiting deploy.** The pipeline shipped nothing because
|
* **Logs: flowing; container labels land with the 2026-10-21 deploy.** The
|
||||||
a rootless Alloy cannot read the `0640` journal files, and the container-log
|
pipeline once shipped nothing (rootless Alloy cannot read the `0640` journal;
|
||||||
globs point at a root-storage layout that does not exist here. The fix is built
|
the container-log globs pointed at a root-storage layout that does not exist
|
||||||
and tested in-repo (2026-10-20): Alloy is now a **root system service on
|
here) — fixed 2026-10-20 by making Alloy a **root system service on
|
||||||
`Network=host`**, reading `system.journal` + `user-*.journal` and pushing to
|
`Network=host`**. Journal lines now reach Loki (measured ~6.5k lines/5min),
|
||||||
Loki's `127.0.0.1:3100` loopback publish — a throwaway root probe read 31,825
|
but the stream carried only `job`/`service_name`, so every per-container
|
||||||
lines and pushed 26,966 with zero drops. The **live** instance is still the old
|
view was structurally empty: Loki metrics queries group only by INDEXED
|
||||||
rootless unit until `deploy.sh dankclaw --service alloy` runs. Detail in
|
stream labels. Alloy now promotes podman's `CONTAINER_NAME` to a `container`
|
||||||
[[Alloy]] → Why root; Loki's store state in [[Loki]] → Contents.
|
label (plus a `host` label) at ingestion — `loki.process` in
|
||||||
* **Not configured:** no blackbox exporter (the structural downs have no real
|
`shared/alloy/config.alloy`; detail in [[Alloy]] → Why root, and the
|
||||||
uptime signal) and no Alertmanager vhost. The log pipeline's fix is in code but
|
purpose-test law in [[Observability Provisioning]].
|
||||||
not yet deployed (see *Logs* above).
|
|
||||||
|
|
||||||
## Citations
|
## Citations
|
||||||
|
|
||||||
- `dankclaw/observability/quadlet/{prometheus,alertmanager,grafana,loki,alloy,node-exporter,cadvisor}.container`
|
- `dankclaw/observability/quadlet/{prometheus,alertmanager,grafana,loki,alloy,node-exporter,cadvisor,blackbox-exporter}.container`
|
||||||
- `scripts/deploy-remote.sh:219-300` (copiers + relay), `:521-536` (health waits), `:477-486` (grafana env seed)
|
- `scripts/deploy-remote.sh:219-300` (copiers + relay), `:521-536` (health waits), `:477-486` (grafana env seed)
|
||||||
- `scripts/signal-relay.py:26-49`, `scripts/signal-relay.service`
|
- `scripts/signal-relay.py:26-49`, `scripts/signal-relay.service`
|
||||||
- `scripts/gpu-exporter.py`, `scripts/gpu-exporter.service` (per-GPU host exporter)
|
- `scripts/gpu-exporter.py`, `scripts/gpu-exporter.service` (per-GPU host exporter)
|
||||||
- `shared/prometheus/prometheus.yml`, `shared/prometheus/alertmanager.yml`, `shared/prometheus/rules/llm-fleet.yml`
|
- `shared/prometheus/prometheus.yml`, `shared/prometheus/alertmanager.yml`, `shared/prometheus/rules/llm-fleet.yml`
|
||||||
- `shared/grafana/provisioning/`, `shared/loki/loki-config.yaml`, `shared/alloy/config.alloy`
|
- `shared/grafana/provisioning/`, `shared/loki/loki-config.yaml`, `shared/alloy/config.alloy`
|
||||||
|
- `shared/blackbox/blackbox.yml` (prober modules + loopback bind)
|
||||||
- `tests/observability_quadlet.bats` (boot persistence, no wildcard publish, probe addresses, delivery contract)
|
- `tests/observability_quadlet.bats` (boot persistence, no wildcard publish, probe addresses, delivery contract)
|
||||||
- Host evidence 2026-10-18: `ss -ltnp`, `podman ps`, `systemctl --user is-enabled|is-active`, `curl` on `127.0.0.1:9090/api/v1/{targets,rules,alerts}`, `127.0.0.1:3100/loki/api/v1/labels`, `127.0.0.1:12345/metrics`, `127.0.0.1:3002/api/health`, `192.168.0.143:18093/health`, `journalctl --user -u signal-relay`
|
- Host evidence 2026-10-18: `ss -ltnp`, `podman ps`, `systemctl --user is-enabled|is-active`, `curl` on `127.0.0.1:9090/api/v1/{targets,rules,alerts}`, `127.0.0.1:3100/loki/api/v1/labels`, `127.0.0.1:12345/metrics`, `127.0.0.1:3002/api/health`, `192.168.0.143:18093/health`, `journalctl --user -u signal-relay`
|
||||||
|
|||||||
@@ -42,29 +42,27 @@ Authentik; post-auth the Caddy vhost routes straight to `prometheus:9090`) —
|
|||||||
|
|
||||||
The `frank-medium` job is **retired**: no `frank-medium` job exists in the config or in `/api/v1/targets` (2026-10-18), and the endpoint itself left the fleet — its history is on [[frank-archon]], which holds the port today.
|
The `frank-medium` job is **retired**: no `frank-medium` job exists in the config or in `/api/v1/targets` (2026-10-18), and the endpoint itself left the fleet — its history is on [[frank-archon]], which holds the port today.
|
||||||
|
|
||||||
**Three jobs read down today, and they are not outages** (re-audited against `curl -s http://127.0.0.1:9090/api/v1/targets`, 2026-10-18; first audited 2026-09-16):
|
**No job reads down structurally any more** (the three permanent-reds were
|
||||||
|
retired 2026-10-21; census re-run after each deploy):
|
||||||
|
|
||||||
| Job | Target | `health` | `lastError`, verbatim |
|
| Job | Target | how |
|
||||||
|-----|--------|----------|------------------------|
|
|---|---|---|
|
||||||
| `gitea` | `http://192.168.0.143:3000/metrics` | down | `server returned HTTP status 404 Not Found` |
|
| `gitea` | `192.168.0.143:3000/metrics` | metrics enabled in the quadlet (`GITEA__metrics__ENABLED=true`, `ENABLED_HOST=0.0.0.0`) — the page once said "stays off until `[metrics] ENABLED = true` in its own config"; the env form is what the deployer ships |
|
||||||
| `comfyui` | `http://comfyui:8188/` | down | `received unsupported Content-Type "text/html" and no fallback_scrape_protocol specified for target` |
|
| `services` | `http://192.168.0.143:8081` (SearXNG), `:3005` (OpenWebUI), `http://comfyui:8188` | **blackbox exporter** probe, module `http_2xx` — the comfyui metrics job is retired, one owner per service |
|
||||||
| `services` | `http://192.168.0.143:8081/` | down | `received unsupported Content-Type "text/html; charset=utf-8" and no fallback_scrape_protocol specified for target` |
|
|
||||||
| `services` | `http://192.168.0.143:3005/` | down | same `unsupported Content-Type "text/html; charset=utf-8"` error |
|
|
||||||
|
|
||||||
Gitea's exporter stays off until `[metrics] ENABLED = true` in its own config (no metrics env is set in the container, and `curl -s http://127.0.0.1:3000/metrics` → 404, 2026-10-18); ComfyUI and the two `services` probes are HTML pages, which Prometheus 3 refuses as metrics. Everything else is green: `prometheus`, `node-dankclaw`, `node-dankserver`, `grafana`, `loki`, `frank-zealot`, `frank-dragoon`, `frank-archon`.
|
Scraping a web root is not a health check: an HTML page fails the scrape
|
||||||
|
protocol (`unsupported Content-Type`), which is what kept those targets red
|
||||||
|
beside healthy services. The classification rule and the honesty law live in
|
||||||
|
[[Stale Signal Audit]]. The prober is `blackbox-exporter.container` (host
|
||||||
|
netns, config `shared/blackbox/blackbox.yml`, bound 127.0.0.1:9115; Prometheus
|
||||||
|
reaches it at the `host.containers.internal` gateway name). Plain targets stay
|
||||||
|
`host:port`, never URLs — the probe job's URL targets are the documented
|
||||||
|
exception, relabeled onto the prober.
|
||||||
|
|
||||||
**`frank-zealot` reads up since 2026-10-18, and did not before.** llama.cpp only answers `/metrics` when started with `--metrics`, so `up{job="frank-zealot"}` was 0 across the whole retained history (the oldest sample, 2026-10-11, is already 0) while the service answered `/health` 200 the whole time; the flag and its opt-out are on [[frank-zealot]]. Read `lastError` before believing an outage: a target can read down beside a healthy service.
|
**`frank-zealot` reads up since 2026-10-18, and did not before.** llama.cpp only answers `/metrics` when started with `--metrics`, so `up{job="frank-zealot"}` was 0 across the whole retained history (the oldest sample, 2026-10-11, is already 0) while the service answered `/health` 200 the whole time; the flag and its opt-out are on [[frank-zealot]]. Read `lastError` before believing an outage: a target can read down beside a healthy service.
|
||||||
|
|
||||||
Alert state is a live property, not a page fact: `curl -s http://127.0.0.1:9090/api/v1/alerts` returned no alerts at the 2026-10-18 morning census, one firing `LlmPrefixCacheCollapse` (info, `frank-zealot`, `activeAt` 11:10:48Z) minutes later, and none again by 11:56Z.
|
Alert state is a live property, not a page fact: `curl -s http://127.0.0.1:9090/api/v1/alerts` returned no alerts at the 2026-10-18 morning census, one firing `LlmPrefixCacheCollapse` (info, `frank-zealot`, `activeAt` 11:10:48Z) minutes later, and none again by 11:56Z.
|
||||||
|
|
||||||
The remedy for a probe-shaped question is the **blackbox exporter**; the
|
|
||||||
classification rule and the honesty law for it live in [[Stale Signal Audit]].
|
|
||||||
Until the exporter is deployed, these three stay red for a structural reason:
|
|
||||||
deleting them would
|
|
||||||
hide a real service, and fixing them by publishing databases or enabling metrics
|
|
||||||
would trade a wrong signal for a real exposure. Targets are `host:port`, never
|
|
||||||
URLs.
|
|
||||||
|
|
||||||
## Volumes
|
## Volumes
|
||||||
|
|
||||||
| Container path | Host path |
|
| Container path | Host path |
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ logging {
|
|||||||
// Loki is published on the host's loopback (127.0.0.1:3100). Alloy shares the
|
// Loki is published on the host's loopback (127.0.0.1:3100). Alloy shares the
|
||||||
// host network, so it pushes by loopback, never by container name — a root
|
// host network, so it pushes by loopback, never by container name — a root
|
||||||
// container is not on the rootless dankclaw_default network.
|
// container is not on the rootless dankclaw_default network.
|
||||||
|
// GRAFANA_HOST_LABEL is replaced with this host's name by
|
||||||
|
// copy_alloy_config at deploy time; it must not appear verbatim in the
|
||||||
|
// installed config.
|
||||||
loki.write "default" {
|
loki.write "default" {
|
||||||
endpoint {
|
endpoint {
|
||||||
url = "http://127.0.0.1:3100/loki/api/v1/push"
|
url = "http://127.0.0.1:3100/loki/api/v1/push"
|
||||||
@@ -32,7 +35,34 @@ loki.write "default" {
|
|||||||
loki.source.journal "journal" {
|
loki.source.journal "journal" {
|
||||||
format_as_json = true
|
format_as_json = true
|
||||||
labels = {
|
labels = {
|
||||||
job = "systemd-journal",
|
job = "systemd-journal",
|
||||||
|
host = "GRAFANA_HOST_LABEL",
|
||||||
|
}
|
||||||
|
forward_to = [loki.process.relabel.receiver]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promote the fields that ARE the point of the log stream to real Loki labels.
|
||||||
|
// Without this the journal arrives as one opaque JSON line per entry and every
|
||||||
|
// per-container view is structurally impossible: Loki metrics queries can only
|
||||||
|
// group by INDEXED stream labels, so `sum by (container_name)` returns a single
|
||||||
|
// anonymous series and the logs-overview board read empty beside 6.5k
|
||||||
|
// lines/5min of live traffic (measured 2026-10-21). CONTAINER_NAME is what
|
||||||
|
// podman's journald logging writes (uppercase); host units lack it and stay on
|
||||||
|
// the host-only stream. `format_as_json = true` above guarantees every line
|
||||||
|
// reaching this stage is a JSON object, so there is no non-JSON branch to
|
||||||
|
// protect — alloy 1.21's json stage has no `on_error` attribute (validated
|
||||||
|
// against the pinned image); a malformed line would drop, which is the
|
||||||
|
// correct fate for a line Loki could not index anyway.
|
||||||
|
loki.process "relabel" {
|
||||||
|
stage.json {
|
||||||
|
expressions = {
|
||||||
|
container = "CONTAINER_NAME",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage.labels {
|
||||||
|
values = {
|
||||||
|
container = "container",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
forward_to = [loki.write.default.receiver]
|
forward_to = [loki.write.default.receiver]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Blackbox exporter modules — the honest answer to "is this HTTP service
|
||||||
|
# answering?". A Prometheus scrape cannot express it (an HTML front page is a
|
||||||
|
# 200 that fails the scrape protocol), which is why the old `services` and
|
||||||
|
# `comfyui` jobs sat permanently red on `unsupported Content-Type`.
|
||||||
|
# See serverAppConfigs-docs/Architecture/Stale Signal Audit.md §1.
|
||||||
|
# Loopback bind: the image's default 0.0.0.0:9115 would ride the LAN on this
|
||||||
|
# Network=host container. Prometheus (bridge) still reaches it — its traffic
|
||||||
|
# arrives on the podman gateway interface, which is not loopback.
|
||||||
|
network:
|
||||||
|
listen_address: 127.0.0.1:9115
|
||||||
|
|
||||||
|
modules:
|
||||||
|
http_2xx:
|
||||||
|
prober: http
|
||||||
|
timeout: 10s
|
||||||
|
http:
|
||||||
|
preferred_ip_protocol: ip4
|
||||||
|
valid_status_codes: [] # default: 2xx + 3xx
|
||||||
@@ -2,74 +2,242 @@
|
|||||||
"uid": "logs-overview",
|
"uid": "logs-overview",
|
||||||
"title": "Logs \u2014 Loki (systemd journal)",
|
"title": "Logs \u2014 Loki (systemd journal)",
|
||||||
"description": "Everything in Loki arrives as the journal (job=systemd-journal); the container is a JSON field parsed at query time, not a stream label. Starter breadth \u2014 the volume/error panels group by the parsed container_name. Loki keeps ~7 days; wide ranges are cheap counts, not the raw log panel.",
|
"description": "Everything in Loki arrives as the journal (job=systemd-journal); the container is a JSON field parsed at query time, not a stream label. Starter breadth \u2014 the volume/error panels group by the parsed container_name. Loki keeps ~7 days; wide ranges are cheap counts, not the raw log panel.",
|
||||||
"tags": ["logs", "loki"],
|
"tags": [
|
||||||
|
"logs",
|
||||||
|
"loki"
|
||||||
|
],
|
||||||
"timezone": "browser",
|
"timezone": "browser",
|
||||||
"schemaVersion": 39,
|
"schemaVersion": 39,
|
||||||
"refresh": "30s",
|
"refresh": "30s",
|
||||||
"time": { "from": "now-1h", "to": "now" },
|
"time": {
|
||||||
|
"from": "now-1h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
"templating": {
|
"templating": {
|
||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"name": "container",
|
"name": "container",
|
||||||
"label": "container",
|
"label": "container",
|
||||||
"type": "query",
|
"type": "query",
|
||||||
"datasource": { "type": "loki", "uid": "loki" },
|
"datasource": {
|
||||||
"query": { "query": "{job=\"systemd-journal\"} | json", "label": "container_name", "refId": "A" },
|
"type": "loki",
|
||||||
|
"uid": "loki"
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"query": "label_values({job=\"systemd-journal\"}, container)",
|
||||||
|
"refId": "LokiVariableQueryEditor-VariableQuery"
|
||||||
|
},
|
||||||
"includeAll": true,
|
"includeAll": true,
|
||||||
"multi": true,
|
"multi": true,
|
||||||
"refresh": 2,
|
"refresh": 2,
|
||||||
"current": { "text": "All", "value": "$__all" }
|
"current": {
|
||||||
|
"text": "All",
|
||||||
|
"value": "$__all"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"panels": [
|
"panels": [
|
||||||
{
|
{
|
||||||
"id": 1, "type": "timeseries", "title": "Log volume by container (lines/s)",
|
"id": 1,
|
||||||
"description": "Parse the journal JSON, count lines per container. Spikes = a chatty or crashing unit.",
|
"type": "timeseries",
|
||||||
"gridPos": { "h": 9, "w": 24, "x": 0, "y": 0 },
|
"title": "Log volume by container (lines/s)",
|
||||||
"datasource": { "type": "loki", "uid": "loki" },
|
"description": "Per-container journal line rate. The `container` label is promoted at ingestion by Alloy (loki.process) from podman's CONTAINER_NAME journald field - Loki metrics can only group by indexed stream labels.",
|
||||||
"fieldConfig": { "defaults": { "unit": "reqps", "custom": { "fillOpacity": 20, "stacking": { "mode": "normal" } } }, "overrides": [] },
|
"gridPos": {
|
||||||
"targets": [{ "expr": "sum by (container_name) (count_over_time({job=\"systemd-journal\"} | json [$__auto]))", "legendFormat": "{{container_name}}", "refId": "A" }]
|
"h": 9,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "loki"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "reqps",
|
||||||
|
"custom": {
|
||||||
|
"fillOpacity": 20,
|
||||||
|
"stacking": {
|
||||||
|
"mode": "normal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum by (container) (count_over_time({job=\"systemd-journal\", container!=\"\"} [$__auto]))",
|
||||||
|
"legendFormat": "{{container_name}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2, "type": "timeseries", "title": "Total log rate",
|
"id": 2,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Total log rate",
|
||||||
"description": "All journal lines per second.",
|
"description": "All journal lines per second.",
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 },
|
"gridPos": {
|
||||||
"datasource": { "type": "loki", "uid": "loki" },
|
"h": 8,
|
||||||
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
|
"w": 12,
|
||||||
"targets": [{ "expr": "sum(count_over_time({job=\"systemd-journal\"} [$__auto]))", "legendFormat": "all lines/s", "refId": "A" }]
|
"x": 0,
|
||||||
|
"y": 9
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "loki"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "reqps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(count_over_time({job=\"systemd-journal\"} [$__auto]))",
|
||||||
|
"legendFormat": "all lines/s",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3, "type": "timeseries", "title": "Error-level line rate",
|
"id": 3,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Error-ish line rate (text match)",
|
||||||
"description": "Lines matching error/panic/fatal/fail. A blunt text match \u2014 the journal carries no structured level for all units.",
|
"description": "Lines matching error/panic/fatal/fail. A blunt text match \u2014 the journal carries no structured level for all units.",
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 },
|
"gridPos": {
|
||||||
"datasource": { "type": "loki", "uid": "loki" },
|
"h": 8,
|
||||||
"fieldConfig": { "defaults": { "unit": "reqps", "color": { "mode": "fixed", "fixedColor": "red" } }, "overrides": [] },
|
"w": 12,
|
||||||
"targets": [{ "expr": "sum(count_over_time({job=\"systemd-journal\"} |~ \"(?i)(error|panic|fatal|fail)\" [$__auto]))", "legendFormat": "error lines/s", "refId": "A" }]
|
"x": 12,
|
||||||
|
"y": 9
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "loki"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "reqps",
|
||||||
|
"color": {
|
||||||
|
"mode": "fixed",
|
||||||
|
"fixedColor": "red"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(count_over_time({job=\"systemd-journal\"} |~ \"(?i)(error|panic|fatal|fail)\" [$__auto]))",
|
||||||
|
"legendFormat": "error lines/s",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4, "type": "timeseries", "title": "Errors by container",
|
"id": 4,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Errors by container",
|
||||||
"description": "Which unit is throwing. Rank by the top offenders.",
|
"description": "Which unit is throwing. Rank by the top offenders.",
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 },
|
"gridPos": {
|
||||||
"datasource": { "type": "loki", "uid": "loki" },
|
"h": 8,
|
||||||
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
|
"w": 12,
|
||||||
"targets": [{ "expr": "sum by (container_name) (count_over_time({job=\"systemd-journal\"} |~ \"(?i)(error|panic|fatal)\" | json [$__auto]))", "legendFormat": "{{container_name}}", "refId": "A" }]
|
"x": 0,
|
||||||
|
"y": 17
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "loki"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "reqps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum by (container) (count_over_time({job=\"systemd-journal\", container!=\"\"} |~ \"(?i)(error|panic|fatal)\" [$__auto]))",
|
||||||
|
"legendFormat": "{{container_name}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 5, "type": "stat", "title": "Error lines (last 15m)",
|
"id": 5,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Error lines (last 15m)",
|
||||||
"description": "Absolute count over the window, not a rate.",
|
"description": "Absolute count over the window, not a rate.",
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 },
|
"gridPos": {
|
||||||
"datasource": { "type": "loki", "uid": "loki" },
|
"h": 8,
|
||||||
"fieldConfig": { "defaults": { "unit": "short", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 50 }, { "color": "red", "value": 500 }] } }, "overrides": [] },
|
"w": 12,
|
||||||
"targets": [{ "expr": "sum(count_over_time({job=\"systemd-journal\"} |~ \"(?i)(error|panic|fatal)\" [15m]))", "instant": true, "legendFormat": "errors/15m", "refId": "A" }]
|
"x": 12,
|
||||||
|
"y": 17
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "loki"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "yellow",
|
||||||
|
"value": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 500
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(count_over_time({job=\"systemd-journal\"} |~ \"(?i)(error|panic|fatal)\" [15m]))",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "errors/15m",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 6, "type": "logs", "title": "Journal (filtered)",
|
"id": 6,
|
||||||
|
"type": "logs",
|
||||||
|
"title": "Journal (filtered)",
|
||||||
"description": "Raw lines for the selected containers. Set the container picker; keep the window tight.",
|
"description": "Raw lines for the selected containers. Set the container picker; keep the window tight.",
|
||||||
"gridPos": { "h": 12, "w": 24, "x": 0, "y": 25 },
|
"gridPos": {
|
||||||
"datasource": { "type": "loki", "uid": "loki" },
|
"h": 12,
|
||||||
"options": { "showTime": true, "wrapLogMessage": true, "sortOrder": "Descending", "enableLogDetails": true },
|
"w": 24,
|
||||||
"targets": [{ "expr": "{job=\"systemd-journal\"} | json | container_name=~\"$container\"", "refId": "A" }]
|
"x": 0,
|
||||||
|
"y": 25
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "loki"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"showTime": true,
|
||||||
|
"wrapLogMessage": true,
|
||||||
|
"sortOrder": "Descending",
|
||||||
|
"enableLogDetails": true
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "{job=\"systemd-journal\", container=~\"$container\"}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,95 +2,384 @@
|
|||||||
"uid": "observability",
|
"uid": "observability",
|
||||||
"title": "Observability \u2014 self-monitoring",
|
"title": "Observability \u2014 self-monitoring",
|
||||||
"description": "The health of the stack that watches the fleet: Prometheus scrape/TSDB, Loki ingest, Grafana. If this is red the dashboards lie. Note: Alertmanager is running but NOT scraped by Prometheus (no job) \u2014 its metrics are absent by design gap, not fault.",
|
"description": "The health of the stack that watches the fleet: Prometheus scrape/TSDB, Loki ingest, Grafana. If this is red the dashboards lie. Note: Alertmanager is running but NOT scraped by Prometheus (no job) \u2014 its metrics are absent by design gap, not fault.",
|
||||||
"tags": ["observability", "meta"],
|
"tags": [
|
||||||
|
"observability",
|
||||||
|
"meta"
|
||||||
|
],
|
||||||
"timezone": "browser",
|
"timezone": "browser",
|
||||||
"schemaVersion": 39,
|
"schemaVersion": 39,
|
||||||
"refresh": "30s",
|
"refresh": "30s",
|
||||||
"time": { "from": "now-6h", "to": "now" },
|
"time": {
|
||||||
|
"from": "now-6h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
"panels": [
|
"panels": [
|
||||||
{
|
{
|
||||||
"id": 1, "type": "stat", "title": "Targets up",
|
"id": 1,
|
||||||
"gridPos": { "h": 4, "w": 4, "x": 0, "y": 0 },
|
"type": "stat",
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"title": "Targets up",
|
||||||
"fieldConfig": { "defaults": { "unit": "short", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } }, "overrides": [] },
|
"gridPos": {
|
||||||
"targets": [{ "expr": "count(up == 1)", "instant": true, "refId": "A" }]
|
"h": 4,
|
||||||
|
"w": 4,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "count(up == 1)",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2, "type": "stat", "title": "Targets down",
|
"id": 2,
|
||||||
"description": "Known structural downs: comfyui (no metrics endpoint), gitea (metrics off), and the homepage 'services' probes. Real regressions ride up from here too \u2014 check the table before dismissing.",
|
"type": "stat",
|
||||||
"gridPos": { "h": 4, "w": 4, "x": 4, "y": 0 },
|
"title": "Targets down",
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"description": "Every scrape target that is down. ZERO IS THE HEALTHY STATE: the HTML-probe jobs and the disabled Gitea metrics were retired 2026-10-21 (HTTP liveness now rides the blackbox exporter, job \"services\"). A non-zero count here is a real regression - read /api/v1/targets lastError before triaging.",
|
||||||
"fieldConfig": { "defaults": { "unit": "short", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] } }, "overrides": [] },
|
"gridPos": {
|
||||||
"targets": [{ "expr": "count(up == 0)", "instant": true, "refId": "A" }]
|
"h": 4,
|
||||||
|
"w": 4,
|
||||||
|
"x": 4,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "count(up == 0)",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3, "type": "stat", "title": "TSDB head series",
|
"id": 3,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "TSDB head series",
|
||||||
"description": "Active series in memory. A jump = a new exporter or a label-cardinality leak.",
|
"description": "Active series in memory. A jump = a new exporter or a label-cardinality leak.",
|
||||||
"gridPos": { "h": 4, "w": 5, "x": 8, "y": 0 },
|
"gridPos": {
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"h": 4,
|
||||||
"fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] },
|
"w": 5,
|
||||||
"targets": [{ "expr": "prometheus_tsdb_head_series", "instant": true, "refId": "A" }]
|
"x": 8,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "prometheus_tsdb_head_series",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4, "type": "stat", "title": "Discovered targets",
|
"id": 4,
|
||||||
"gridPos": { "h": 4, "w": 5, "x": 13, "y": 0 },
|
"type": "stat",
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"title": "Discovered targets",
|
||||||
"fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] },
|
"gridPos": {
|
||||||
"targets": [{ "expr": "sum(prometheus_sd_discovered_targets)", "instant": true, "refId": "A" }]
|
"h": 4,
|
||||||
|
"w": 5,
|
||||||
|
"x": 13,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(prometheus_sd_discovered_targets)",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 5, "type": "stat", "title": "Prometheus mem",
|
"id": 5,
|
||||||
"gridPos": { "h": 4, "w": 3, "x": 18, "y": 0 },
|
"type": "stat",
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"title": "Prometheus mem",
|
||||||
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
|
"gridPos": {
|
||||||
"targets": [{ "expr": "process_resident_memory_bytes{job=\"prometheus\"}", "instant": true, "refId": "A" }]
|
"h": 4,
|
||||||
|
"w": 3,
|
||||||
|
"x": 18,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "process_resident_memory_bytes{job=\"prometheus\"}",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 6, "type": "stat", "title": "Grafana up",
|
"id": 6,
|
||||||
"gridPos": { "h": 4, "w": 3, "x": 21, "y": 0 },
|
"type": "stat",
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"title": "Grafana up",
|
||||||
"fieldConfig": { "defaults": { "mappings": [{ "type": "value", "options": { "1": { "text": "UP", "color": "green" }, "0": { "text": "DOWN", "color": "red" } } }], "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } }, "overrides": [] },
|
"gridPos": {
|
||||||
"targets": [{ "expr": "up{job=\"grafana\"}", "instant": true, "refId": "A" }]
|
"h": 4,
|
||||||
|
"w": 3,
|
||||||
|
"x": 21,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {
|
||||||
|
"1": {
|
||||||
|
"text": "UP",
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
"0": {
|
||||||
|
"text": "DOWN",
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "up{job=\"grafana\"}",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 7, "type": "table", "title": "Target health",
|
"id": 7,
|
||||||
|
"type": "table",
|
||||||
|
"title": "Target health",
|
||||||
"description": "Every scrape target and its current up. Sort by Value to surface regressions.",
|
"description": "Every scrape target and its current up. Sort by Value to surface regressions.",
|
||||||
"gridPos": { "h": 9, "w": 12, "x": 0, "y": 4 },
|
"gridPos": {
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"h": 9,
|
||||||
"options": { "sortBy": [{ "displayName": "Value", "sortOrder": "Asc" }] },
|
"w": 12,
|
||||||
"targets": [{ "expr": "up", "format": "table", "instant": true, "refId": "A" }]
|
"x": 0,
|
||||||
|
"y": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"sortBy": [
|
||||||
|
{
|
||||||
|
"displayName": "Value",
|
||||||
|
"sortOrder": "Asc"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "up",
|
||||||
|
"format": "table",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 8, "type": "timeseries", "title": "Scrape interval drift (p99 actual)",
|
"id": 8,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Scrape interval drift (p99 actual)",
|
||||||
"description": "Actual seconds between scrapes (summary quantile). Above the 15s scrape_interval = Prometheus is falling behind its own cadence (slow endpoints or overload).",
|
"description": "Actual seconds between scrapes (summary quantile). Above the 15s scrape_interval = Prometheus is falling behind its own cadence (slow endpoints or overload).",
|
||||||
"gridPos": { "h": 9, "w": 12, "x": 12, "y": 4 },
|
"gridPos": {
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"h": 9,
|
||||||
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
|
"w": 12,
|
||||||
"targets": [{ "expr": "prometheus_target_interval_length_seconds{quantile=\"0.99\"}", "legendFormat": "{{instance}}", "refId": "A" }]
|
"x": 12,
|
||||||
|
"y": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "s"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "prometheus_target_interval_length_seconds{quantile=\"0.99\"}",
|
||||||
|
"legendFormat": "{{instance}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 9, "type": "timeseries", "title": "Scrape samples/s",
|
"id": 9,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Scrape samples/s",
|
||||||
"description": "Total samples ingested across all jobs.",
|
"description": "Total samples ingested across all jobs.",
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 },
|
"gridPos": {
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"h": 8,
|
||||||
"fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] },
|
"w": 12,
|
||||||
"targets": [{ "expr": "sum(rate(scrape_samples_scraped[5m]))", "legendFormat": "samples/s", "refId": "A" }]
|
"x": 0,
|
||||||
|
"y": 13
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(scrape_samples_scraped[5m]))",
|
||||||
|
"legendFormat": "samples/s",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 10, "type": "timeseries", "title": "Loki request rate",
|
"id": 10,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Loki request rate",
|
||||||
"description": "Loki served requests/s (push, query). A drop to zero means ingest or query has stopped.",
|
"description": "Loki served requests/s (push, query). A drop to zero means ingest or query has stopped.",
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 },
|
"gridPos": {
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"h": 8,
|
||||||
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
|
"w": 12,
|
||||||
"targets": [{ "expr": "sum by (route) (rate(loki_request_duration_seconds_count[5m]))", "legendFormat": "{{route}}", "refId": "A" }]
|
"x": 12,
|
||||||
|
"y": 13
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "reqps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum by (route) (rate(loki_request_duration_seconds_count[5m]))",
|
||||||
|
"legendFormat": "{{route}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 11, "type": "timeseries", "title": "Exporter process CPU",
|
"id": 11,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Exporter process CPU",
|
||||||
"description": "CPU by monitored process \u2014 spot a runaway exporter or the Prometheus process itself.",
|
"description": "CPU by monitored process \u2014 spot a runaway exporter or the Prometheus process itself.",
|
||||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 21 },
|
"gridPos": {
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"h": 8,
|
||||||
"fieldConfig": { "defaults": { "unit": "percentunit" }, "overrides": [] },
|
"w": 24,
|
||||||
"targets": [{ "expr": "sum by (job) (rate(process_cpu_seconds_total[5m]))", "legendFormat": "{{job}}", "refId": "A" }]
|
"x": 0,
|
||||||
|
"y": 21
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum by (job) (rate(process_cpu_seconds_total[5m]))",
|
||||||
|
"legendFormat": "{{job}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -60,20 +60,31 @@ scrape_configs:
|
|||||||
- targets: ["loki:3100"]
|
- targets: ["loki:3100"]
|
||||||
metrics_path: "/metrics"
|
metrics_path: "/metrics"
|
||||||
|
|
||||||
# ComfyUI (no native /metrics; probe the UI)
|
# HTTP liveness, probed honestly through the blackbox exporter. Scraping a
|
||||||
- job_name: "comfyui"
|
# web root is NOT a health check — an HTML front page fails the scrape
|
||||||
metrics_path: "/"
|
# protocol (`unsupported Content-Type`), which is exactly why the old
|
||||||
static_configs:
|
# `services` and `comfyui` jobs were red forever beside healthy services
|
||||||
- targets: ["comfyui:8188"]
|
# (Stale Signal Audit §1). The prober is a host-netns container reached at
|
||||||
|
# the podman gateway name (HostAddr= on the prometheus unit); it in turn
|
||||||
# Blackbox-style probes for service up/down (published LAN ports;
|
# reaches both the host LAN IP and bridge container names. comfyui's UI probe
|
||||||
# gitea and comfyui are covered by their own jobs above)
|
# lives here now — its own metrics-scrape job is retired, one owner per
|
||||||
|
# service.
|
||||||
- job_name: "services"
|
- job_name: "services"
|
||||||
metrics_path: "/"
|
metrics_path: /probe
|
||||||
|
params:
|
||||||
|
module: [http_2xx]
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets:
|
- targets:
|
||||||
- "192.168.0.143:8081" # SearXNG (via the Gluetun tunnel)
|
- "http://192.168.0.143:8081" # SearXNG (via the Gluetun tunnel)
|
||||||
- "192.168.0.143:3005" # OpenWebUI
|
- "http://192.168.0.143:3005" # OpenWebUI
|
||||||
|
- "http://comfyui:8188" # ComfyUI UI (gfx1151 generator)
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels: [__address__]
|
||||||
|
target_label: __param_target
|
||||||
|
- source_labels: [__param_target]
|
||||||
|
target_label: instance
|
||||||
|
- target_label: __address__
|
||||||
|
replacement: "host.containers.internal:9115"
|
||||||
|
|
||||||
# LLM fleet. Scraped on the LAN addresses where the endpoint is reachable from
|
# LLM fleet. Scraped on the LAN addresses where the endpoint is reachable from
|
||||||
# this container, not the guarded vhosts: these are machine clients and
|
# this container, not the guarded vhosts: these are machine clients and
|
||||||
|
|||||||
+12
-1
@@ -193,7 +193,18 @@ CADDYFILE="$REPO_ROOT/dankclaw/Caddyfile"
|
|||||||
}
|
}
|
||||||
|
|
||||||
@test "prometheus targets are host:port (no URL schemes)" {
|
@test "prometheus targets are host:port (no URL schemes)" {
|
||||||
! grep -nE '^\s*-\s*"?http' "$REPO_ROOT/shared/prometheus/prometheus.yml"
|
# Plain scrape targets are host:port; a URL target is only meaningful for
|
||||||
|
# the blackbox prober job, whose relabel_configs copy it to __param_target
|
||||||
|
# and point __address__ at the prober. Anywhere else a URL target is the
|
||||||
|
# old HTML-probe defect wearing a new hat.
|
||||||
|
awk '
|
||||||
|
/job_name:/ { job=$0; sub(/.*"/, "", job); sub(/".*/, "", job); probe = 0 }
|
||||||
|
/metrics_path: \/probe/ { probe = 1 }
|
||||||
|
/^[[:space:]]*-[[:space:]]*"http/ && !probe {
|
||||||
|
print FILENAME ":" FNR ": URL target outside a prober job: " $0; bad = 1
|
||||||
|
}
|
||||||
|
END { exit bad }
|
||||||
|
' "$REPO_ROOT/shared/prometheus/prometheus.yml"
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "observability containers run as container root (rootless = host user)" {
|
@test "observability containers run as container root (rootless = host user)" {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ load helpers
|
|||||||
|
|
||||||
UNIT_DIR="$REPO_ROOT/dankclaw/observability/quadlet"
|
UNIT_DIR="$REPO_ROOT/dankclaw/observability/quadlet"
|
||||||
REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
|
REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
|
||||||
UNITS=(prometheus grafana loki alloy node-exporter cadvisor alertmanager)
|
UNITS=(prometheus grafana loki alloy node-exporter cadvisor alertmanager blackbox-exporter)
|
||||||
|
|
||||||
# container_section <unit> — the [Container] key/value block only
|
# container_section <unit> — the [Container] key/value block only
|
||||||
container_section() {
|
container_section() {
|
||||||
@@ -33,7 +33,7 @@ service_section() {
|
|||||||
awk '/^\[Service\]/{f=1;next} /^\[/{f=0} f' "$1"
|
awk '/^\[Service\]/{f=1;next} /^\[/{f=0} f' "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "all seven observability units exist under dankclaw/observability/quadlet/" {
|
@test "all eight observability units exist under dankclaw/observability/quadlet/" {
|
||||||
local svc
|
local svc
|
||||||
for svc in "${UNITS[@]}"; do
|
for svc in "${UNITS[@]}"; do
|
||||||
[ -f "$UNIT_DIR/$svc.container" ] || { echo "missing $svc.container"; return 1; }
|
[ -f "$UNIT_DIR/$svc.container" ] || { echo "missing $svc.container"; return 1; }
|
||||||
@@ -644,3 +644,113 @@ assert not any('container_network' in e or 'container_fs_' in e for e in names),
|
|||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
[ "$output" = "cadvisor" ]
|
[ "$output" = "cadvisor" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "the grafana copier provisions EVERY dashboard json, not a hand-listed subset" {
|
||||||
|
# Behavioral, not a text grep: the copier is extracted and executed with a
|
||||||
|
# stubbed run_privileged against a sandbox. The fleet provider turns each
|
||||||
|
# file in its directory into a dashboard, so a file the copier skips is a
|
||||||
|
# dashboard that silently never exists (the containers board lived in the
|
||||||
|
# repo, never reached /opt, and its fixes were undeployable).
|
||||||
|
# BATS_TEST_TMPDIR: per-test, removed by bats itself; a mktemp dir with a
|
||||||
|
# RETURN trap races bats' own trap machinery and vanishes mid-function.
|
||||||
|
local sandbox="$BATS_TEST_TMPDIR/sandbox"
|
||||||
|
(
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
eval "$(sed -n '/^copy_grafana_provisioning()/,/^}/p' scripts/deploy-remote.sh)"
|
||||||
|
run_privileged() { "$@"; }
|
||||||
|
export GRAFANA_PROV_ROOT="$sandbox/opt/grafana"
|
||||||
|
copy_grafana_provisioning
|
||||||
|
)
|
||||||
|
local f
|
||||||
|
for f in "$REPO_ROOT"/shared/grafana/provisioning/dashboards/*.json; do
|
||||||
|
[ -f "$sandbox/opt/grafana/provisioning/dashboards/$(basename "$f")" ]
|
||||||
|
done
|
||||||
|
[ -f "$sandbox/opt/grafana/provisioning/dashboards/provider.yml" ]
|
||||||
|
[ -f "$sandbox/opt/grafana/provisioning/datasources/prometheus.yml" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "changes under shared/blackbox/ plan the prober" {
|
||||||
|
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankclaw \
|
||||||
|
--paths shared/blackbox/blackbox.yml
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$output" = "blackbox-exporter" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the copier runs the same code path the deployer runs" {
|
||||||
|
# Textually pinned so a refactor cannot quietly route the deployer around
|
||||||
|
# the copy_* functions the seams execute.
|
||||||
|
grep -q ' prometheus)' "$REMOTE_DEPLOY"
|
||||||
|
grep -q 'copy_prometheus_config' "$REMOTE_DEPLOY"
|
||||||
|
grep -q 'copy_alertmanager_config' "$REMOTE_DEPLOY"
|
||||||
|
grep -q 'copy_loki_config' "$REMOTE_DEPLOY"
|
||||||
|
grep -q ' blackbox-exporter)' "$REMOTE_DEPLOY"
|
||||||
|
grep -q 'copy_blackbox_config' "$REMOTE_DEPLOY"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the alloy copier installs the host-labeled config, token-free" {
|
||||||
|
# Executes copy_alloy_config for real against a sandbox (stubbed
|
||||||
|
# run_privileged). A surviving GRAFANA_HOST_LABEL token would ship a
|
||||||
|
# config whose host label is a literal placeholder — the exact class of
|
||||||
|
# silent-label defect this tranche retired the board for.
|
||||||
|
local sandbox="$BATS_TEST_TMPDIR/opt"
|
||||||
|
(
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
eval "$(sed -n '/^copy_alloy_config()/,/^}/p' scripts/deploy-remote.sh)"
|
||||||
|
run_privileged() { "$@"; }
|
||||||
|
# NOTE: VAR=x func does not bind for shell functions — export.
|
||||||
|
export TARGET=dankclaw ALLOY_CONF_ROOT="$sandbox"
|
||||||
|
copy_alloy_config
|
||||||
|
)
|
||||||
|
[ -f "$sandbox/config/config.alloy" ]
|
||||||
|
grep -q 'host = "dankclaw",' "$sandbox/config/config.alloy"
|
||||||
|
! grep -q "GRAFANA_HOST_LABEL" "$sandbox/config/config.alloy"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the blackbox copier installs the prober config" {
|
||||||
|
local sandbox="$BATS_TEST_TMPDIR/blackbox"
|
||||||
|
(
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
eval "$(sed -n '/^copy_blackbox_config()/,/^}/p' scripts/deploy-remote.sh)"
|
||||||
|
run_privileged() { "$@"; }
|
||||||
|
export BLACKBOX_CONF_ROOT="$sandbox"
|
||||||
|
copy_blackbox_config
|
||||||
|
)
|
||||||
|
[ -f "$sandbox/blackbox.yml" ]
|
||||||
|
grep -q "listen_address: 127.0.0.1:9115" "$sandbox/blackbox.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the alloy copier refuses to install a config the engine rejects" {
|
||||||
|
# The syntax gate: alloy attributes are position-sensitive and a
|
||||||
|
# misplaced one only explodes at run time (crash-loop behind a green
|
||||||
|
# deploy). Prove the copier refuses a config the pinned image won't
|
||||||
|
# validate, and installs nothing. Needs the alloy image; skipped
|
||||||
|
# where it is absent (the copier's gate is likewise image-gated).
|
||||||
|
if ! podman image exists localhost/franksdank/alloy:latest; then
|
||||||
|
skip "franksdank/alloy image not present on this box"
|
||||||
|
fi
|
||||||
|
local sandbox="$BATS_TEST_TMPDIR/opt" cfg="$BATS_TEST_TMPDIR/broken.alloy"
|
||||||
|
sed 's/GRAFANA_HOST_LABEL/dankclaw/g' shared/alloy/config.alloy > "$cfg"
|
||||||
|
printf 'bogus_attr = "x"\n' >> "$cfg"
|
||||||
|
! (
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
eval "$(sed -n '/^copy_alloy_config()/,/^}/p' scripts/deploy-remote.sh)"
|
||||||
|
run_privileged() { "$@"; }
|
||||||
|
cp() { if [ "$1" = "$cfg" ]; then return 1; fi; command cp "$@"; }
|
||||||
|
export TARGET=dankclaw ALLOY_CONF_ROOT="$sandbox"
|
||||||
|
copy_alloy_config
|
||||||
|
)
|
||||||
|
[ ! -f "$sandbox/config/config.alloy" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the installed alloy config is engine-valid" {
|
||||||
|
# Runs the pinned image's own validator over the shipped config. The
|
||||||
|
# 2026-10-21 defect (`on_error` on the labels stage, which alloy 1.21
|
||||||
|
# does not define) passed every textual seam and only failed at boot.
|
||||||
|
if ! podman image exists localhost/franksdank/alloy:latest; then
|
||||||
|
skip "franksdank/alloy image not present on this box"
|
||||||
|
fi
|
||||||
|
local cfg="$BATS_TEST_TMPDIR/rendered.alloy"
|
||||||
|
sed 's/GRAFANA_HOST_LABEL/dankclaw/g' shared/alloy/config.alloy > "$cfg"
|
||||||
|
podman run --rm -v "$cfg:/c.alloy:ro" --entrypoint /usr/bin/alloy \
|
||||||
|
localhost/franksdank/alloy:latest validate /c.alloy
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user