fix(cli): fail host metrics setup when the reload fails or the stack is too old
Follow-up to !436 (closed). It fixes the issues from the review note on that MR, and targets !436 (closed)'s branch. Relates to https://gitlab.com/postgres-ai/internal/-/issues/354
Round 2 (2026-09-30) is at the end: onboarding through mon local-install, key handling, stopped-stack bugs, mon update and PGAI_TAG, and a real ClickHouse Cloud run.
1. A failed reload now fails the command. mon targets add and mon targets remove now check the exit code of the SIGHUP to sink-prometheus. If the reload fails, they exit 1 and print "Reloading sink-prometheus failed. Run 'postgresai mon restart' to load the host metrics change."
2. An older stack no longer gets a false success. Before the reload, add runs a check inside the running sink-prometheus container. It confirms that the mounted prometheus.yml has scrape_config_files and that the new /etc/pgai/host-metrics/clickhouse-<name>.yml is visible, which proves the ./host-metrics mount. If either is missing, or the container is not running, the command exits 1 and tells the user to run postgresai mon update, mon stop and mon start (round 2: mon update now moves PGAI_TAG itself). The scrape files are kept. We point to stop and start rather than mon restart because docker compose restart keeps the old config-init image and the old mounts.
3. A reload that VictoriaMetrics receives but does not apply now fails the command. VictoriaMetrics applies a SIGHUP asynchronously. When it cannot parse a file under scrape_config_files, it logs the error, skips the file, and still reports the reload as successful: we saw vm_promscrape_config_last_reload_successful stay at 1 and vm_promscrape_config_reloads_errors_total stay at 0 on v1.140.0. So the reload metrics cannot catch this, and only /api/v1/targets shows the result. After the SIGHUP, the CLI runs a short script inside sink-prometheus that polls /api/v1/targets on 127.0.0.1:9090. It sends basic auth when VM_AUTH_USERNAME is set. Each request times out after 2 s, and the poll stops after 10 s.
addwaits for the exact new file. The scrape file now has a__pgai_revtarget label, a hash of the rendered config. VictoriaMetrics drops labels that start with__from stored series, so no series changes. The label still shows in the targets list, which means a re-add cannot pass by matching the job that was loaded before the reload. If the revision does not appear, the command exits 1 with "sink-prometheus did not load the scrape job 'clickhouse-' after the reload. Check 'docker logs sink-prometheus' for the error. The scrape files are saved."removewaits until theclickhouse-<name>scrape pool is gone from a successful targets response. Otherwise it exits 1 with "sink-prometheus still scrapes 'clickhouse-' after the reload. Check 'docker logs sink-prometheus' for the error."
The line "Host metrics: ClickHouse Cloud ..." now prints only after the check, the reload and the verification all succeed.
Tests
cli/test/clickhouse-reload.test.tsspawns the real CLI with a temporary HOME, XDG_CONFIG_HOME and project dir. A fakedockerplaced first on PATH records every compose call, and theexec/killsequence is compared against a golden list. It covers a failed reload, an older stack, a rejected add, a remove that still scrapes, and success for both add and remove.cli/test/clickhouse-verify.test.tsruns the exact verification script under/bin/sh, with a fakewgetin place of the HTTP call. It covers the request arguments (timeout, auth header with@:/in the password), waiting for the new revision instead of accepting the old one,clickhouse-avsclickhouse-ab, an error response, no answer, and requests that time out. The failure cases finish between 9 and 14 s.cli/test/clickhouse.test.ts: the scrape-config golden now includes__pgai_rev. The revision stays the same for the same input and changes when the input changes, and it is read from the parsed label, not from revision-like text in another label.- Each fix has a failing test commit first.
- Full suite locally: 1985 pass, 1 fail. The one failure,
checkup-apiCHECKUP_ALLOW_HTTP=1, is a network timeout that happens without this change too.tscreports the same 23 errors in unrelated test files as before this change. - Manual check against a real
victoriametrics/victoria-metrics:v1.140.0container with basic auth on: add and a changed re-add pass right away, a rejected file fails after 10 s, remove passes. Busyboxwget -T 2in that image times out at 2 s on a listener that never answers. The existing real-VM integration test,clickhouse-scrape.integration.test.ts, passes with the new label.
What we did not check
- End-to-end runs on a full local compose stack, including an upgrade from a 0.16.0 stack, are in the comments.
- The basic auth header is passed to
wgeton its command line insidesink-prometheus. It is visible there only to processes in that container, which already has the password in its environment. - If a re-add changes only the file and VictoriaMetrics still has the old revision loaded, we wait up to 10 s. We did not measure reload latency on a busy instance, where a slow reload could cause a false failure.
runComposetreats a process killed by a signal as exit 0. This was here before this change, and we did not change it.
Round 2 (2026-09-30)
Rebased onto !436 (closed)'s head (the only conflict was the import line in cli/bin/postgres-ai.ts) and merged !436 (closed), which now carries !434 (closed)'s table_describe hardening.
What changed
mon local-install --db-urlsets up ClickHouse host metrics. It and the interactive prompt now add the target throughaddTarget, the same path asmon targets add, so the main onboarding path writes the scrape job and the key file. The stack starts afterwards and loads the files, so nothing is reloaded at that point. The target name drops the URL query: ClickHouse Cloud URLs end in?sslmode=require&channel_binding=require, and that used to become part of the name. local-install now stops with exit 1 when the target was not saved, instead of starting a stack without it.- The org API key.
host-metrics/is created 0700, and tightened to 0700 when an older CLI created it 0755. The key file stays 0600. Ahost-metricssymlink is refused. The key never appears in output or errors, and tests cover add, the failure paths, and local-install. We checked the real API for the least privilege that works: a key with only the Basic Service API Reader role gets 200 on the service list, the service details and/prometheus(49PostgresServer_lines), and 403 on PATCH. An Organization API Reader key gets 403 on the details and on/prometheus. The 403 error,targets add --help, cli/README (new "ClickHouse Cloud host metrics" section) and CHANGELOG now recommend that role and say where the key is kept and how to rotate it. - Read-only commands in a directory the CLI cannot write. The pre-compose
mkdir host-metricsinresolveOrInitPathsis now best effort. Before,mon targets listin a read-only project dir died with an uncaught EACCES. removeon a stopped stack now reads thesink-prometheuscontainer state (compose ps -a -qplusdocker inspect, which docker-compose v1 also supports). A created, exited or dead container, or no container, means there is nothing to reload, so the command exits 0 with "sink-prometheus is not running; it will not load 'clickhouse-' when it starts." A paused container, a running one, or an unknown state still goes through reload and verification, and those fail loudly.- A failed
addleaves the stack alone. It returns beforeup -d pgwatch-*, which started a stoppedsink-prometheusas a dependency, and prints "The Postgres target is saved but not applied. Fix the error above and re-run this command." mon updatemoves PGAI_TAG. It sets every PGAI_TAG assignment in.envto the CLI version, as local-install does, and keepsexport, quotes, comments and CRLF. It never downgrades a newer tag, including prereleases. It does not move anything from a non-release CLI build, or when an assignment is not a plain value (interpolation,PGAI_TAG: x); in both cases it prints the exact line to set. It carries the move intoprocess.env, because Bun loads.envinto the environment and compose prefers the environment. It now always suggestsmon stop && mon start:mon restartkeeps the old containers. The older-stack hint and the README upgrade steps no longer ask for a manual.envedit.
Evidence
- Red/green: each fix has a failing test commit first (
ffda04a,5d258f5,05e1f1d,ce4d13d,b85ab15,79fadb1,61fc78e,62befe0), then the fix. New and extended tests:clickhouse-local-install.test.ts(runs the realmon local-installagainst a fake ClickHouse API, including the interactive prompt),mon-update-tag.test.ts,clickhouse-reload.test.ts,clickhouse-targets.test.ts. bun test clickhouse local-install mon-update-tag upgrade: 214 pass, 0 fail (156/156 on the final commit forclickhouse,local-installandclickhouse-reload).tsc: the same 23 errors as the base, none in changed files. The full suite locally: 8 failures, none from this change. 7 areinit.test.tslocal-install tests that stop at "already running" because another project's monitoring containers were running on this machine. The eighth is the knownCHECKUP_ALLOW_HTTPnetwork timeout.- Cross-model security review of each diff. Round 1 found 5 blocking issues, round 2 found 4, and round 3 found 1 (tag parsing, local-install after a save failure, a symlinked dir, a failed exec counted as stopped, a retry hint, docker-compose v1
ps --format, the interactive prompt, a v1 one-off container listed before the real one). All of them are fixed, with tests, and the last round found nothing blocking. - Real ClickHouse Cloud, 2026-09-30. One r6gd.medium PG 17 service in us-east-1 was created at 00:04:13 UTC; its GET returned 404 at 00:16:36 UTC, and the org's service list is empty. We ran
prepare-dbas the service superuser, thenmon local-install --db-url <postgres_ai_mon URL> --tag fix-clickhouse-host-metrics-followups -ywith onlyCLICKHOUSE_ORG_IDand a Basic Service API Reader key in the environment. It used a separate compose project with a temporary HOME, XDG_CONFIG_HOME and project dir. Exit 0: "Host metrics: ClickHouse Cloud Prometheus endpoint for service pgai-acc-726652".host-metrics/wasdrwx------and the key file-rw-------. The scrape job wasupin about 60 s. VictoriaMetrics stored 39 series, with 21PostgresServer_*names andPostgresServiceInfo, and no__pgai_revlabel. pgwatch stored 192 metric names for the target. The reporter, run from monitoring data, built A002 (17.11), A003 (426 settings), H001 and K001 (empty on a fresh database).mon targets removeon the running stack exited 0, and the job and files were gone. A re-add exited 0 after a verified reload.removewithsink-prometheusstopped exited 0 with the not-running message. Both test keys were deleted. - Also on that service:
table_describefrom !434 (closed) hassearch_path=pg_catalog, pg_temp, and it did not run a plantedpublic.format(text, text, text).
What we did not check
- The changes made after the real-cloud run (the state probe through
compose ps -q/inspect, tag parsing, the local-install save guard) were tested against a fake docker, not against a real stack. - A
mon updatethat actually moves the tag, end to end: this build reports0.0.0-dev.0, so the move is covered by unit tests ofplanUpdateTag/writeEnvTagonly. - docker-compose v1, Linux hosts and rootless Docker. We ran on Docker Desktop, arm64. VictoriaMetrics runs as root there, so it can read the 0600 key file. A non-root VictoriaMetrics could not.
mon local-installstill prints the--db-urlwith its password ("Adding PostgreSQL instance from: ..."). That is old behaviour, and we did not change it.- Dashboards for
PostgresServer_*: another change owns the host dashboard row, and metric names and labels are unchanged here.
https://claude.ai/code/session_01GRNbWXUQJebE3vqWjAgTFh
Round 3 (review of 2026-09-29, round 2)
mon updatemovingPGAI_TAGis now its own MR, !444, stacked on !436 (closed)'s branch like this one. Until it lands, the older-stack hint tells the user to setPGAI_TAGbeforemon update,mon stop,mon start.composeServiceState(ps -a -qplusdocker inspectfor compose v1 one-off containers) is replaced by onedocker compose ps --status running --status pausedcall. Checked on Docker Compose 5.0.2: running and paused list the container, stopped and absent list nothing. When compose cannot answer (docker-compose v1 has no--status),targets removereloads and verifies as for a running service instead of failing.maskConnectionStringfalls back toredactTextSecretsinstead of its own regexes.local-installno longer parses the URL itself;addTargetderives the same<host>-<db>name.targets removeof a name likeprod.dbno longer fails (from !436 (closed)).- Red
b240726,d8e1cb0; green9621e29,47e20b5. ClickHouse CLI tests: 139 pass. - Real ClickHouse Managed Postgres, 2026-09-30:
clickhouse.cloud.test.tspassed from this branch (provider detected, scope line,--verify, the/prometheusendpoint serves every documented series); the test deleted its service. - Own delta: code +167/−65 (was +241/−37), tests +549 (was +660).