Files
windmill/backend/windmill-common/src/instance_config.rs
T
8741843d2e feat: external instance cluster for data tables and Ducklake catalogs (#11197)
* fix pg_dump stuck on version 17 on nix

* fix(datatables): refuse a malformed role annotation instead of ignoring it

`-- Role operator`, `-- role operator;` and `-- role operator -- why` all failed
the annotation parser's exact-match rule, so the query fell through to the data
table's default role and ran, silently, under a login the author did not choose.
Naming a role exists precisely to not do that.

A leading comment whose first word is `role` is now an annotation attempt: the
keyword matches case-insensitively, one trailing `;` is tolerated, and anything
else is an error naming the line. Only callers that already know the target is a
`datatable://` reference ever run this, so ordinary SQL keeps its comments.

Also bumps the dev shell's postgres client to 18 — it trailed the server the dev
database runs, which takes out every data table export, clone and fork-with-data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): refuse a malformed role query string instead of ignoring it

`?Role=analytics`, `?role=` and `?x=1&role=…` all fell through the reference
parser's exact-match rule, so the connection resolved to the data table's default
role and ran under a login the caller never asked for — the URI half of the same
trap as a malformed `-- role` annotation.

The key now matches case-insensitively, and anything else in the query string is
an error naming it; `role` is the only parameter a reference takes. Callers that
only need the entry keep a lenient `datatable_ref_name`, since they never act on
the role. The DuckDB `ATTACH` parser propagates it rather than attaching under
the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): carry the role annotation into the row_to_json retry

The retry rebuilds its SQL from `pruneComments(code)`, so the leading comment
block never reached the second attempt — and with it the `-- role <name>` line
that decides which login the query runs as. The retry connected as the data
table's default role instead, so a query the first attempt was denied could
succeed on the second, reported as "recovered with the row_to_json fix".

Carry the leading comment block over. The retry itself is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* chore(datatables): don't mount the roles UI until the ACL editor lands

Enforcement ships first. The permissions drawer is what turns roles on, and the
catalog section is what creates them — both are only useful once there is a way
to grant a role the privileges it needs, which arrives with the ACL editor. Left
mounted they would offer a feature whose other half does not exist.

The two components are complete and reviewed; only their call sites here are
commented out, with a note pointing the follow-up PRs at them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* fix(datatables): honour `-- role: x`, and fix the DuckDB attach test

Two review findings, both real.

`attach_datatable_parses_name_and_role` never compiled: `parse_attach_datatable`
returns `Result<Option<_>>` now and one call site kept a single `unwrap`. Its
`?Role=analytics` case also asserted a refusal, contradicting the parser in the
same commit, which matches the key case-insensitively. Replaced with the cases
that are genuinely malformed, and a positive one for the cased key.

`-- role: analytics` fell through to the default role — the silent fallback the
strict parser exists to remove, for the spelling most likely to be typed. The
keyword now accepts an optional colon, attached or spaced, while a word that
merely starts with it (`rolebased`) is still not an attempt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* fix(datatables): clone a fork's pointer instead of failing after the copy

Forking a fork with cloning left an orphan database. The preflight resolves the
pointer and sees the governing entry, so both endpoints ran and filled the new
database; `apply_forked_datatable` then refused the inherited pointer and rolled
the fork back, stranding a registered `wm_fork_*` that no entry names and whose
name blocks the retry.

Refusing earlier would have been the smaller change, but forking a fork and
cloning worked before pointers existed, so it would trade an orphan for a
regression. Resolve what the pointer names and write the terminal entry the
clone needs: the whole `database` object rather than a patch of its
`resource_path`, since a pointer has none, and `reference` removed with it.

Also accepts `-- role=x` and `-- Role = x`, two more spellings that fell through
to the default role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* fix(datatables): refuse to roll back the catalog while roles exist

The down migration dropped the table and left every role behind: live Postgres
logins whose passwords only that table carried, so after a revert Windmill could
neither use, disable nor delete them, and re-applying could not recreate them
because the names were taken. Cleaning up here is not possible either — dropping
a role means reassigning what it owns in every instance database, and a
migration runs in one — so it now refuses while the catalog is non-empty and
says to delete the roles through instance settings, which does the cluster work.

Also enforces the instance-only invariant the resolved-pointer clone relies on
rather than only asserting it in a comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* refactor(datatables): settle clonability in one place, before anything is created

A clone is three stages a workspace apart — `create_pg_database`, then
`import_pg_database`, then `apply_forked_datatable` inside the fork transaction.
Only the third can roll back, and `CREATE DATABASE` is not transactional, so any
refusal that lives there strands a registered `wm_fork_*` that no entry names
and whose name blocks the retry.

That orphan has now been fixed three times, most recently reintroduced by a
guard added one commit ago. Patching each new refusal into the first endpoint is
not the fix; having two places that can refuse is. `ensure_datatable_is_clonable`
now answers every reason a copy can be refused and returns what it resolved, and
the stage that writes the entry only does the work.

Also takes an ACCESS EXCLUSIVE lock before the rollback guard counts, so a role
created concurrently cannot slip between the check and the drop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* fix(datatables): let a retried clone reclaim its own leftover database

A clone creates its target database one request before it copies into it, and
the fork that would name it is written a request after that. Any failure in
between — a pg_dump error, a bad restore, a dropped connection, the source's
roles changing mid-flow — left a registered `wm_fork_*` that no entry names,
and every retry then failed on its name. This predates data table roles.

`create_pg_database` now reclaims such a leftover before creating: only a
`wm_fork_*` database Windmill registered as a data table database and that no
data table or ducklake entry names, in any workspace, archived ones included.
The drop never terminates connections, so a clone still copying into it makes
the reclaim fail instead of being cut off. It is limited to callers who
administer the source — reaching it is not enough, since on a data table
without roles every member reaches it — and anyone else gets the refusal an
existing database always got.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Revert "fix(datatables): let a retried clone reclaim its own leftover database"

This reverts commit 7dd3275a10.

The reclaim tied the caller to the source they administer, but not to the
database it dropped. Between another workspace's import and its final fork
request, that workspace's target is full, registered, unnamed and has no open
connection, so an admin of any instance data table could name it and have it
dropped and recreated empty. The victim's fork would then commit pointing at
the empty copy. Safe reclaim needs durable clone ownership and serialization
with the request that names the database; until then the leftover stays, as it
did before this PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(datatables): record the stale clone database as a known limitation

A clone is three requests and `CREATE DATABASE` is not transactional, so a
failure after the first leaves a registered `wm_fork_*` behind, as it did
before data table roles. Accepted for this PR: it is harmless to data and goes
away once the clone is a single server-side operation.

The comment also records why the obvious fix is wrong: reclaiming the leftover
on retry, without durable clone ownership, can drop another workspace's fully
copied database between its import and its final fork request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): bounce the streams reading a data table when it is deleted

Deleting a governing data table, or the workspace that holds it, only collected
the fork pointers it stranded, for the warning. A Postgres trigger or capture
already streaming through one of those pointers kept the replication connection
it opened while the pointer still resolved, so it went on dispatching the
governing database's rows after the fork lost access — until its connection
happened to restart. The governing workspace's own streams on a deleted entry
did the same.

Both deletion paths now bounce the affected listeners inside their own
transaction, through the helper a permission change already uses, so a
listener that reconnects re-resolves the entry and finds it gone. The helper is
split so a caller can pass the (workspace, local name) pairs it already holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): keep the fork schema baseline, and bounce streams on every removal

Three fixes from review.

`edit_datatable_config` took `forked_from` wholesale from the stored entry, so
the fork schema diff's save of an advanced baseline was silently discarded and
an applied change was offered again. Whether an entry carries a clone stamp is
still carried from the store, since that is what marks its database droppable,
but the baseline inside it is now taken from the request.

The stranded-pointer warning and the stream bounce ran over the optional
`deleted_datatables` hint, which the settings-sync CLI never sends, so removing
a governing data table through `wmill` bounced nothing. Removals are now derived
from the stored configuration against the saved one.

`delete_workspace` read the pointers to bounce before its transaction, so a fork
committing a pointer during the deletion was missed. The read now happens inside
the transaction, after the workspace row is deleted: a fork's insert key-share
locks that row through its parent foreign key, so it is either seen or fails on
the missing parent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(datatables): keep Postgres triggers and data table roles apart

A replication stream reads every row of every table whatever the data table's
roles grant, and its listener checks access only when it connects. Rather than
chase every way access can change and bounce the streams each one affects, a
data table now carries one or the other:

- a Postgres trigger or capture cannot be created on, or connect to, a data
  table under roles;
- roles cannot be turned on while an enabled trigger or a live capture reads
  the data table, its own or a fork's through its pointer. The refusal names
  each one to disable.

This removes the stream bounces on roles edits and on data table and workspace
deletion, and the trigger gate that admitted admins. The fork schema baseline
fix from the same review round is kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): refuse a Postgres trigger on a data table under roles when it is saved

Creating or editing a trigger that points at a data table under roles was
accepted, and its listener then retried the refused connection every 30
seconds forever. The save is now refused, and a trigger that reaches such a
data table anyway (re-enabled, or cloned into a fork) is disabled by its
listener with the reason, as a missing replication slot is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): disable a data table role before deleting it

Deleting a role reassigns and drops what it owns in each registered
database on its own connection, and each of those passes commits as it
goes. A database failing part-way left the role enabled in the catalog and
able to log in, but already stripped in the databases reached before it.
The role is now disabled in its own commit first, so a failed delete
leaves a disabled role to retry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): serialize roles going on with a stream starting

Turning roles on looked for enabled triggers and live captures once,
without a lock anything starting a stream also took. A trigger enabled in
that window could have its listener connect before roles committed, and a
healthy listener never checks again. Both transitions now serialize on one
advisory lock: roles going on hold it exclusive while they look, and
trigger create, edit and enable, and capture setup and ping hold it shared
while they commit. Either the look sees the stream, or the listener
connects after roles are committed and refuses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): wait out live listeners, and resolve stored names containing `?`

Turning roles on counted a trigger as gone once disabled, and a capture
once its client stopped pinging, but the listener keeps its replication
connection until its next heartbeat notices. A trigger or capture whose
listener pinged in the last 15 seconds, the window a server holds a
listener for, now still counts as streaming.

Data table names could contain `?` before they were restricted, and such
entries are still stored. Splitting `?role=` off a reference misread them:
`a?b` became `a` with an unknown parameter, and the clone checks looked at
a different entry than the one copied. An entry stored under the whole
reference is now looked up first, in the Postgres executor, DuckDB ATTACH
and the clone checks. Agent workers cannot read the workspace and keep
the strict parse, which refuses such a name rather than misreading it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): warn when a settings sync strands fork pointers

A settings save reported the fork pointers left resolving to nothing only
for the names in `deleted_datatables`, which `wmill sync push` never sends.
The save now works out what it removed from the locked entries, and the
CLI prints the stranded pointers it returns.

Also correct the replication helper's contract: no role or admin check
makes a replication connection safe, so a data table under roles is
refused outright rather than gated as an admin operation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* fix(datatables): refuse a save that drops a data table's roles through an undeclared rename

A data table's roles follow its entry only through a declared rename. A
settings sync sends the whole map and never declares one, so renaming a
data table under roles there read as a delete and a new entry on the same
database: the new entry carried no roles, and every caller connected as
admin. Such a save is now refused, naming both entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* fix(datatables): no entry without roles may newly reach a database under roles

The previous guard only caught a new name replacing an entry under roles.
A whole-map save could also repoint an existing entry without roles at
that database, or another workspace could point one there, and every
caller of that entry would connect as admin. The rule is now stated on
the saved entries: one that carries no roles and newly points at an
instance database any entry under roles uses, in this workspace or
another, is refused. A declared rename carries its roles and passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* feat(datatables): move data table role catalog and resolution to the enterprise edition

Roles are an Enterprise Edition feature. The catalog, the Postgres logins,
CONNECT convergence, tenant evaluation and the role half of connection
resolution move to windmill-ee-private. Every public function keeps its path
and signature and forwards through datatable_roles_oss, which re-exports the
enterprise implementation or, without it, refuses.

Without the enterprise edition a data table under roles, or a caller naming a
role, is refused a connection rather than resolved as admin, and the reach and
admin-access checks refuse one under roles. A data table not under roles
resolves as before in every edition, and an instance database keeps the
CONNECT grants it was created with. The catalog lock, the stream lock, the
tenant cascades and the permissions stripping stay in OSS: they only restrict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* feat(datatables): move the data table permissions endpoints to the enterprise edition

The permissions read, save and usable-roles handlers move to
windmill-ee-private; the routes stay registered and, without the enterprise
edition, answer that data table roles are an Enterprise Edition feature.
ensure_governs_datatable and ensure_reaches_datatable keep their paths: the
first refuses, the second passes a data table not under roles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* feat(datatables): move the data table role catalog endpoints to the enterprise edition

The superadmin list, create, update and delete handlers move to
windmill-ee-private. The routes stay registered and, without the enterprise
edition, refuse after authentication.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* test(datatables): run the roles tests on the enterprise edition, refusals without it

Each test that exercises roles runs with private and enterprise. Two tests run
without them: every roles route answers the Enterprise refusal, and a data
table saved under roles, or a named role, is refused a connection while one
not under roles resolves as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* feat(datatables): gate the roles UI mount sites on an enterprise license

Both mount sites are still commented out; the gate travels with them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* test(datatables): run the tenant matcher test on the enterprise edition

The matcher it covers is enterprise code now, so without the enterprise
edition the test hit the stub and failed the default windmill-common run. It
runs with private and enterprise, and a counterpart without them asserts that
no tenant list covers anyone, the wildcard and a workspace admin included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* chore: update ee-repo-ref to a1873dbb67f2302b85ff5362f8387b48eccdb607

This commit updates the EE repository reference after PR #783 was merged in windmill-ee-private.

Previous ee-repo-ref: 5c853e2c20eca6b748415fc0d6862a6ebfb5fec4

New ee-repo-ref: a1873dbb67f2302b85ff5362f8387b48eccdb607

Automated by sync-ee-ref workflow.

* fix(datatables): refuse roles while a same-workspace alias reaches the database

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(datatables): add an ACL editor for data table roles

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(datatables): data table roles in the DB manager and raw apps

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: never add a role to the reference of a data table whose name contains '?'

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: read the roles of a data table whose name contains '?'

The generated client leaves a '?' in a path param unencoded, so the lookup
404'd and the raw-app picker blocked Start on such a data table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: take every pooled connection before the ACL apply locks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: refresh grant options only after the ACL apply validates its plan

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix(datatables): refuse a reference naming both a legacy data table and a role

When a workspace stores both `sales` and a legacy `sales?role=analytics`, the
reference resolved to the legacy entry without a role, so browsing `sales` as
`analytics` reached another data table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: declare the default role in migrations written for a data table whose name contains '?'

Such a data table connects as its default role without naming it, so the
migrations the manager wrote for it declared no role and ran as admin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): let CE migrations connect as an explicitly named admin

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: add only missing grant options before an ACL apply, never default privileges

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix(datatables): serialize roles going on with aliases saved from other workspaces

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(datatables): note that legacy names with ? cannot be migrated

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: run one data table ACL apply at a time per server before it connects

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* feat(datatables): set up an external instance cluster for data tables

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): send external cluster passwords as SCRAM verifiers

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): scope external cluster credential readers to the crate

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* [ee] feat(datatables): external_instance data tables on the external cluster

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: hold the ACL connection to the database that was authorized

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix(datatables): compare the external cluster settings under a row lock before storing setup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): only drop external databases Windmill marked, and check use under the lock

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: build the ACL connection from the authorized data table entry

Resolving the settings again could land on a resource with the same
database name on another server, which the later entry checks never see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: check ACL read reach against the entry it connects from

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* feat(datatables): put a data table's connection under Postgres roles

A data table backed by the instance database resolved to exactly one Postgres connection,
`custom_instance_user`, for everyone who could reach it at all. There was no way to say
this job reads, that one writes, this one never sees the salaries table.

A data table role is now a real Postgres login on the cluster, defined once for the
instance by a superadmin and named exactly as they named it. A script that declares
`-- role analytics` connects as `analytics`, and Postgres decides what it may touch —
grants are ordinary SQL. Windmill answers only "may this caller ask for this role", from
the tenant lists on the data table entry: `u/alice`, `g/analysts`, `f/finance` or `*`.
A data table with no `permissions` block behaves exactly as before.

Everything that opens a connection on someone's behalf goes through one chokepoint,
`get_datatable_resource_from_db`, which takes the identity explicitly and fails closed when
there is none. The role logs in as itself — never `SET ROLE`, which a script could
`RESET ROLE` its way out of.

A fork's data table entry becomes a pointer at the workspace that governs it rather than a
copy of it. The settings clone used to hand a fork a byte-identical entry naming the
parent's database, which a fork admin could edit to grant themselves `admin` there; a
pointer has nothing local to edit, and its tenants are evaluated as a member of the
governing workspace, by email. `permissions` is stripped from the workspace export and
ignored on import: tenants name principals of one workspace, and a settings push is not
where an access decision should be made.

Operations that see the whole database whatever the roles grant stay with the governing
workspace's admins: editing the roles, a migration that declares none, and opening a
replication stream for a Postgres trigger or capture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): gate the paths that reach a whole database as admin

Auditing what still resolved through the unchecked resolver turned up three that act for a
caller and hand back the admin connection: `resolve_pg_source_checked` (behind schema
export, the full-schema read, database creation, import and the forked-database drop), the
connection test, and the schema snapshot a fork clone takes of its parent. On a data table
under roles each let any workspace member — or a fork admin who is nobody in the governing
workspace — read or copy the whole database whatever its roles grant.

All three now require admin reach on the governing workspace. A dump taken under a
restricted role would be a silently truncated copy rather than an error, so refusing is the
only right answer for the copy paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): confine roles to the instance database, and stop a fork reaching the parent's bookkeeping

A data table role is a login on Windmill's own Postgres. Nothing stopped a workspace admin
putting a *resource-backed* data table under roles, at which point the executor dialled the
host that resource names — one the admin chose — with the role's real cluster password, and
`CONNECT` is granted to every registered instance database. Both ends now refuse: the
permissions endpoint rejects the save, and the chokepoint refuses to substitute credentials
on a non-instance entry rather than trusting the record it read.

Two more places reached the governing database without answering to it. The initial-migration
generator returned a `pg_dump` of the whole schema to any member. And the migration
rename/delete cascade followed a fork's pointer into the parent, so a fork admin renaming or
removing their own local entry relabelled or wiped the parent's `_wm_migrations` — after
which the parent re-runs every migration from zero. The remote half is now skipped when the
entry resolves into another workspace, which is also just correct: a fork renaming what it
calls a data table changes nothing about the data table.

Also: revoking a tenant now bounces the replication streams of every workspace holding an
entry that resolves here, not only the governing one, so a fork's trigger stops rather than
living on inside its open connection; the instance role catalog and the governing workspace's
tenant lists are no longer returned to someone who cannot edit them; and the tenant rename
dedup collapses non-adjacent duplicates, per role rather than once any role changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): fail loudly where a role or a pointer can be left half-recorded

