dbctl 0.7.2__tar.gz → 0.7.4__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.7.2 → dbctl-0.7.4}/CHANGELOG.md +67 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/PKG-INFO +69 -11
- {dbctl-0.7.2 → dbctl-0.7.4}/README.md +66 -10
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/cli.py +27 -1
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/config.py +41 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/db.py +12 -4
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/init.py +41 -1
- dbctl-0.7.4/dbctl/tunnels/azure.py +81 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/tunnels/base.py +12 -0
- dbctl-0.7.4/dbctl/tunnels/gcp.py +76 -0
- dbctl-0.7.4/dbctl/ui/__init__.py +8 -0
- dbctl-0.7.4/dbctl/ui/app.py +234 -0
- dbctl-0.7.4/dbctl/ui/connection_tree.py +539 -0
- dbctl-0.7.4/dbctl/ui/editor_tab.py +164 -0
- dbctl-0.7.4/dbctl/ui/grouping.py +63 -0
- dbctl-0.7.4/dbctl/ui/operation_tab.py +171 -0
- dbctl-0.7.4/dbctl/ui/registry.py +39 -0
- dbctl-0.7.4/dbctl/ui/results.py +29 -0
- dbctl-0.7.4/dbctl/ui/schema.py +89 -0
- dbctl-0.7.4/dbctl/ui/screens.py +146 -0
- dbctl-0.7.4/dbctl/ui/session.py +137 -0
- dbctl-0.7.4/dbctl/ui/splitter.py +109 -0
- dbctl-0.7.4/dbctl/ui/sql_templates.py +55 -0
- dbctl-0.7.4/dbctl/ui/tabs.py +87 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/docs/connections.md +102 -1
- dbctl-0.7.4/docs/logo_small.png +0 -0
- dbctl-0.7.4/docs/tui.md +184 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/pyproject.toml +14 -2
- dbctl-0.7.4/tests/test_azure_tunnel.py +284 -0
- dbctl-0.7.4/tests/test_gcp_tunnel.py +248 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_regressions.py +3 -3
- dbctl-0.7.4/tests/ui/conftest.py +76 -0
- dbctl-0.7.4/tests/ui/test_app.py +43 -0
- dbctl-0.7.4/tests/ui/test_connection_tree.py +140 -0
- dbctl-0.7.4/tests/ui/test_connection_tree_grouping.py +200 -0
- dbctl-0.7.4/tests/ui/test_editor_tab.py +99 -0
- dbctl-0.7.4/tests/ui/test_grouping.py +112 -0
- dbctl-0.7.4/tests/ui/test_operation_launcher.py +119 -0
- dbctl-0.7.4/tests/ui/test_operation_tab.py +126 -0
- dbctl-0.7.4/tests/ui/test_resize.py +79 -0
- dbctl-0.7.4/tests/ui/test_schema.py +50 -0
- dbctl-0.7.4/tests/ui/test_screens.py +115 -0
- dbctl-0.7.4/tests/ui/test_session.py +74 -0
- dbctl-0.7.4/tests/ui/test_sql_templates.py +89 -0
- dbctl-0.7.4/tests/ui/test_status_bar_and_loading.py +135 -0
- dbctl-0.7.4/tests/ui/test_tab_resize.py +139 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/uv.lock +853 -680
- dbctl-0.7.2/docs/logo_small.png +0 -0
- dbctl-0.7.2/tests/test_issue_1.py +0 -580
- {dbctl-0.7.2 → dbctl-0.7.4}/.dbctl/connections.yaml +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/.dbctl/operations.yaml +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/.github/workflows/ci.yml +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/.github-local/ci.yml +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/.gitignore +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/Makefile +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/__init__.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/__main__.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/audit.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/connections.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/execute.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/multi.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/operations.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/refs.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/reports.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/runtime.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/tunnels/__init__.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/tunnels/direct.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/tunnels/k8s.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/tunnels/ssh.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/tunnels/ssm.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/docker-compose.yml +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/docs/ACTION_OUTPUT.md +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/docs/DESIGN.md +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/docs/SESSION_STATE.md +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/docs/logo.png +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/docs/operations.md +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/docs/tutorial.md +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/seed/mssql.sql +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/seed/mysql.sql +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/seed/postgres.sql +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_bastion_tags.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_connections_loader.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_copy_features.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_k8s_tunnel.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_refs.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_smoke.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_sso_cache.py +0 -0
|
@@ -5,6 +5,73 @@ 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.7.4] — 2026-08-10
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`dbctl ui`** — an interactive [Textual](https://textual.textualize.io/)
|
|
13
|
+
TUI on top of the same connections/operations/tunnels/audit-log stack as
|
|
14
|
+
the CLI (`textual` is a base dependency, no separate install step). See
|
|
15
|
+
`docs/tui.md` for the full reference.
|
|
16
|
+
- **Connection tree** doubling as a lazily-loaded schema browser: `m`
|
|
17
|
+
toggles a simple `connection → table → column` view and a fuller
|
|
18
|
+
`connection → schema → Tables/Views → table/view → Columns/Indexes`
|
|
19
|
+
view; `g` toggles a flat list and a grouping that treats `-` as a path
|
|
20
|
+
separator (`in-gateway-ifp-dev` nests under `in-gateway` → `ifp`), with
|
|
21
|
+
single-child folders compressed and support for a name that's both a
|
|
22
|
+
group and a real connection at once (`ifp` / `ifp-gateway`). `c`/`d`/`t`
|
|
23
|
+
connect/disconnect/test-tunnel; `a`/`e` open `connections.yaml` in
|
|
24
|
+
`$EDITOR`.
|
|
25
|
+
- **Tabbed workspace**: a SQL editor (syntax-highlighted) or an
|
|
26
|
+
operation-launcher (a form built from a declared operation's
|
|
27
|
+
parameters) per tab, each with an independently resizable results
|
|
28
|
+
table and a status line (row count / rows-affected + duration) below
|
|
29
|
+
it. `Ctrl+N` new tab, `Ctrl+O` searchable operation launcher (type to
|
|
30
|
+
filter), `Ctrl+W` close, `Ctrl+R` run. Activating a table/view in the
|
|
31
|
+
tree opens a SQL tab pre-filled with a dialect-correct preview query
|
|
32
|
+
(`LIMIT` / `TOP` / `FETCH FIRST … ROWS ONLY`, with per-dialect
|
|
33
|
+
identifier quoting for mixed-case table/schema names).
|
|
34
|
+
- Connect/disconnect/test-tunnel and every SQL/operation run happen on a
|
|
35
|
+
background thread with a loading indicator, so the UI stays responsive
|
|
36
|
+
instead of freezing while a tunnel spins up or a query runs.
|
|
37
|
+
- Every run still respects `safety.read_only` / `safety.confirm` /
|
|
38
|
+
`allowed_operations` and is appended to `~/.dbctl/history.jsonl`,
|
|
39
|
+
exactly like a CLI-driven run.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **`uv.lock` could resolve against a contributor's local package-index
|
|
44
|
+
override instead of public PyPI** — a machine-level `uv` config pointing
|
|
45
|
+
at an internal package mirror silently became the index every regenerated
|
|
46
|
+
`uv.lock` entry was resolved and hashed against, which both leaked an
|
|
47
|
+
internal hostname into the lock file and broke `uv sync --frozen` for
|
|
48
|
+
anyone (CI included) without access to that mirror. Pinned
|
|
49
|
+
`[[tool.uv.index]]` in `pyproject.toml` with `default = true` so this
|
|
50
|
+
project always locks against `pypi.org`/`files.pythonhosted.org`
|
|
51
|
+
regardless of what index a contributor's environment defaults to.
|
|
52
|
+
|
|
53
|
+
## [0.7.3] — 2026-08-04
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- **`azure` tunnel type** — reaches a VM through Azure Bastion via
|
|
58
|
+
`az network bastion tunnel` (requires the Bastion resource on the
|
|
59
|
+
Standard SKU). New `AzureBastionTunnel` connection block
|
|
60
|
+
(`resource_group` / `bastion_name` / `target_resource_id` /
|
|
61
|
+
`subscription` / `remote_port` / `local_port`), same lifecycle
|
|
62
|
+
(subprocess + `atexit` cleanup, `local_port: 0` auto-pick) as the
|
|
63
|
+
existing `ssm` / `ssh` / `k8s` tunnels. Shells out to the `az` CLI —
|
|
64
|
+
no Azure SDK dependency.
|
|
65
|
+
- **`gcp` tunnel type** — reaches a Compute Engine instance through
|
|
66
|
+
Identity-Aware Proxy via `gcloud compute start-iap-tunnel` (requires
|
|
67
|
+
IAP TCP forwarding on the instance's network and the
|
|
68
|
+
`roles/iap.tunnelResourceAccessor` IAM role). New `GcpIapTunnel`
|
|
69
|
+
connection block (`project` / `zone` / `instance` / `remote_port` /
|
|
70
|
+
`local_port`). Shells out to the `gcloud` CLI — no GCP SDK dependency.
|
|
71
|
+
- Both new types are wired into `dbctl doctor` (reports `az`/`gcloud` on
|
|
72
|
+
`PATH`, only flagged `required` when a configured connection actually
|
|
73
|
+
uses them), `dbctl tunnel list`, and the `dbctl init` wizard.
|
|
74
|
+
|
|
8
75
|
## [0.7.2] — 2026-08-04
|
|
9
76
|
|
|
10
77
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dbctl
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.4
|
|
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
|
|
@@ -25,8 +25,10 @@ Requires-Dist: pyodbc>=5
|
|
|
25
25
|
Requires-Dist: pyyaml>=6
|
|
26
26
|
Requires-Dist: rich>=13
|
|
27
27
|
Requires-Dist: sqlalchemy>=2
|
|
28
|
+
Requires-Dist: textual>=0.60
|
|
28
29
|
Provides-Extra: dev
|
|
29
30
|
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
30
32
|
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
31
33
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
32
34
|
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
@@ -71,11 +73,13 @@ leaving the terminal.
|
|
|
71
73
|
| DML is one `Ctrl-Enter` away | DML is **dry-run by default** until `--apply`; `--yes` skips the prompt |
|
|
72
74
|
| No cross-DB diff | `dbctl diff user-count prod-tenant1 int-tenant1` |
|
|
73
75
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
`dbctl tunnel
|
|
76
|
+
The declarative CLI above is still the primary interface: declaring
|
|
77
|
+
operations in YAML keeps "what can be run against this DB" discoverable from
|
|
78
|
+
a versioned file instead of buried in your shell history. For ad-hoc
|
|
79
|
+
exploration, `dbctl ui` (see below) gives you a schema browser and a SQL
|
|
80
|
+
editor without leaving `dbctl`'s tunnel/safety/audit model - or open the
|
|
81
|
+
tunnel with `dbctl tunnel open <conn>` and point your favourite client at
|
|
82
|
+
the local bind.
|
|
79
83
|
|
|
80
84
|
### How it reaches a database
|
|
81
85
|
|
|
@@ -85,6 +89,8 @@ your shell history. (For ad-hoc exploration open the tunnel with
|
|
|
85
89
|
| `ssh` | Classic `ssh -N -L` port-forward through a bastion | `ssh` CLI on PATH, an SSH key file |
|
|
86
90
|
| `k8s` | `kubectl port-forward` to a Service / Pod in a cluster | `kubectl` CLI on PATH, a valid kubeconfig |
|
|
87
91
|
| `direct` | No tunnel — connect to the upstream host:port directly | none |
|
|
92
|
+
| `azure` | Azure Bastion tunnel to a VM (`az network bastion tunnel`) | `az` CLI on PATH, active `az login` session, Bastion on Standard SKU |
|
|
93
|
+
| `gcp` | IAP TCP tunnel to a Compute Engine instance (`gcloud compute start-iap-tunnel`) | `gcloud` CLI on PATH, active `gcloud auth login` session |
|
|
88
94
|
|
|
89
95
|
Each connection declares its SQLAlchemy URL scheme (`postgresql+psycopg`,
|
|
90
96
|
`mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`, `oracle+oracledb`,
|
|
@@ -145,9 +151,11 @@ uv pip install -e .
|
|
|
145
151
|
uv run dbctl --help
|
|
146
152
|
```
|
|
147
153
|
|
|
148
|
-
The `aws` and `
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
The `aws`, `ssh`, `az`, and `gcloud` binaries are expected on `PATH` (only
|
|
155
|
+
the ones your configured tunnel types actually need — `dbctl doctor` tells
|
|
156
|
+
you which). No `boto3`, no `paramiko`, no Azure/GCP SDKs — dbctl always
|
|
157
|
+
shells out so you keep your existing SSO session, key agents, and MFA
|
|
158
|
+
flows.
|
|
151
159
|
|
|
152
160
|
## Quick start with the bundled docker-compose fleet
|
|
153
161
|
|
|
@@ -348,6 +356,41 @@ reference.
|
|
|
348
356
|
Secret-typed **operation** parameters (`type: secret`) are redacted in the
|
|
349
357
|
audit log regardless of which DB password source the connection uses.
|
|
350
358
|
|
|
359
|
+
## Interactive UI (`dbctl ui`)
|
|
360
|
+
|
|
361
|
+
A [Textual](https://textual.textualize.io/) TUI for interactive work, on top
|
|
362
|
+
of the same connections/operations/tunnels/audit-log stack as the CLI - not
|
|
363
|
+
a separate code path with its own rules. `textual` is a base dependency, so
|
|
364
|
+
no separate install step.
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
dbctl ui
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
- **Left pane**: a connection tree that doubles as a lazily-loaded schema
|
|
371
|
+
browser (`m` toggles a simple `connection → table → column` view and a
|
|
372
|
+
fuller `connection → schema → Tables/Views → table/view →
|
|
373
|
+
Columns/Indexes` view). Expanding a connection connects it if needed;
|
|
374
|
+
activating (Enter) a table/view opens a SQL tab pre-filled with a
|
|
375
|
+
dialect-correct preview query using the real table name. `g` toggles a
|
|
376
|
+
grouped view that treats `-` as a path separator (`in-gateway-ifp-dev`
|
|
377
|
+
nests under `in-gateway` → `ifp`) - handy once a fleet has more than a
|
|
378
|
+
handful of connections.
|
|
379
|
+
- **Right pane**: a tabbed workspace of SQL editor / operation-launcher
|
|
380
|
+
tabs (`Ctrl+N` new tab, `Ctrl+O` searchable operation launcher, `Ctrl+W`
|
|
381
|
+
close, `Ctrl+R` run), each with an independently resizable results table
|
|
382
|
+
below it and a status line (row count / rows affected + duration) after
|
|
383
|
+
each run.
|
|
384
|
+
- Connecting, disconnecting, testing a tunnel, and every SQL/operation run
|
|
385
|
+
happen on a background thread with a loading indicator, so the UI stays
|
|
386
|
+
responsive instead of freezing while a tunnel spins up or a query runs.
|
|
387
|
+
- Every run still respects `safety.read_only` / `safety.confirm` /
|
|
388
|
+
`allowed_operations` and is appended to `~/.dbctl/history.jsonl`, exactly
|
|
389
|
+
like a CLI-driven run.
|
|
390
|
+
|
|
391
|
+
See [`docs/tui.md`](docs/tui.md) for the full keybinding reference and
|
|
392
|
+
per-dialect SQL details (row-limit clause, identifier quoting).
|
|
393
|
+
|
|
351
394
|
## Shell completion
|
|
352
395
|
|
|
353
396
|
```bash
|
|
@@ -371,6 +414,7 @@ dbctl/
|
|
|
371
414
|
├── docs/
|
|
372
415
|
│ ├── connections.md # connections.yaml reference
|
|
373
416
|
│ ├── operations.md # operations.yaml reference
|
|
417
|
+
│ ├── tui.md # dbctl ui reference (keybindings, dialect SQL)
|
|
374
418
|
│ └── DESIGN.md # architecture and design decisions
|
|
375
419
|
└── dbctl/
|
|
376
420
|
├── cli.py # dynamic groups + per-op Click commands
|
|
@@ -384,7 +428,21 @@ dbctl/
|
|
|
384
428
|
├── reports.py # rich tables / json / csv / yaml + diff + copy/sync/validate rendering
|
|
385
429
|
├── audit.py # history.jsonl
|
|
386
430
|
├── runtime.py # opened_conn() ctx-mgr (tunnel+engine+healthcheck)
|
|
387
|
-
|
|
431
|
+
├── init.py # dbctl init wizard
|
|
432
|
+
└── ui/ # Textual TUI (`dbctl ui`)
|
|
433
|
+
├── app.py # DbctlApp: connection tree + tabbed workspace
|
|
434
|
+
├── session.py # per-connection tunnel+engine kept open across tab-runs
|
|
435
|
+
├── connection_tree.py # lazy schema browser (simple/normal + flat/grouped views)
|
|
436
|
+
├── grouping.py # "-" as a path separator -> compressed connection-name trie
|
|
437
|
+
├── schema.py # sqlalchemy.inspect() wrapper for the tree
|
|
438
|
+
├── editor_tab.py # SQL editor tab
|
|
439
|
+
├── operation_tab.py # operation-launcher tab
|
|
440
|
+
├── sql_templates.py # dialect-aware default SELECT + identifier quoting
|
|
441
|
+
├── screens.py # confirm / new-tab / operation-launcher modal screens
|
|
442
|
+
├── splitter.py # draggable pane-resize bars
|
|
443
|
+
├── tabs.py # shared tab base: resize, toolbar, status bar, loading indicator
|
|
444
|
+
├── results.py # DataTable rendering helpers
|
|
445
|
+
└── registry.py # tolerant connections/operations loading
|
|
388
446
|
```
|
|
389
447
|
|
|
390
448
|
## Development
|
|
@@ -393,7 +451,7 @@ dbctl/
|
|
|
393
451
|
uv sync --extra dev
|
|
394
452
|
make help # list all Makefile targets
|
|
395
453
|
make check # lint + unit tests (the pre-commit gate)
|
|
396
|
-
make test # unit tests (~
|
|
454
|
+
make test # unit tests (~270 tests, sqlite-backed, no docker)
|
|
397
455
|
make typecheck # mypy strict (pre-existing debt; non-blocking)
|
|
398
456
|
make smoke # docker compose up + dbctl doctor against the fleet
|
|
399
457
|
make build # wheel + sdist via uv
|
|
@@ -37,11 +37,13 @@ leaving the terminal.
|
|
|
37
37
|
| DML is one `Ctrl-Enter` away | DML is **dry-run by default** until `--apply`; `--yes` skips the prompt |
|
|
38
38
|
| No cross-DB diff | `dbctl diff user-count prod-tenant1 int-tenant1` |
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
`dbctl tunnel
|
|
40
|
+
The declarative CLI above is still the primary interface: declaring
|
|
41
|
+
operations in YAML keeps "what can be run against this DB" discoverable from
|
|
42
|
+
a versioned file instead of buried in your shell history. For ad-hoc
|
|
43
|
+
exploration, `dbctl ui` (see below) gives you a schema browser and a SQL
|
|
44
|
+
editor without leaving `dbctl`'s tunnel/safety/audit model - or open the
|
|
45
|
+
tunnel with `dbctl tunnel open <conn>` and point your favourite client at
|
|
46
|
+
the local bind.
|
|
45
47
|
|
|
46
48
|
### How it reaches a database
|
|
47
49
|
|
|
@@ -51,6 +53,8 @@ your shell history. (For ad-hoc exploration open the tunnel with
|
|
|
51
53
|
| `ssh` | Classic `ssh -N -L` port-forward through a bastion | `ssh` CLI on PATH, an SSH key file |
|
|
52
54
|
| `k8s` | `kubectl port-forward` to a Service / Pod in a cluster | `kubectl` CLI on PATH, a valid kubeconfig |
|
|
53
55
|
| `direct` | No tunnel — connect to the upstream host:port directly | none |
|
|
56
|
+
| `azure` | Azure Bastion tunnel to a VM (`az network bastion tunnel`) | `az` CLI on PATH, active `az login` session, Bastion on Standard SKU |
|
|
57
|
+
| `gcp` | IAP TCP tunnel to a Compute Engine instance (`gcloud compute start-iap-tunnel`) | `gcloud` CLI on PATH, active `gcloud auth login` session |
|
|
54
58
|
|
|
55
59
|
Each connection declares its SQLAlchemy URL scheme (`postgresql+psycopg`,
|
|
56
60
|
`mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`, `oracle+oracledb`,
|
|
@@ -111,9 +115,11 @@ uv pip install -e .
|
|
|
111
115
|
uv run dbctl --help
|
|
112
116
|
```
|
|
113
117
|
|
|
114
|
-
The `aws` and `
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
The `aws`, `ssh`, `az`, and `gcloud` binaries are expected on `PATH` (only
|
|
119
|
+
the ones your configured tunnel types actually need — `dbctl doctor` tells
|
|
120
|
+
you which). No `boto3`, no `paramiko`, no Azure/GCP SDKs — dbctl always
|
|
121
|
+
shells out so you keep your existing SSO session, key agents, and MFA
|
|
122
|
+
flows.
|
|
117
123
|
|
|
118
124
|
## Quick start with the bundled docker-compose fleet
|
|
119
125
|
|
|
@@ -314,6 +320,41 @@ reference.
|
|
|
314
320
|
Secret-typed **operation** parameters (`type: secret`) are redacted in the
|
|
315
321
|
audit log regardless of which DB password source the connection uses.
|
|
316
322
|
|
|
323
|
+
## Interactive UI (`dbctl ui`)
|
|
324
|
+
|
|
325
|
+
A [Textual](https://textual.textualize.io/) TUI for interactive work, on top
|
|
326
|
+
of the same connections/operations/tunnels/audit-log stack as the CLI - not
|
|
327
|
+
a separate code path with its own rules. `textual` is a base dependency, so
|
|
328
|
+
no separate install step.
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
dbctl ui
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
- **Left pane**: a connection tree that doubles as a lazily-loaded schema
|
|
335
|
+
browser (`m` toggles a simple `connection → table → column` view and a
|
|
336
|
+
fuller `connection → schema → Tables/Views → table/view →
|
|
337
|
+
Columns/Indexes` view). Expanding a connection connects it if needed;
|
|
338
|
+
activating (Enter) a table/view opens a SQL tab pre-filled with a
|
|
339
|
+
dialect-correct preview query using the real table name. `g` toggles a
|
|
340
|
+
grouped view that treats `-` as a path separator (`in-gateway-ifp-dev`
|
|
341
|
+
nests under `in-gateway` → `ifp`) - handy once a fleet has more than a
|
|
342
|
+
handful of connections.
|
|
343
|
+
- **Right pane**: a tabbed workspace of SQL editor / operation-launcher
|
|
344
|
+
tabs (`Ctrl+N` new tab, `Ctrl+O` searchable operation launcher, `Ctrl+W`
|
|
345
|
+
close, `Ctrl+R` run), each with an independently resizable results table
|
|
346
|
+
below it and a status line (row count / rows affected + duration) after
|
|
347
|
+
each run.
|
|
348
|
+
- Connecting, disconnecting, testing a tunnel, and every SQL/operation run
|
|
349
|
+
happen on a background thread with a loading indicator, so the UI stays
|
|
350
|
+
responsive instead of freezing while a tunnel spins up or a query runs.
|
|
351
|
+
- Every run still respects `safety.read_only` / `safety.confirm` /
|
|
352
|
+
`allowed_operations` and is appended to `~/.dbctl/history.jsonl`, exactly
|
|
353
|
+
like a CLI-driven run.
|
|
354
|
+
|
|
355
|
+
See [`docs/tui.md`](docs/tui.md) for the full keybinding reference and
|
|
356
|
+
per-dialect SQL details (row-limit clause, identifier quoting).
|
|
357
|
+
|
|
317
358
|
## Shell completion
|
|
318
359
|
|
|
319
360
|
```bash
|
|
@@ -337,6 +378,7 @@ dbctl/
|
|
|
337
378
|
├── docs/
|
|
338
379
|
│ ├── connections.md # connections.yaml reference
|
|
339
380
|
│ ├── operations.md # operations.yaml reference
|
|
381
|
+
│ ├── tui.md # dbctl ui reference (keybindings, dialect SQL)
|
|
340
382
|
│ └── DESIGN.md # architecture and design decisions
|
|
341
383
|
└── dbctl/
|
|
342
384
|
├── cli.py # dynamic groups + per-op Click commands
|
|
@@ -350,7 +392,21 @@ dbctl/
|
|
|
350
392
|
├── reports.py # rich tables / json / csv / yaml + diff + copy/sync/validate rendering
|
|
351
393
|
├── audit.py # history.jsonl
|
|
352
394
|
├── runtime.py # opened_conn() ctx-mgr (tunnel+engine+healthcheck)
|
|
353
|
-
|
|
395
|
+
├── init.py # dbctl init wizard
|
|
396
|
+
└── ui/ # Textual TUI (`dbctl ui`)
|
|
397
|
+
├── app.py # DbctlApp: connection tree + tabbed workspace
|
|
398
|
+
├── session.py # per-connection tunnel+engine kept open across tab-runs
|
|
399
|
+
├── connection_tree.py # lazy schema browser (simple/normal + flat/grouped views)
|
|
400
|
+
├── grouping.py # "-" as a path separator -> compressed connection-name trie
|
|
401
|
+
├── schema.py # sqlalchemy.inspect() wrapper for the tree
|
|
402
|
+
├── editor_tab.py # SQL editor tab
|
|
403
|
+
├── operation_tab.py # operation-launcher tab
|
|
404
|
+
├── sql_templates.py # dialect-aware default SELECT + identifier quoting
|
|
405
|
+
├── screens.py # confirm / new-tab / operation-launcher modal screens
|
|
406
|
+
├── splitter.py # draggable pane-resize bars
|
|
407
|
+
├── tabs.py # shared tab base: resize, toolbar, status bar, loading indicator
|
|
408
|
+
├── results.py # DataTable rendering helpers
|
|
409
|
+
└── registry.py # tolerant connections/operations loading
|
|
354
410
|
```
|
|
355
411
|
|
|
356
412
|
## Development
|
|
@@ -359,7 +415,7 @@ dbctl/
|
|
|
359
415
|
uv sync --extra dev
|
|
360
416
|
make help # list all Makefile targets
|
|
361
417
|
make check # lint + unit tests (the pre-commit gate)
|
|
362
|
-
make test # unit tests (~
|
|
418
|
+
make test # unit tests (~270 tests, sqlite-backed, no docker)
|
|
363
419
|
make typecheck # mypy strict (pre-existing debt; non-blocking)
|
|
364
420
|
make smoke # docker compose up + dbctl doctor against the fleet
|
|
365
421
|
make build # wheel + sdist via uv
|
|
@@ -1020,7 +1020,7 @@ def _aliases(conns):
|
|
|
1020
1020
|
|
|
1021
1021
|
def _root_list(ctx: click.Context) -> list[str]:
|
|
1022
1022
|
conns, ops = registries(ctx)
|
|
1023
|
-
static = ["connections", "operations", "status", "doctor", "init", "history", "tunnel"]
|
|
1023
|
+
static = ["connections", "operations", "status", "doctor", "init", "history", "tunnel", "ui"]
|
|
1024
1024
|
# multi-op operation-first top-level commands + deprecated verb-first groups
|
|
1025
1025
|
multi_ops = {n for n, o in ops.items() if o.scope.value == "multi"}
|
|
1026
1026
|
multi_modes = {o.mode.value for o in ops.values() if o.scope.value == "multi"}
|
|
@@ -1039,6 +1039,7 @@ def _root_get(ctx: click.Context, name: str):
|
|
|
1039
1039
|
"init": init_cmd,
|
|
1040
1040
|
"history": history_cmd,
|
|
1041
1041
|
"tunnel": tunnel_cmd,
|
|
1042
|
+
"ui": ui_cmd,
|
|
1042
1043
|
}
|
|
1043
1044
|
if name in static:
|
|
1044
1045
|
return static[name]
|
|
@@ -1289,6 +1290,8 @@ def _doctor_deps(ctx, conns) -> None:
|
|
|
1289
1290
|
("kubectl", TunnelType.k8s, "install: https://kubernetes.io/docs/tasks/tools/"),
|
|
1290
1291
|
("aws", TunnelType.ssm, "install: `pip install awscli` or your OS package"),
|
|
1291
1292
|
("ssh", TunnelType.ssh, "install: OpenSSH client (openssh-clients / openssh-client)"),
|
|
1293
|
+
("az", TunnelType.azure, "install: https://learn.microsoft.com/cli/azure/install-azure-cli"),
|
|
1294
|
+
("gcloud", TunnelType.gcp, "install: https://cloud.google.com/sdk/docs/install"),
|
|
1292
1295
|
]
|
|
1293
1296
|
|
|
1294
1297
|
dep_table = Table(title="optional dependencies", header_style="bold cyan")
|
|
@@ -1327,6 +1330,15 @@ def init_cmd(ctx):
|
|
|
1327
1330
|
run_wizard(profile=ctx.obj.get("profile"))
|
|
1328
1331
|
|
|
1329
1332
|
|
|
1333
|
+
@click.command("ui")
|
|
1334
|
+
@click.pass_context
|
|
1335
|
+
def ui_cmd(ctx):
|
|
1336
|
+
"""Launch the interactive TUI: connection tree + tabbed SQL editor / operations."""
|
|
1337
|
+
from dbctl.ui.app import DbctlApp
|
|
1338
|
+
|
|
1339
|
+
DbctlApp(profile=ctx.obj.get("profile")).run()
|
|
1340
|
+
|
|
1341
|
+
|
|
1330
1342
|
@main.group("history")
|
|
1331
1343
|
def history_cmd():
|
|
1332
1344
|
"""Show the audit log."""
|
|
@@ -1367,6 +1379,8 @@ def tunnel_cmd():
|
|
|
1367
1379
|
- ssh: Classic ssh -N -L port-forward through a bastion
|
|
1368
1380
|
- k8s: kubectl port-forward to a Service or Pod
|
|
1369
1381
|
- direct: No tunnel — connect to upstream host:port
|
|
1382
|
+
- azure: Azure Bastion tunnel to a VM (az network bastion tunnel)
|
|
1383
|
+
- gcp: GCP IAP TCP tunnel to a Compute Engine instance (gcloud compute start-iap-tunnel)
|
|
1370
1384
|
|
|
1371
1385
|
\b
|
|
1372
1386
|
Subcommands:
|
|
@@ -1507,6 +1521,18 @@ def tunnel_list(ctx):
|
|
|
1507
1521
|
if c.k8s.namespace:
|
|
1508
1522
|
info_parts.append(f"ns={c.k8s.namespace}")
|
|
1509
1523
|
info_parts.append(f"port={c.k8s.remote_port}")
|
|
1524
|
+
elif ttype == "azure":
|
|
1525
|
+
assert c.azure
|
|
1526
|
+
info_parts.append(f"bastion={c.azure.bastion_name}")
|
|
1527
|
+
info_parts.append(f"target={c.azure.target_resource_id}")
|
|
1528
|
+
info_parts.append(f"port={c.azure.remote_port}")
|
|
1529
|
+
elif ttype == "gcp":
|
|
1530
|
+
assert c.gcp
|
|
1531
|
+
info_parts.append(f"instance={c.gcp.instance}")
|
|
1532
|
+
info_parts.append(f"zone={c.gcp.zone}")
|
|
1533
|
+
if c.gcp.project:
|
|
1534
|
+
info_parts.append(f"project={c.gcp.project}")
|
|
1535
|
+
info_parts.append(f"port={c.gcp.remote_port}")
|
|
1510
1536
|
table.add_row(name, ttype, driver, ", ".join(info_parts))
|
|
1511
1537
|
|
|
1512
1538
|
console.print(table)
|
|
@@ -26,6 +26,8 @@ class TunnelType(StrEnum):
|
|
|
26
26
|
ssh = "ssh"
|
|
27
27
|
k8s = "k8s"
|
|
28
28
|
direct = "direct"
|
|
29
|
+
azure = "azure"
|
|
30
|
+
gcp = "gcp"
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
class OpScope(StrEnum):
|
|
@@ -146,6 +148,37 @@ class DirectTunnel(BaseModel):
|
|
|
146
148
|
port: int = 5432
|
|
147
149
|
|
|
148
150
|
|
|
151
|
+
class AzureBastionTunnel(BaseModel):
|
|
152
|
+
"""Azure Bastion tunnel via ``az network bastion tunnel`` subprocess.
|
|
153
|
+
|
|
154
|
+
Requires an Azure Bastion resource on the Standard SKU (native client
|
|
155
|
+
support / tunnel command needs Standard, not Basic) in the target VM's
|
|
156
|
+
VNet.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
model_config = ConfigDict(extra="forbid")
|
|
160
|
+
resource_group: str
|
|
161
|
+
bastion_name: str
|
|
162
|
+
target_resource_id: str # full ARM resource id of the target VM
|
|
163
|
+
subscription: str | None = None # az CLI --subscription (name or id)
|
|
164
|
+
remote_port: int = 5432 # --resource-port on the target VM
|
|
165
|
+
local_port: int = 0 # 0 = auto-pick free port
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class GcpIapTunnel(BaseModel):
|
|
169
|
+
"""GCP Identity-Aware Proxy TCP tunnel via ``gcloud compute
|
|
170
|
+
start-iap-tunnel`` subprocess. Requires IAP TCP forwarding enabled on
|
|
171
|
+
the target instance's network and the caller to have the
|
|
172
|
+
``roles/iap.tunnelResourceAccessor`` IAM role."""
|
|
173
|
+
|
|
174
|
+
model_config = ConfigDict(extra="forbid")
|
|
175
|
+
project: str | None = None # gcloud --project; omit to use the CLI's active project
|
|
176
|
+
zone: str
|
|
177
|
+
instance: str
|
|
178
|
+
remote_port: int = 5432 # port on the instance to forward
|
|
179
|
+
local_port: int = 0 # 0 = auto-pick free port
|
|
180
|
+
|
|
181
|
+
|
|
149
182
|
# --------------------------------------------------------------------------- #
|
|
150
183
|
# info / healthcheck
|
|
151
184
|
# --------------------------------------------------------------------------- #
|
|
@@ -194,6 +227,8 @@ class Connection(BaseModel):
|
|
|
194
227
|
ssh: SshTunnel | None = None
|
|
195
228
|
k8s: K8sTunnel | None = None
|
|
196
229
|
direct: DirectTunnel | None = None
|
|
230
|
+
azure: AzureBastionTunnel | None = None
|
|
231
|
+
gcp: GcpIapTunnel | None = None
|
|
197
232
|
|
|
198
233
|
healthcheck: Healthcheck = Field(default_factory=Healthcheck)
|
|
199
234
|
info: list[InfoQuery] = Field(default_factory=list)
|
|
@@ -214,6 +249,12 @@ class Connection(BaseModel):
|
|
|
214
249
|
case TunnelType.direct:
|
|
215
250
|
if self.direct is None:
|
|
216
251
|
raise ValueError("'direct' block required when type=direct")
|
|
252
|
+
case TunnelType.azure:
|
|
253
|
+
if self.azure is None:
|
|
254
|
+
raise ValueError("'azure' block required when type=azure")
|
|
255
|
+
case TunnelType.gcp:
|
|
256
|
+
if self.gcp is None:
|
|
257
|
+
raise ValueError("'gcp' block required when type=gcp")
|
|
217
258
|
|
|
218
259
|
if self.url is not None:
|
|
219
260
|
# Full-URL mode: driver/database/username/password fields are all
|
|
@@ -60,7 +60,7 @@ def resolve_password(conn: Connection) -> str | None:
|
|
|
60
60
|
raise DBError("no password source configured")
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
def
|
|
63
|
+
def driver_name(conn: Connection) -> str:
|
|
64
64
|
"""Return the SQLAlchemy driver scheme for this connection.
|
|
65
65
|
|
|
66
66
|
When the user provides a full ``url:`` string, the driver is the
|
|
@@ -79,7 +79,7 @@ def _driver_name(conn: Connection) -> str:
|
|
|
79
79
|
def _connect_args(conn: Connection, timeout: float) -> dict:
|
|
80
80
|
"""Driver-specific connect-time knobs (mainly connect_timeout)."""
|
|
81
81
|
args: dict = {}
|
|
82
|
-
driver =
|
|
82
|
+
driver = driver_name(conn)
|
|
83
83
|
if driver.startswith(("postgresql", "mysql", "mariadb", "oracle")):
|
|
84
84
|
args["connect_timeout"] = int(max(1, timeout))
|
|
85
85
|
# sqlite + duckdb are file-based — no connect_timeout; SQLAlchemy
|
|
@@ -111,7 +111,7 @@ def build_engine(conn: Connection, tunnel: Tunnel, *, echo: bool = False) -> Eng
|
|
|
111
111
|
|
|
112
112
|
conn = resolve_connection(conn)
|
|
113
113
|
|
|
114
|
-
driver =
|
|
114
|
+
driver = driver_name(conn)
|
|
115
115
|
_check_driver_available(driver)
|
|
116
116
|
|
|
117
117
|
from sqlalchemy import URL, make_url
|
|
@@ -264,4 +264,12 @@ def fmt_db_error(e: BaseException) -> str:
|
|
|
264
264
|
return msg
|
|
265
265
|
|
|
266
266
|
|
|
267
|
-
__all__ = [
|
|
267
|
+
__all__ = [
|
|
268
|
+
"build_engine",
|
|
269
|
+
"resolve_password",
|
|
270
|
+
"healthcheck",
|
|
271
|
+
"DBError",
|
|
272
|
+
"fmt_db_error",
|
|
273
|
+
"driver_name",
|
|
274
|
+
"text",
|
|
275
|
+
]
|
|
@@ -12,8 +12,10 @@ import yaml
|
|
|
12
12
|
from rich.console import Console
|
|
13
13
|
|
|
14
14
|
from dbctl.config import (
|
|
15
|
+
AzureBastionTunnel,
|
|
15
16
|
Connection,
|
|
16
17
|
DirectTunnel,
|
|
18
|
+
GcpIapTunnel,
|
|
17
19
|
Healthcheck,
|
|
18
20
|
K8sTunnel,
|
|
19
21
|
SshTunnel,
|
|
@@ -44,7 +46,7 @@ def run_wizard(*, profile: str | None) -> None:
|
|
|
44
46
|
default=False,
|
|
45
47
|
)
|
|
46
48
|
|
|
47
|
-
ssm = ssh = k8s = direct = None
|
|
49
|
+
ssm = ssh = k8s = direct = azure = gcp = None
|
|
48
50
|
url = None
|
|
49
51
|
driver = None
|
|
50
52
|
database = None
|
|
@@ -113,6 +115,10 @@ def run_wizard(*, profile: str | None) -> None:
|
|
|
113
115
|
ssh = _ask_ssh()
|
|
114
116
|
elif type_ == "k8s":
|
|
115
117
|
k8s = _ask_k8s()
|
|
118
|
+
elif type_ == "azure":
|
|
119
|
+
azure = _ask_azure()
|
|
120
|
+
elif type_ == "gcp":
|
|
121
|
+
gcp = _ask_gcp()
|
|
116
122
|
else:
|
|
117
123
|
host = click.prompt("host", default="localhost")
|
|
118
124
|
port = click.prompt("port", type=int, default=_default_port(driver or ""))
|
|
@@ -138,6 +144,8 @@ def run_wizard(*, profile: str | None) -> None:
|
|
|
138
144
|
ssh=ssh,
|
|
139
145
|
k8s=k8s,
|
|
140
146
|
direct=direct,
|
|
147
|
+
azure=azure,
|
|
148
|
+
gcp=gcp,
|
|
141
149
|
healthcheck=healthcheck,
|
|
142
150
|
safety={"confirm": confirm, "read_only": read_only},
|
|
143
151
|
)
|
|
@@ -215,6 +223,38 @@ def _ask_k8s() -> K8sTunnel:
|
|
|
215
223
|
)
|
|
216
224
|
|
|
217
225
|
|
|
226
|
+
def _ask_azure() -> AzureBastionTunnel:
|
|
227
|
+
resource_group = click.prompt("resource group", type=str)
|
|
228
|
+
bastion_name = click.prompt("bastion name", type=str)
|
|
229
|
+
target_resource_id = click.prompt("target VM resource id (full ARM resource id)", type=str)
|
|
230
|
+
subscription = click.prompt("azure subscription (name or id, optional)", default="")
|
|
231
|
+
remote_port = click.prompt("remote port (on the target VM)", type=int, default=5432)
|
|
232
|
+
local_port = click.prompt("local port (0 = auto)", type=int, default=0)
|
|
233
|
+
return AzureBastionTunnel(
|
|
234
|
+
resource_group=resource_group,
|
|
235
|
+
bastion_name=bastion_name,
|
|
236
|
+
target_resource_id=target_resource_id,
|
|
237
|
+
subscription=subscription or None,
|
|
238
|
+
remote_port=remote_port,
|
|
239
|
+
local_port=local_port,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _ask_gcp() -> GcpIapTunnel:
|
|
244
|
+
project = click.prompt("gcp project (optional; blank = gcloud's active project)", default="")
|
|
245
|
+
zone = click.prompt("zone", type=str)
|
|
246
|
+
instance = click.prompt("instance name", type=str)
|
|
247
|
+
remote_port = click.prompt("remote port (on the instance)", type=int, default=5432)
|
|
248
|
+
local_port = click.prompt("local port (0 = auto)", type=int, default=0)
|
|
249
|
+
return GcpIapTunnel(
|
|
250
|
+
project=project or None,
|
|
251
|
+
zone=zone,
|
|
252
|
+
instance=instance,
|
|
253
|
+
remote_port=remote_port,
|
|
254
|
+
local_port=local_port,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
218
258
|
def _default_port(driver: str) -> int:
|
|
219
259
|
if driver.startswith("postgresql"):
|
|
220
260
|
return 5432
|