Skip to content

deploy: SQL-only two-phase install with server-side fan-out and per-node verification (#692 part 2) - #694

Merged
BorisTyshkevich merged 7 commits into
mainfrom
feat/692-sql-fanout-installer
Oct 26, 2026
Merged

BorisTyshkevich merged 7 commits into
mainfrom
feat/692-sql-fanout-installer

Conversation

@BorisTyshkevich

@BorisTyshkevich BorisTyshkevich commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Part 2 of #692: deploy/install.sh (and uninstall.sh) now distribute sql.html and sql-config.json to every node of a multi-replica cluster using SQL only (no ssh, no per-node upload), with per-node verification. This PR contains the installer (commit 134cb9f, part A) plus the integration suite, fixes found by it, CI, release bundle, and docs (part B).

Design

Two-phase so the live file is never the target of anything that can fail on a remote source. A failed CREATE TABLE ... AS SELECT truncates its target, and cluster-function INSERTs are rejected (Code 55), so fan-out is CREATE ... ON CLUSTER ... ENGINE=File('RawBLOB', ...) AS SELECT:

  1. preflight (every node reachable, interserver auth works) -> pre-clean DROP IF EXISTS ... ON CLUSTER ... SYNC
  2. phase 1 to _asb_next_* (stage: upload once to the connected node and nodes copy it; or --pull-url), verify length + SHA-256 on every node
  3. phase 2: each node promotes its own verified copy over the live files; verify live; always-cleanup trap.

Spike evidence: #692 (comment) and the later design comment on #692.

Flags

--cluster, --artifact, --pull-url, --transport native|http (password never in argv), --http-port, --ddl-timeout (>= 1; distributed_ddl_output_mode=throw pinned; also bounds a --pull-url download via max_execution_time + HTTP timeouts), --verify-only; uninstall.sh empties both files on every node.

Tested matrix

npm run test:deploy (CI job deploy-integration, path-filtered, in the CI gate) on clickhouse/clickhouse-server:26.3.18.32 (exact tag; keeper 25.8.33.6) and altinity/clickhouse-server:25.8.33.20001.altinityantalya; Keeper + 2 shards x 2 replicas with a cluster <secret>. Per-node byte-exact SHA of /sql and /sql/config.json for: fresh install (native+stage), upgrade, http transport, --pull-url via a 2-hop redirect, wiped-node repair, --verify-only on a tampered node, a node stopped before the run (preflight rejects it), a node killed mid-run after the stage upload (installer fails, the other nodes keep the previous good version, DDL replay settles, re-install succeeds), --verify-only as a minimal deploy_verify user, unreachable pull URL, missing REMOTE / URL grants, single node, uninstall (0 bytes). All pass on both images; npm test, tsc --noEmit, npm run pretest and shellcheck are green.

Hardening after review

Pre-clean and uninstall empty _asb_next_*; verification requires one row per node, distinct hosts == nodes, n == 1; zero-byte artifact refused; cleanup ignores INT/TERM, caps DDL at 30 s, always removes the credential file; password reaches only clickhouse-client (not exported); curl -q; concurrent runs unsupported (documented, no lock).

Grants (tested)

stage: FILE, CLUSTER, REMOTE on *.*, CREATE TABLE, DROP TABLE ON default.*, SELECT ON system.clusters; --pull-url adds URL; --verify-only needs FILE, REMOTE, CREATE TEMPORARY TABLE, SELECT ON system.clusters. Tested sufficient (negative-tested: REMOTE, URL, install-as-verify-user only). Cluster needs <secret> for cross-node reads (per-replica credentials were spike-verified, not in CI) and the deploy user on every node.

Fixed along the way

  • Single-node mode used a CTAS that demanded INSERT on the temp table; it now uses INSERT INTO FUNCTION file (FILE grant only).
  • install.sh --artifact from the release bundle layout failed when dist/ did not exist; the release bundle now ships deploy/ (install.sh, uninstall.sh, lib/, http_handlers.xml).

Known limits

  • _asb_next_* is intentionally kept as a full copy (~2 MB/node).
  • A dead node makes the installer fail after --ddl-timeout; its queued DDL replays later against whatever source exists then, so re-run after bringing it back.
  • Not covered: the real GitHub release URL, live TLS (--secure), a node dying between preflight and a DDL statement (simulator only). A personal ~/.clickhouse-client/config.xml can override credentials (documented).
  • Full restricted-environment runbook is PR 3.

Refs #692

🤖 Generated with Claude Code

https://claude.ai/code/session_01P3CopPwrpzeWAJd7vaaLSk

BorisTyshkevich and others added 7 commits October 26, 2026 12:53
…#692)

install.sh now distributes sql.html + sql-config.json to every node of a
cluster via ON CLUSTER CTAS into File(RawBLOB) tables: preflight, pre-clean,
phase 1 to _asb_next_* (stage upload or --pull-url), per-node length+sha256
verify, phase 2 local promote, verify live, always-cleanup trap. Live files
are only ever written from a node-local, verified copy. New flags: --artifact,
--pull-url, --transport native|http, --http-port, --ddl-timeout, --verify-only.
HTTP transport keeps credentials in a 0600 curl header file, never argv.
uninstall.sh empties both files on every node and verifies. Shared plumbing
in deploy/lib/asb-ch.sh. Unit tests run the real scripts against simulated
clickhouse-client/curl shims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3CopPwrpzeWAJd7vaaLSk
…pull, docs, CI (#692 part 2)

- tests/deploy: Keeper + 2x2 ClickHouse cluster with <secret>, minimal-grant
  deploy users, real http_handlers.xml, redirecting file server; scenarios
  assert byte-exact /sql and /sql/config.json from every node (npm run test:deploy)
- install/uninstall: single-node mode writes with INSERT INTO FUNCTION file
  (a CTAS demanded INSERT on the temp table); url() pull bounded by
  max_execution_time and HTTP connect/receive timeouts from --ddl-timeout;
  create dist/ when run from the release bundle layout
- CI: path-filtered deploy-integration job (26.3 + Antalya 25.8.33) in the gate;
  release bundle ships deploy/
- docs: ASSET-DISTRIBUTION installer procedure, grants, limits, manual SQL appendix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3CopPwrpzeWAJd7vaaLSk
Installer: --ddl-timeout >= 1 and distributed_ddl_output_mode=throw pinned on
both transports; pre-clean and uninstall empty the _asb_next_* files; strict
verification rows (row count, distinct hosts, n==1; n==0 only for uninstall's
0-byte check); zero-byte artifact refused; cleanup ignores INT/TERM, caps its DDL
timeout at 30 s and always removes the credential header file; password no longer
exported (clickhouse-client only); curl -q; clear error without a build tree and
without --artifact; concurrent runs documented as unsupported.

Tests: simulator knobs (dropped/extra/duplicate-host/n=0 rows), mutation-checked
row/host/n/stage-MATCH guards, secrets/env/argv, cleanup robustness, sh shims.
Integration: node killed mid-run, deploy_verify user (needs CREATE TEMPORARY
TABLE), all-node temp-table/stage checks with positive controls, exact image
tags, container logs on any failure. CI bundle step runs the shipped installer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3CopPwrpzeWAJd7vaaLSk
- shellcheck SC2015 fixes (if/then in tests/deploy/run.sh and the rc capture)
- node identity is hostName():tcpPort() in verify/preflight/stage checks, so
  several instances per host pass; duplicate identities fail closed
- CREATE TEMPORARY TABLE grant measured: required only by a verify-only user
  (ACCESS_DENIED without it on 26.3 and Antalya 25.8); install/uninstall users
  pass without it. users.xml, docs grant table and failure hint reconciled
- unit shims dump the whole environment; password value asserted absent
- cleanup is bounded: client-side curl/native timeouts; exact http cleanup
  timeout assertions; behavioral SIGINT-during-cleanup test
- validate_pos: ^[1-9][0-9]{0,5}$ (no octal/overflow)
- build-tree guard checks the build entry point and package.json
- scenario (m) asserts outcomes (survivors serve B or C; re-install -> C);
  docs: orphaned _asb_stage_* after a killed run and how to empty it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3CopPwrpzeWAJd7vaaLSk
tcpPort() can fail on servers without a plain tcp_port (TLS-only). Verify and
preflight now key on toString(serverUUID()), unique per server instance and
independent of ports; hostName() stays as a displayed column. Sim, unit tests
and docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3CopPwrpzeWAJd7vaaLSk
- docs: verified orphaned-stage listing (file(..., 'One') works on both images, lists 0-byte files); one file per killed run
- native cleanup calls wrapped in coreutils timeout when present (total bound), idle timeouts otherwise; docs reworded
- ASB_TEST_BARRIER_TIMEOUT regex ^[1-9][0-9]{0,3}$
- barrier hook: unset from child environments, loud stderr WARNING when active
- tests/deploy (m)/(m3): assert failure reason; print survivors promoted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3CopPwrpzeWAJd7vaaLSk
@BorisTyshkevich
BorisTyshkevich merged commit 1ca835f into main Oct 26, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant