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.
Files changed (87) hide show
  1. {dbctl-0.7.2 → dbctl-0.7.4}/CHANGELOG.md +67 -0
  2. {dbctl-0.7.2 → dbctl-0.7.4}/PKG-INFO +69 -11
  3. {dbctl-0.7.2 → dbctl-0.7.4}/README.md +66 -10
  4. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/cli.py +27 -1
  5. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/config.py +41 -0
  6. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/db.py +12 -4
  7. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/init.py +41 -1
  8. dbctl-0.7.4/dbctl/tunnels/azure.py +81 -0
  9. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/tunnels/base.py +12 -0
  10. dbctl-0.7.4/dbctl/tunnels/gcp.py +76 -0
  11. dbctl-0.7.4/dbctl/ui/__init__.py +8 -0
  12. dbctl-0.7.4/dbctl/ui/app.py +234 -0
  13. dbctl-0.7.4/dbctl/ui/connection_tree.py +539 -0
  14. dbctl-0.7.4/dbctl/ui/editor_tab.py +164 -0
  15. dbctl-0.7.4/dbctl/ui/grouping.py +63 -0
  16. dbctl-0.7.4/dbctl/ui/operation_tab.py +171 -0
  17. dbctl-0.7.4/dbctl/ui/registry.py +39 -0
  18. dbctl-0.7.4/dbctl/ui/results.py +29 -0
  19. dbctl-0.7.4/dbctl/ui/schema.py +89 -0
  20. dbctl-0.7.4/dbctl/ui/screens.py +146 -0
  21. dbctl-0.7.4/dbctl/ui/session.py +137 -0
  22. dbctl-0.7.4/dbctl/ui/splitter.py +109 -0
  23. dbctl-0.7.4/dbctl/ui/sql_templates.py +55 -0
  24. dbctl-0.7.4/dbctl/ui/tabs.py +87 -0
  25. {dbctl-0.7.2 → dbctl-0.7.4}/docs/connections.md +102 -1
  26. dbctl-0.7.4/docs/logo_small.png +0 -0
  27. dbctl-0.7.4/docs/tui.md +184 -0
  28. {dbctl-0.7.2 → dbctl-0.7.4}/pyproject.toml +14 -2
  29. dbctl-0.7.4/tests/test_azure_tunnel.py +284 -0
  30. dbctl-0.7.4/tests/test_gcp_tunnel.py +248 -0
  31. {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_regressions.py +3 -3
  32. dbctl-0.7.4/tests/ui/conftest.py +76 -0
  33. dbctl-0.7.4/tests/ui/test_app.py +43 -0
  34. dbctl-0.7.4/tests/ui/test_connection_tree.py +140 -0
  35. dbctl-0.7.4/tests/ui/test_connection_tree_grouping.py +200 -0
  36. dbctl-0.7.4/tests/ui/test_editor_tab.py +99 -0
  37. dbctl-0.7.4/tests/ui/test_grouping.py +112 -0
  38. dbctl-0.7.4/tests/ui/test_operation_launcher.py +119 -0
  39. dbctl-0.7.4/tests/ui/test_operation_tab.py +126 -0
  40. dbctl-0.7.4/tests/ui/test_resize.py +79 -0
  41. dbctl-0.7.4/tests/ui/test_schema.py +50 -0
  42. dbctl-0.7.4/tests/ui/test_screens.py +115 -0
  43. dbctl-0.7.4/tests/ui/test_session.py +74 -0
  44. dbctl-0.7.4/tests/ui/test_sql_templates.py +89 -0
  45. dbctl-0.7.4/tests/ui/test_status_bar_and_loading.py +135 -0
  46. dbctl-0.7.4/tests/ui/test_tab_resize.py +139 -0
  47. {dbctl-0.7.2 → dbctl-0.7.4}/uv.lock +853 -680
  48. dbctl-0.7.2/docs/logo_small.png +0 -0
  49. dbctl-0.7.2/tests/test_issue_1.py +0 -580
  50. {dbctl-0.7.2 → dbctl-0.7.4}/.dbctl/connections.yaml +0 -0
  51. {dbctl-0.7.2 → dbctl-0.7.4}/.dbctl/operations.yaml +0 -0
  52. {dbctl-0.7.2 → dbctl-0.7.4}/.github/workflows/ci.yml +0 -0
  53. {dbctl-0.7.2 → dbctl-0.7.4}/.github-local/ci.yml +0 -0
  54. {dbctl-0.7.2 → dbctl-0.7.4}/.gitignore +0 -0
  55. {dbctl-0.7.2 → dbctl-0.7.4}/Makefile +0 -0
  56. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/__init__.py +0 -0
  57. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/__main__.py +0 -0
  58. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/audit.py +0 -0
  59. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/connections.py +0 -0
  60. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/execute.py +0 -0
  61. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/multi.py +0 -0
  62. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/operations.py +0 -0
  63. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/refs.py +0 -0
  64. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/reports.py +0 -0
  65. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/runtime.py +0 -0
  66. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/tunnels/__init__.py +0 -0
  67. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/tunnels/direct.py +0 -0
  68. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/tunnels/k8s.py +0 -0
  69. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/tunnels/ssh.py +0 -0
  70. {dbctl-0.7.2 → dbctl-0.7.4}/dbctl/tunnels/ssm.py +0 -0
  71. {dbctl-0.7.2 → dbctl-0.7.4}/docker-compose.yml +0 -0
  72. {dbctl-0.7.2 → dbctl-0.7.4}/docs/ACTION_OUTPUT.md +0 -0
  73. {dbctl-0.7.2 → dbctl-0.7.4}/docs/DESIGN.md +0 -0
  74. {dbctl-0.7.2 → dbctl-0.7.4}/docs/SESSION_STATE.md +0 -0
  75. {dbctl-0.7.2 → dbctl-0.7.4}/docs/logo.png +0 -0
  76. {dbctl-0.7.2 → dbctl-0.7.4}/docs/operations.md +0 -0
  77. {dbctl-0.7.2 → dbctl-0.7.4}/docs/tutorial.md +0 -0
  78. {dbctl-0.7.2 → dbctl-0.7.4}/seed/mssql.sql +0 -0
  79. {dbctl-0.7.2 → dbctl-0.7.4}/seed/mysql.sql +0 -0
  80. {dbctl-0.7.2 → dbctl-0.7.4}/seed/postgres.sql +0 -0
  81. {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_bastion_tags.py +0 -0
  82. {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_connections_loader.py +0 -0
  83. {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_copy_features.py +0 -0
  84. {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_k8s_tunnel.py +0 -0
  85. {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_refs.py +0 -0
  86. {dbctl-0.7.2 → dbctl-0.7.4}/tests/test_smoke.py +0 -0
  87. {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.2
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
- `dbctl` is **not** a schema browser or a query playground — it deliberately
75
- has no ad-hoc query command. Declaring operations in YAML keeps "what can be
76
- run against this DB" discoverable from a versioned file instead of buried in
77
- your shell history. (For ad-hoc exploration open the tunnel with
78
- `dbctl tunnel open <conn>` and point your favourite client at the local bind.)
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 `ssh` binaries are expected on `PATH`. No `boto3`, no
149
- `paramiko` — dbctl always shells out so you keep your existing SSO session,
150
- key agents, and MFA flows.
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
- └── init.py # dbctl init wizard
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 (~80 tests, in-memory SQLite, no docker)
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
- `dbctl` is **not** a schema browser or a query playground — it deliberately
41
- has no ad-hoc query command. Declaring operations in YAML keeps "what can be
42
- run against this DB" discoverable from a versioned file instead of buried in
43
- your shell history. (For ad-hoc exploration open the tunnel with
44
- `dbctl tunnel open <conn>` and point your favourite client at the local bind.)
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 `ssh` binaries are expected on `PATH`. No `boto3`, no
115
- `paramiko` — dbctl always shells out so you keep your existing SSO session,
116
- key agents, and MFA flows.
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
- └── init.py # dbctl init wizard
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 (~80 tests, in-memory SQLite, no docker)
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 _driver_name(conn: Connection) -> str:
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 = _driver_name(conn)
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 = _driver_name(conn)
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__ = ["build_engine", "resolve_password", "healthcheck", "DBError", "fmt_db_error", "text"]
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