Three ways the feature could end up in a state nobody could see or undo.

Creating a role writes the cluster first and the catalog second, but the catalog write was an
`UPDATE` that matched nothing when the instance Postgres settings row was absent — leaving a
live login with a password nobody recorded: invisible to the catalog, un-recreatable because
the name is taken, and un-deletable because there is no entry to delete. It now errors, so
the operation is retryable once the row is restored.

Deleting a workspace only nulls the fork lineage; the data table entries pointing at it are
left resolving to nothing. Sweeping them is not an option — turning a pointer back into a copy
would hand each fork the database outright — so the delete now names the data tables it
stranded, and resolving one says which workspace is missing rather than reporting a data table
this workspace never had.

`InstanceDatatableRole` derived `Debug` while holding a Postgres password; it is now
hand-written so `{:?}` on the catalog cannot put a live credential in a log line.

Adds the two branches the reviews found unpinned: a caller who is not a member of the
governing workspace at all, and `NoIdentity` — the compatibility path for an agent worker that
predates this and sends no job id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): unbreak two operator messages and two comments that described other code

The two strings this branch added for states an operator hits once — the catalog write that
matched nothing, and the delete that stranded a pointer — were collapsed from their multi-line
form with the indentation left in, so both rendered with a fourteen-space gap mid-sentence.

`list_datatables` claimed to report a chain it cannot follow and then dropped it; it does drop
it, and the comment now says why that is the right place to stay quiet. The non-superadmin
check in `edit_datatable_config` was introduced as also covering references, which it does not
and need not: `reference` is overwritten from the stored entry for every caller before the
check runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): serialize role catalog mutations, and state each helper's authorization contract

The catalog is one JSON document, so create, rename, enable and delete are all
read-modify-write. Two concurrent creates read the same snapshot, both succeed in the
cluster, and the second write drops the first — leaving a live Postgres login with a password
nobody recorded, which is the exact state the delete path exists to prevent. Every mutation
now runs in one transaction holding an advisory lock across the read, the cluster DDL and the
write, so a lost update cannot happen and a failure rolls the whole thing back. The DDL
helpers take that transaction rather than the pool, which is what makes the lock cover them.

Their statements moved off `sqlx::raw_sql`: the simple protocol is only needed for genuinely
multi-statement SQL, and its future is not `Send`, which an axum handler holding the
transaction requires. Each of these is one statement anyway.

The new cross-crate surface now says what callers must do. `read_role_catalog` returns
plaintext credentials; `create`/`rename`/`set_login`/`drop_instance_role` and
`converge_connect_grants` mutate cluster-wide state; `read_datatable_entry` reads a workspace's
raw config. All of them are superadmin-gated by their current handlers, but nothing said so at
the definition, which is where the next caller looks.

Also: the roles table reloads after a failed login toggle instead of leaving it claiming a flip
that did not land; the rename affordance is the design-system `Button`, not a raw one; and
`resolve_datatable_pg_as_caller` drops a `role` parameter no caller ever filled — browsing
resolves as the data table's default until the database manager grows a picker.

Why role passwords stay a plain `String` while the instance user's password beside them is a
`StringOrSecretRef`, asked three times across reviews: that one is a secret ref because an
operator supplies it and may want it from their own backend, while these are minted here and
never entered by anyone, so there is nothing for a ref to point at. Encrypting generated
secrets at rest is a separate change that would take the replication password with it. Now
said at the field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): give the role catalog its own row, out of reach of the config machinery

Putting it inside `custom_instance_pg_databases` was the wrong call, and it cost two ways.
The catalog serializes a generated Postgres password per role, and that row is the
operator-facing instance config, so the passwords reached `get_instance_config` and its YAML
editor — a live cluster credential in a response body, a UI field and any log of either.
Worse in the other direction: `to_settings_map` strips the catalog, so a full-row upsert of
that key writes the row back without it and the catalog is gone, while the cluster keeps every
login it described.

`custom_instance_replication_pwd` is the precedent and says exactly why — a generated secret,
written only by the server, never operator-authored, hidden so the config machinery cannot
read, rewrite or drop it. The catalog is the same thing, so it now has the same shape:
`datatable_roles`, in `HIDDEN_SETTINGS`, `PROTECTED_SETTINGS` and the agent-worker denylist.
No redaction to keep in step with three code paths, and no way for a neighbouring write to
take it out.

Two races on the same shared documents. `edit_datatable_config` read the stored data tables
outside its transaction and then wrote the whole `datatable` document, so a permissions save
committing in between was silently rolled back; it now reads under `FOR UPDATE`. And
`set_datatable_permissions` validated role ids against the catalog before opening its
transaction, so a deletion in between let it write a deleted role back — including as the
default, which every later job then fails on; it now holds the catalog lock and the settings
row across validation and write.

Completes the authorization contracts the previous commit claimed but did not finish:
`read_datatable_entry` (which it named and missed), `resolve_governing_datatable`, whose whole
job is to answer for a workspace the caller may not belong to, and
`converge_connect_grants_with`, which had not inherited its wrapper's.

Also the generic Python SDK reference: `_format_py_params` learned the bare `*` last time, but
`extract_py_functions` is a second formatter and still rendered `datatable(name, role)`, so
code written from that page passed a keyword-only argument positionally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): make the concurrency test pin the handlers, and the contracts describe what is enforced

The concurrency test reimplemented the read-modify-write inline, so deleting the lock from all
three handlers left it green — it pinned Postgres, not the code it was written for. It now
drives `create_datatable_role` twice concurrently and asserts the catalog kept both names.
Checked the way the last one should have been: removing the lock from the handler makes it
fail with "wmtest_a_… is a live cluster login the catalog forgot".

The contracts added last commit were stricter than this PR's own callers, which is worse than
none — the next reader sees a rule already broken and learns to ignore it.
`read_role_catalog` said superadmin-only while two of its four callers are open to any
workspace member, and `converge_connect_grants` said superadmin while
`set_datatable_permissions` reaches it as a workspace admin. Both were fine on substance: the
rule that actually holds is about the credential never reaching a response, log, audit record
or export, not about who may call. They now say that. `read_datatable_entry` gets the same
treatment rather than the one the earlier message claimed for it: it is the primitive every
resolution goes through, so it is deliberately open, and what must not escape is `permissions`
— it names the governing workspace's users, groups and folders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): close the last ways a role or a pointer can be left pointing at nothing

The raw settings readers hand back whatever is in the row, so moving the catalog into its own
`global_settings` key protected the config machinery and left `GET /settings/global/datatable_roles`
and the settings listing returning every live password. Both now filter that one key. The
neighbouring `custom_instance_replication_pwd` has the same shape and is not touched here: it
predates this and widening the fix to it is a decision about an operator workflow, not a
consequence of this change.

Three ways a save could leave something resolving to nothing:

A permissioned data table could be moved to a PostgreSQL resource. The block was carried across
as a server-owned field, the runtime refuses roles on a resource-backed table, so the save
succeeded and every job afterwards failed. Refused instead — turning roles off first is one step,
and it keeps discarding an access decision something somebody chose.

Renaming a governing data table left every fork pointing at the old name: the data table
disappears from their pickers and their jobs stop, with nothing in the renaming workspace to
suggest why. The rename now follows into the pointers in the same transaction.

Deleting one cannot be followed the same way, so it is reported instead — the response names what
it stranded, the way deleting a workspace does, and the fork's own error already says which
workspace is gone.

Also: `ensure_instance_db_grant_options_unchecked` claimed superadmin while the permissions
handler reaches it as a workspace admin (the same class fixed last commit, one instance missed);
the role entry kept an `instance_config_schema` derive it no longer needs; `write_role_catalog`
was the one writer of that table not stamping `updated_at`; and the concurrency test dropped its
roles only on success — a failing run is exactly the one that creates them without recording them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* refactor(datatables): put the role catalog in its own table, not in global_settings

Five findings across three rounds were all the same choice. A set of live Postgres credentials
was living in `global_settings`, which has generic read, list, write, config-export and CLI
round-trip paths that know nothing about what they carry: the passwords reached the instance
config and its YAML editor, a full-row upsert of a neighbouring key erased the catalog,
`GET /settings/global/{key}` and the settings listing returned them raw, and this round the
redaction that fixed the last two turned `wmill instance push` into something that wipes every
password — a fix breaking the assumption the previous fix made. `POST /settings/global/datatable_roles`
could also empty it outside the lock.

The approved plan offered a table or `global_settings`, so this is the other option it already
allowed rather than a new design. `datatable_role` is a table: no generic settings path can read
it, list it, export it, write it or round-trip it, so none of the five needs a guard. The
redaction, the hidden/protected/agent-denylist entries and the JSON document all go with it.

One row per role also removes the read-modify-write the concurrency work was about: two
concurrent creates are two inserts, and the unique index on `name` is what settles a collision.
The advisory lock stays for the one window rows do not cover — `CREATE ROLE` is invisible to
another transaction until commit, so without it both creates pass their `pg_roles` check.

Also from this round: rename mappings are checked against the configuration they claim to
describe, since fork pointers are rewritten from them — a caller could otherwise submit
`main -> missing` against an unchanged config and repoint every fork of `main` at a name nothing
has, and `A -> B` plus `B -> C` moved what pointed at `A` all the way to `C`. And the warning
naming forks a delete stranded reached the response but not the screen: both the data table
settings save and the workspace delete now show it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): validate a rename against the save it describes, and re-check under the locks

Three from the round, all about deciding on state that could already have moved.

A permission save resolved the data table and checked it was instance-backed before taking any
lock, then wrote under one. A config save committing in between could move the table onto a
PostgreSQL resource — recreating exactly what the transition guard refuses — or rename it, in
which case the write targeted a key that no longer existed and reported success having changed
nothing. It now re-resolves and re-checks on the locked state.

Rename validation checked that the source existed before and the target existed after, which
still accepts `main -> decoy` against a save that keeps both: every fork of `main` then follows
onto a different data table, silently, because it keeps resolving. The rule is now the actual
old-to-new key transition — a source may only survive if another rename took its name, and a
target may only pre-exist if another rename freed it. That also stops two sources sharing one
target, and it admits a swap, which the previous guard refused: `datatables` is keyed by name, so
a swap cannot be done one save at a time, and refusing it was a regression against main. The
pointer cascade now runs in two passes through a temporary name, the way the migration cascade
one layer down already handles the same shape, so `A -> B` with `B -> C` moves each pointer once
from what it named before the save.

The tenant mutators say what they are for: they write an access decision for any workspace named,
with an arbitrary mutation, and exist for the transaction that frees or renames a principal.
Editing a decision on purpose belongs in the permissions endpoint.

Carried in the same change: the stranded-fork list is a field rather than a phrase to grep out of
a success string; the pointer cascade matches with `EXISTS` instead of a `LIKE` over the whole
document, so a workspace whose pointers name something else is not rewritten to a byte-identical
value under an exclusive lock; and `InstanceDatatableRole` drops the serde derives left over from
the JSON document, one of which would emit `pwd`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): cascade on the leave route that is used, gate migrations before the admin connection, and drop a role atomically

The tenant cascade on leaving went onto `/users/leave`. The UI and the generated client call
`/workspaces/leave` — a different handler in a different crate with the same name — which
deleted the membership and left `u/<username>` in the tenant lists. Leaving and rejoining
therefore restored the access the leave was supposed to end, and a later account taking the
username would have inherited it. The regression test drives the route the client actually
calls; without the fix it fails with "leaving kept the tenant".

The migration endpoints authorized too late. `run_datatable_migrations` opened the data table's
admin connection, created `_wm_migrations` and read it before reaching the per-migration role
check — so with nothing pending, nothing was checked at all. Rollback returned before its check
when nothing was applied, and the status endpoint had none. All three now ask, before any
connection is opened, whether the caller can reach the data table as any role at all; which role
a given migration runs as is still decided per migration, and by the executor after that.

Deleting a role committed the cluster drop and the catalog row, then swept the tenant lists in
separate transactions. A sweep failing part-way left workspaces naming a role nothing can connect
as, while the retry answered `NotFound` because the catalog entry was already gone. The sweep now
runs in the same transaction, so the drop, the row and every tenant list commit together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): refuse to copy a data table that is under roles

pg_dump carries no roles and the import runs with --no-privileges, so a copied
data table arrives owned by the admin connection with no GRANT for any role.
The settings clone brings `permissions` across, so the fork's tenants pass
Windmill's check, connect as the role they were given, and are denied by
Postgres on everything: an entry that reads as configured and answers nothing.

Refuse the copy — in the import endpoint before any data moves, and in the fork
path the CLI takes. Replaying the source's owners and ACLs into the clone is
what lifts this, and is a change of its own. Dropping `permissions` from the
copy instead would be the unsafe half, since the copy holds the parent's rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): refuse the clone's database too, not only its data

A clone is two endpoints: `create_pg_database` then `import_pg_database`. Only
the second refused a data table under roles, so a fork asking to clone one
created and registered an empty `wm_fork_…` instance database and then failed —
and nothing collects it, since `drop_forked_datatable_databases` only drops
entries carrying `forked_from` and no entry names this one.

Refuse in both, so the clone stops before a database exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* nit worker error msg

* fix pg_dump stuck on version 17 on nix

* fix(datatables): refuse a malformed role annotation instead of ignoring it

`-- Role operator`, `-- role operator;` and `-- role operator -- why` all failed
the annotation parser's exact-match rule, so the query fell through to the data
table's default role and ran, silently, under a login the author did not choose.
Naming a role exists precisely to not do that.

A leading comment whose first word is `role` is now an annotation attempt: the
keyword matches case-insensitively, one trailing `;` is tolerated, and anything
else is an error naming the line. Only callers that already know the target is a
`datatable://` reference ever run this, so ordinary SQL keeps its comments.

Also bumps the dev shell's postgres client to 18 — it trailed the server the dev
database runs, which takes out every data table export, clone and fork-with-data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): refuse a malformed role query string instead of ignoring it

`?Role=analytics`, `?role=` and `?x=1&role=…` all fell through the reference
parser's exact-match rule, so the connection resolved to the data table's default
role and ran under a login the caller never asked for — the URI half of the same
trap as a malformed `-- role` annotation.

The key now matches case-insensitively, and anything else in the query string is
an error naming it; `role` is the only parameter a reference takes. Callers that
only need the entry keep a lenient `datatable_ref_name`, since they never act on
the role. The DuckDB `ATTACH` parser propagates it rather than attaching under
the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR

* fix(datatables): carry the role annotation into the row_to_json retry

The retry rebuilds its SQL from `pruneComments(code)`, so the leading comment
block never reached the second attempt — and with it the `-- role <name>` line
that decides which login the query runs as. The retry connected as the data
table's default role instead, so a query the first attempt was denied could
succeed on the second, reported as "recovered with the row_to_json fix".

Carry the leading comment block over. The retry itself is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* chore(datatables): don't mount the roles UI until the ACL editor lands

Enforcement ships first. The permissions drawer is what turns roles on, and the
catalog section is what creates them — both are only useful once there is a way
to grant a role the privileges it needs, which arrives with the ACL editor. Left
mounted they would offer a feature whose other half does not exist.

The two components are complete and reviewed; only their call sites here are
commented out, with a note pointing the follow-up PRs at them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* fix(datatables): honour `-- role: x`, and fix the DuckDB attach test

Two review findings, both real.

`attach_datatable_parses_name_and_role` never compiled: `parse_attach_datatable`
returns `Result<Option<_>>` now and one call site kept a single `unwrap`. Its
`?Role=analytics` case also asserted a refusal, contradicting the parser in the
same commit, which matches the key case-insensitively. Replaced with the cases
that are genuinely malformed, and a positive one for the cased key.

`-- role: analytics` fell through to the default role — the silent fallback the
strict parser exists to remove, for the spelling most likely to be typed. The
keyword now accepts an optional colon, attached or spaced, while a word that
merely starts with it (`rolebased`) is still not an attempt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* fix(datatables): clone a fork's pointer instead of failing after the copy

Forking a fork with cloning left an orphan database. The preflight resolves the
pointer and sees the governing entry, so both endpoints ran and filled the new
database; `apply_forked_datatable` then refused the inherited pointer and rolled
the fork back, stranding a registered `wm_fork_*` that no entry names and whose
name blocks the retry.

Refusing earlier would have been the smaller change, but forking a fork and
cloning worked before pointers existed, so it would trade an orphan for a
regression. Resolve what the pointer names and write the terminal entry the
clone needs: the whole `database` object rather than a patch of its
`resource_path`, since a pointer has none, and `reference` removed with it.

Also accepts `-- role=x` and `-- Role = x`, two more spellings that fell through
to the default role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* fix(datatables): refuse to roll back the catalog while roles exist

The down migration dropped the table and left every role behind: live Postgres
logins whose passwords only that table carried, so after a revert Windmill could
neither use, disable nor delete them, and re-applying could not recreate them
because the names were taken. Cleaning up here is not possible either — dropping
a role means reassigning what it owns in every instance database, and a
migration runs in one — so it now refuses while the catalog is non-empty and
says to delete the roles through instance settings, which does the cluster work.

Also enforces the instance-only invariant the resolved-pointer clone relies on
rather than only asserting it in a comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* refactor(datatables): settle clonability in one place, before anything is created

A clone is three stages a workspace apart — `create_pg_database`, then
`import_pg_database`, then `apply_forked_datatable` inside the fork transaction.
Only the third can roll back, and `CREATE DATABASE` is not transactional, so any
refusal that lives there strands a registered `wm_fork_*` that no entry names
and whose name blocks the retry.

That orphan has now been fixed three times, most recently reintroduced by a
guard added one commit ago. Patching each new refusal into the first endpoint is
not the fix; having two places that can refuse is. `ensure_datatable_is_clonable`
now answers every reason a copy can be refused and returns what it resolved, and
the stage that writes the entry only does the work.

Also takes an ACCESS EXCLUSIVE lock before the rollback guard counts, so a role
created concurrently cannot slip between the check and the drop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* fix(datatables): let a retried clone reclaim its own leftover database

A clone creates its target database one request before it copies into it, and
the fork that would name it is written a request after that. Any failure in
between — a pg_dump error, a bad restore, a dropped connection, the source's
roles changing mid-flow — left a registered `wm_fork_*` that no entry names,
and every retry then failed on its name. This predates data table roles.

`create_pg_database` now reclaims such a leftover before creating: only a
`wm_fork_*` database Windmill registered as a data table database and that no
data table or ducklake entry names, in any workspace, archived ones included.
The drop never terminates connections, so a clone still copying into it makes
the reclaim fail instead of being cut off. It is limited to callers who
administer the source — reaching it is not enough, since on a data table
without roles every member reaches it — and anyone else gets the refusal an
existing database always got.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Revert "fix(datatables): let a retried clone reclaim its own leftover database"

This reverts commit 7dd3275a10.

