dbctl 0.5.2__tar.gz → 0.5.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {dbctl-0.5.2 → dbctl-0.5.3}/.dbctl/operations.yaml +15 -0
  2. {dbctl-0.5.2 → dbctl-0.5.3}/CHANGELOG.md +40 -0
  3. {dbctl-0.5.2 → dbctl-0.5.3}/PKG-INFO +88 -18
  4. {dbctl-0.5.2 → dbctl-0.5.3}/README.md +87 -17
  5. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/cli.py +59 -6
  6. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/execute.py +25 -2
  7. {dbctl-0.5.2 → dbctl-0.5.3}/docs/connections.md +27 -4
  8. {dbctl-0.5.2 → dbctl-0.5.3}/docs/operations.md +31 -0
  9. {dbctl-0.5.2 → dbctl-0.5.3}/docs/tutorial.md +9 -14
  10. {dbctl-0.5.2 → dbctl-0.5.3}/pyproject.toml +1 -1
  11. dbctl-0.5.3/tests/test_regressions.py +210 -0
  12. {dbctl-0.5.2 → dbctl-0.5.3}/tests/test_smoke.py +32 -0
  13. {dbctl-0.5.2 → dbctl-0.5.3}/uv.lock +1 -1
  14. dbctl-0.5.2/tests/test_regressions.py +0 -80
  15. {dbctl-0.5.2 → dbctl-0.5.3}/.dbctl/connections.yaml +0 -0
  16. {dbctl-0.5.2 → dbctl-0.5.3}/.github/workflows/ci.yml +0 -0
  17. {dbctl-0.5.2 → dbctl-0.5.3}/.github-local/ci.yml +0 -0
  18. {dbctl-0.5.2 → dbctl-0.5.3}/.gitignore +0 -0
  19. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/__init__.py +0 -0
  20. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/__main__.py +0 -0
  21. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/audit.py +0 -0
  22. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/config.py +0 -0
  23. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/connections.py +0 -0
  24. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/db.py +0 -0
  25. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/init.py +0 -0
  26. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/multi.py +0 -0
  27. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/operations.py +0 -0
  28. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/reports.py +0 -0
  29. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/runtime.py +0 -0
  30. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/tunnels/__init__.py +0 -0
  31. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/tunnels/base.py +0 -0
  32. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/tunnels/direct.py +0 -0
  33. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/tunnels/k8s.py +0 -0
  34. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/tunnels/ssh.py +0 -0
  35. {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/tunnels/ssm.py +0 -0
  36. {dbctl-0.5.2 → dbctl-0.5.3}/docker-compose.yml +0 -0
  37. {dbctl-0.5.2 → dbctl-0.5.3}/docs/ACTION_OUTPUT.md +0 -0
  38. {dbctl-0.5.2 → dbctl-0.5.3}/docs/DESIGN.md +0 -0
  39. {dbctl-0.5.2 → dbctl-0.5.3}/docs/SESSION_STATE.md +0 -0
  40. {dbctl-0.5.2 → dbctl-0.5.3}/seed/mssql.sql +0 -0
  41. {dbctl-0.5.2 → dbctl-0.5.3}/seed/mysql.sql +0 -0
  42. {dbctl-0.5.2 → dbctl-0.5.3}/seed/postgres.sql +0 -0
  43. {dbctl-0.5.2 → dbctl-0.5.3}/tests/test_bastion_tags.py +0 -0
  44. {dbctl-0.5.2 → dbctl-0.5.3}/tests/test_connections_loader.py +0 -0
  45. {dbctl-0.5.2 → dbctl-0.5.3}/tests/test_k8s_tunnel.py +0 -0
@@ -21,6 +21,21 @@ operations:
21
21
  type = EXCLUDED.type,
22
22
  updated_at = NOW()
23
23
 
24
+ increase-quota:
25
+ description: "Increase a user's daily and yearly quota by a percentage"
26
+ scope: single
27
+ mode: execute
28
+ confirm: true
29
+ parameters:
30
+ - { name: name, type: string, required: true, position: 1, description: "User to bump" }
31
+ - { name: pct, type: float, required: true, position: 2, description: "Percentage increase (e.g. 10 = +10%)" }
32
+ sql: |
33
+ UPDATE users
34
+ SET quota_daily = (quota_daily * (1 + $pct / 100.0))::integer,
35
+ quota_yearly = (quota_yearly * (1 + $pct / 100.0))::integer,
36
+ updated_at = NOW()
37
+ WHERE name = $name
38
+
24
39
  list-users:
25
40
  description: "List users (top N)"
26
41
  scope: single
@@ -5,6 +5,46 @@ All notable changes to this project will be documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.3] — 2026-08-01
9
+
10
+ ### Fixed
11
+
12
+ - **`$name::type` casts broke psycopg** (the `::type` suffix collides with
13
+ the bind-param parser). `to_bindparams` now rewrites the Postgres cast
14
+ idiom to the SQL-standard `CAST(:name AS type)` form, so the same
15
+ operation YAML works cross-dialect. Parenthesised precision
16
+ (`$x::numeric(10, 2)`) and schema-qualified types (`$x::pg.text`) are
17
+ preserved. The bundled `report-logs` operation (which uses
18
+ `$since::timestamp`) now runs instead of failing with
19
+ `syntax error at or near ":"`.
20
+ - **`dbctl connections show <alias>`** reported "unknown connection" for a
21
+ registered alias (e.g. `postgres`) because `connections_show` did a direct
22
+ dict lookup instead of going through the alias-aware `resolve()`. Now
23
+ resolves aliases to the canonical connection and dumps it under the
24
+ canonical name.
25
+ - **`dbctl diff <op> <conn> <unknown>`** crashed with a raw
26
+ `UnknownConnectionError` traceback. The multi-op callback now catches the
27
+ `KeyError` and emits a one-line message on stderr with exit code 2.
28
+ - **Negative positional arguments** (e.g.
29
+ `dbctl pg increase-quota alice -10`) failed with the opaque Click error
30
+ `No such option '-1'`. Dynamic commands now append a hint pointing at the
31
+ `--` separator and showing the user's actual token (`-10`).
32
+
33
+ ### Added
34
+
35
+ - **`increase-quota`** bundled operation — bumps a user's daily and yearly
36
+ quota by a percentage (positive or negative via `--`), with postgres
37
+ `::integer` rounding.
38
+ - **README + docs** rewritten to reflect the tool's actual thesis — a
39
+ one-command replacement for the DBeaver multi-click flow when
40
+ administering a database explosion (multiple environments × tenants)
41
+ through SSO + SSH tunnels. Adds a "Why not DBeaver?" comparison, documents
42
+ the `password` / `password_env` / `prompt` credential sources (the docs
43
+ previously claimed there was no plaintext-password field, which was
44
+ wrong), the loader-resilience guarantee (one bad connection no longer
45
+ nukes the registry), the `$name::type` CAST syntax, and the
46
+ negative-positional-arg `--` hint.
47
+
8
48
  ## [0.5.2] — 2026-08-01
9
49
 
10
50
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbctl
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: Generic CLI to monitor, control, and administer multiple databases via SSM, SSH, or direct connection.
5
5
  Author: dbctl contributors
6
6
  License: MIT
@@ -34,13 +34,51 @@ Description-Content-Type: text/markdown
34
34
  # dbctl
35
35
 
36
36
  `dbctl` is a small Python CLI for **monitoring, controlling, and administering
37
- multiple databases** through a single declarative config. It supports three
38
- ways of reaching a database:
37
+ multiple databases** through a single declarative config. It is built for
38
+ operators who manage a **database explosion** — the same application deployed
39
+ across several environments (dev / test / int / prod) and several tenants
40
+ (Germany / USA / India / …) — where administering a row means opening a GUI
41
+ client, navigating a connection tree, opening a tunnel, and clicking through
42
+ to the right schema. `dbctl` replaces that multi-click flow with one command:
43
+
44
+ ```text
45
+ dbctl prod-de increase-quota alice 10 --apply # bump a user's quota, in prod, Germany
46
+ dbctl prod-us increase-quota alice 10 --apply # same op, different tenant
47
+ dbctl int-in doctor # healthcheck integration India
48
+ ```
49
+
50
+ Every connection's tunnel (`ssm` / `ssh` / `k8s` / `direct`), credentials,
51
+ safety policy, and introspection queries live in one versioned
52
+ `~/.dbctl/connections.yaml`; every common SQL action lives in one
53
+ `~/.dbctl/operations.yaml`. `dbctl` synthesises one Click subcommand per
54
+ connection × operation, opens the tunnel, runs the SQL with bind parameters,
55
+ writes an audit-log entry, and tears the tunnel down — without you ever
56
+ leaving the terminal.
57
+
58
+ ### Why not DBeaver / a GUI client?
59
+
60
+ | GUI client (DBeaver, etc.) | `dbctl` |
61
+ |-----------------------------------------------------|-----------------------------------------------|
62
+ | Click through a connection tree every time | `dbctl <conn> <op>` — one command |
63
+ | Re-establish SSO / SSH per session, often by hand | Tunnels auto-opened from YAML, SSO re-used |
64
+ | Ad-hoc SQL with no audit trail | Every run appended to `~/.dbctl/history.jsonl` (secrets redacted) |
65
+ | "What can I run here?" is whatever you remember | Operations are declared + versioned; `--help` lists them |
66
+ | DML is one `Ctrl-Enter` away | DML is **dry-run by default** until `--apply`; `--yes` skips the prompt |
67
+ | No cross-DB diff | `dbctl diff user-count prod-de int-de` |
68
+
69
+ `dbctl` is **not** a schema browser or a query playground — it deliberately
70
+ has no ad-hoc query command. Declaring operations in YAML keeps "what can be
71
+ run against this DB" discoverable from a versioned file instead of buried in
72
+ your shell history. (For ad-hoc exploration open the tunnel with
73
+ `dbctl tunnel open <conn>` and point your favourite client at the local bind.)
74
+
75
+ ### How it reaches a database
39
76
 
40
77
  | type | how | external deps |
41
78
  |----------|----------------------------------------------------------------|---------------|
42
79
  | `ssm` | AWS SSM port-forward through an EC2 bastion to a private host | `aws` CLI on PATH, active SSO session |
43
80
  | `ssh` | Classic `ssh -N -L` port-forward through a bastion | `ssh` CLI on PATH, an SSH key file |
81
+ | `k8s` | `kubectl port-forward` to a Service / Pod in a cluster | `kubectl` CLI on PATH, a valid kubeconfig |
44
82
  | `direct` | No tunnel — connect to the upstream host:port directly | none |
45
83
 
