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.
- {dbctl-0.5.2 → dbctl-0.5.3}/.dbctl/operations.yaml +15 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/CHANGELOG.md +40 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/PKG-INFO +88 -18
- {dbctl-0.5.2 → dbctl-0.5.3}/README.md +87 -17
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/cli.py +59 -6
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/execute.py +25 -2
- {dbctl-0.5.2 → dbctl-0.5.3}/docs/connections.md +27 -4
- {dbctl-0.5.2 → dbctl-0.5.3}/docs/operations.md +31 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/docs/tutorial.md +9 -14
- {dbctl-0.5.2 → dbctl-0.5.3}/pyproject.toml +1 -1
- dbctl-0.5.3/tests/test_regressions.py +210 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/tests/test_smoke.py +32 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/uv.lock +1 -1
- dbctl-0.5.2/tests/test_regressions.py +0 -80
- {dbctl-0.5.2 → dbctl-0.5.3}/.dbctl/connections.yaml +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/.github/workflows/ci.yml +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/.github-local/ci.yml +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/.gitignore +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/__init__.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/__main__.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/audit.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/config.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/connections.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/db.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/init.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/multi.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/operations.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/reports.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/runtime.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/tunnels/__init__.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/tunnels/base.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/tunnels/direct.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/tunnels/k8s.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/tunnels/ssh.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/dbctl/tunnels/ssm.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/docker-compose.yml +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/docs/ACTION_OUTPUT.md +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/docs/DESIGN.md +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/docs/SESSION_STATE.md +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/seed/mssql.sql +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/seed/mysql.sql +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/seed/postgres.sql +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/tests/test_bastion_tags.py +0 -0
- {dbctl-0.5.2 → dbctl-0.5.3}/tests/test_connections_loader.py +0 -0
- {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.
|
|
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
|
|
38
|
-
|
|
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
|
-
#
|
|
125
|
-
|
|
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.
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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/ #
|
|
302
|
-
uv run mypy dbctl
|
|
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
|
|
5
|
-
|
|
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
|
-
#
|
|
92
|
-
|
|
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.
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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/ #
|
|
269
|
-
uv run mypy dbctl
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
618
|
-
|
|
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({
|
|
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
|
-
|
|
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(
|
|
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
|
-
| `
|
|
28
|
-
| `
|
|
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
|
|
38
|
-
|
|
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.
|
|
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
|
-
|
|
89
|
-
section 11.
|
|
90
|
-
|
|
91
|
-
The sample config
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
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
|
# --------------------------------------------------------------------------- #
|
|
@@ -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
|
|
File without changes
|