The reclaim tied the caller to the source they administer, but not to the
database it dropped. Between another workspace's import and its final fork
request, that workspace's target is full, registered, unnamed and has no open
connection, so an admin of any instance data table could name it and have it
dropped and recreated empty. The victim's fork would then commit pointing at
the empty copy. Safe reclaim needs durable clone ownership and serialization
with the request that names the database; until then the leftover stays, as it
did before this PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(datatables): record the stale clone database as a known limitation

A clone is three requests and `CREATE DATABASE` is not transactional, so a
failure after the first leaves a registered `wm_fork_*` behind, as it did
before data table roles. Accepted for this PR: it is harmless to data and goes
away once the clone is a single server-side operation.

The comment also records why the obvious fix is wrong: reclaiming the leftover
on retry, without durable clone ownership, can drop another workspace's fully
copied database between its import and its final fork request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): bounce the streams reading a data table when it is deleted

Deleting a governing data table, or the workspace that holds it, only collected
the fork pointers it stranded, for the warning. A Postgres trigger or capture
already streaming through one of those pointers kept the replication connection
it opened while the pointer still resolved, so it went on dispatching the
governing database's rows after the fork lost access — until its connection
happened to restart. The governing workspace's own streams on a deleted entry
did the same.

Both deletion paths now bounce the affected listeners inside their own
transaction, through the helper a permission change already uses, so a
listener that reconnects re-resolves the entry and finds it gone. The helper is
split so a caller can pass the (workspace, local name) pairs it already holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): keep the fork schema baseline, and bounce streams on every removal

Three fixes from review.

`edit_datatable_config` took `forked_from` wholesale from the stored entry, so
the fork schema diff's save of an advanced baseline was silently discarded and
an applied change was offered again. Whether an entry carries a clone stamp is
still carried from the store, since that is what marks its database droppable,
but the baseline inside it is now taken from the request.

The stranded-pointer warning and the stream bounce ran over the optional
`deleted_datatables` hint, which the settings-sync CLI never sends, so removing
a governing data table through `wmill` bounced nothing. Removals are now derived
from the stored configuration against the saved one.

`delete_workspace` read the pointers to bounce before its transaction, so a fork
committing a pointer during the deletion was missed. The read now happens inside
the transaction, after the workspace row is deleted: a fork's insert key-share
locks that row through its parent foreign key, so it is either seen or fails on
the missing parent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(datatables): keep Postgres triggers and data table roles apart

A replication stream reads every row of every table whatever the data table's
roles grant, and its listener checks access only when it connects. Rather than
chase every way access can change and bounce the streams each one affects, a
data table now carries one or the other:

- a Postgres trigger or capture cannot be created on, or connect to, a data
  table under roles;
- roles cannot be turned on while an enabled trigger or a live capture reads
  the data table, its own or a fork's through its pointer. The refusal names
  each one to disable.

This removes the stream bounces on roles edits and on data table and workspace
deletion, and the trigger gate that admitted admins. The fork schema baseline
fix from the same review round is kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): refuse a Postgres trigger on a data table under roles when it is saved

Creating or editing a trigger that points at a data table under roles was
accepted, and its listener then retried the refused connection every 30
seconds forever. The save is now refused, and a trigger that reaches such a
data table anyway (re-enabled, or cloned into a fork) is disabled by its
listener with the reason, as a missing replication slot is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): disable a data table role before deleting it

Deleting a role reassigns and drops what it owns in each registered
database on its own connection, and each of those passes commits as it
goes. A database failing part-way left the role enabled in the catalog and
able to log in, but already stripped in the databases reached before it.
The role is now disabled in its own commit first, so a failed delete
leaves a disabled role to retry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): serialize roles going on with a stream starting

Turning roles on looked for enabled triggers and live captures once,
without a lock anything starting a stream also took. A trigger enabled in
that window could have its listener connect before roles committed, and a
healthy listener never checks again. Both transitions now serialize on one
advisory lock: roles going on hold it exclusive while they look, and
trigger create, edit and enable, and capture setup and ping hold it shared
while they commit. Either the look sees the stream, or the listener
connects after roles are committed and refuses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): wait out live listeners, and resolve stored names containing `?`

Turning roles on counted a trigger as gone once disabled, and a capture
once its client stopped pinging, but the listener keeps its replication
connection until its next heartbeat notices. A trigger or capture whose
listener pinged in the last 15 seconds, the window a server holds a
listener for, now still counts as streaming.

Data table names could contain `?` before they were restricted, and such
entries are still stored. Splitting `?role=` off a reference misread them:
`a?b` became `a` with an unknown parameter, and the clone checks looked at
a different entry than the one copied. An entry stored under the whole
reference is now looked up first, in the Postgres executor, DuckDB ATTACH
and the clone checks. Agent workers cannot read the workspace and keep
the strict parse, which refuses such a name rather than misreading it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): warn when a settings sync strands fork pointers

A settings save reported the fork pointers left resolving to nothing only
for the names in `deleted_datatables`, which `wmill sync push` never sends.
The save now works out what it removed from the locked entries, and the
CLI prints the stranded pointers it returns.

Also correct the replication helper's contract: no role or admin check
makes a replication connection safe, so a data table under roles is
refused outright rather than gated as an admin operation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* fix(datatables): refuse a save that drops a data table's roles through an undeclared rename

A data table's roles follow its entry only through a declared rename. A
settings sync sends the whole map and never declares one, so renaming a
data table under roles there read as a delete and a new entry on the same
database: the new entry carried no roles, and every caller connected as
admin. Such a save is now refused, naming both entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* fix(datatables): no entry without roles may newly reach a database under roles

The previous guard only caught a new name replacing an entry under roles.
A whole-map save could also repoint an existing entry without roles at
that database, or another workspace could point one there, and every
caller of that entry would connect as admin. The rule is now stated on
the saved entries: one that carries no roles and newly points at an
instance database any entry under roles uses, in this workspace or
another, is refused. A declared rename carries its roles and passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* feat(datatables): move data table role catalog and resolution to the enterprise edition

Roles are an Enterprise Edition feature. The catalog, the Postgres logins,
CONNECT convergence, tenant evaluation and the role half of connection
resolution move to windmill-ee-private. Every public function keeps its path
and signature and forwards through datatable_roles_oss, which re-exports the
enterprise implementation or, without it, refuses.

Without the enterprise edition a data table under roles, or a caller naming a
role, is refused a connection rather than resolved as admin, and the reach and
admin-access checks refuse one under roles. A data table not under roles
resolves as before in every edition, and an instance database keeps the
CONNECT grants it was created with. The catalog lock, the stream lock, the
tenant cascades and the permissions stripping stay in OSS: they only restrict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* feat(datatables): move the data table permissions endpoints to the enterprise edition

The permissions read, save and usable-roles handlers move to
windmill-ee-private; the routes stay registered and, without the enterprise
edition, answer that data table roles are an Enterprise Edition feature.
ensure_governs_datatable and ensure_reaches_datatable keep their paths: the
first refuses, the second passes a data table not under roles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* feat(datatables): move the data table role catalog endpoints to the enterprise edition

The superadmin list, create, update and delete handlers move to
windmill-ee-private. The routes stay registered and, without the enterprise
edition, refuse after authentication.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* test(datatables): run the roles tests on the enterprise edition, refusals without it

Each test that exercises roles runs with private and enterprise. Two tests run
without them: every roles route answers the Enterprise refusal, and a data
table saved under roles, or a named role, is refused a connection while one
not under roles resolves as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* feat(datatables): gate the roles UI mount sites on an enterprise license

Both mount sites are still commented out; the gate travels with them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* test(datatables): run the tenant matcher test on the enterprise edition

The matcher it covers is enterprise code now, so without the enterprise
edition the test hit the stub and failed the default windmill-common run. It
runs with private and enterprise, and a counterpart without them asserts that
no tenant list covers anyone, the wildcard and a workspace admin included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb

* chore: update ee-repo-ref to a1873dbb67f2302b85ff5362f8387b48eccdb607

This commit updates the EE repository reference after PR #783 was merged in windmill-ee-private.

Previous ee-repo-ref: 5c853e2c20eca6b748415fc0d6862a6ebfb5fec4

New ee-repo-ref: a1873dbb67f2302b85ff5362f8387b48eccdb607

Automated by sync-ee-ref workflow.

* fix(datatables): refuse roles while a same-workspace alias reaches the database

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): let CE migrations connect as an explicitly named admin

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): serialize roles going on with aliases saved from other workspaces

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(datatables): note that legacy names with ? cannot be migrated

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(datatables): add an ACL editor for data table roles

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: take every pooled connection before the ACL apply locks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: refresh grant options only after the ACL apply validates its plan

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: add only missing grant options before an ACL apply, never default privileges

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: run one data table ACL apply at a time per server before it connects

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: hold the ACL connection to the database that was authorized

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: build the ACL connection from the authorized data table entry

Resolving the settings again could land on a resource with the same
database name on another server, which the later entry checks never see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: check ACL read reach against the entry it connects from

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* feat(datatables): Ducklake catalogs on the external instance cluster

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): never grant CREATEROLE to custom_instance_user on the external cluster

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(datatables): state the authorization contract of external database usage lookups

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): drop a DuckDB data table secret once its ATTACH has used it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(datatables): resolve a workspace's data tables per pointer hop, not per entry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): hold the parent's settings while a fork points at its data tables

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): refuse repointing the external cluster while it is in use, and keep verify-ca working for pg_dump

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): protect external databases pending fork cleanup, and describe Ducklake usage in the API

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(datatables): per-cluster data table role catalogs, with roles on the external instance cluster

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): write the external cluster setting under the lifecycle lock, and check fork targets are registered

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): register external fork catalogs under the lifecycle lock, and keep certificate verification in DuckDB attaches

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): keep certificate verification when DuckDB attaches an external data table

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): keep certificate verification when DuckDB attaches an external data table

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): refuse fork cleanup of an external database another workspace uses

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): refuse rolling back while external data tables are under roles, and type external_instance in the CLI

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): stop counting storage-only fork cleanup rows as uses of an external database

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): bind fork database copies to their workspace, and count every use before dropping one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): authenticate instance database setup before writing its status, and keep a fork reservation across it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): create external databases only on a cluster setup succeeded on, and document the registry reader

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): keep only the most recently used DuckDB root certificate files

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): migrate fork reservations on workspace rename, and lock the parent's data tables for the whole fork

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): take the fork data table lock once, before the external cluster's

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(datatables): describe the external instance cluster and how to run one locally

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): keep fork reservations private, drop a cleaned-up entry with its database, and serialize cleanup with settings saves

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): hold the fork lock across a fork import, and carry the reservation inside the setup write

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): check the external cluster setting on its own transaction, and gate the registry probe

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(datatables): add an ACL editor for data table roles

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: take every pooled connection before the ACL apply locks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: refresh grant options only after the ACL apply validates its plan

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: add only missing grant options before an ACL apply, never default privileges

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: run one data table ACL apply at a time per server before it connects

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: hold the ACL connection to the database that was authorized

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: build the ACL connection from the authorized data table entry

Resolving the settings again could land on a resource with the same
database name on another server, which the later entry checks never see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* fix: check ACL read reach against the entry it connects from

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb

* chore: update ee-repo-ref to 7e338e4dabf91689bfd7fb0333c6534040b17b59

This commit updates the EE repository reference after PR #787 was merged in windmill-ee-private.

Previous ee-repo-ref: 0edd40979cf36bfba59323f3f6a0811ae1369cf5

New ee-repo-ref: 7e338e4dabf91689bfd7fb0333c6534040b17b59

Automated by sync-ee-ref workflow.

* fix(datatables): keep DuckDB root certificate files in the job directory

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: drop the unused json import from the settings crate

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): drop the serde_json::json import left unused by the fork setup write

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): accept external_instance data tables in the settings form type

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): serialize fork database reservations, cleanup, setup and Ducklake saves on the database

- A fork copy is created and registered under its workspace's fork lock, and refused once the
  workspace is archived; a rename re-migrates reservations under that lock after archiving.
- Ducklake saves lock every instance database they newly name, as data table saves do.
- Instance database setup holds the database's lock until its entry is written.
- Fork import also holds the database's lock across the restore.
- Cleanup re-reads the entry under its locks before dropping anything.
- Non-superadmins no longer see fork copies reserved for workspaces they are not in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): state the authorization contract of the external cluster status and write check

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): lock the fork copy at finalization, and order cleanup's registry write after its settings row

- Fork finalization holds the copy's database lock from its availability check to the commit.
- Cleanup removes the registry entry in its own transaction, in a task of its own, instead of on a
  second connection; a rename migrates reservations after its settings rewrites, so both take the
  settings rows before the registry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): hold the fork lock across a rename's settings copy

Fork cleanup of the old id could otherwise drop a copy the renamed workspace goes on using. Also
states the authorization contract of the instance database drop helpers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): refuse moving the external cluster to another host or port while it holds databases

Every settings writer goes through the same check as removal: the login, TLS and maintenance
database may still change, the cluster may not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(datatables): clone a data table under roles with its owners and grants (#11120)

Claude-Session: https://claude.ai/code/session_01UbrtwiYNfayrmqouBJHwGV

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: open the raw app data table drawer when the workspace has none

Selecting the first data table of an empty list passed undefined to the name
check, which threw instead of opening the drawer on no data table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): offer cloning a data table under roles where its grants can be replayed

The server clones such a data table and replays the source's owners and grants,
which only the Enterprise Edition does, so the fork wizard hid both clone
options everywhere instead of on a build that cannot replay them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: name the placeholder the empty raw app data drawer renders

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): replace a job directory file at the DuckDB root certificate path instead of trusting it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: pin the enterprise refusal the role pickers read as 'not under roles'

The server's sentence and the frontend's copy of it were coupled by nothing,
so rewording either one turned every role picker on a community build into a
failed lookup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): type Ducklake catalogs on the external instance cluster in the settings form

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): read a roles answer only for the workspace it was asked in

A fork and its parent each have their own roles on a data table of the same
name, so an answer stamped with the name alone settled the role from the
workspace the editor was acting on before.

Also derive the AI table creation flag from the data replaced into the editor:
data naming no data table left the flag on from before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): keep an instance database a settings save is waiting to name

Cleanup for a database whose setup failed took the lock first, read no user,
and dropped it while a save blocked on that same lock was about to commit a
reference to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): check the workspace stamp in the default database selector too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): read what a save racing instance-database cleanup committed

A transaction blocked on the lock may still roll back, so keeping the database
for it stranded one whose name then blocks every retry: it is let through and
its outcome read instead. The waiter query also matches this database's locks
only, since pg_locks spans the cluster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): tell a waiting request apart from the workspaces using a database

Both callers render what cleanup returns as the workspaces that keep the
database, so a waiting request's pid read as one of them. Each now words that
case itself, and the give-up comment names where the kept name actually goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): take the cleanup lock on a connection the pool cannot reclaim

A session lock outlives the future holding it, so a cancellation between
taking it and releasing it handed a locked session back to the pool, where
every later settings save waits on it. Detached, the connection closes when it
is dropped and the server releases the lock with the session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): close the cleanup connection on drop instead of detaching it

Detaching released the pool permit while the session stayed alive, so
concurrent cleanups waiting on their locks could open as many connections as
they liked. Closing on drop covers the same cancellation and keeps them
counted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 179d2454a063ee818a9387a1eafdd354a217a16c

This commit updates the EE repository reference after PR #802 was merged in windmill-ee-private.

Previous ee-repo-ref: c2e43f5b5ff753d339b70e232fd17b6ffecb054d

New ee-repo-ref: 179d2454a063ee818a9387a1eafdd354a217a16c

Automated by sync-ee-ref workflow.

* chore: update ee-repo-ref to fd5b8af748f2c985b13e18d9ea30894f3bd7e9a3

This commit updates the EE repository reference after PR #798 was merged in windmill-ee-private.

Previous ee-repo-ref: 3145e422d61d580f0a82804f075285c112879da0

New ee-repo-ref: fd5b8af748f2c985b13e18d9ea30894f3bd7e9a3

Automated by sync-ee-ref workflow.

* fix(datatables): classify a fork import's target from the resolution that built its connection

A second read could see the entry flipped to a resource and skip the reservation check while the
connection already built still reached the instance cluster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): run external database lifecycle on the caller's transaction, one lock order for fork cleanup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): check a fork copy's reservation on the locked transaction's connection

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): make and drop fork copies of external data tables on the external cluster

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): refuse a fork whose external copy something already names

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): check an external fork copy's uses before removing its entry, so child fork pointers still count

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): migrate external cluster fork reservations on workspace rename

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(datatables): configure the external instance cluster and pick its databases from the UI

Instance settings gets an External Postgres tab: the cluster's connection, the setup run
with its report, and the databases Windmill created there. Data table and Ducklake settings
offer the external_instance kind, with a picker over those databases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): keep the external cluster form out of the instance settings the page bulk-saves

The form seeded its key into the settings store after the page snapshotted them, so merely
opening the tab made the page send a setting the validator refuses, failing an admin's
unrelated save. The form is local state now, and only the setup writes the key.

The database picker also treats a superadmin-only listing as authoritative: a workspace
admin, who cannot list, no longer sees a saved database as missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): app paths are validated, so a guest app cannot be renamed to one with a space

Path validation on apps landed after this test, which still expected a space to be accepted
and created its fixture on a ':' path. The scopable-path guard it exists for is kept by
planting that path on the row, which is now the only way an app can hold one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(datatables): one Managed Postgres tab, and a switch for Windmill's own database

The instance settings tab covers both substrates Windmill administers: the external cluster,
which can now be disabled once nothing sits on it, and Windmill's own database, which an
operator can turn off so the cluster is the only one a workspace may newly name. A save that
names it then refuses, as the create endpoint does.

Workspace settings name them the way an admin meets them: Postgres Resource, and Managed
instance, qualified as Internal or External only while both are on offer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(datatables): offer the external cluster in the add-data-table wizard, and give Ducklake its room back

The wizard gets the external cluster as a fourth substrate: pick a database it already holds
or name a new one, which the run creates before writing the entry, so Try again does not trip
on a database the last attempt made.

Ducklake's maintenance column moves into the settings popover next to the extra args, which
the wider catalog select had squeezed the name box out of.

A setting a component saves itself now also moves the page's baseline, or Discard would
restore what it replaced and a later save would send that stale value back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): only a database this wizard run made makes its retry idempotent

A retry skipped the create whenever the name was registered, which let a run adopt a database
another superadmin had made and share their data without the warning the existing-database
branch shows. The run tracks what it created instead, and reports it so Discard can say the
database is still there. Creating one now refreshes the registry the next run's default name
and its validation read, and that default counts the external cluster's databases rather than
the instance's.

Row ownership carries the substrate: "instance" and "external_instance" can hold the same name,
and a row repointed between them while a run probes must not read as that run's own.

Also fixes the merge leftovers in the DuckDB executor's tests, which cargo check never compiles:
the new PgDatabase field and the attach helper's job directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): shorten the picked managed kind, and say what an empty database list means