46
84
  Each connection declares its SQLAlchemy URL scheme (`postgresql+psycopg`,
@@ -121,12 +159,8 @@ multi-connection `diff` commands.
121
159
  ```bash
122
160
  docker compose up -d
123
161
 
124
- # passwords for the sample config (set them in your shell):
125
- export DBCTL_PG_PASSWORD=pwd_postgres
126
- export DBCTL_MY_PASSWORD=pwd_mysql
127
- export DBCTL_MS_PASSWORD='PwdSqlServer2026!'
128
-
129
- # install the sample config
162
+ # install the sample config (uses plaintext passwords out of the box for dev;
163
+ # swap to `password_env: VARNAME` or `prompt: true` for real environments)
130
164
  mkdir -p ~/.dbctl
131
165
  cp .dbctl/connections.yaml ~/.dbctl/connections.yaml
132
166
  cp .dbctl/operations.yaml ~/.dbctl/operations.yaml
@@ -139,6 +173,7 @@ dbctl pg info row_counts
139
173
  dbctl pg list-users 10
140
174
  dbctl pg add-user stephen 12 --show-sql # dry-run (prints SQL)
141
175
  dbctl pg add-user stephen 12 --apply --yes # commit (no prompt)
176
+ dbctl pg increase-quota alice 10 --apply -y # +10% on alice's quotas
142
177
 
143
178
  dbctl diff user-count pg my # multi-DB diff
144
179
  dbctl diff compare-quotas pg my Daily
@@ -167,6 +202,21 @@ dbctl tunnel open pg # hold tunnel for ad-hoc psql
167
202
  writes it back to `~/.dbctl/connections.yaml` — it will test the tunnel +
168
203
  healthcheck before saving (for `direct` connections).
169
204
 
205
+ ### Loader resilience
206
+
207
+ Each connection is validated **independently**, not as one big
208
+ `ConnectionsFile`. A mis-configured connection (e.g. a reference template
209
+ with all password sources commented out, or a typo'd driver) is skipped with
210
+ a one-line warning on stderr — the rest of the registry still loads so the
211
+ dashboard, `--help`, and the good connections keep working:
212
+
213
+ ```
214
+ connections.yaml: 1 invalid connection skipped:
215
+ pg-ssm: set 'password', 'password_env' or 'prompt: true' for each connection
216
+ ```
217
+
218
+ `dbctl operations validate` checks `operations.yaml` the same way.
219
+
170
220
  ## Adding an operation
171
221
 
172
222
  Operations are global — every connection sees them. Declare one:
@@ -197,9 +247,21 @@ dbctl pg reset-quota alice --apply --yes # commit
197
247
 
198
248
  `$name` and `$floor` are rewritten to SQLAlchemy bind-params (`:name`,
199
249
  `:floor`) — they are always parameterised, never string-interpolated, so SQL
200
- injection through a parameter value is not possible. (For *dynamic
201
- identifiers* such as table names, write one operation per table for now;
202
- `${var}` identifier interpolation is on the v2 roadmap.)
250
+ injection through a parameter value is not possible. The Postgres cast idiom
251
+ `$name::type` is rewritten to the portable `CAST(:name AS type)` form so the
252
+ same operation YAML works cross-dialect:
253
+
254
+ ```yaml
255
+ sql: |
256
+ SELECT level, COUNT(*) AS events
257
+ FROM logs
258
+ WHERE created_at >= $since::timestamp # → CAST(:since AS timestamp)
259
+ AND created_at < $until::timestamp
260
+ GROUP BY level
261
+ ```
262
+
263
+ (For *dynamic identifiers* such as table names, write one operation per table
264
+ for now; `${var}` identifier interpolation is on the v2 roadmap.)
203
265
 
204
266
  See [`docs/operations.md`](docs/operations.md) for the full parameter and
205
267
  mode reference.
@@ -250,10 +312,16 @@ Each connection has a `safety` block:
250
312
  `dbctl` **confirms before opening the transaction** — saying `N` at the
251
313
  prompt leaves the database untouched.
252
314
 
253
- Only `password_env: VARNAME` and `prompt: true` are accepted as DB password
254
- sources — no plaintext in YAML, no Secrets Manager code (you said your SSO
255
- session already wraps the secrets you need). Secret-typed operation parameters
256
- (`type: secret`) are redacted in the audit log.
315
+ DB password sources are mutually exclusive — pick **one** per connection:
316
+
317
+ | source | use |
318
+ |-----------------|------|
319
+ | `password: "…"` | plaintext (local dev only). Don't commit real secrets. |
320
+ | `password_env: VAR` | read from the named environment variable. Recommended for any shared/CI host. |
321
+ | `prompt: true` | prompt for the DB password interactively each run. Good for break-glass access. |
322
+
323
+ Secret-typed **operation** parameters (`type: secret`) are redacted in the
324
+ audit log regardless of which DB password source the connection uses.
257
325
 
258
326
  ## Shell completion
259
327
 
@@ -298,11 +366,13 @@ dbctl/
298
366
  ```bash
299
367
  uv sync --extra dev
300
368
  uv run ruff check dbctl tests # lint
301
- uv run pytest tests/ # 15 unit tests
302
- uv run mypy dbctl # type-check
369
+ uv run pytest tests/ # ~60 unit tests against in-memory SQLite
370
+ uv run mypy dbctl # type-check (pre-existing debt; non-blocking in CI)
303
371
  ```
304
372
 
305
373
  The unit tests use an in-memory SQLite database so they run without docker.
374
+ For a live end-to-end smoke test, `docker compose up -d` and follow the
375
+ [quick start](#quick-start-with-the-bundled-docker-compose-fleet) above.
306
376
 
307
377
  ## Roadmap
308
378
 
@@ -1,13 +1,51 @@
1
1
  # dbctl
2
2
 
3
3
  `dbctl` is a small Python CLI for **monitoring, controlling, and administering
4
- multiple databases** through a single declarative config. It supports three
5
- ways of reaching a database:
4
+ multiple databases** through a single declarative config. It is built for
5
+ operators who manage a **database explosion** — the same application deployed
6
+ across several environments (dev / test / int / prod) and several tenants
7
+ (Germany / USA / India / …) — where administering a row means opening a GUI
8
+ client, navigating a connection tree, opening a tunnel, and clicking through
9
+ to the right schema. `dbctl` replaces that multi-click flow with one command:
10
+
11
+ ```text
12
+ dbctl prod-de increase-quota alice 10 --apply # bump a user's quota, in prod, Germany
13
+ dbctl prod-us increase-quota alice 10 --apply # same op, different tenant
14
+ dbctl int-in doctor # healthcheck integration India
15
+ ```
16
+
17
+ Every connection's tunnel (`ssm` / `ssh` / `k8s` / `direct`), credentials,
18
+ safety policy, and introspection queries live in one versioned
19
+ `~/.dbctl/connections.yaml`; every common SQL action lives in one
20
+ `~/.dbctl/operations.yaml`. `dbctl` synthesises one Click subcommand per
21
+ connection × operation, opens the tunnel, runs the SQL with bind parameters,
22
+ writes an audit-log entry, and tears the tunnel down — without you ever
23
+ leaving the terminal.
24
+
25
+ ### Why not DBeaver / a GUI client?
26
+
27
+ | GUI client (DBeaver, etc.) | `dbctl` |
28
+ |-----------------------------------------------------|-----------------------------------------------|
29
+ | Click through a connection tree every time | `dbctl <conn> <op>` — one command |
30
+ | Re-establish SSO / SSH per session, often by hand | Tunnels auto-opened from YAML, SSO re-used |
31
+ | Ad-hoc SQL with no audit trail | Every run appended to `~/.dbctl/history.jsonl` (secrets redacted) |
32
+ | "What can I run here?" is whatever you remember | Operations are declared + versioned; `--help` lists them |
33
+ | DML is one `Ctrl-Enter` away | DML is **dry-run by default** until `--apply`; `--yes` skips the prompt |
34
+ | No cross-DB diff | `dbctl diff user-count prod-de int-de` |
35
+
36
+ `dbctl` is **not** a schema browser or a query playground — it deliberately
37
+ has no ad-hoc query command. Declaring operations in YAML keeps "what can be
38
+ run against this DB" discoverable from a versioned file instead of buried in
39
+ your shell history. (For ad-hoc exploration open the tunnel with
40
+ `dbctl tunnel open <conn>` and point your favourite client at the local bind.)
41
+
42
+ ### How it reaches a database
6
43
 
7
44
  | type | how | external deps |
8
45
  |----------|----------------------------------------------------------------|---------------|
9
46
  | `ssm` | AWS SSM port-forward through an EC2 bastion to a private host | `aws` CLI on PATH, active SSO session |
10
47
  | `ssh` | Classic `ssh -N -L` port-forward through a bastion | `ssh` CLI on PATH, an SSH key file |
48
+ | `k8s` | `kubectl port-forward` to a Service / Pod in a cluster | `kubectl` CLI on PATH, a valid kubeconfig |
11
49
  | `direct` | No tunnel — connect to the upstream host:port directly | none |
12
50
 
13
51
  Each connection declares its SQLAlchemy URL scheme (`postgresql+psycopg`,
@@ -88,12 +126,8 @@ multi-connection `diff` commands.
88
126
  ```bash
89
127
  docker compose up -d
90
128
 
91
- # passwords for the sample config (set them in your shell):
92
- export DBCTL_PG_PASSWORD=pwd_postgres
93
- export DBCTL_MY_PASSWORD=pwd_mysql
94
- export DBCTL_MS_PASSWORD='PwdSqlServer2026!'
95
-
96
- # install the sample config
129
+ # install the sample config (uses plaintext passwords out of the box for dev;
130
+ # swap to `password_env: VARNAME` or `prompt: true` for real environments)
97
131
  mkdir -p ~/.dbctl
98
132
  cp .dbctl/connections.yaml ~/.dbctl/connections.yaml
99
133
  cp .dbctl/operations.yaml ~/.dbctl/operations.yaml
@@ -106,6 +140,7 @@ dbctl pg info row_counts
106
140
  dbctl pg list-users 10
107
141
  dbctl pg add-user stephen 12 --show-sql # dry-run (prints SQL)
108
142
  dbctl pg add-user stephen 12 --apply --yes # commit (no prompt)
143
+ dbctl pg increase-quota alice 10 --apply -y # +10% on alice's quotas
109
144
 
110
145
  dbctl diff user-count pg my # multi-DB diff
111
146
  dbctl diff compare-quotas pg my Daily
@@ -134,6 +169,21 @@ dbctl tunnel open pg # hold tunnel for ad-hoc psql
134
169
  writes it back to `~/.dbctl/connections.yaml` — it will test the tunnel +
135
170
  healthcheck before saving (for `direct` connections).
136
171
 
172
+ ### Loader resilience
173
+
174
+ Each connection is validated **independently**, not as one big
175
+ `ConnectionsFile`. A mis-configured connection (e.g. a reference template
176
+ with all password sources commented out, or a typo'd driver) is skipped with
177
+ a one-line warning on stderr — the rest of the registry still loads so the
178
+ dashboard, `--help`, and the good connections keep working:
179
+
180
+ ```
181
+ connections.yaml: 1 invalid connection skipped:
182
+ pg-ssm: set 'password', 'password_env' or 'prompt: true' for each connection
183
+ ```
184
+
185
+ `dbctl operations validate` checks `operations.yaml` the same way.
186
+
137
187
  ## Adding an operation
138
188
 
139
189
  Operations are global — every connection sees them. Declare one:
@@ -164,9 +214,21 @@ dbctl pg reset-quota alice --apply --yes # commit
164
214
 
165
215
  `$name` and `$floor` are rewritten to SQLAlchemy bind-params (`:name`,
166
216
  `:floor`) — they are always parameterised, never string-interpolated, so SQL
167
- injection through a parameter value is not possible. (For *dynamic
168
- identifiers* such as table names, write one operation per table for now;
169
- `${var}` identifier interpolation is on the v2 roadmap.)
217
+ injection through a parameter value is not possible. The Postgres cast idiom
218
+ `$name::type` is rewritten to the portable `CAST(:name AS type)` form so the
219
+ same operation YAML works cross-dialect:
220
+
221
+ ```yaml
222
+ sql: |
223
+ SELECT level, COUNT(*) AS events
224
+ FROM logs
225
+ WHERE created_at >= $since::timestamp # → CAST(:since AS timestamp)
226
+ AND created_at < $until::timestamp
227
+ GROUP BY level
228
+ ```
229
+
230
+ (For *dynamic identifiers* such as table names, write one operation per table
231
+ for now; `${var}` identifier interpolation is on the v2 roadmap.)
170
232
 
171
233
  See [`docs/operations.md`](docs/operations.md) for the full parameter and
172
234
  mode reference.
@@ -217,10 +279,16 @@ Each connection has a `safety` block:
217
279
  `dbctl` **confirms before opening the transaction** — saying `N` at the
218
280
  prompt leaves the database untouched.
219
281
 
220
- Only `password_env: VARNAME` and `prompt: true` are accepted as DB password
221
- sources — no plaintext in YAML, no Secrets Manager code (you said your SSO
222
- session already wraps the secrets you need). Secret-typed operation parameters
223
- (`type: secret`) are redacted in the audit log.
282
+ DB password sources are mutually exclusive — pick **one** per connection:
283
+
284
+ | source | use |
285
+ |-----------------|------|
286
+ | `password: "…"` | plaintext (local dev only). Don't commit real secrets. |
287
+ | `password_env: VAR` | read from the named environment variable. Recommended for any shared/CI host. |
288
+ | `prompt: true` | prompt for the DB password interactively each run. Good for break-glass access. |
289
+
290
+ Secret-typed **operation** parameters (`type: secret`) are redacted in the
291
+ audit log regardless of which DB password source the connection uses.
224
292
 
225
293
  ## Shell completion
226
294
 
@@ -265,11 +333,13 @@ dbctl/
265
333
  ```bash
266
334
  uv sync --extra dev
267
335
  uv run ruff check dbctl tests # lint
268
- uv run pytest tests/ # 15 unit tests
269
- uv run mypy dbctl # type-check
336
+ uv run pytest tests/ # ~60 unit tests against in-memory SQLite
337
+ uv run mypy dbctl # type-check (pre-existing debt; non-blocking in CI)
270
338
  ```
271
339
 
272
340
  The unit tests use an in-memory SQLite database so they run without docker.
341
+ For a live end-to-end smoke test, `docker compose up -d` and follow the
342
+ [quick start](#quick-start-with-the-bundled-docker-compose-fleet) above.
273
343
 
274
344
  ## Roadmap
275
345
 
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import contextlib
14
14
  import os
15
+ import re
15
16
  import sys
16
17
  import time
17
18
  from typing import Any
@@ -32,6 +33,48 @@ from dbctl.runtime import (
32
33
  registries,
33
34
  )
34
35
 
36
+ # --------------------------------------------------------------------------- #
37
+ # Negative-number hint for dynamic commands
38
+ # --------------------------------------------------------------------------- #
39
+ _NEG_OPT_RE = re.compile(r"No such option '(-\d[^']*)'")
40
+
41
+
42
+ class _NegNumberHintCommand(click.Command):
43
+ """Click can't parse a negative number as a positional Argument value
44
+ (only Options support negative-number detection, and only with a Range
45
+ type and `min < 0`). When the user writes ``dbctl pg increase-quota
46
+ zelda -10`` Click reports ``No such option '-1'`` which is opaque.
47
+
48
+ This subclass intercepts that exact error and appends a hint pointing
49
+ the user at the ``--`` separator: ``dbctl <conn> <op> ... -- <args>``.
50
+ See https://github.com/pallets/click/issues/2169 for the upstream
51
+ limitation.
52
+ """
53
+
54
+ def parse_args(self, ctx, args):
55
+ # Click's parser mutates `args` in place while it consumes tokens,
56
+ # so by the time the except block runs the list is empty. Snapshot
57
+ # here while still pristine.
58
+ original_args = list(args)
59
+ try:
60
+ return super().parse_args(ctx, args)
61
+ except click.exceptions.UsageError as e:
62
+ m = _NEG_OPT_RE.match(e.message or "")
63
+ if m:
64
+ # Click truncates the bad token to the first option char
65
+ # (e.g. `-10` → `-1`), so scan the original argv for the
66
+ # offending token to show the user what *they* typed.
67
+ bad = next(
68
+ (a for a in original_args if a.startswith("-") and len(a) > 1 and a[1].isdigit()),
69
+ m.group(1),
70
+ )
71
+ e.message = (
72
+ f"{e.message}\n hint: a positional argument can't start "
73
+ f"with '-'. Separate options from positionals with '--':\n"
74
+ f" {ctx.command_path} [opts] -- <positional args including {bad}>"
75
+ )
76
+ raise
77
+
35
78
 
36
79
  # --------------------------------------------------------------------------- #
37
80
  # param type mapping
@@ -241,7 +284,7 @@ def _make_single_op_command(conn_name: str, op_name: str, op: Operation) -> clic
241
284
  help_text = (op.description or "").strip()
242
285
  if sig:
243
286
  help_text = f"{help_text}\n\n dbctl {conn_name} {op_name} {sig}"
244
- return click.Command(
287
+ return _NegNumberHintCommand(
245
288
  name=op_name,
246
289
  params=click_params,
247
290
  callback=callback,
@@ -453,10 +496,16 @@ def _make_multi_op_command(verb: str, op_name: str, op: Operation) -> click.Comm
453
496
  try:
454
497
  for role in op.roles:
455
498
  cname = role_conns[role]
456
- canonical, conn = resolve(cname, conns_all)
499
+ try:
500
+ canonical, conn = resolve(cname, conns_all)
501
+ except KeyError as e:
502
+ err_console.print(f"[red]{e}[/red]")
503
+ raise SystemExit(2)
457
504
  oc = opened(canonical, conn).__enter__()
458
505
  opened_list.append(oc)
459
506
  results[role] = run_role(op, role, oc, bound)
507
+ except SystemExit:
508
+ raise
460
509
  except RuntimeError as e:
461
510
  err_console.print(f"[red]{e}[/red]")
462
511
  raise SystemExit(1)
@@ -497,7 +546,7 @@ def _make_multi_op_command(verb: str, op_name: str, op: Operation) -> click.Comm
497
546
  sig = " ".join(r.upper() for r in op.roles) + (
498
547
  " " + " ".join(p.name.upper() for p in pos_params) if pos_params else ""
499
548
  )
500
- return click.Command(
549
+ return _NegNumberHintCommand(
501
550
  name=op_name,
502
551
  params=click_params,
503
552
  callback=callback,
@@ -613,12 +662,16 @@ def connections_list(ctx):
613
662
  @click.argument("name")
614
663
  @click.pass_context
615
664
  def connections_show(ctx, name):
665
+ from dbctl.connections import resolve
666
+
616
667
  conns, _ = registries(ctx)
617
- if name not in conns:
618
- err_console.print(f"[red]unknown connection {name!r}[/red]")
668
+ try:
669
+ canonical, conn = resolve(name, conns)
670
+ except KeyError as e:
671
+ err_console.print(f"[red]{e}[/red]")
619
672
  raise SystemExit(2)
620
673
  console.print(
621
- yaml.safe_dump({name: conns[name].model_dump(mode="json")}, sort_keys=False, allow_unicode=True)
674
+ yaml.safe_dump({canonical: conn.model_dump(mode="json")}, sort_keys=False, allow_unicode=True)
622
675
  )
623
676
 
624
677
 
@@ -14,18 +14,41 @@ from sqlalchemy.exc import SQLAlchemyError
14
14
  if TYPE_CHECKING:
15
15
  from dbctl.config import Operation
16
16
 
17
- _PLACEHOLDER = re.compile(r"\$([a-zA-Z_][a-zA-Z0-9_]*)")
17
+ # A SQL type name after `$name::`. Allows schema-qualified names
18
+ # (`pg.text`) and parenthesised precision/length (`varchar(255)`,
19
+ # `numeric(10, 2)`, `timestamp(6)`), but NOT surrounding spaces or
20
+ # trailing operators (so `$x::text || $y::int` rewrites cleanly).
21
+ _TYPE = r"[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*(?:\([^)]*\))?"
22
+ _PLACEHOLDER = re.compile(rf"\$([a-zA-Z_][a-zA-Z0-9_]*)(?:::({_TYPE}))?")
23
+
24
+
25
+ def _replace(match: re.Match[str]) -> str:
26
+ name = match.group(1)
27
+ cast = match.group(2)
28
+ if cast:
29
+ # `$name::type` (Postgres idiom) — emit the SQL-standard
30
+ # `CAST(:name AS type)` form so the rewritten SQL works on
31
+ # every dialect (psycopg rejects `:name::type` because the
32
+ # `::type` suffix collides with its bind param syntax).
33
+ return f"CAST(:{name} AS {cast})"
34
+ return f":{name}"
18
35
 
19
36
 
20
37
  def to_bindparams(sql: str) -> str:
21
38
  """Rewrite ``$name`` → ``:name`` while leaving everything else (literals,
22
39
  operators, dollar-quoting edge cases) untouched.
23
40
 
41
+ Also handles the Postgres cast idiom ``$name::type`` by rewriting it to
42
+ the portable ``CAST(:name AS type)`` form. This is necessary because
43
+ SQLAlchemy/psycopg rejects ``:name::type`` (the trailing ``::type``
44
+ collides with the bind param name parser), and because ``CAST`` is
45
+ SQL-standard so the same operation YAML works cross-dialect.
46
+
24
47
  We deliberately do **not** touch ``$$ ... $$`` dollar-quoted strings: those
25
48
  are matched only when the char after ``$`` is a digit (which is an invalid
26
49
  bindparam name anyway), so they survive intact.
27
50
  """
28
- return _PLACEHOLDER.sub(r":\1", sql)
51
+ return _PLACEHOLDER.sub(_replace, sql)
29
52
 
30
53
 
31
54
  @dataclass
@@ -24,8 +24,9 @@ falling back to defaults.
24
24
  | `driver` | string | **yes** | SQLAlchemy URL scheme. Supported conventions: `postgresql+psycopg`, `mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`. Any other SQLAlchemy scheme works as long as its driver is importable. |
25
25
  | `database` | string | **yes** | database / catalog name passed to SQLAlchemy. |
26
26
  | `username` | string | **yes** | DB user. |
27
- | `password_env` | string | see rule | name of the environment variable holding the DB password. Mutually exclusive with `prompt`. |
28
- | `prompt` | bool | see rule | prompt for the DB password interactively each run. Mutually exclusive with `password_env`. |
27
+ | `password` | string | see rule | plaintext DB password (local dev only — don't commit real secrets to YAML). Mutually exclusive with `password_env` and `prompt`. |
28
+ | `password_env` | string | see rule | name of the environment variable holding the DB password. Mutually exclusive with `password` and `prompt`. |
29
+ | `prompt` | bool | see rule | prompt for the DB password interactively each run. Mutually exclusive with `password` and `password_env`. |
29
30
  | `ssm` | [`SsmTunnel`](#ssm-block) | yes if `type: ssm` | tunnel params. |
30
31
  | `ssh` | [`SshTunnel`](#ssh-block) | yes if `type: ssh` | tunnel params. |
31
32
  | `k8s` | [`K8sTunnel`](#k8s-block) | yes if `type: k8s` | tunnel params. |
@@ -34,8 +35,30 @@ falling back to defaults.
34
35
  | `info` | list of [`InfoQuery`](#info-query) | no | named introspection queries that `dbctl <conn> info <name>` can run. |
35
36
  | `safety` | [`Safety`](#safety-block) | no | gates DML. |
36
37
 
37
- **Credential rule.** Exactly one of `password_env` and `prompt` must be set;
38
- there is no plaintext-password field and no Secrets Manager integration.
38
+ **Credential rule.** Exactly one of `password`, `password_env`, and `prompt`
39
+ must be set; they are mutually exclusive. `password` (plaintext) is fine for
40
+ local dev fleets like the bundled docker-compose sample; for anything shared
41
+ or production-shaped prefer `password_env` (the value lives in your shell /
42
+ secret manager, never in YAML) or `prompt: true` (interactive, leaves no
43
+ trace). There is no Secrets Manager / Vault integration — the assumption is
44
+ your existing SSO session and shell environment already wrap the secrets you
45
+ need.
46
+
47
+ ### Loader resilience
48
+
49
+ Each connection is validated **individually**, not as one all-or-nothing
50
+ `ConnectionsFile`. A mis-configured connection (a reference template with no
51
+ password source, a typo'd driver name, an unknown tunnel block, …) is skipped
52
+ with a one-line warning on stderr, and the good connections still load so the
53
+ dashboard and `--help` keep working:
54
+
55
+ ```
56
+ connections.yaml: 1 invalid connection skipped:
57
+ pg-ssm: set 'password', 'password_env' or 'prompt: true' for each connection
58
+ ```
59
+
60
+ The offending connection is simply absent from `dbctl connections list` until
61
+ you fix it — the CLI never refuses to start because of one bad entry.
39
62
 
40
63
  ## `ssm` block
41
64
 
@@ -78,12 +78,28 @@ sql: |
78
78
 
79
79
  - `$1`, `$2`, … are **not** rewritten (they're positional placeholders in
80
80
  some dialects) — the regex only matches `$<identifier>`.
81
+ - The Postgres cast idiom `$name::type` is rewritten to the SQL-standard
82
+ `CAST(:name AS type)` form so the same operation YAML works cross-dialect
83
+ and doesn't collide with psycopg's bind-param parser. Parenthesised
84
+ precision / length is preserved, e.g. `$x::numeric(10, 2)` →
85
+ `CAST(:x AS numeric(10, 2))`, and schema-qualified types work too
86
+ (`$x::pg.text` → `CAST(:x AS pg.text)`).
81
87
  - PostgreSQL dollar-quoted strings (`$$ ... $$`) are not specially handled:
82
88
  avoid mixing dollar-quoting with `$name` parameters in the same operation.
83
89
  Use single-quoted string literals for any in-SQL text.
84
90
  - The same placeholder can appear twice in one statement
85
91
  (`VALUES ($quota, $quota * 365)`); the driver reuses the value.
86
92
 
93
+ > **Negative positional arguments.** Click can't distinguish a negative
94
+ > number from an unknown option when it appears as a positional `Argument`
95
+ > value (e.g. `dbctl pg increase-quota alice -10` is parsed as the unknown
96
+ > option `-1`). The error message includes a hint pointing at the `--`
97
+ > separator, which you can use to unambiguously mark the start of positionals:
98
+ >
99
+ > ```bash
100
+ > dbctl pg increase-quota --apply -y -- alice -10
101
+ > ```
102
+
87
103
  ## <a name="mode"></a> `mode` (single-scope)
88
104
 
89
105
  | mode | behaviour |
@@ -218,6 +234,21 @@ operations:
218
234
  sql: |
219
235
  SELECT name, quota_daily, is_active FROM users WHERE name ILIKE $prefix || '%'
220
236
 
237
+ increase-quota:
238
+ description: "Increase a user's daily and yearly quota by a percentage"
239
+ scope: single
240
+ mode: execute
241
+ confirm: true
242
+ parameters:
243
+ - { name: name, type: string, required: true, position: 1, description: "User to bump" }
244
+ - { name: pct, type: float, required: true, position: 2, description: "Percentage increase (e.g. 10 = +10%) — use `--` for negative values" }
245
+ sql: |
246
+ UPDATE users
247
+ SET quota_daily = (quota_daily * (1 + $pct / 100.0))::integer,
248
+ quota_yearly = (quota_yearly * (1 + $pct / 100.0))::integer,
249
+ updated_at = NOW()
250
+ WHERE name = $name
251
+
221
252
  report-logs:
222
253
  description: "Summarise logs by level between two dates"
223
254
  scope: single
@@ -43,7 +43,7 @@ Run `dbctl` once to confirm it's alive:
43
43
 
44
44
  ```bash
45
45
  ▶ uv run dbctl --version
46
- dbctl, version 0.1.0
46
+ dbctl, version 0.5.3
47
47
  ```
48
48
 
49
49
  ---
@@ -85,19 +85,14 @@ You should see all three services `healthy`.
85
85
  ```
86
86
 
87
87
  The bundled config has three "live" connections — `pg`, `my`, `ms` — plus
88
- two reference-only templates — `pg-ssm`, `pg-ssh` — that we'll use in
89
- section 11.
90
-
91
- The sample config reads DB passwords from environment variables (there is
92
- no plaintext-password field in dbctl). Export them now:
93
-
94
- ```bash
95
- ▶ export DBCTL_PG_PASSWORD=pwd_postgres
96
- ▶ export DBCTL_MY_PASSWORD=pwd_mysql
97
- ▶ export DBCTL_MS_PASSWORD='PwdSqlServer2026!'
98
- ```
99
-
100
- (These match the values in `docker-compose.yml`.)
88
+ three reference-only templates — `pg-ssm`, `pg-k8s`, `pg-ssh` — that we'll
89
+ use in section 11.
90
+
91
+ The sample config ships with **plaintext passwords** out of the box so the
92
+ docker fleet "just works" after `cp` — see the `password:` field in
93
+ `~/.dbctl/connections.yaml`. For any real environment swap to
94
+ `password_env: VARNAME` (read from your shell) or `prompt: true` (typed each
95
+ run); the three sources are mutually exclusive.
101
96
 
102
97
  ---
103
98
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dbctl"
7
- version = "0.5.2"
7
+ version = "0.5.3"
8
8
  description = "Generic CLI to monitor, control, and administer multiple databases via SSM, SSH, or direct connection."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,210 @@
1
+ """Regression tests for bugs found while validating against the docker fleet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from types import SimpleNamespace
7
+ from unittest import mock
8
+
9
+ from click.testing import CliRunner
10
+
11
+ from dbctl.audit import append, read
12
+ from dbctl.cli import _make_multi_op_command
13
+ from dbctl.config import Operation
14
+
15
+
16
+ # --------------------------------------------------------------------------- #
17
+ # Multi-op CLI used to crash on every invocation: click lowercases argument
18
+ # names when exposing them as kwargs, but the callback popped `r.upper()`.
19
+ # Verifying the fix + guarding against a reintroduction.
20
+ # --------------------------------------------------------------------------- #
21
+ def test_multi_op_command_uses_lowercase_role_kwargs(tmp_path, monkeypatch):
22
+ op = Operation.model_validate(
23
+ {
24
+ "description": "test diff",
25
+ "scope": "multi",
26
+ "mode": "diff",
27
+ "roles": ["src", "trg"],
28
+ "queries": {
29
+ "src": "SELECT 'u' AS t, 1 AS n",
30
+ "trg": "SELECT 'u' AS t, 2 AS n",
31
+ },
32
+ "diff": {"key": ["t"], "show": ["n"]},
33
+ }
34
+ )
35
+
36
+ conns = {
37
+ "pg": SimpleNamespace(description="a", aliases=[]),
38
+ "my": SimpleNamespace(description="b", aliases=[]),
39
+ }
40
+
41
+ @contextmanager
42
+ def fake_opened(name, conn):
43
+ yield SimpleNamespace(name=name, engine=None, tunnel=mock.MagicMock())
44
+
45
+ captured: dict = {}
46
+
47
+ def fake_run_role(_op, role, opened_conn, _params):
48
+ captured.setdefault("role_conns", {})[role] = opened_conn.name
49
+ return [{"t": "u", "n": 1 if role == "src" else 2}]
50
+
51
+ monkeypatch.setattr("dbctl.cli.registries", lambda _ctx: (conns, {"test-diff": op}))
52
+ monkeypatch.setattr("dbctl.multi.opened", fake_opened)
53
+ monkeypatch.setattr("dbctl.multi.run_role", fake_run_role)
54
+ monkeypatch.setattr("dbctl.audit.history_path", lambda profile=None: tmp_path / "h.jsonl")
55
+
56
+ cmd = _make_multi_op_command("diff", "test-diff", op)
57
+ result = CliRunner().invoke(cmd, ["pg", "my"], obj={})
58
+ assert result.exit_code == 0, result.output
59
+ assert captured["role_conns"] == {"src": "pg", "trg": "my"}
60
+
61
+
62
+ # --------------------------------------------------------------------------- #
63
+ # audit.read() must skip corrupt lines *before* truncating to `limit`, not
64
+ # after — otherwise a half-written trailing line evicts a valid older entry
65
+ # from the window.
66
+ # --------------------------------------------------------------------------- #
67
+ def test_audit_read_skips_corrupt_lines_without_losing_history(tmp_path, monkeypatch):
68
+ from dbctl import audit
69
+
70
+ monkeypatch.setattr(audit, "history_path", lambda profile=None: tmp_path / "h.jsonl")
71
+
72
+ append(profile=None, connection="pg", operation="op-a", params={}, mode="execute", status="ok")
73
+ append(profile=None, connection="pg", operation="op-b", params={}, mode="execute", status="ok")
74
+
75
+ # append a corrupted tail (simulating a crash mid-write)
76
+ (tmp_path / "h.jsonl").open("a").write("{ broken json\n")
77
+
78
+ entries = read(None, limit=5)
79
+ assert len(entries) == 2
80
+ assert {e["operation"] for e in entries} == {"op-a", "op-b"}
81
+
82
+
83
+ # --------------------------------------------------------------------------- #
84
+ # Bug: `$name::type` cast broke psycopg (syntax error at ":").
85
+ # `to_bindparams` now rewrites to `CAST(:name AS type)`.
86
+ # --------------------------------------------------------------------------- #
87
+ def test_to_bindparams_handles_cast_idiom():
88
+ from dbctl.execute import to_bindparams
89
+
90
+ assert to_bindparams("SELECT $x::timestamp") == "SELECT CAST(:x AS timestamp)"
91
+ assert (
92
+ to_bindparams("WHERE created_at >= $since::timestamp")
93
+ == "WHERE created_at >= CAST(:since AS timestamp)"
94
+ )
95
+
96
+
97
+ # --------------------------------------------------------------------------- #
98
+ # Bug: a negative number for a positional Argument (e.g.
99
+ # `dbctl pg increase-quota zelda -10`) failed with the opaque
100
+ # `No such option '-1'`. The CLI now sub-classes `click.Command` to
101
+ # append a "-- separator" hint showing the user's actual token.
102
+ # --------------------------------------------------------------------------- #
103
+ def test_negative_positional_arg_emits_separator_hint():
104
+ from dbctl.cli import _make_single_op_command
105
+ from dbctl.config import Operation
106
+
107
+ op = Operation.model_validate(
108
+ {
109
+ "description": "bump pct",
110
+ "scope": "single",
111
+ "mode": "execute",
112
+ "confirm": False, # don't prompt; we never reach execution
113
+ "parameters": [
114
+ {"name": "name", "type": "string", "required": True, "position": 1},
115
+ {"name": "pct", "type": "float", "required": True, "position": 2},
116
+ ],
117
+ "sql": "UPDATE users SET quota = quota * (1 + $pct / 100) WHERE name = $name",
118
+ }
119
+ )
120
+ cmd = _make_single_op_command("pg", "increase-quota", op)
121
+ result = CliRunner().invoke(cmd, ["zelda", "-10"], obj={}, color=False)
122
+ # Click returns exit code 2 on UsageError; we just want the hint.
123
+ assert "No such option '-1'" in result.output
124
+ assert "hint:" in result.output
125
+ # The user's actual token `-10` must appear (Click truncates to `-1`).
126
+ assert "-10" in result.output
127
+ assert "--" in result.output
128
+
129
+
130
+ # --------------------------------------------------------------------------- #
131
+ # Bug: `dbctl connections show <alias>` reported "unknown connection"
132
+ # because `connections_show` did a direct dict lookup instead of going
133
+ # through the alias-aware `resolve()`.
134
+ # --------------------------------------------------------------------------- #
135
+ def test_connections_show_resolves_alias(tmp_path, monkeypatch):
136
+ from dbctl.config import Connection, TunnelType
137
+
138
+ conns = {
139
+ "pg": Connection(
140
+ description="Postgres dev",
141
+ aliases=["postgres"],
142
+ type=TunnelType.direct,
143
+ driver="postgresql+psycopg",
144
+ database="app",
145
+ username="u",
146
+ password="p",
147
+ direct={"host": "127.0.0.1", "port": 5432},
148
+ )
149
+ }
150
+ monkeypatch.setattr("dbctl.cli.registries", lambda _ctx: (conns, {}))
151
+
152
+ from dbctl.cli import connections_cmd
153
+
154
+ # Canonical name resolves.
155
+ r = CliRunner().invoke(connections_cmd, ["show", "pg"], obj={}, color=False)
156
+ assert r.exit_code == 0, r.output
157
+ assert "pg:" in r.output
158
+ assert "Postgres dev" in r.output
159
+
160
+ # Alias resolves to the canonical connection (top key is "pg", not "postgres").
161
+ r = CliRunner().invoke(connections_cmd, ["show", "postgres"], obj={}, color=False)
162
+ assert r.exit_code == 0, r.output
163
+ assert "pg:" in r.output
164
+ assert "postgres:" not in r.output.split("pg:")[0] # not the alias as the key
165
+
166
+ # Unknown connection still produces a clean error + exit code 2.
167
+ r = CliRunner().invoke(connections_cmd, ["show", "zzz"], obj={}, color=False)
168
+ assert r.exit_code == 2
169
+ assert "unknown connection 'zzz'" in r.output
170
+
171
+
172
+ # --------------------------------------------------------------------------- #
173
+ # Bug: `dbctl diff user-count pg zzz` crashed with a raw
174
+ # `UnknownConnectionError` traceback. The multi-op callback now catches
175
+ # `KeyError` and emits a one-line message on stderr instead.
176
+ # --------------------------------------------------------------------------- #
177
+ def test_multi_op_unknown_connection_emits_clean_error(tmp_path, monkeypatch):
178
+ op = Operation.model_validate(
179
+ {
180
+ "description": "test diff",
181
+ "scope": "multi",
182
+ "mode": "diff",
183
+ "roles": ["src", "trg"],
184
+ "queries": {"src": "SELECT 1", "trg": "SELECT 1"},
185
+ }
186
+ )
187
+ conns = {
188
+ "pg": SimpleNamespace(description="a", aliases=[]),
189
+ # note: no "zzz" connection
190
+ }
191
+
192
+ @contextmanager
193
+ def fake_opened(name, conn):
194
+ yield SimpleNamespace(name=name, engine=None, tunnel=mock.MagicMock())
195
+
196
+ def fake_run_role(_op, role, opened_conn, _params):
197
+ return [{"t": "u", "n": 1}]
198
+
199
+ monkeypatch.setattr("dbctl.cli.registries", lambda _ctx: (conns, {"test-diff": op}))
200
+ monkeypatch.setattr("dbctl.multi.opened", fake_opened)
201
+ monkeypatch.setattr("dbctl.multi.run_role", fake_run_role)
202
+ monkeypatch.setattr("dbctl.audit.history_path", lambda profile=None: tmp_path / "h.jsonl")
203
+
204
+ cmd = _make_multi_op_command("diff", "test-diff", op)
205
+ result = CliRunner().invoke(cmd, ["pg", "zzz"], obj={}, color=False)
206
+ assert result.exit_code == 2, result.output
207
+ assert "unknown connection" in result.output
208
+ # No traceback leaked to the user.
209
+ assert "Traceback" not in result.output
210
+ assert "UnknownConnectionError" not in result.output
@@ -96,6 +96,38 @@ def test_to_bindparams_leaves_dollar_digit_alone():
96
96
  assert to_bindparams("SELECT $1") == "SELECT $1"
97
97
 
98
98
 
99
+ def test_to_bindparams_cast_simple():
100
+ # Postgres idiom `$name::type` rewrites to the SQL-standard
101
+ # `CAST(:name AS type)` so it works on every dialect and doesn't
102
+ # collide with psycopg's bindparam parser.
103
+ assert to_bindparams("SELECT $x::timestamp") == "SELECT CAST(:x AS timestamp)"
104
+
105
+
106
+ def test_to_bindparams_cast_multiple_in_expression():
107
+ assert to_bindparams("$x::text || $y::int") == "CAST(:x AS text) || CAST(:y AS int)"
108
+
109
+
110
+ def test_to_bindparams_cast_with_precision():
111
+ assert to_bindparams("SELECT $x::varchar(255)") == "SELECT CAST(:x AS varchar(255))"
112
+
113
+
114
+ def test_to_bindparams_cast_with_numeric_precision():
115
+ assert to_bindparams("SELECT $x::numeric(10, 2)") == "SELECT CAST(:x AS numeric(10, 2))"
116
+
117
+
118
+ def test_to_bindparams_cast_schema_qualified():
119
+ assert to_bindparams("SELECT $x::pg.text") == "SELECT CAST(:x AS pg.text)"
120
+
121
+
122
+ def test_to_bindparams_cast_in_where_clause():
123
+ # The actual `report-logs` SQL shape that broke psycopg pre-fix.
124
+ sql = "WHERE created_at >= $since::timestamp AND created_at < $until::timestamp"
125
+ assert (
126
+ to_bindparams(sql)
127
+ == "WHERE created_at >= CAST(:since AS timestamp) AND created_at < CAST(:until AS timestamp)"
128
+ )
129
+
130
+
99
131
  # --------------------------------------------------------------------------- #
100
132
  # parameter binding
101
133
  # --------------------------------------------------------------------------- #
@@ -168,7 +168,7 @@ toml = [
168
168
 
169
169
  [[package]]
170
170
  name = "dbctl"
171
- version = "0.5.2"
171
+ version = "0.5.3"
172
172
  source = { editable = "." }
173
173
  dependencies = [
174
174
  { name = "click" },
@@ -1,80 +0,0 @@
1
- """Regression tests for bugs found while validating against the docker fleet."""
2
-
3
- from __future__ import annotations
4
-
5
- from contextlib import contextmanager
6
- from types import SimpleNamespace
7
- from unittest import mock
8
-
9
- from click.testing import CliRunner
10
-
11
- from dbctl.audit import append, read
12
- from dbctl.cli import _make_multi_op_command
13
- from dbctl.config import Operation
14
-
15
-
16
- # --------------------------------------------------------------------------- #
17
- # Multi-op CLI used to crash on every invocation: click lowercases argument
18
- # names when exposing them as kwargs, but the callback popped `r.upper()`.
19
- # Verifying the fix + guarding against a reintroduction.
20
- # --------------------------------------------------------------------------- #
21
- def test_multi_op_command_uses_lowercase_role_kwargs(tmp_path, monkeypatch):
22
- op = Operation.model_validate(
23
- {
24
- "description": "test diff",
25
- "scope": "multi",
26
- "mode": "diff",
27
- "roles": ["src", "trg"],
28
- "queries": {
29
- "src": "SELECT 'u' AS t, 1 AS n",
30
- "trg": "SELECT 'u' AS t, 2 AS n",
31
- },
32
- "diff": {"key": ["t"], "show": ["n"]},
33
- }
34
- )
35
-
36
- conns = {
37
- "pg": SimpleNamespace(description="a", aliases=[]),
38
- "my": SimpleNamespace(description="b", aliases=[]),
39
- }
40
-
41
- @contextmanager
42
- def fake_opened(name, conn):
43
- yield SimpleNamespace(name=name, engine=None, tunnel=mock.MagicMock())
44
-
45
- captured: dict = {}
46
-
47
- def fake_run_role(_op, role, opened_conn, _params):
48
- captured.setdefault("role_conns", {})[role] = opened_conn.name
49
- return [{"t": "u", "n": 1 if role == "src" else 2}]
50
-
51
- monkeypatch.setattr("dbctl.cli.registries", lambda _ctx: (conns, {"test-diff": op}))
52
- monkeypatch.setattr("dbctl.multi.opened", fake_opened)
53
- monkeypatch.setattr("dbctl.multi.run_role", fake_run_role)
54
- monkeypatch.setattr("dbctl.audit.history_path", lambda profile=None: tmp_path / "h.jsonl")
55
-
56
- cmd = _make_multi_op_command("diff", "test-diff", op)
57
- result = CliRunner().invoke(cmd, ["pg", "my"], obj={})
58
- assert result.exit_code == 0, result.output
59
- assert captured["role_conns"] == {"src": "pg", "trg": "my"}
60
-
61
-
62
- # --------------------------------------------------------------------------- #
63
- # audit.read() must skip corrupt lines *before* truncating to `limit`, not
64
- # after — otherwise a half-written trailing line evicts a valid older entry
65
- # from the window.
66
- # --------------------------------------------------------------------------- #
67
- def test_audit_read_skips_corrupt_lines_without_losing_history(tmp_path, monkeypatch):
68
- from dbctl import audit
69
-
70
- monkeypatch.setattr(audit, "history_path", lambda profile=None: tmp_path / "h.jsonl")
71
-
72
- append(profile=None, connection="pg", operation="op-a", params={}, mode="execute", status="ok")
73
- append(profile=None, connection="pg", operation="op-b", params={}, mode="execute", status="ok")
74
-
75
- # append a corrupted tail (simulating a crash mid-write)
76
- (tmp_path / "h.jsonl").open("a").write("{ broken json\n")
77
-
78
- entries = read(None, limit=5)
79
- assert len(entries) == 2
80
- assert {e["operation"] for e in entries} == {"op-a", "op-b"}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes