Skip to content

Watching a Server for Drift

A WatchOp over a sql environment runs chant lifecycle diff <env> --live on a schedule and records the run’s Drift outcome. It works the same way for ClickHouse and Postgres. The example the lexicon ships, examples/drift-watch, is a ClickHouse project with one.

ops/schema-watch.op.ts
import { WatchOp } from "@intentius/chant/op";
const { op } = WatchOp({ name: "schema-watch", env: "prod", schedule: "17 * * * *" });
export default op;

env selects sql.profiles.prod. The watch only reads, so give that profile a user that can only read:

chant.config.ts
export default {
lexicons: ["sql"],
ownership: { stack: "shop", env: "prod" },
sql: {
dialect: "clickhouse",
profiles: {
prod: {
url: "https://clickhouse.example.com:8443",
user: { env: "CLICKHOUSE_READER_USER" },
password: { env: "CLICKHOUSE_READER_PASSWORD" },
databases: ["shop"],
},
},
},
} satisfies ChantConfig;

On ClickHouse, a user with SELECT on the project’s databases and readonly = 1 is enough. On Postgres, a role that can log in, with USAGE on the project’s schemas and default_transaction_read_only = on; the catalogs it reads are readable by every role.

chant run schema-watch prints the diff and the outcome:

On the serverThe diffDrift
what the build declares, as appliedNo drift detectedfalse
an object the project did not create, beside the owned onesnothing; the diff reads only declared objectsfalse
an owned object’s property changed out of band (ALTER TABLE ... MODIFY TTL, a column default)PROPERTY DRIFT, the property’s declared and live valuestrue
an owned object droppedMISSING, the object and the address that was readtrue
a Postgres table’s columns in another order than the declaration’s, such as a column a PostgresMigrationOp rename added lastnothing; columns are compared by name, since Postgres cannot move a column without rewriting the tablefalse

The diff compares the server with what the checkout declares, not with what was last applied, so a change merged but not yet applied reads as drift too.

With topology: "cluster:<name>" on the profile, the live read covers every replica of every shard, through the profile’s server (clusterAllReplicas('<name>', system.tables)), as well as the profile’s server itself. A change made on one server without ON CLUSTER (for example ALTER TABLE events MODIFY TTL ... run on shard 2 only) is drift even though the profile’s server still holds the declaration. When the servers’ copies of a table differ, the report shows the copy that departs most from the declaration and names its server:

- events (ClickHouse::Table)
ttl: ts + INTERVAL 30 DAY → ts + toIntervalDay(7) [seen on: ch-s2r1]

The read needs every server to answer. If one cannot be read, every object is reported unobserved with the reason; none is reported as matching.

A Distributed table’s cluster, database and table arguments compare the same whether they are declared as identifiers (Distributed(main, shop, events, id)) or as strings. The server prints them quoted.

generateOpsPipeline renders the watch as a scheduled job on the Op’s own cron. Pass the reader’s credentials as the job’s variables and nothing else:

import { generateOpsPipeline } from "@intentius/chant/op";
await generateOpsPipeline(
[{
name: "schema-watch",
variables: {
CLICKHOUSE_READER_USER: "chant_reader",
CLICKHOUSE_READER_PASSWORD: "${{ secrets.CLICKHOUSE_READER_PASSWORD }}",
},
}],
"github",
{ beforeScript: ["npm ci", 'echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"'] },
);
ForgeScheduleRepository token
GitHubon.schedulepermissions: contents: read
Forgejoon.schedulethe runner’s own; Forgejo reads no permissions: block, so none is written
GitLaba Pipeline Schedule with CHANT_SCHEDULED_OP=schema-watch, noted at the top of the filethe job’s own CI_JOB_TOKEN; no other token is added

On GitLab, name the secret as a CI/CD variable ($CLICKHOUSE_READER_PASSWORD). chant run calls chant lifecycle itself, so chant has to be on the job’s path, which the beforeScript above sees to; on GitLab, export PATH="$PWD/node_modules/.bin:$PATH" does the same.