The closed select has room for the qualifier, not the whole name, so a picked managed kind
reads "Managed (Internal)". An external entry gets the tooltip its internal counterpart has,
and a database picker with nothing in it says to type a name rather than "No items found".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): keep the wizard's external-database bookkeeping optional and parked

The run deps gained a required field, which every existing caller of runSetup -- the sibling
test suite included -- does not pass. It defaults to empty instead, the parked payload carries
it across a Supabase redirect as it claims to, and a resumed run counts it as something left
behind. Two regressions pin the create: made on a first attempt, skipped only for a database
this run made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): review an external-cluster database as one, not as a Postgres resource

The review step fell through to the resource branch for the new provider, so it announced a
connection already in the workspace for a database that does not exist yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): show the server's refusal instead of an object in the toast

Saving data table settings passed the whole error to the toast, which rendered the request
and response as JSON, and the instance-database wizard replaced it with "check console".
Both surface the message the API sent, through the helper that exists for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: point at the EE commit dropping the unused import

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): define a role on the cluster its data table sits on

The permissions drawer created and listed roles without naming a cluster, so a role defined
from an external data table became a login on Windmill's own, and the refresh then replaced
the correct catalog with the internal one. The drawer passes the data table's cluster to both.

The wizard also read a database it had just created as a name collision, which blocked
returning to the failed attempt and finishing under the same name.

And the merge had left the CONNECT-grant pass and its log in both the caller and the callee.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(datatables): manage the external cluster's role catalog, and name the cluster in the copy

The roles drawer offered one catalog, so the external cluster's roles could only be reached
through a data table sitting on it — and became unreachable once the last one was gone, while
the cluster could not be unset until they were dropped. Opened from the page it now offers the
clusters the instance has; a data table's own drawer still pins its cluster, and its copy names
that cluster rather than "the instance".

A DuckDB attach also decides whether to verify certificates the way every other Postgres
connection does, so a resource carrying a root certificate is no longer downgraded to require
here alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(datatables): bind the roles list to the cluster it was read for

Switching catalogs left the previous cluster's rows on screen and applied whichever response
landed last, so a slower read could seat one cluster's logins under the other's heading while
every control acted on the wrong id — invisible where a name exists on both. The switch clears
the rows, a token discards a response the selection has moved past, and the controls stay inert
while a catalog is being read.

The drop confirmation also names the cluster whose databases it reaches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to dfe7b8b9204d21e0264fbea1c6f6eedf9e738d56

This commit updates the EE repository reference after PR #810 was merged in windmill-ee-private.

Previous ee-repo-ref: 30db1b33bda0446f5fc5dfe353fbb226d57a26d4

New ee-repo-ref: dfe7b8b9204d21e0264fbea1c6f6eedf9e738d56

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-29 14:45:47 +02:00

3119 lines
121 KiB
Rust

use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::global_settings::LICENSE_KEY_SETTING;
// ---------------------------------------------------------------------------
// Kubernetes Secret reference support
// ---------------------------------------------------------------------------
/// Reference to a key inside a Kubernetes Secret.
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct SecretKeyRef {
pub name: String,
pub key: String,
}
/// Wrapper that produces `{ "secretKeyRef": { "name": "…", "key": "…" } }` in YAML/JSON.
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct SecretKeyRefWrapper {
#[serde(rename = "secretKeyRef")]
pub secret_key_ref: SecretKeyRef,
}
/// Reference to an environment variable.
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct EnvRefWrapper {
#[serde(rename = "envRef")]
pub env_ref: String,
}
/// A string field that can be a literal value, a reference to a Kubernetes
/// Secret key, or a reference to an environment variable.
///
/// Uses `#[serde(untagged)]` so that in YAML/JSON:
///
/// - A plain string deserializes as `Literal("…")`
/// - An object `{ secretKeyRef: { name, key } }` deserializes as `SecretRef(…)`
/// - An object `{ envRef: "VAR_NAME" }` deserializes as `EnvRef(…)`
///
/// `Literal` serializes back to a plain JSON string, preserving backwards
/// compatibility with existing consumers.
#[derive(Deserialize, Serialize, Clone)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum StringOrSecretRef {
Literal(String),
SecretRef(SecretKeyRefWrapper),
EnvRef(EnvRefWrapper),
}
impl fmt::Debug for StringOrSecretRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Literal(_) => f.write_str("Literal(****)"),
Self::SecretRef(w) => f.debug_tuple("SecretRef").field(w).finish(),
Self::EnvRef(w) => f.debug_tuple("EnvRef").field(w).finish(),
}
}
}
impl StringOrSecretRef {
/// Returns the literal string value, or `None` if this is an unresolved ref.
pub fn as_literal(&self) -> Option<&str> {
match self {
Self::Literal(s) => Some(s),
Self::SecretRef(_) | Self::EnvRef(_) => None,
}
}
/// Returns the literal value, panicking if this is an unresolved ref.
/// Use only after resolution.
pub fn literal_value(&self) -> &str {
match self {
Self::Literal(s) => s,
Self::SecretRef(_) => panic!("literal_value() called on unresolved secret ref"),
Self::EnvRef(_) => panic!("literal_value() called on unresolved env ref"),
}
}
/// Returns `true` if this is a secret reference (not yet resolved).
pub fn is_secret_ref(&self) -> bool {
matches!(self, Self::SecretRef(_))
}
/// Returns the inner `SecretKeyRef` if this is a secret reference.
pub fn as_secret_ref(&self) -> Option<&SecretKeyRef> {
match self {
Self::SecretRef(w) => Some(&w.secret_key_ref),
_ => None,
}
}
/// Returns `true` if this is an environment variable reference.
pub fn is_env_ref(&self) -> bool {
matches!(self, Self::EnvRef(_))
}
/// Returns the env var name if this is an env ref.
pub fn as_env_ref(&self) -> Option<&str> {
match self {
Self::EnvRef(w) => Some(&w.env_ref),
_ => None,
}
}
}
impl Default for StringOrSecretRef {
fn default() -> Self {
Self::Literal(String::new())
}
}
impl From<String> for StringOrSecretRef {
fn from(s: String) -> Self {
Self::Literal(s)
}
}
impl From<&str> for StringOrSecretRef {
fn from(s: &str) -> Self {
Self::Literal(s.to_string())
}
}
impl PartialEq<str> for StringOrSecretRef {
fn eq(&self, other: &str) -> bool {
self.as_literal() == Some(other)
}
}
impl PartialEq<StringOrSecretRef> for StringOrSecretRef {
fn eq(&self, other: &StringOrSecretRef) -> bool {
match (self, other) {
(Self::Literal(a), Self::Literal(b)) => a == b,
(Self::SecretRef(a), Self::SecretRef(b)) => a == b,
(Self::EnvRef(a), Self::EnvRef(b)) => a == b,
_ => false,
}
}
}
impl fmt::Display for StringOrSecretRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Literal(s) => f.write_str(s),
Self::SecretRef(w) => write!(
f,
"secretKeyRef({}/{})",
w.secret_key_ref.name, w.secret_key_ref.key
),
Self::EnvRef(w) => write!(f, "envRef({})", w.env_ref),
}
}
}
// ---------------------------------------------------------------------------
// Top-level wrapper
// ---------------------------------------------------------------------------
/// Unified instance configuration combining global settings and worker configs.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct InstanceConfig {
#[serde(default)]
pub global_settings: GlobalSettings,
#[serde(default)]
pub worker_configs: BTreeMap<String, WorkerGroupConfig>,
}
// ---------------------------------------------------------------------------
// Global settings
// ---------------------------------------------------------------------------
/// Generate a schema for opaque JSON objects (used for EE-private settings).
/// Produces `{"type": "object", "nullable": true}` so the CRD passes K8s
/// structural schema validation while still accepting any JSON object.
#[cfg(feature = "instance_config_schema")]
fn opaque_json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::schema::SchemaObject {
instance_type: Some(schemars::schema::InstanceType::Object.into()),
metadata: Some(Box::default()),
extensions: {
let mut m = schemars::Map::new();
m.insert("nullable".to_string(), serde_json::Value::Bool(true));
m.insert(
"x-kubernetes-preserve-unknown-fields".to_string(),
serde_json::Value::Bool(true),
);
m
},
..Default::default()
}
.into()
}
/// Typed global settings with schema validation.
/// Known settings have explicit fields; unknown settings pass through via `extra`.
///
/// `#[serde(remote = "Self")]` turns the derived (de)serializers into inherent
/// associated functions so we can wrap them with the manual `Deserialize` impl
/// below. The wrapper preserves explicit `null` values (which the typed
/// `Option<T>` fields would otherwise silently drop on round-trip through
/// `to_settings_map`) by stashing them in `extra`, where they survive
/// re-serialization and reach `diff_global_settings` as proper deletes.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[serde(remote = "Self")]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct GlobalSettings {
// Numeric settings
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license_key: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retention_period_secs: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_default_timeout: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nsjail_tmpfs_size_mb: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nsjail_tmp_backing: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bun_install_min_release_age: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uv_exclude_newer: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_size_limit_mb: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_wait_result: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cancel_stranded_jobs_after_days: Option<i64>,
// Boolean settings
#[serde(skip_serializing_if = "Option::is_none")]
pub expose_metrics: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expose_debug_metrics: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_job_dir: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_stats: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub require_preexisting_user_for_oauth: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dev_instance: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub critical_alert_mute_ui: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub critical_alert_mute_stranded_jobs: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub monitor_logs_on_s3: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub app_workspaced_route: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_route_workspaced_route: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub no_default_maven: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_tags_per_workspace: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preview_tags_override: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_hub: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_build_binary_on_deploy: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_sessions_instance_storage_fallback: Option<bool>,
// String settings
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_build_binary_tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ws_base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub github_app_webhook_base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email_domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hub_base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hub_accessible_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hub_api_secret: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwt_secret: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scim_token: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub saml_metadata: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_events_webhook: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub openai_azure_base_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_keep_alive_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_python_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pip_index_url: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pip_extra_index_url: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ruff_config: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub npm_config_registry: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bunfig_install_scopes: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub npmrc: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nuget_config: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub maven_repos: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ruby_repos: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub powershell_repo_url: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub powershell_repo_pat: Option<StringOrSecretRef>,
// Array settings
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_tags_workspaces: Option<Vec<String>>,
// Structured settings
#[serde(skip_serializing_if = "Option::is_none")]
pub smtp_settings: Option<SmtpSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
pub indexer_settings: Option<IndexerSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
pub oauths: Option<BTreeMap<String, OAuthClientEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub otel: Option<OtelSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
pub otel_tracing_proxy: Option<OtelTracingProxySettings>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub object_store_cache_config: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub critical_error_channels: Option<Vec<CriticalErrorChannel>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub critical_alerts_on_db_oversize: Option<DbOversizeAlert>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ducklake_settings: Option<DucklakeSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_instance_pg_databases: Option<CustomInstancePgDatabases>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_instance_pg: Option<ExternalInstancePg>,
// Opaque settings (EE-private structs or no clear schema)
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub secret_backend: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub slack: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub teams: Option<serde_json::Value>,
// Workspace-specific registry overrides
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub workspace_registries: Option<
std::collections::HashMap<String, std::collections::HashMap<String, serde_json::Value>>,
>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub ai_config: Option<serde_json::Value>,
/// Catch-all for settings not yet covered by typed fields.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl Serialize for GlobalSettings {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
Self::serialize(self, serializer)
}
}
impl<'de> Deserialize<'de> for GlobalSettings {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
// Capture top-level keys explicitly set to `null` so they survive the
// `to_settings_map` round-trip — typed `Option<T>` fields all use
// `skip_serializing_if = "Option::is_none"`, which would otherwise
// silently drop the null. We stash the null entries in `extra`, which
// serializes them back out as `null` for `diff_global_settings` to
// route to deletes.
let mut value = serde_json::Value::deserialize(deserializer)?;
let null_keys: Vec<String> = value
.as_object()
.map(|m| {
m.iter()
.filter_map(|(k, v)| v.is_null().then(|| k.clone()))
.collect()
})
.unwrap_or_default();
if let Some(obj) = value.as_object_mut() {
for k in &null_keys {
obj.remove(k);
}
}
let mut s = Self::deserialize(value).map_err(serde::de::Error::custom)?;
for k in null_keys {
s.extra.insert(k, serde_json::Value::Null);
}
Ok(s)
}
}
impl GlobalSettings {
/// Convert to a flat `BTreeMap` suitable for DB sync.
pub fn to_settings_map(&self) -> BTreeMap<String, serde_json::Value> {
// Serialize the whole struct to a JSON object, which flattens all fields
// including `extra` into a single map. skip_serializing_if ensures
// None fields are omitted.
let value = serde_json::to_value(self).expect("GlobalSettings serialization cannot fail");
let mut map: BTreeMap<String, serde_json::Value> = match value {
serde_json::Value::Object(map) => map.into_iter().collect(),
_ => unreachable!(),
};
// Strip runtime-only `databases` sub-field from custom_instance_pg_databases.
// It contains setup status/logs managed by the setup endpoint, not configuration.
if let Some(pg) = map.get_mut("custom_instance_pg_databases") {
if let Some(obj) = pg.as_object_mut() {
obj.remove("databases");
}
}
map
}
}
// ---------------------------------------------------------------------------
// SMTP
// ---------------------------------------------------------------------------
/// SMTP server configuration.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct SmtpSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub smtp_host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smtp_username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smtp_password: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smtp_port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smtp_from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smtp_tls_implicit: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smtp_disable_tls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smtp_clicktracking_off: Option<bool>,
}
// ---------------------------------------------------------------------------
// Indexer
// ---------------------------------------------------------------------------
/// Full-text search indexer configuration.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct IndexerSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub writer_memory_budget: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit_job_max_batch_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit_log_max_batch_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_index_period: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_log_index_period: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_indexed_job_log_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_index_time_window_secs: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub should_clear_job_index: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub should_clear_log_index: Option<bool>,
}
// ---------------------------------------------------------------------------
// OAuth
// ---------------------------------------------------------------------------
/// Wrapper that tries to deserialize as a typed [`OAuthClient`] and falls
/// back to a raw JSON value when the stored config contains unexpected types
/// (e.g. `"true"` instead of `true` for a boolean field). This prevents the
/// entire `/api/settings/instance_config` endpoint from failing because of
/// one malformed provider entry.
#[derive(Serialize, Clone, Debug)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum OAuthClientEntry {
Typed(OAuthClient),
/// Fallback: the raw JSON value plus the error that prevented typed parsing.
Raw {
value: serde_json::Value,
/// Human-readable deserialization error (not serialized).
#[serde(skip)]
error: String,
},
}
impl<'de> serde::Deserialize<'de> for OAuthClientEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = serde_json::Value::deserialize(deserializer)?;
match serde_json::from_value::<OAuthClient>(raw.clone()) {
Ok(client) => Ok(OAuthClientEntry::Typed(client)),
Err(e) => Ok(OAuthClientEntry::Raw { value: raw, error: e.to_string() }),
}
}
}
impl OAuthClientEntry {
/// Returns a mutable reference to the inner [`OAuthClient`] if this entry
/// was successfully parsed, or `None` for raw/fallback entries.
pub fn as_typed_mut(&mut self) -> Option<&mut OAuthClient> {
match self {
OAuthClientEntry::Typed(c) => Some(c),
OAuthClientEntry::Raw { .. } => None,
}
}
}
/// OAuth client configuration for a single provider.
#[derive(Deserialize, Serialize, Clone, Debug)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct OAuthClient {
pub id: String,
pub secret: StringOrSecretRef,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_domains: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub connect_config: Option<OAuthConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub login_config: Option<OAuthConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tenant: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub share_with_workspaces: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub grant_types: Vec<String>,
/// Catch-all for provider-specific fields (e.g. Keycloak `org`, Okta `domain`/`custom`).
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
/// OAuth provider endpoint configuration.
#[derive(Deserialize, Serialize, Clone, Debug)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct OAuthConfig {
pub auth_url: String,
pub token_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_params: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_params_callback: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub req_body_auth: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub grant_types: Vec<String>,
/// Optional URL overrides for the provider's sandbox environment.
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<OAuthSandboxOverride>,
}
/// URL overrides for an OAuth provider's sandbox environment.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct OAuthSandboxOverride {
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_url: Option<String>,
}
// ---------------------------------------------------------------------------
// OpenTelemetry
// ---------------------------------------------------------------------------
/// OpenTelemetry exporter configuration.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct OtelSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub metrics_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logs_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tracing_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub otel_exporter_otlp_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub otel_exporter_otlp_headers: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub otel_exporter_otlp_protocol: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub otel_exporter_otlp_compression: Option<String>,
}
/// Per-language HTTP request tracing proxy configuration.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct OtelTracingProxySettings {
#[serde(default)]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub enabled_languages: Vec<ScriptLang>,
/// Comma-separated list of host patterns injected as NO_PROXY into jobs so their HTTP
/// clients bypass the local MITM tracing proxy. Independent of the worker's own
/// NO_PROXY env (which governs the proxy's upstream relay). Use this for clients that
/// pin their own CA (kubectl, helm, terraform providers, aws cli for EKS, etc.).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_proxy_hosts: Option<String>,
/// Comma-separated host/IP patterns for which the MITM proxy skips upstream TLS
/// verification. Unlike `no_proxy_hosts` the hosts stay traced — only the proxy's own
/// upstream certificate check is disabled.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub insecure_upstream_hosts: Option<String>,
/// Extra CA certificates (PEM bundle) added to the MITM proxy's upstream trust store,
/// on top of the system roots, so internal endpoints signed by a private CA verify.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream_ca_certs: Option<String>,
}
/// Script language identifier (for instance config use).
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum ScriptLang {
Python3,
Deno,
Go,
Bash,
Powershell,
Postgresql,
Bun,
Bunnative,
Mysql,
Bigquery,
Snowflake,
Graphql,
Nativets,
Mssql,
#[serde(rename = "oracledb")]
OracleDB,
#[serde(rename = "duckdb")]
DuckDb,
Php,
Rust,
Ansible,
#[serde(rename = "csharp")]
CSharp,
Nu,
Java,
Ruby,
Rlang,
}
// ---------------------------------------------------------------------------
// Critical error channels
// ---------------------------------------------------------------------------
/// A channel for delivering critical error alerts.
#[derive(Deserialize, Serialize, Clone, Debug)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum CriticalErrorChannel {
Email { email: String },
Slack { slack_channel: String },
Teams { teams_channel: TeamsChannel },
}
/// Microsoft Teams channel reference.
#[derive(Deserialize, Serialize, Clone, Debug)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct TeamsChannel {
pub team_id: String,
pub team_name: String,
pub channel_id: String,
pub channel_name: String,
}
// ---------------------------------------------------------------------------
// DB oversize alert
// ---------------------------------------------------------------------------
/// Configuration for critical alerts when the database exceeds a size threshold.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct DbOversizeAlert {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub value: f32,
}
// ---------------------------------------------------------------------------
// DuckLake
// ---------------------------------------------------------------------------
/// DuckLake catalog database settings.
#[derive(Deserialize, Serialize, Clone, Debug)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct DucklakeSettings {
pub ducklakes: BTreeMap<String, Ducklake>,
}
/// A single DuckLake instance configuration.
#[derive(Deserialize, Serialize, Clone, Debug)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct Ducklake {
pub catalog: DucklakeCatalog,
pub storage: DucklakeStorage,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_args: Option<String>,
}
/// DuckLake catalog backend reference.
#[derive(Deserialize, Serialize, Clone, Debug)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct DucklakeCatalog {
pub resource_type: DucklakeCatalogResourceType,
pub resource_path: String,
}
/// DuckLake storage location.
#[derive(Deserialize, Serialize, Clone, Debug)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct DucklakeStorage {
#[serde(skip_serializing_if = "Option::is_none")]
pub storage: Option<String>,
pub path: String,
}
/// The type of database backing a DuckLake catalog.
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum DucklakeCatalogResourceType {
Postgresql,
Mysql,
Instance,
}
// ---------------------------------------------------------------------------
// Custom instance PG databases
// ---------------------------------------------------------------------------
/// Custom PostgreSQL databases managed by the instance. `user_pwd` is operator-configurable
/// (resolved from a Kubernetes secretKeyRef by the EE operator); `databases` is runtime
/// setup status. The replication-role password lives in a separate hidden setting
/// (`custom_instance_replication_pwd`), never in this operator-facing config row.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct CustomInstancePgDatabases {
#[serde(skip_serializing_if = "Option::is_none")]
pub user_pwd: Option<StringOrSecretRef>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub databases: BTreeMap<String, CustomInstanceDb>,
}
/// Status of a single custom instance database.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct CustomInstanceDb {
#[serde(default)]
pub logs: CustomInstanceDbLogs,
#[serde(default)]
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
/// The workspace a member created this fork copy for. Absent when a superadmin created it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
}
/// Setup log entries for a custom instance database.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
#[serde(default)]
pub struct CustomInstanceDbLogs {
pub super_admin: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub database_credentials: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub valid_dbname: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub created_database: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub db_connect: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub grant_permissions: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub replication_user: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub replication_user_error: Option<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub user_connect: String,
}
// ---------------------------------------------------------------------------
// External instance PG cluster
// ---------------------------------------------------------------------------
/// The external Postgres cluster Windmill manages for `external_instance` data tables and Ducklake
/// catalogs. `user` logs in as the cluster's administrator: it needs `CREATEDB` and `CREATEROLE`.
/// `dbname` is only where that login connects to run cluster-wide statements.
///
/// Every field defaults rather than being required: this deserializes as part of the whole
/// instance config, and one malformed row must not make every other setting unreadable. The
/// write path and every use reject an incomplete value instead.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct ExternalInstancePg {
#[serde(default)]
pub host: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(default)]
pub user: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dbname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sslmode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub root_certificate_pem: Option<String>,
}
// ---------------------------------------------------------------------------
// Autoscaling (worker config)
// ---------------------------------------------------------------------------
/// Worker group autoscaling configuration.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct AutoscalingConfig {
#[serde(default)]
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_workers: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_workers: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cooldown_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inc_scale_num_jobs_waiting: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_scale_cooldown_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_scale_jobs_waiting: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dec_scale_occupancy_rate: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inc_scale_occupancy_rate: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inc_num_workers: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub integration: Option<AutoscalingIntegration>,
}
/// Autoscaling integration backend.
///
/// The `type` field selects the backend: `"script"`, `"dryrun"`, or `"kubernetes"`.
/// For `"script"`, `path` is required and `tag` is optional.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct AutoscalingIntegration {
#[serde(rename = "type")]
pub integration_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
}
// ---------------------------------------------------------------------------
// Worker group config
// ---------------------------------------------------------------------------
/// Worker group configuration.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct WorkerGroupConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub worker_tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority_tags: Option<BTreeMap<String, u8>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dedicated_worker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dedicated_workers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub init_bash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub periodic_script_bash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub periodic_script_interval_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub additional_python_paths: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pip_local_dependencies: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env_vars_static: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env_vars_allowlist: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_alive_workers_alert_threshold: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub autoscaling: Option<AutoscalingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_mode: Option<bool>,
/// Object store this group's dependency cache uses instead of the instance one. Same shape
/// as the instance `object_store_cache_config`.
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub object_store_cache_config: Option<serde_json::Value>,
/// Catch-all for fields not yet covered by typed fields.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
// ---------------------------------------------------------------------------
// Diff + apply logic
// ---------------------------------------------------------------------------
/// Controls whether absent keys are deleted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ApplyMode {
/// UI: only upsert changed keys, never delete absent ones.
Merge,
/// Operator: upsert changed + delete absent (except protected).
Replace,
}
/// The diff result for global settings.
#[derive(Debug, Default)]
pub struct SettingsDiff {
pub upserts: BTreeMap<String, serde_json::Value>,
pub deletes: Vec<String>,
/// Previous values for keys being updated or deleted (for logging).
pub previous_values: BTreeMap<String, serde_json::Value>,
/// Count of desired keys that matched the current value exactly.
pub unchanged_count: usize,
}
/// The diff result for worker configs.
#[derive(Debug, Default)]
pub struct ConfigsDiff {
pub upserts: BTreeMap<String, serde_json::Value>,
pub deletes: Vec<String>,
}
/// Settings that must never be deleted by the operator or bulk API.
pub const PROTECTED_SETTINGS: &[&str] = &[
"ducklake_user_pg_pwd",
"ducklake_settings",
"custom_instance_pg_databases",
"custom_instance_replication_pwd",
"external_instance_pg_state",
"uid",
"rsa_keys",
"jwt_secret",
"min_keep_alive_version",
];
/// Internal settings that are never exposed via the API or included in config exports.
/// Note: jwt_secret is intentionally NOT hidden — it is included in YAML exports so that
/// operators can set it via ConfigMap. It is protected from deletion (PROTECTED_SETTINGS)
/// and from being set to empty/null, and its value is partially redacted in log output.
pub const HIDDEN_SETTINGS: &[&str] = &[
"uid",
"min_keep_alive_version",
"automate_username_creation",
"_restart_coordination",
// Legacy ghost: worker configs live in the `config` table with a
// `worker__` prefix, not as a single blob in `global_settings`. Older
// Windmill versions stored them here and the row would be resurrected on
// every bulk InstanceSettings save via `GlobalSettings::extra`. Hiding it
// on read + rejecting it in `diff_global_settings` breaks that loop.
"worker_configs",
// Auto-generated password for the REPLICATION role used by postgres triggers.
// Server-only (written by setup/refresh via direct SQL), never operator-authored —
// hidden so the config machinery can't read, rewrite, or drop it.
"custom_instance_replication_pwd",
// Same for the passwords and database registry Windmill keeps for the external cluster.
"external_instance_pg_state",
];
/// Top-level settings whose entire value is sensitive and must be fully redacted in logs.
const SENSITIVE_SETTINGS: &[&str] = &[
"jwt_secret",
"rsa_keys",
"scim_token",
"hub_api_secret",
"license_key",
"ducklake_user_pg_pwd",
"custom_instance_replication_pwd",
"external_instance_pg_state",
"pip_index_url",
"pip_extra_index_url",
"npm_config_registry",
"bunfig_install_scopes",
"npmrc",
"maven_repos",
"ruby_repos",
"powershell_repo_pat",
"workspace_registries",
"sandbox_registry_auth",
];
/// Object-valued settings that contain sensitive sub-fields.
/// Maps a top-level key to the sub-field names that must be redacted.
const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[
("smtp_settings", &["smtp_password"]),
(
"secret_backend",
&["token", "client_secret", "secret_access_key"],
),
(
"object_store_cache_config",
&["secret_key", "serviceAccountKey", "accessKey"],
),
("custom_instance_pg_databases", &["user_pwd"]),
("external_instance_pg", &["password"]),
("github_enterprise_app", &["private_key"]),
];
fn redact_json_value(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::String(s) => serde_json::json!(redact_string(s)),
_ => serde_json::json!("[redacted]"),
}
}
fn mask_nested_sensitive(key: &str, value: &serde_json::Value) -> serde_json::Value {
for &(parent_key, sub_fields) in NESTED_SENSITIVE_FIELDS {
if key == parent_key {
if let serde_json::Value::Object(map) = value {
let mut masked = map.clone();
for &field in sub_fields {
if let Some(v) = masked.get(field) {
masked.insert(field.to_string(), redact_json_value(v));
}
}
return serde_json::Value::Object(masked);
}
}
}
// Settings that are maps-of-objects where each child has a sensitive sub-field.
const NESTED_MAP_SENSITIVE: &[(&str, &str)] = &[("oauths", "secret")];
for &(parent_key, child_field) in NESTED_MAP_SENSITIVE {
if key == parent_key {
if let serde_json::Value::Object(entries) = value {
let mut masked = entries.clone();
for (_entry_key, entry_val) in masked.iter_mut() {
if let serde_json::Value::Object(ref mut obj) = entry_val {
if let Some(v) = obj.get(child_field) {
obj.insert(child_field.to_string(), redact_json_value(v));
}
}
}
return serde_json::Value::Object(masked);
}
}
}
value.clone()
}
fn redact_string(s: &str) -> String {
let char_count = s.chars().count();
if char_count <= 6 {
"****".to_string()
} else {
let show = (char_count / 4).min(4);
let prefix: String = s.chars().take(show).collect();
let suffix: String = s.chars().skip(char_count - show).collect();
format!("{prefix}****{suffix}")
}
}
pub fn format_setting_value(key: &str, value: &serde_json::Value) -> String {
if SENSITIVE_SETTINGS.contains(&key) {
return match value {
serde_json::Value::String(s) => format!("\"{}\"", redact_string(s)),
_ => "[redacted]".to_string(),
};
}
let value = mask_nested_sensitive(key, value);
crate::utils::truncate_with_ellipsis(&value.to_string(), 197)
}
/// Extract the expiry timestamp from a license key JSON value.
///
/// License keys have the format `<client_id>.<expiry>.<signature>`.
/// Returns `None` if the value is not a string or doesn't match the format.
fn license_key_expiry(value: &serde_json::Value) -> Option<u64> {
let s = value.as_str()?;
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 3 {
return None;
}
parts[1].parse::<u64>().ok()
}
/// Returns true if two license key values share the same client ID
/// (i.e. they belong to the same customer, possibly with different expiry/signature).
fn license_keys_same_client(a: &serde_json::Value, b: &serde_json::Value) -> bool {
let (Some(a_str), Some(b_str)) = (a.as_str(), b.as_str()) else {
return false;
};
let a_parts: Vec<&str> = a_str.split('.').collect();
let b_parts: Vec<&str> = b_str.split('.').collect();
if a_parts.len() != 3 || b_parts.len() != 3 {
return false;
}
a_parts[0] == b_parts[0]
}
fn is_empty_or_null(value: &serde_json::Value) -> bool {
value.is_null() || value.as_str().map_or(false, |s| s.trim().is_empty())
}
/// Maximum retention period in seconds for CE builds (30 days).
pub const CE_MAX_RETENTION_PERIOD_SECS: i64 = 30 * 24 * 3600;
/// Clamp `retention_period_secs` to `CE_MAX_RETENTION_PERIOD_SECS` on CE builds.
/// Returns the (possibly clamped) value. On EE builds this is a no-op.
pub fn clamp_retention_period(value: serde_json::Value) -> serde_json::Value {
#[cfg(feature = "enterprise")]
{
value
}
#[cfg(not(feature = "enterprise"))]
{
if let Some(secs) = value.as_i64() {
if secs > CE_MAX_RETENTION_PERIOD_SECS {
tracing::warn!(
"Clamping retention_period_secs from {} to {} (CE max: 30 days)",
secs,
CE_MAX_RETENTION_PERIOD_SECS
);
return serde_json::json!(CE_MAX_RETENTION_PERIOD_SECS);
}
}
value
}
}
/// Compute the diff between current and desired global settings.
pub fn diff_global_settings(
current: &BTreeMap<String, serde_json::Value>,
desired: &BTreeMap<String, serde_json::Value>,
mode: ApplyMode,
) -> SettingsDiff {
let mut upserts = BTreeMap::new();
let mut deletes = Vec::new();
let mut previous_values = BTreeMap::new();
let mut unchanged_count: usize = 0;
for (key, desired_value) in desired {
// Hidden settings are server-managed and never driven by config: they are
// filtered out on read (`from_db`) and must be ignored on write too, so a client
// PUT that flattened one into `GlobalSettings::extra` can't resurrect or clobber
// the row (e.g. `worker_configs`, or the custom-instance credentials/status).
if HIDDEN_SETTINGS.contains(&key.as_str()) {
tracing::warn!(
"Ignoring hidden setting '{key}' in global_settings diff (server-managed, not configurable)"
);
continue;
}
if PROTECTED_SETTINGS.contains(&key.as_str())
&& is_empty_or_null(desired_value)
&& current.contains_key(key)
{
tracing::warn!(
"Skipping {key} update: protected setting cannot be overwritten with empty/null value"
);
continue;
}
// An empty string or explicit null means "unset": route it to deletes
// so clearing a setting via the bulk endpoint has the same effect as
// clearing it via the per-key endpoint (`set_global_setting_internal`
// already treats both `""` and `null` as deletes).
// Without this, the InstanceSettings YAML editor cannot delete keys
// (Merge mode silently preserves missing/null values), and stale `""`
// rows linger and trip the EE registry gate, producing spurious
// "requires Enterprise" warnings on every CE job.
if is_empty_or_null(desired_value) {
if let Some(existing) = current.get(key) {
previous_values.insert(key.clone(), existing.clone());
deletes.push(key.clone());
} else {
unchanged_count += 1;
}
continue;
}
let mut value = if key == "retention_period_secs" {
clamp_retention_period(desired_value.clone())
} else {
desired_value.clone()
};
// Preserve the runtime-only `databases` sub-field inside
// `custom_instance_pg_databases` so that config sync never wipes
// setup status/logs that are managed by the setup endpoint.
if key == "custom_instance_pg_databases" {
if let Some(existing) = current.get(key) {
if let Some(databases) = existing.get("databases") {
if let Some(obj) = value.as_object_mut() {
obj.entry("databases").or_insert_with(|| databases.clone());
}
}
}
}
match current.get(key) {
Some(existing) if *existing == value => {
unchanged_count += 1;
}
Some(existing) if key == LICENSE_KEY_SETTING => {
if license_keys_same_client(existing, &value) {
let current_expiry = license_key_expiry(existing).unwrap_or(0);
let desired_expiry = license_key_expiry(&value).unwrap_or(0);
if desired_expiry > current_expiry {
previous_values.insert(key.clone(), existing.clone());
upserts.insert(key.clone(), value);
} else {
tracing::info!(
"Skipping license_key update: desired expiry ({}) is not posterior to current expiry ({})",
desired_expiry,
current_expiry
);
unchanged_count += 1;
}
} else {
previous_values.insert(key.clone(), existing.clone());
upserts.insert(key.clone(), value);
}
}
Some(existing) => {
previous_values.insert(key.clone(), existing.clone());
upserts.insert(key.clone(), value);
}
None => {
upserts.insert(key.clone(), value);
}
}
}
if matches!(mode, ApplyMode::Replace) {
for key in current.keys() {
if !desired.contains_key(key)
&& !PROTECTED_SETTINGS.contains(&key.as_str())
&& !HIDDEN_SETTINGS.contains(&key.as_str())
{
previous_values.insert(key.clone(), current[key].clone());
deletes.push(key.clone());
}
}
}
SettingsDiff { upserts, deletes, previous_values, unchanged_count }
}
/// Compute the diff between current and desired worker configs.
pub fn diff_worker_configs(
current: &BTreeMap<String, serde_json::Value>,
desired: &BTreeMap<String, serde_json::Value>,
mode: ApplyMode,
) -> ConfigsDiff {
let mut upserts = BTreeMap::new();
for (key, value) in desired {
match current.get(key) {
Some(existing) if existing == value => {} // no change
_ => {
upserts.insert(key.clone(), value.clone());
}
}
}
let mut deletes = Vec::new();
if matches!(mode, ApplyMode::Replace) {
for key in current.keys() {
if !desired.contains_key(key) {
deletes.push(key.clone());
}
}
}
ConfigsDiff { upserts, deletes }
}
/// Declaratively replace the global settings, rejecting a
/// `github_app_webhook_base_url` or `http_route_default_allowed_origins` the
/// API would reject.
///
/// Every declarative writer (the `sync-config` CLI, the Kubernetes operator's
/// ConfigMap sync) MUST go through this rather than calling
/// [`apply_settings_diff`] directly, or that validation is silently skipped.
/// Note this is *not* the full equivalent of the HTTP layer's pre-write hook —
/// that also enforces rules for `automate_username_creation`,
/// `critical_alert_mute_ui` and `app_workspaced_route`, which the declarative
/// paths have never applied. A new rule added there does not appear here for free.
///
/// Registered webhooks are deliberately not re-pointed here. The receiver is set at
/// instance setup, before any GitHub App is connected, so there is normally nothing
/// registered to move; an instance that changes it later finds the affected
/// workspaces listed in instance settings and re-saves them.
///
/// AUTHORIZATION: replaces instance-wide settings and takes no authed context, so
/// callers MUST have established superadmin or equivalent system authority (the CLI
/// and the operator both run with direct instance credentials).
pub async fn sync_global_settings_declarative(
db: &sqlx::Pool<sqlx::Postgres>,
current: &BTreeMap<String, serde_json::Value>,
desired: &BTreeMap<String, serde_json::Value>,
) -> anyhow::Result<()> {
let webhook_key = crate::global_settings::GITHUB_APP_WEBHOOK_BASE_URL_SETTING;
// Non-string shapes are rejected rather than ignored: `as_str()` alone would let a
// bool/number/object through as if the key were absent, and the diff below would
// then persist it — where the HTTP path answers "must be a URL string".
match desired.get(webhook_key) {
None | Some(serde_json::Value::Null) => {}
Some(serde_json::Value::String(s)) if s.trim().is_empty() => {}
Some(serde_json::Value::String(s)) => crate::global_settings::validate_webhook_base_url(s)
.map_err(|e| anyhow::anyhow!("{webhook_key}: {e}"))?,
// Names the JSON kind rather than printing it: this is the last message on
// this path that could report submitted content, and an object or array could
// carry a secret into `sync-config` output and operator logs.
Some(other) => {
let kind = match other {
serde_json::Value::Bool(_) => "a boolean",
serde_json::Value::Number(_) => "a number",
serde_json::Value::Array(_) => "an array",
serde_json::Value::Object(_) => "an object",
_ => "a non-string value",
};
return Err(anyhow::anyhow!(
"{webhook_key} must be a URL string, got {kind}"
));
}
}
let banner_key = crate::global_settings::INSTANCE_BANNER_SETTING;
match desired.get(banner_key) {
None | Some(serde_json::Value::Null) => {}
Some(serde_json::Value::String(s)) if s.trim().is_empty() => {}
Some(banner) => crate::global_settings::validate_instance_banner(banner)
// The validator's messages name the offending field and its expected type,
// never the submitted value, so they are safe to surface here.
.map_err(|e| anyhow::anyhow!("{banner_key}: {e}"))?,
}
let accent_key = crate::global_settings::ACCENT_COLOR_SETTING;
match desired.get(accent_key) {
None | Some(serde_json::Value::Null) => {}
Some(serde_json::Value::String(s)) if s.trim().is_empty() => {}
Some(color) => crate::global_settings::validate_accent_color(color)
.map_err(|e| anyhow::anyhow!("{accent_key}: {e}"))?,
}
// An origin list that cannot be parsed is dropped at boot, leaving the
// empty default — which is no restriction at all. Rejecting it here is what
// keeps a typo in a ConfigMap from silently widening CORS instance-wide.
let origins_key = crate::global_settings::HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING;
crate::global_settings::parse_allowed_origins_setting(desired.get(origins_key))
.map_err(|e| anyhow::anyhow!("{origins_key}: {e}"))?;
let max_expiration_key = crate::global_settings::MAX_TOKEN_EXPIRATION_DAYS_SETTING;
crate::global_settings::parse_max_token_expiration_days(desired.get(max_expiration_key))
.map_err(|e| anyhow::anyhow!("{max_expiration_key}: {e}"))?;
let mut diff = diff_global_settings(current, desired, ApplyMode::Replace);
crate::external_instance_pg::write_external_instance_pg_from_diff(db, &mut diff).await?;
apply_settings_diff(db, &diff).await?;
Ok(())
}
/// Apply a settings diff to the global_settings table.
pub async fn apply_settings_diff(
db: &sqlx::Pool<sqlx::Postgres>,
diff: &SettingsDiff,
) -> anyhow::Result<()> {
for (key, value) in &diff.upserts {
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
)
.bind(key)
.bind(value)
.execute(db)
.await?;
if let Some(old_value) = diff.previous_values.get(key) {
tracing::info!(
"Updated global setting: {key} ({} -> {})",
format_setting_value(key, old_value),
format_setting_value(key, value)
);
} else {
tracing::info!(
"Created global setting: {key} (value: {})",
format_setting_value(key, value)
);
}
}
for key in &diff.deletes {
sqlx::query("DELETE FROM global_settings WHERE name = $1")
.bind(key)
.execute(db)
.await?;
if let Some(old_value) = diff.previous_values.get(key) {
tracing::info!(
"Deleted global setting: {key} (was: {})",
format_setting_value(key, old_value)
);
} else {
tracing::info!("Deleted global setting: {key}");
}
}
if diff.unchanged_count > 0 {
tracing::info!("{} global setting(s) unchanged", diff.unchanged_count);
}
Ok(())
}
/// Apply a configs diff to the config table (worker__ prefix).
pub async fn apply_configs_diff(
db: &sqlx::Pool<sqlx::Postgres>,
diff: &ConfigsDiff,
) -> anyhow::Result<()> {
for (group_name, config_value) in &diff.upserts {
let db_key = format!("worker__{group_name}");
sqlx::query(
"INSERT INTO config (name, config) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET config = EXCLUDED.config",
)
.bind(&db_key)
.bind(config_value)
.execute(db)
.await?;
tracing::info!("Synced worker config: {db_key}");
}
for group_name in &diff.deletes {
let db_key = format!("worker__{group_name}");
sqlx::query("DELETE FROM config WHERE name = $1")
.bind(&db_key)
.execute(db)
.await?;
tracing::info!("Deleted worker config: {db_key}");
}
Ok(())
}
// ---------------------------------------------------------------------------
// Env ref resolution
// ---------------------------------------------------------------------------
fn resolve_env_field(field: &mut StringOrSecretRef) -> Result<(), String> {
if let Some(var_name) = field.as_env_ref() {
let var_name = var_name.to_string();
let value = std::env::var(&var_name).map_err(|_| var_name)?;
*field = StringOrSecretRef::Literal(value);
}
Ok(())
}
fn resolve_env_option(field: &mut Option<StringOrSecretRef>) -> Result<(), String> {
if let Some(val) = field {
resolve_env_field(val)?;
}
Ok(())
}
/// Resolve all `EnvRef` fields in `GlobalSettings` by reading the
/// corresponding environment variables. Each resolved `EnvRef` is replaced
/// with a `Literal`. Returns `Err(var_name)` if a referenced env var is not set.
pub fn resolve_env_refs(settings: &mut GlobalSettings) -> Result<(), String> {
resolve_env_option(&mut settings.license_key)?;
resolve_env_option(&mut settings.hub_api_secret)?;
resolve_env_option(&mut settings.scim_token)?;
if let Some(smtp) = &mut settings.smtp_settings {
resolve_env_option(&mut smtp.smtp_password)?;
}
if let Some(oauths) = &mut settings.oauths {
for (name, entry) in oauths.iter_mut() {
match entry {
OAuthClientEntry::Typed(oauth) => {
resolve_env_field(&mut oauth.secret)?;
}
OAuthClientEntry::Raw { error, .. } => {
tracing::error!(
"OAuth entry '{name}' could not be deserialized as OAuthClient \
and was kept as raw JSON — it likely has an unexpected shape: {error}"
);
}
}
}
}
if let Some(pg) = &mut settings.custom_instance_pg_databases {
resolve_env_option(&mut pg.user_pwd)?;
}
if let Some(pg) = &mut settings.external_instance_pg {
resolve_env_option(&mut pg.password)?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Read from DB
// ---------------------------------------------------------------------------
impl InstanceConfig {
/// Read the full instance configuration from the database.
pub async fn from_db(db: &sqlx::Pool<sqlx::Postgres>) -> anyhow::Result<Self> {
// Read global_settings table → flat map → deserialize into GlobalSettings
let rows: Vec<(String, serde_json::Value)> =
sqlx::query_as("SELECT name, value FROM global_settings")
.fetch_all(db)
.await?;
let map: serde_json::Map<String, serde_json::Value> = rows
.into_iter()
.filter(|(name, _)| !HIDDEN_SETTINGS.contains(&name.as_str()))
.collect();
let global_settings: GlobalSettings =
serde_json::from_value(serde_json::Value::Object(map))?;
// Read config table (worker__ prefix) → BTreeMap<String, WorkerGroupConfig>
let config_rows: Vec<(String, serde_json::Value)> =
sqlx::query_as("SELECT name, config FROM config WHERE name LIKE 'worker__%'")
.fetch_all(db)
.await?;
let worker_configs = config_rows
.into_iter()
.map(|(name, config)| {
let group = name.strip_prefix("worker__").unwrap_or(&name).to_string();
let wc: WorkerGroupConfig = serde_json::from_value(config).unwrap_or_default();
(group, wc)
})
.collect();
Ok(Self { global_settings, worker_configs })
}
/// Sync this config to the database using `ApplyMode::Replace`.
///
/// Reads the current state, computes a diff, then applies upserts and
/// deletes for both global settings and worker configs.
pub async fn sync_to_db(&self, db: &sqlx::Pool<sqlx::Postgres>) -> anyhow::Result<()> {
let desired_settings = self.global_settings.to_settings_map();
// Current global settings
let rows: Vec<(String, serde_json::Value)> =
sqlx::query_as("SELECT name, value FROM global_settings")
.fetch_all(db)
.await?;
let current_settings: BTreeMap<String, serde_json::Value> = rows.into_iter().collect();
sync_global_settings_declarative(db, &current_settings, &desired_settings).await?;
// Worker configs
let desired_configs: BTreeMap<String, serde_json::Value> = self
.worker_configs
.iter()
.map(|(k, v)| {
(
k.clone(),
serde_json::to_value(v).expect("WorkerGroupConfig serialization cannot fail"),
)
})
.collect();
let config_rows: Vec<(String, serde_json::Value)> =
sqlx::query_as("SELECT name, config FROM config WHERE name LIKE 'worker__%'")
.fetch_all(db)
.await?;
let current_configs: BTreeMap<String, serde_json::Value> = config_rows
.into_iter()
.map(|(name, config)| {
let group = name.strip_prefix("worker__").unwrap_or(&name).to_string();
(group, config)
})
.collect();
let configs_diff =
diff_worker_configs(&current_configs, &desired_configs, ApplyMode::Replace);
apply_configs_diff(db, &configs_diff).await?;
Ok(())
}
/// Serialize to YAML with sorted global_settings keys and worker_configs
/// ordered with "default" and "native" first.
pub fn to_sorted_yaml(&self) -> Result<String, String> {
let settings_map = self.global_settings.to_settings_map();
let mut yaml = String::from("global_settings:\n");
for (key, value) in &settings_map {
let value_yaml = serde_yml::to_string(value)
.map_err(|e| format!("YAML serialization failed for {key}: {e}"))?;
write_yaml_field(&mut yaml, key, value, &value_yaml, 1);
}
if !self.worker_configs.is_empty() {
yaml.push_str("worker_configs:\n");
let priority_keys = ["default", "native"];
for &pk in &priority_keys {
if let Some(wc) = self.worker_configs.get(pk) {
let wc_value = serde_json::to_value(wc)
.map_err(|e| format!("JSON serialization failed for {pk}: {e}"))?;
let wc_yaml = serde_yml::to_string(&wc_value)
.map_err(|e| format!("YAML serialization failed for {pk}: {e}"))?;
write_yaml_field(&mut yaml, pk, &wc_value, &wc_yaml, 1);
}
}
for (key, wc) in &self.worker_configs {
if priority_keys.contains(&key.as_str()) {
continue;
}
let wc_value = serde_json::to_value(wc)
.map_err(|e| format!("JSON serialization failed for {key}: {e}"))?;
let wc_yaml = serde_yml::to_string(&wc_value)
.map_err(|e| format!("YAML serialization failed for {key}: {e}"))?;
write_yaml_field(&mut yaml, key, &wc_value, &wc_yaml, 1);
}
}
Ok(yaml)
}
}
fn write_yaml_field(
yaml: &mut String,
key: &str,
value: &serde_json::Value,
value_yaml: &str,
indent: usize,
) {
use std::fmt::Write;
let prefix = " ".repeat(indent);
let trimmed = value_yaml.trim();
let is_nested = match value {
serde_json::Value::Object(m) => !m.is_empty(),
serde_json::Value::Array(a) => !a.is_empty(),
_ => false,
};
if is_nested {
let _ = writeln!(yaml, "{prefix}{key}:");
let inner_prefix = " ".repeat(indent + 1);
for line in trimmed.lines() {
let _ = writeln!(yaml, "{inner_prefix}{line}");
}
} else {
let _ = writeln!(yaml, "{prefix}{key}: {trimmed}");
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn global_settings_to_map() {
let settings = GlobalSettings {
base_url: Some("https://example.com".to_string()),
retention_period_secs: Some(86400),
expose_metrics: Some(true),
smtp_settings: Some(SmtpSettings {
smtp_host: Some("mail.example.com".to_string()),
smtp_port: Some(587),
..Default::default()
}),
..Default::default()
};
let map = settings.to_settings_map();
assert_eq!(map["base_url"], serde_json::json!("https://example.com"));
assert_eq!(map["retention_period_secs"], serde_json::json!(86400));
assert_eq!(map["expose_metrics"], serde_json::json!(true));
assert_eq!(
map["smtp_settings"],
serde_json::json!({"smtp_host": "mail.example.com", "smtp_port": 587})
);
assert!(
!map.contains_key("license_key"),
"None fields should be omitted"
);
}
#[test]
fn global_settings_extra_fields_passthrough() {
let json = r#"{
"base_url": "https://example.com",
"some_future_setting": {"nested": true}
}"#;
let settings: GlobalSettings =
serde_json::from_str(json).expect("Should deserialize with unknown fields");
assert_eq!(settings.base_url.as_deref(), Some("https://example.com"));
assert_eq!(
settings.extra["some_future_setting"],
serde_json::json!({"nested": true})
);
let map = settings.to_settings_map();
assert!(map.contains_key("some_future_setting"));
}
#[test]
fn worker_config_extra_fields_passthrough() {
let json = r#"{
"init_bash": "echo hello",
"some_future_field": 42
}"#;
let config: WorkerGroupConfig =
serde_json::from_str(json).expect("Should deserialize with unknown fields");
assert_eq!(config.init_bash.as_deref(), Some("echo hello"));
assert_eq!(config.extra["some_future_field"], serde_json::json!(42));
}
#[test]
fn instance_config_deserializes_full_example() {
let json = r##"{
"global_settings": {
"base_url": "https://windmill.example.com",
"license_key": "my-key",
"retention_period_secs": 2592000,
"smtp_settings": {"smtp_host": "smtp.example.com", "smtp_port": 587},
"otel": {
"metrics_enabled": true,
"otel_exporter_otlp_endpoint": "http://otel:4317"
},
"critical_error_channels": [
{"email": "admin@example.com"},
{"slack_channel": "#alerts"}
],
"custom_tags": ["gpu", "high-mem"]
},
"worker_configs": {
"default": {"init_bash": "echo hello"},
"gpu": {
"dedicated_worker": "ws:f/gpu",
"autoscaling": {
"enabled": true,
"min_workers": 1,
"max_workers": 10,
"integration": {"type": "kubernetes"}
}
}
}
}"##;
let config: InstanceConfig =
serde_json::from_str(json).expect("Should deserialize full config");
assert_eq!(
config.global_settings.base_url.as_deref(),
Some("https://windmill.example.com")
);
assert_eq!(config.global_settings.retention_period_secs, Some(2592000));
assert!(config.global_settings.smtp_settings.is_some());
let smtp = config.global_settings.smtp_settings.as_ref().unwrap();
assert_eq!(smtp.smtp_host.as_deref(), Some("smtp.example.com"));
assert_eq!(smtp.smtp_port, Some(587));
let otel = config.global_settings.otel.as_ref().unwrap();
assert_eq!(otel.metrics_enabled, Some(true));
assert_eq!(
otel.otel_exporter_otlp_endpoint.as_deref(),
Some("http://otel:4317")
);
let channels = config
.global_settings
.critical_error_channels
.as_ref()
.unwrap();
assert_eq!(channels.len(), 2);
let tags = config.global_settings.custom_tags.as_ref().unwrap();
assert_eq!(tags, &["gpu", "high-mem"]);
assert_eq!(config.worker_configs.len(), 2);
assert_eq!(
config.worker_configs["default"].init_bash.as_deref(),
Some("echo hello")
);
assert_eq!(
config.worker_configs["gpu"].dedicated_worker.as_deref(),
Some("ws:f/gpu")
);
let autoscaling = config.worker_configs["gpu"].autoscaling.as_ref().unwrap();
assert!(autoscaling.enabled);
assert_eq!(autoscaling.min_workers, Some(1));
assert_eq!(autoscaling.max_workers, Some(10));
let integration = autoscaling.integration.as_ref().unwrap();
assert_eq!(integration.integration_type, "kubernetes");
}
#[test]
fn instance_config_roundtrips() {
let config = InstanceConfig {
global_settings: GlobalSettings {
base_url: Some("https://example.com".to_string()),
expose_metrics: Some(true),
..Default::default()
},
worker_configs: {
let mut m = BTreeMap::new();
m.insert(
"default".to_string(),
WorkerGroupConfig {
init_bash: Some("echo hello".to_string()),
..Default::default()
},
);
m
},
};
let json = serde_json::to_string(&config).expect("Should serialize");
let deserialized: InstanceConfig = serde_json::from_str(&json).expect("Should deserialize");
assert_eq!(
config.global_settings.base_url,
deserialized.global_settings.base_url
);
assert_eq!(
config.global_settings.expose_metrics,
deserialized.global_settings.expose_metrics
);
assert_eq!(
config.worker_configs["default"].init_bash,
deserialized.worker_configs["default"].init_bash
);
}
#[test]
fn autoscaling_script_integration_roundtrips() {
let json = r#"{
"enabled": true,
"min_workers": 2,
"max_workers": 8,
"integration": {"type": "script", "path": "f/scale", "tag": "admin"}
}"#;
let config: AutoscalingConfig =
serde_json::from_str(json).expect("Should deserialize autoscaling config");
assert!(config.enabled);
let integration = config.integration.as_ref().unwrap();
assert_eq!(integration.integration_type, "script");
assert_eq!(integration.path.as_deref(), Some("f/scale"));
assert_eq!(integration.tag.as_deref(), Some("admin"));
}
#[test]
fn ducklake_settings_roundtrips() {
let json = r#"{
"ducklakes": {
"main": {
"catalog": {"resource_type": "postgresql", "resource_path": "u/admin/pg"},
"storage": {"path": "/data/ducklake"}
}
}
}"#;
let settings: DucklakeSettings =
serde_json::from_str(json).expect("Should deserialize ducklake settings");
assert!(settings.ducklakes.contains_key("main"));
assert_eq!(
settings.ducklakes["main"].catalog.resource_type,
DucklakeCatalogResourceType::Postgresql
);
}
#[test]
fn critical_error_channels_roundtrips() {
let json = r##"[
{"email": "admin@example.com"},
{"slack_channel": "#alerts"},
{"teams_channel": {"team_id": "t1", "team_name": "T1", "channel_id": "c1", "channel_name": "C1"}}
]"##;
let channels: Vec<CriticalErrorChannel> =
serde_json::from_str(json).expect("Should deserialize channels");
assert_eq!(channels.len(), 3);
assert!(matches!(channels[0], CriticalErrorChannel::Email { .. }));
assert!(matches!(channels[1], CriticalErrorChannel::Slack { .. }));
assert!(matches!(channels[2], CriticalErrorChannel::Teams { .. }));
}
// -----------------------------------------------------------------------
// Diff tests
// -----------------------------------------------------------------------
#[test]
fn diff_global_settings_detects_changes() {
let mut current = BTreeMap::new();
current.insert("a".to_string(), serde_json::json!("old"));
current.insert("b".to_string(), serde_json::json!(1));
let mut desired = BTreeMap::new();
desired.insert("a".to_string(), serde_json::json!("new"));
desired.insert("b".to_string(), serde_json::json!(1)); // unchanged
desired.insert("c".to_string(), serde_json::json!(true)); // new
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert_eq!(diff.upserts.len(), 2); // a (changed) + c (new)
assert!(diff.upserts.contains_key("a"));
assert!(diff.upserts.contains_key("c"));
assert!(!diff.upserts.contains_key("b"));
assert!(diff.deletes.is_empty(), "Merge mode should not delete");
}
#[test]
fn diff_global_settings_replace_mode_deletes() {
let mut current = BTreeMap::new();
current.insert("keep".to_string(), serde_json::json!("v"));
current.insert("remove".to_string(), serde_json::json!("v"));
current.insert(
"ducklake_settings".to_string(),
serde_json::json!("protected"),
);
let mut desired = BTreeMap::new();
desired.insert("keep".to_string(), serde_json::json!("v"));
let diff = diff_global_settings(&current, &desired, ApplyMode::Replace);
assert!(diff.upserts.is_empty(), "No changes to upsert");
assert_eq!(diff.deletes, vec!["remove".to_string()]);
assert!(
!diff.deletes.contains(&"ducklake_settings".to_string()),
"Protected settings should not be deleted"
);
}
#[test]
fn diff_worker_configs_merge() {
let mut current = BTreeMap::new();
current.insert("default".to_string(), serde_json::json!({"a": 1}));
current.insert("old_group".to_string(), serde_json::json!({"b": 2}));
let mut desired = BTreeMap::new();
desired.insert("default".to_string(), serde_json::json!({"a": 1})); // unchanged
desired.insert("new_group".to_string(), serde_json::json!({"c": 3}));
let diff = diff_worker_configs(&current, &desired, ApplyMode::Merge);
assert_eq!(diff.upserts.len(), 1);
assert!(diff.upserts.contains_key("new_group"));
assert!(diff.deletes.is_empty());
}
#[test]
fn diff_worker_configs_replace() {
let mut current = BTreeMap::new();
current.insert("default".to_string(), serde_json::json!({"a": 1}));
current.insert("old_group".to_string(), serde_json::json!({"b": 2}));
let mut desired = BTreeMap::new();
desired.insert("default".to_string(), serde_json::json!({"a": 1}));
let diff = diff_worker_configs(&current, &desired, ApplyMode::Replace);
assert!(diff.upserts.is_empty());
assert_eq!(diff.deletes, vec!["old_group".to_string()]);
}
// -----------------------------------------------------------------------
// Diff edge cases
// -----------------------------------------------------------------------
#[test]
fn diff_global_settings_both_empty() {
let current = BTreeMap::new();
let desired = BTreeMap::new();
let diff_merge = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert!(diff_merge.upserts.is_empty());
assert!(diff_merge.deletes.is_empty());
let diff_replace = diff_global_settings(&current, &desired, ApplyMode::Replace);
assert!(diff_replace.upserts.is_empty());
assert!(diff_replace.deletes.is_empty());
}
#[test]
fn diff_global_settings_empty_current() {
let current = BTreeMap::new();
let mut desired = BTreeMap::new();
desired.insert("a".to_string(), serde_json::json!(1));
desired.insert("b".to_string(), serde_json::json!("two"));
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert_eq!(diff.upserts.len(), 2);
assert!(diff.deletes.is_empty());
}
#[test]
fn diff_global_settings_identical() {
let mut current = BTreeMap::new();
current.insert("a".to_string(), serde_json::json!(1));
current.insert("b".to_string(), serde_json::json!({"nested": true}));
let desired = current.clone();
let diff_merge = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert!(diff_merge.upserts.is_empty());
assert!(diff_merge.deletes.is_empty());
let diff_replace = diff_global_settings(&current, &desired, ApplyMode::Replace);
assert!(diff_replace.upserts.is_empty());
assert!(diff_replace.deletes.is_empty());
}
#[test]
fn diff_global_settings_replace_protects_all() {
let mut current = BTreeMap::new();
for key in PROTECTED_SETTINGS {
current.insert(key.to_string(), serde_json::json!("val"));
}
current.insert("unprotected".to_string(), serde_json::json!("val"));
let desired = BTreeMap::new();
let diff = diff_global_settings(&current, &desired, ApplyMode::Replace);
assert_eq!(diff.deletes, vec!["unprotected".to_string()]);
for key in PROTECTED_SETTINGS {
assert!(
!diff.deletes.contains(&key.to_string()),
"Protected key {key} should not be in deletes"
);
}
}
#[test]
fn diff_global_settings_merge_deletes_on_null_or_empty_string() {
// Frontend sends explicit null when the user removes a key (e.g.
// deletes `object_store_cache_config` in the YAML editor). Merge mode
// must honor this as a deletion, just like `set_global_setting_internal`.
let mut current = BTreeMap::new();
current.insert(
"object_store_cache_config".to_string(),
serde_json::json!({"type": "S3"}),
);
current.insert("base_url".to_string(), serde_json::json!("http://x"));
current.insert("hub_base_url".to_string(), serde_json::json!("http://y"));
let mut desired = BTreeMap::new();
desired.insert(
"object_store_cache_config".to_string(),
serde_json::Value::Null,
);
desired.insert("base_url".to_string(), serde_json::json!("http://x"));
desired.insert("hub_base_url".to_string(), serde_json::json!(""));
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert!(diff.upserts.is_empty(), "no upserts expected");
let mut deletes = diff.deletes.clone();
deletes.sort();
assert_eq!(
deletes,
vec![
"hub_base_url".to_string(),
"object_store_cache_config".to_string()
]
);
}
#[test]
fn diff_global_settings_merge_never_deletes() {
let mut current = BTreeMap::new();
current.insert("a".to_string(), serde_json::json!(1));
current.insert("b".to_string(), serde_json::json!(2));
current.insert("c".to_string(), serde_json::json!(3));
let mut desired = BTreeMap::new();
desired.insert("a".to_string(), serde_json::json!(1));
// b and c are absent from desired
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert!(
diff.deletes.is_empty(),
"Merge mode should never produce deletes regardless of absent keys"
);
}
#[test]
fn diff_worker_configs_both_empty() {
let current = BTreeMap::new();
let desired = BTreeMap::new();
let diff = diff_worker_configs(&current, &desired, ApplyMode::Replace);
assert!(diff.upserts.is_empty());
assert!(diff.deletes.is_empty());
}
#[test]
fn diff_worker_configs_empty_current() {
let current = BTreeMap::new();
let mut desired = BTreeMap::new();
desired.insert("default".to_string(), serde_json::json!({"init": "bash"}));
let diff = diff_worker_configs(&current, &desired, ApplyMode::Replace);
assert_eq!(diff.upserts.len(), 1);
assert!(diff.deletes.is_empty());
}
#[test]
fn diff_worker_configs_identical() {
let mut data = BTreeMap::new();
data.insert("g1".to_string(), serde_json::json!({"a": 1}));
let diff = diff_worker_configs(&data, &data.clone(), ApplyMode::Replace);
assert!(diff.upserts.is_empty());
assert!(diff.deletes.is_empty());
}
// -----------------------------------------------------------------------
// to_settings_map edge cases
// -----------------------------------------------------------------------
/// Regression test for bulk-endpoint deletion of typed settings.
///
/// A naive `#[derive(Deserialize)]` would route
/// `{"object_store_cache_config": null}` to the typed `Option<...>` field
/// as `None`, and `skip_serializing_if = "Option::is_none"` would then
/// strip it from `to_settings_map`, so `diff_global_settings` (Merge mode)
/// would never see the deletion. The manual `Deserialize` impl on
/// `GlobalSettings` captures explicit top-level nulls into `extra` so they
/// survive the round-trip and reach `diff_global_settings` as proper
/// deletes.
#[test]
fn explicit_null_on_typed_field_survives_round_trip() {
let json = serde_json::json!({
"object_store_cache_config": null,
"secret_backend": null,
"smtp_settings": null,
"base_url": "https://x",
});
let settings: GlobalSettings =
serde_json::from_value(json).expect("null should deserialize");
assert!(settings.object_store_cache_config.is_none());
assert!(settings.secret_backend.is_none());
assert!(settings.smtp_settings.is_none());
assert_eq!(settings.base_url.as_deref(), Some("https://x"));
let map = settings.to_settings_map();
assert_eq!(map["object_store_cache_config"], serde_json::Value::Null);
assert_eq!(map["secret_backend"], serde_json::Value::Null);
assert_eq!(map["smtp_settings"], serde_json::Value::Null);
assert_eq!(map["base_url"], serde_json::json!("https://x"));
}
/// Absent keys remain absent — critical so a PUT that only sets a single
/// field doesn't accidentally delete every other setting in Merge mode.
#[test]
fn absent_typed_fields_do_not_appear_in_map() {
let settings: GlobalSettings =
serde_json::from_value(serde_json::json!({"base_url": "https://x"})).unwrap();
let map = settings.to_settings_map();
assert!(!map.contains_key("object_store_cache_config"));
assert!(!map.contains_key("smtp_settings"));
assert_eq!(map.get("base_url"), Some(&serde_json::json!("https://x")));
}
/// End-to-end: deserialize → `to_settings_map` → diff in Merge mode with
/// an explicit null produces a delete (the scenario that silently failed
/// before the manual `Deserialize` impl).
#[test]
fn deserialize_then_diff_deletes_typed_null() {
let mut current = BTreeMap::new();
current.insert(
"object_store_cache_config".to_string(),
serde_json::json!({"type": "S3", "bucket": "b"}),
);
let desired: GlobalSettings =
serde_json::from_value(serde_json::json!({"object_store_cache_config": null})).unwrap();
let desired_map = desired.to_settings_map();
let diff = diff_global_settings(&current, &desired_map, ApplyMode::Merge);
assert!(diff.upserts.is_empty());
assert_eq!(diff.deletes, vec!["object_store_cache_config".to_string()]);
}
/// Nested nulls inside a typed sub-struct are not promoted to top-level
/// deletes — only the top-level key matters, mirroring the per-key API.
#[test]
fn nested_null_inside_typed_field_is_not_treated_as_top_level_null() {
let settings: GlobalSettings =
serde_json::from_value(serde_json::json!({"smtp_settings": {"smtp_host": null}}))
.unwrap();
let map = settings.to_settings_map();
assert!(
map["smtp_settings"].is_object(),
"smtp_settings should be an object, not null"
);
}
/// Unknown/extra keys with explicit null still flow through `extra` — this
/// was already correct before the manual impl; guard against regression.
#[test]
fn explicit_null_on_extra_field_survives_round_trip() {
let settings: GlobalSettings =
serde_json::from_value(serde_json::json!({"unknown_legacy_setting": null})).unwrap();
let map = settings.to_settings_map();
assert_eq!(map["unknown_legacy_setting"], serde_json::Value::Null);
}
/// Top-level non-object input must reject with a deserialize error rather
/// than silently producing defaults. Matches the previous derive behavior.
#[test]
fn non_object_top_level_input_errors() {
assert!(serde_json::from_value::<GlobalSettings>(serde_json::json!(null)).is_err());
assert!(serde_json::from_value::<GlobalSettings>(serde_json::json!("s")).is_err());
assert!(serde_json::from_value::<GlobalSettings>(serde_json::json!(42)).is_err());
assert!(serde_json::from_value::<GlobalSettings>(serde_json::json!([])).is_err());
}
#[test]
fn to_settings_map_empty_defaults() {
let settings = GlobalSettings::default();
let map = settings.to_settings_map();
assert!(
map.is_empty(),
"Default GlobalSettings should produce an empty map"
);
}
#[test]
fn to_settings_map_preserves_extra_and_typed_fields() {
let mut settings = GlobalSettings {
base_url: Some("https://example.com".to_string()),
expose_metrics: Some(true),
..Default::default()
};
settings
.extra
.insert("custom_key".to_string(), serde_json::json!("custom_val"));
let map = settings.to_settings_map();
assert_eq!(map["base_url"], serde_json::json!("https://example.com"));
assert_eq!(map["expose_metrics"], serde_json::json!(true));
assert_eq!(map["custom_key"], serde_json::json!("custom_val"));
}
#[test]
fn to_settings_map_roundtrip_through_deserialization() {
let original = GlobalSettings {
base_url: Some("https://windmill.dev".to_string()),
retention_period_secs: Some(86400),
expose_metrics: Some(false),
smtp_settings: Some(SmtpSettings {
smtp_host: Some("mail.example.com".to_string()),
smtp_port: Some(465),
smtp_tls_implicit: Some(true),
..Default::default()
}),
custom_tags: Some(vec!["gpu".to_string(), "mem".to_string()]),
..Default::default()
};
let map = original.to_settings_map();
let json_map: serde_json::Map<String, serde_json::Value> = map.into_iter().collect();
let reconstructed: GlobalSettings =
serde_json::from_value(serde_json::Value::Object(json_map))
.expect("Should deserialize from settings map");
assert_eq!(original.base_url, reconstructed.base_url);
assert_eq!(
original.retention_period_secs,
reconstructed.retention_period_secs
);
assert_eq!(original.expose_metrics, reconstructed.expose_metrics);
assert_eq!(
original.smtp_settings.as_ref().unwrap().smtp_host,
reconstructed.smtp_settings.as_ref().unwrap().smtp_host
);
assert_eq!(
original.smtp_settings.as_ref().unwrap().smtp_port,
reconstructed.smtp_settings.as_ref().unwrap().smtp_port
);
assert_eq!(original.custom_tags, reconstructed.custom_tags);
}
#[test]
fn smtp_settings_keep_clicktracking_off() {
let smtp = serde_json::json!({
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"smtp_tls_implicit": false,
"smtp_disable_tls": false,
"smtp_clicktracking_off": true,
});
let settings: GlobalSettings =
serde_json::from_value(serde_json::json!({ "smtp_settings": smtp })).unwrap();
assert_eq!(settings.to_settings_map()["smtp_settings"], smtp);
}
// -----------------------------------------------------------------------
// Serialization edge cases
// -----------------------------------------------------------------------
#[test]
fn global_settings_null_json_value_in_extra() {
let json = r#"{"unknown_null_key": null, "base_url": "https://x.com"}"#;
let settings: GlobalSettings = serde_json::from_str(json).unwrap();
assert_eq!(settings.base_url.as_deref(), Some("https://x.com"));
assert_eq!(settings.extra["unknown_null_key"], serde_json::Value::Null);
let map = settings.to_settings_map();
assert_eq!(map["unknown_null_key"], serde_json::Value::Null);
}
#[test]
fn worker_config_all_typed_fields() {
let json = r#"{
"worker_tags": ["tag1", "tag2"],
"priority_tags": {"tag1": 5},
"dedicated_worker": "ws:f/my_script",
"dedicated_workers": ["ws:f/a", "ws:f/b"],
"init_bash": "apt-get install -y curl",
"periodic_script_bash": "echo ping",
"periodic_script_interval_seconds": 300,
"cache_clear": 7,
"additional_python_paths": ["/opt/py"],
"pip_local_dependencies": ["requests"],
"env_vars_static": {"FOO": "bar"},
"env_vars_allowlist": ["PATH"],
"min_alive_workers_alert_threshold": 2,
"autoscaling": {
"enabled": true,
"min_workers": 1,
"max_workers": 5,
"integration": {"type": "dryrun"}
}
}"#;
let config: WorkerGroupConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.worker_tags.as_ref().unwrap().len(), 2);
assert_eq!(config.priority_tags.as_ref().unwrap()["tag1"], 5);
assert_eq!(config.dedicated_worker.as_deref(), Some("ws:f/my_script"));
assert_eq!(config.dedicated_workers.as_ref().unwrap().len(), 2);
assert_eq!(config.init_bash.as_deref(), Some("apt-get install -y curl"));
assert_eq!(config.periodic_script_bash.as_deref(), Some("echo ping"));
assert_eq!(config.periodic_script_interval_seconds, Some(300));
assert_eq!(config.extra["cache_clear"], serde_json::json!(7));
assert_eq!(config.additional_python_paths.as_ref().unwrap().len(), 1);
assert_eq!(config.pip_local_dependencies.as_ref().unwrap().len(), 1);
assert_eq!(config.env_vars_static.as_ref().unwrap()["FOO"], "bar");
assert_eq!(config.env_vars_allowlist.as_ref().unwrap().len(), 1);
assert_eq!(config.min_alive_workers_alert_threshold, Some(2));
let auto = config.autoscaling.as_ref().unwrap();
assert!(auto.enabled);
assert_eq!(auto.min_workers, Some(1));
assert_eq!(auto.max_workers, Some(5));
assert_eq!(
auto.integration.as_ref().unwrap().integration_type,
"dryrun"
);
}
#[test]
fn otel_tracing_proxy_languages_roundtrip() {
let json = r#"{"enabled": true, "enabled_languages": ["python3", "bun", "deno"]}"#;
let settings: OtelTracingProxySettings = serde_json::from_str(json).unwrap();
assert!(settings.enabled);
assert_eq!(settings.enabled_languages.len(), 3);
assert_eq!(settings.enabled_languages[0], ScriptLang::Python3);
assert_eq!(settings.enabled_languages[1], ScriptLang::Bun);
assert_eq!(settings.enabled_languages[2], ScriptLang::Deno);
let serialized = serde_json::to_string(&settings).unwrap();
let deserialized: OtelTracingProxySettings = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.enabled_languages.len(), 3);
}
#[test]
fn oauth_full_config_roundtrips() {
let json = r#"{
"id": "client_id_123",
"secret": "secret_456",
"allowed_domains": ["example.com", "test.com"],
"connect_config": {
"auth_url": "https://auth.example.com/authorize",
"token_url": "https://auth.example.com/token",
"userinfo_url": "https://auth.example.com/userinfo",
"scopes": ["openid", "profile"],
"extra_params": {"prompt": "consent"},
"req_body_auth": true
},
"login_config": {
"auth_url": "https://login.example.com/authorize",
"token_url": "https://login.example.com/token"
}
}"#;
let client: OAuthClient = serde_json::from_str(json).unwrap();
assert_eq!(client.id, "client_id_123");
assert_eq!(client.secret, *"secret_456");
assert_eq!(client.allowed_domains.as_ref().unwrap().len(), 2);
let cc = client.connect_config.as_ref().unwrap();
assert_eq!(cc.auth_url, "https://auth.example.com/authorize");
assert_eq!(cc.scopes.as_ref().unwrap().len(), 2);
assert_eq!(cc.extra_params.as_ref().unwrap()["prompt"], "consent");
assert_eq!(cc.req_body_auth, Some(true));
let lc = client.login_config.as_ref().unwrap();
assert_eq!(lc.token_url, "https://login.example.com/token");
assert!(lc.userinfo_url.is_none());
let roundtripped = serde_json::to_string(&client).unwrap();
let parsed: OAuthClient = serde_json::from_str(&roundtripped).unwrap();
assert_eq!(parsed.id, client.id);
}
#[test]
fn oauth_entry_valid_config_deserializes_as_typed() {
let json = r#"{
"id": "slack_id",
"secret": "slack_secret",
"connect_config": {
"auth_url": "https://slack.com/oauth/v2/authorize",
"token_url": "https://slack.com/api/oauth.v2.access",
"scopes": ["channels:read", "chat:write"],
"req_body_auth": true
}
}"#;
let entry: OAuthClientEntry = serde_json::from_str(json).unwrap();
match &entry {
OAuthClientEntry::Typed(c) => {
assert_eq!(c.id, "slack_id");
let cc = c.connect_config.as_ref().unwrap();
assert_eq!(cc.req_body_auth, Some(true));
}
OAuthClientEntry::Raw { .. } => panic!("expected Typed variant"),
}
// round-trip preserves the value
let serialized = serde_json::to_string(&entry).unwrap();
assert!(serialized.contains("slack_id"));
}
#[test]
fn oauth_entry_string_bool_falls_back_to_raw_with_error() {
// This is the real-world scenario: req_body_auth stored as "true" (string)
let json = r#"{
"id": "custom_provider",
"secret": "secret",
"connect_config": {
"auth_url": "https://example.com/auth",
"token_url": "https://example.com/token",
"req_body_auth": "true"
}
}"#;
let entry: OAuthClientEntry = serde_json::from_str(json).unwrap();
match &entry {
OAuthClientEntry::Raw { error, .. } => {
assert!(
error.contains("invalid type"),
"error should mention type mismatch, got: {error}"
);
}
OAuthClientEntry::Typed(_) => panic!("expected Raw fallback for string bool"),
}
// Serialization still round-trips the raw JSON faithfully
let serialized = serde_json::to_string(&entry).unwrap();
assert!(serialized.contains("custom_provider"));
assert!(serialized.contains(r#""req_body_auth":"true""#));
}
#[test]
fn oauth_entry_map_with_mixed_valid_and_invalid() {
// Simulates what InstanceConfig.oauths looks like when one provider is
// well-formed and another has a string-typed boolean.
let json = r#"{
"google": {
"id": "google_id",
"secret": "google_secret",
"connect_config": {
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token"
}
},
"broken": {
"id": "broken_id",
"secret": "broken_secret",
"connect_config": {
"auth_url": "https://example.com/auth",
"token_url": "https://example.com/token",
"req_body_auth": "false"
}
}
}"#;
let map: BTreeMap<String, OAuthClientEntry> = serde_json::from_str(json).unwrap();
assert!(matches!(map["google"], OAuthClientEntry::Typed(_)));
assert!(matches!(map["broken"], OAuthClientEntry::Raw { .. }));
}
#[test]
fn server_generated_db_passwords_are_isolated_from_config() {
// These hold passwords the server generates: written by setup/refresh via direct SQL,
// never operator-authored. They must stay out of the declarative config surface
// (hidden on read) and be undeletable, so config sync can't read, rewrite, or drop them.
for key in [
"custom_instance_replication_pwd",
"external_instance_pg_state",
] {
assert!(HIDDEN_SETTINGS.contains(&key), "{key}");
assert!(PROTECTED_SETTINGS.contains(&key), "{key}");
assert!(SENSITIVE_SETTINGS.contains(&key), "{key}");
// A stray desired value (e.g. flattened into `extra`) is ignored, not upserted.
let mut desired = BTreeMap::new();
desired.insert(key.to_string(), serde_json::json!("attacker-set"));
let diff = diff_global_settings(&BTreeMap::new(), &desired, ApplyMode::Merge);
assert!(diff.upserts.is_empty(), "{key} must not be upserted");
// A current value is never deleted by a Replace that omits it.
let mut current = BTreeMap::new();
current.insert(key.to_string(), serde_json::json!("live"));
let diff = diff_global_settings(&current, &BTreeMap::new(), ApplyMode::Replace);
assert!(
!diff.deletes.contains(&key.to_string()),
"{key} must not be deleted"
);
}
}
#[test]
fn custom_instance_pg_databases_roundtrips_and_redacts_user_pwd() {
// user_pwd stays operator-configurable (EE secretKeyRef); databases is runtime status.
let json = r#"{
"user_pwd": "secret123",
"databases": { "mydb": { "success": true, "tag": "production" } }
}"#;
let pg: CustomInstancePgDatabases = serde_json::from_str(json).unwrap();
assert_eq!(
pg.user_pwd.as_ref().and_then(|v| v.as_literal()),
Some("secret123")
);
assert!(pg.databases["mydb"].success);
let out = format_setting_value(
"custom_instance_pg_databases",
&serde_json::json!({ "user_pwd": "user-plaintext-password" }),
);
assert!(
!out.contains("user-plaintext-password"),
"user_pwd leaked: {out}"
);
}
#[test]
fn db_oversize_alert_defaults() {
let alert: DbOversizeAlert = serde_json::from_str("{}").unwrap();
assert!(!alert.enabled);
assert_eq!(alert.value, 0.0);
let alert_set: DbOversizeAlert =
serde_json::from_str(r#"{"enabled": true, "value": 50.5}"#).unwrap();
assert!(alert_set.enabled);
assert!((alert_set.value - 50.5).abs() < f32::EPSILON);
}
#[test]
fn instance_config_empty_roundtrips() {
let config = InstanceConfig::default();
let json = serde_json::to_string(&config).unwrap();
let parsed: InstanceConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.global_settings.base_url.is_none());
assert!(parsed.worker_configs.is_empty());
}
#[test]
fn diff_value_type_change_detected() {
let mut current = BTreeMap::new();
current.insert("key".to_string(), serde_json::json!("string_value"));
let mut desired = BTreeMap::new();
desired.insert("key".to_string(), serde_json::json!(42));
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert_eq!(diff.upserts.len(), 1);
assert_eq!(diff.upserts["key"], serde_json::json!(42));
}
#[test]
fn diff_nested_json_change_detected() {
let mut current = BTreeMap::new();
current.insert(
"smtp".to_string(),
serde_json::json!({"host": "a.com", "port": 25}),
);
let mut desired = BTreeMap::new();
desired.insert(
"smtp".to_string(),
serde_json::json!({"host": "a.com", "port": 587}),
);
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert_eq!(diff.upserts.len(), 1);
assert_eq!(diff.upserts["smtp"]["port"], 587);
}
// -----------------------------------------------------------------------
// StringOrSecretRef tests
// -----------------------------------------------------------------------
#[test]
fn string_or_secret_ref_deserialize_literal() {
let v: StringOrSecretRef = serde_json::from_str(r#""hello""#).unwrap();
assert_eq!(v.as_literal(), Some("hello"));
assert!(!v.is_secret_ref());
}
#[test]
fn string_or_secret_ref_deserialize_secret_ref() {
let json = r#"{"secretKeyRef": {"name": "my-secret", "key": "password"}}"#;
let v: StringOrSecretRef = serde_json::from_str(json).unwrap();
assert!(v.is_secret_ref());
assert!(v.as_literal().is_none());
let r = v.as_secret_ref().unwrap();
assert_eq!(r.name, "my-secret");
assert_eq!(r.key, "password");
}
#[test]
fn string_or_secret_ref_serialize_literal() {
let v = StringOrSecretRef::Literal("plain".to_string());
let json = serde_json::to_value(&v).unwrap();
assert_eq!(json, serde_json::json!("plain"));
}
#[test]
fn string_or_secret_ref_serialize_secret_ref() {
let v = StringOrSecretRef::SecretRef(SecretKeyRefWrapper {
secret_key_ref: SecretKeyRef { name: "s".to_string(), key: "k".to_string() },
});
let json = serde_json::to_value(&v).unwrap();
assert_eq!(
json,
serde_json::json!({"secretKeyRef": {"name": "s", "key": "k"}})
);
}
#[test]
fn string_or_secret_ref_from_impls() {
let v: StringOrSecretRef = "hello".into();
assert_eq!(v, *"hello");
let v: StringOrSecretRef = String::from("world").into();
assert_eq!(v, *"world");
}
#[test]
fn string_or_secret_ref_debug_masks_literal() {
let v = StringOrSecretRef::Literal("super-secret-value".to_string());
let debug = format!("{v:?}");
assert_eq!(debug, "Literal(****)");
assert!(!debug.contains("super-secret-value"));
}
#[test]
fn format_setting_value_redacts_oauth_secrets() {
let val = serde_json::json!({
"google": {"id": "client-id", "secret": "my-super-secret-12345"}
});
let formatted = format_setting_value("oauths", &val);
assert!(!formatted.contains("my-super-secret-12345"));
assert!(formatted.contains("client-id"));
assert!(formatted.contains("****"));
}
#[test]
fn format_setting_value_redacts_nested_credentials() {
let val = serde_json::json!({
"type": "Azure",
"accountName": "acct",
"containerName": "c",
"accessKey": "azure-storage-account-key-12345"
});
let formatted = format_setting_value("object_store_cache_config", &val);
assert!(!formatted.contains("azure-storage-account-key-12345"));
assert!(formatted.contains("acct"));
let val = serde_json::json!({
"app_id": 1,
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEsecretbody\n-----END RSA PRIVATE KEY-----"
});
let formatted = format_setting_value("github_enterprise_app", &val);
assert!(!formatted.contains("MIIEsecretbody"));
}
#[test]
#[should_panic(expected = "literal_value() called on unresolved secret ref")]
fn string_or_secret_ref_literal_value_panics_on_ref() {
let v = StringOrSecretRef::SecretRef(SecretKeyRefWrapper {
secret_key_ref: SecretKeyRef { name: "s".to_string(), key: "k".to_string() },
});
let _ = v.literal_value();
}
#[test]
fn global_settings_with_mixed_literal_and_ref() {
let json = r#"{
"license_key": {"secretKeyRef": {"name": "wm-secrets", "key": "license"}},
"hub_api_secret": "plain-secret",
"base_url": "https://example.com"
}"#;
let gs: GlobalSettings = serde_json::from_str(json).unwrap();
assert!(gs.license_key.as_ref().unwrap().is_secret_ref());
assert_eq!(
gs.hub_api_secret.as_ref().and_then(|v| v.as_literal()),
Some("plain-secret")
);
assert_eq!(gs.base_url.as_deref(), Some("https://example.com"));
}
#[test]
fn string_or_secret_ref_backward_compat_plain_string() {
let json = r#"{"license_key": "my-key", "scim_token": "tok"}"#;
let gs: GlobalSettings = serde_json::from_str(json).unwrap();
assert_eq!(
gs.license_key.as_ref().and_then(|v| v.as_literal()),
Some("my-key")
);
assert_eq!(
gs.scim_token.as_ref().and_then(|v| v.as_literal()),
Some("tok")
);
let roundtripped = serde_json::to_string(&gs).unwrap();
let gs2: GlobalSettings = serde_json::from_str(&roundtripped).unwrap();
assert_eq!(
gs2.license_key.as_ref().and_then(|v| v.as_literal()),
Some("my-key")
);
}
// -----------------------------------------------------------------------
// EnvRef tests
// -----------------------------------------------------------------------
#[test]
fn env_ref_deserialize() {
let json = r#"{"envRef": "MY_LICENSE_KEY"}"#;
let v: StringOrSecretRef = serde_json::from_str(json).unwrap();
assert!(v.is_env_ref());
assert!(!v.is_secret_ref());
assert!(v.as_literal().is_none());
assert_eq!(v.as_env_ref(), Some("MY_LICENSE_KEY"));
}
#[test]
fn env_ref_serialize() {
let v = StringOrSecretRef::EnvRef(EnvRefWrapper { env_ref: "MY_VAR".to_string() });
let json = serde_json::to_value(&v).unwrap();
assert_eq!(json, serde_json::json!({"envRef": "MY_VAR"}));
}
#[test]
fn env_ref_display() {
let v = StringOrSecretRef::EnvRef(EnvRefWrapper { env_ref: "FOO".to_string() });
assert_eq!(format!("{v}"), "envRef(FOO)");
}
#[test]
#[should_panic(expected = "literal_value() called on unresolved env ref")]
fn env_ref_literal_value_panics() {
let v = StringOrSecretRef::EnvRef(EnvRefWrapper { env_ref: "X".to_string() });
let _ = v.literal_value();
}
#[test]
fn env_ref_equality() {
let a = StringOrSecretRef::EnvRef(EnvRefWrapper { env_ref: "X".to_string() });
let b = StringOrSecretRef::EnvRef(EnvRefWrapper { env_ref: "X".to_string() });
let c = StringOrSecretRef::Literal("X".to_string());
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn global_settings_with_env_ref() {
let json = r#"{
"license_key": {"envRef": "WM_LICENSE_KEY"},
"hub_api_secret": "plain",
"base_url": "https://example.com"
}"#;
let gs: GlobalSettings = serde_json::from_str(json).unwrap();
assert!(gs.license_key.as_ref().unwrap().is_env_ref());
assert_eq!(
gs.license_key.as_ref().unwrap().as_env_ref(),
Some("WM_LICENSE_KEY")
);
assert_eq!(
gs.hub_api_secret.as_ref().and_then(|v| v.as_literal()),
Some("plain")
);
}
#[test]
fn resolve_env_refs_replaces_env_ref() {
// Set a test env var
unsafe { std::env::set_var("__WM_TEST_LICENSE", "resolved-value") };
let mut gs = GlobalSettings {
license_key: Some(StringOrSecretRef::EnvRef(EnvRefWrapper {
env_ref: "__WM_TEST_LICENSE".to_string(),
})),
hub_api_secret: Some(StringOrSecretRef::Literal("plain".to_string())),
..Default::default()
};
resolve_env_refs(&mut gs).unwrap();
assert_eq!(
gs.license_key.as_ref().and_then(|v| v.as_literal()),
Some("resolved-value")
);
// Literal fields are unchanged
assert_eq!(
gs.hub_api_secret.as_ref().and_then(|v| v.as_literal()),
Some("plain")
);
unsafe { std::env::remove_var("__WM_TEST_LICENSE") };
}
#[test]
fn resolve_env_refs_errors_on_missing_var() {
let mut gs = GlobalSettings {
license_key: Some(StringOrSecretRef::EnvRef(EnvRefWrapper {
env_ref: "__WM_TEST_NONEXISTENT_12345".to_string(),
})),
..Default::default()
};
let err = resolve_env_refs(&mut gs).unwrap_err();
assert_eq!(err, "__WM_TEST_NONEXISTENT_12345");
}
#[test]
fn resolve_env_refs_handles_smtp_and_oauth() {
unsafe {
std::env::set_var("__WM_TEST_SMTP_PWD", "smtp-secret");
std::env::set_var("__WM_TEST_OAUTH_SECRET", "oauth-secret");
}
let mut gs = GlobalSettings {
smtp_settings: Some(SmtpSettings {
smtp_password: Some(StringOrSecretRef::EnvRef(EnvRefWrapper {
env_ref: "__WM_TEST_SMTP_PWD".to_string(),
})),
..Default::default()
}),
oauths: Some({
let mut m = BTreeMap::new();
m.insert(
"google".to_string(),
OAuthClientEntry::Typed(OAuthClient {
id: "id".to_string(),
secret: StringOrSecretRef::EnvRef(EnvRefWrapper {
env_ref: "__WM_TEST_OAUTH_SECRET".to_string(),
}),
display_name: None,
allowed_domains: None,
connect_config: None,
login_config: None,
tenant: None,
share_with_workspaces: None,
grant_types: vec![],
extra: BTreeMap::new(),
}),
);
m
}),
..Default::default()
};
resolve_env_refs(&mut gs).unwrap();
assert_eq!(
gs.smtp_settings
.as_ref()
.unwrap()
.smtp_password
.as_ref()
.and_then(|v| v.as_literal()),
Some("smtp-secret")
);
match &gs.oauths.as_ref().unwrap()["google"] {
OAuthClientEntry::Typed(c) => {
assert_eq!(c.secret.as_literal(), Some("oauth-secret"));
}
OAuthClientEntry::Raw { .. } => panic!("expected Typed variant"),
}
unsafe {
std::env::remove_var("__WM_TEST_SMTP_PWD");
std::env::remove_var("__WM_TEST_OAUTH_SECRET");
}
}
#[test]
fn mixed_literal_secret_ref_env_ref() {
let json = r#"{
"license_key": {"envRef": "MY_LIC"},
"hub_api_secret": {"secretKeyRef": {"name": "s", "key": "k"}},
"scim_token": "plain-token"
}"#;
let gs: GlobalSettings = serde_json::from_str(json).unwrap();
assert!(gs.license_key.as_ref().unwrap().is_env_ref());
assert!(gs.hub_api_secret.as_ref().unwrap().is_secret_ref());
assert_eq!(
gs.scim_token.as_ref().and_then(|v| v.as_literal()),
Some("plain-token")
);
}
// -----------------------------------------------------------------------
// License key expiry diff tests
// -----------------------------------------------------------------------
#[test]
fn diff_license_key_skips_older_expiry() {
let mut current = BTreeMap::new();
current.insert(
"license_key".to_string(),
serde_json::json!("client1.2000000000.sig123"),
);
let mut desired = BTreeMap::new();
desired.insert(
"license_key".to_string(),
serde_json::json!("client1.1000000000.sig123"),
);
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert!(
diff.upserts.is_empty(),
"Should not update license_key when desired expiry is older"
);
}
#[test]
fn diff_license_key_skips_equal_expiry_different_sig() {
let mut current = BTreeMap::new();
current.insert(
"license_key".to_string(),
serde_json::json!("client1.2000000000.sig_a"),
);
let mut desired = BTreeMap::new();
desired.insert(
"license_key".to_string(),
serde_json::json!("client1.2000000000.sig_b"),
);
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert!(
diff.upserts.is_empty(),
"Same client and equal expiry should not update even with different signature"
);
}
#[test]
fn diff_license_key_updates_newer_expiry() {
let mut current = BTreeMap::new();
current.insert(
"license_key".to_string(),
serde_json::json!("client1.1000000000.sig123"),
);
let mut desired = BTreeMap::new();
desired.insert(
"license_key".to_string(),
serde_json::json!("client1.2000000000.sig123"),
);
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert_eq!(diff.upserts.len(), 1);
assert_eq!(
diff.upserts["license_key"],
serde_json::json!("client1.2000000000.sig123")
);
}
#[test]
fn diff_license_key_updates_newer_expiry_different_sig() {
let mut current = BTreeMap::new();
current.insert(
"license_key".to_string(),
serde_json::json!("client1.1000000000.sig_old"),
);
let mut desired = BTreeMap::new();
desired.insert(
"license_key".to_string(),
serde_json::json!("client1.2000000000.sig_new"),
);
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert_eq!(
diff.upserts.len(),
1,
"Same client with newer expiry should update even with different signature"
);
}
#[test]
fn diff_license_key_skips_older_expiry_different_sig() {
let mut current = BTreeMap::new();
current.insert(
"license_key".to_string(),
serde_json::json!("client1.2000000000.sig_new"),
);
let mut desired = BTreeMap::new();
desired.insert(
"license_key".to_string(),
serde_json::json!("client1.1000000000.sig_old"),
);
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert!(
diff.upserts.is_empty(),
"Same client with older expiry should not update even with different signature"
);
}
#[test]
fn diff_license_key_updates_different_client() {
let mut current = BTreeMap::new();
current.insert(
"license_key".to_string(),
serde_json::json!("client1.2000000000.sig123"),
);
let mut desired = BTreeMap::new();
desired.insert(
"license_key".to_string(),
serde_json::json!("client2.1000000000.sig456"),
);
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert_eq!(
diff.upserts.len(),
1,
"Different client ID means different key, should always update"
);
}
#[test]
fn diff_license_key_non_string_always_updates() {
let mut current = BTreeMap::new();
current.insert(
"license_key".to_string(),
serde_json::json!({"envRef": "LIC_KEY"}),
);
let mut desired = BTreeMap::new();
desired.insert(
"license_key".to_string(),
serde_json::json!({"envRef": "NEW_LIC_KEY"}),
);
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert_eq!(
diff.upserts.len(),
1,
"Non-string license keys should always be updated when different"
);
}
}