dbctl 0.6.2__tar.gz → 0.6.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 (47) hide show
  1. {dbctl-0.6.2 → dbctl-0.6.4}/.dbctl/connections.yaml +53 -0
  2. {dbctl-0.6.2 → dbctl-0.6.4}/CHANGELOG.md +73 -0
  3. {dbctl-0.6.2 → dbctl-0.6.4}/PKG-INFO +10 -4
  4. {dbctl-0.6.2 → dbctl-0.6.4}/README.md +6 -2
  5. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/cli.py +127 -4
  6. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/config.py +14 -2
  7. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/db.py +23 -6
  8. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/init.py +11 -1
  9. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/multi.py +2 -2
  10. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/tunnels/base.py +19 -3
  11. {dbctl-0.6.2 → dbctl-0.6.4}/docs/connections.md +67 -1
  12. {dbctl-0.6.2 → dbctl-0.6.4}/docs/tutorial.md +31 -0
  13. {dbctl-0.6.2 → dbctl-0.6.4}/pyproject.toml +4 -2
  14. {dbctl-0.6.2 → dbctl-0.6.4}/.dbctl/operations.yaml +0 -0
  15. {dbctl-0.6.2 → dbctl-0.6.4}/.github/workflows/ci.yml +0 -0
  16. {dbctl-0.6.2 → dbctl-0.6.4}/.github-local/ci.yml +0 -0
  17. {dbctl-0.6.2 → dbctl-0.6.4}/.gitignore +0 -0
  18. {dbctl-0.6.2 → dbctl-0.6.4}/Makefile +0 -0
  19. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/__init__.py +0 -0
  20. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/__main__.py +0 -0
  21. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/audit.py +0 -0
  22. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/connections.py +0 -0
  23. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/execute.py +0 -0
  24. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/operations.py +0 -0
  25. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/reports.py +0 -0
  26. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/runtime.py +0 -0
  27. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/tunnels/__init__.py +0 -0
  28. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/tunnels/direct.py +0 -0
  29. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/tunnels/k8s.py +0 -0
  30. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/tunnels/ssh.py +0 -0
  31. {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/tunnels/ssm.py +0 -0
  32. {dbctl-0.6.2 → dbctl-0.6.4}/docker-compose.yml +0 -0
  33. {dbctl-0.6.2 → dbctl-0.6.4}/docs/ACTION_OUTPUT.md +0 -0
  34. {dbctl-0.6.2 → dbctl-0.6.4}/docs/DESIGN.md +0 -0
  35. {dbctl-0.6.2 → dbctl-0.6.4}/docs/SESSION_STATE.md +0 -0
  36. {dbctl-0.6.2 → dbctl-0.6.4}/docs/logo.png +0 -0
  37. {dbctl-0.6.2 → dbctl-0.6.4}/docs/logo_small.png +0 -0
  38. {dbctl-0.6.2 → dbctl-0.6.4}/docs/operations.md +0 -0
  39. {dbctl-0.6.2 → dbctl-0.6.4}/seed/mssql.sql +0 -0
  40. {dbctl-0.6.2 → dbctl-0.6.4}/seed/mysql.sql +0 -0
  41. {dbctl-0.6.2 → dbctl-0.6.4}/seed/postgres.sql +0 -0
  42. {dbctl-0.6.2 → dbctl-0.6.4}/tests/test_bastion_tags.py +0 -0
  43. {dbctl-0.6.2 → dbctl-0.6.4}/tests/test_connections_loader.py +0 -0
  44. {dbctl-0.6.2 → dbctl-0.6.4}/tests/test_k8s_tunnel.py +0 -0
  45. {dbctl-0.6.2 → dbctl-0.6.4}/tests/test_regressions.py +0 -0
  46. {dbctl-0.6.2 → dbctl-0.6.4}/tests/test_smoke.py +0 -0
  47. {dbctl-0.6.2 → dbctl-0.6.4}/uv.lock +0 -0
@@ -226,3 +226,56 @@ connections:
226
226
  safety:
227
227
  confirm: true
228
228
  read_only: true
229
+
230
+ # --------------------------------------------------------------------- #
231
+ # Oracle, SQLite, DuckDB reference templates.
232
+ # --------------------------------------------------------------------- #
233
+
234
+ # Oracle Database — oracledb thin mode (pure Python, no Instant Client).
235
+ # Use `database:` for the service name (or SID). Healthcheck uses
236
+ # `SELECT 1 FROM DUAL` which is the Oracle convention.
237
+ prod-oracle:
238
+ description: "REFERENCE: Oracle via oracledb thin mode (edit before using)"
239
+ aliases: []
240
+ type: direct
241
+ driver: oracle+oracledb
242
+ database: ORCLPDB1
243
+ username: app_admin
244
+ password: "<set-me>"
245
+ direct: { host: db.internal, port: 1521 }
246
+ healthcheck: { query: "SELECT 1 FROM DUAL", timeout_seconds: 10 }
247
+ safety:
248
+ confirm: true
249
+ read_only: true
250
+
251
+ # Local SQLite — file-based, no host/port needed (but config schema
252
+ # requires a `direct:` block). Use `url:` mode for a cleaner config:
253
+ # url: "sqlite:////absolute/path/to/mydata.db"
254
+ local-sqlite:
255
+ description: "REFERENCE: Local SQLite (edit path before using)"
256
+ aliases: []
257
+ type: direct
258
+ driver: sqlite
259
+ database: /tmp/mydata.db
260
+ username: ""
261
+ password: ""
262
+ direct: { host: localhost, port: 0 }
263
+ healthcheck: { query: "SELECT 1" }
264
+ safety:
265
+ confirm: true
266
+ read_only: true
267
+
268
+ # Local DuckDB — file-based (or ":memory:" for in-memory analytics).
269
+ local-duckdb:
270
+ description: "REFERENCE: Local DuckDB (edit path before using)"
271
+ aliases: []
272
+ type: direct
273
+ driver: duckdb
274
+ database: /tmp/mydata.duckdb
275
+ username: ""
276
+ password: ""
277
+ direct: { host: localhost, port: 0 }
278
+ healthcheck: { query: "SELECT 1" }
279
+ safety:
280
+ confirm: true
281
+ read_only: true
@@ -5,6 +5,79 @@ 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.6.4] — 2026-08-03
9
+
10
+ ### Added
11
+
12
+ - **`tunnel open --port`/`-p` flag** — override the local bind port even
13
+ when the config says `local_port: 0` (auto-pick). Works for `ssm` /
14
+ `ssh` / `k8s` tunnels (creates a local listener on the chosen port) and
15
+ `direct` (changes which upstream port SQLAlchemy connects to).
16
+ - **`tunnel test <conn>` subcommand** — opens the tunnel, runs the
17
+ connection's healthcheck query, then closes the tunnel. Prints
18
+ `OK <conn> via <type> (host:port) — ok (latency, total)` (green) on
19
+ success or `FAIL <conn> <stage>: <msg>` (red) on failure. Exit codes
20
+ match the existing convention: 2 unknown conn, 3 tunnel error, 4 engine
21
+ error, 5 healthcheck failed.
22
+ - **`tunnel list` subcommand** — lists every configured connection with
23
+ its tunnel type (`ssm` / `ssh` / `k8s` / `direct`), driver, and key
24
+ parameters in a rich table. For `ssm`: bastion id/tags, remote
25
+ host:port, region. For `ssh`: bastion user@host:port, remote
26
+ host:port. For `k8s`: context, target, namespace, remote port. For
27
+ `direct`: host:port.
28
+ - **`tunnel` group help** — `dbctl tunnel --help` now explains the four
29
+ tunnel types (ssm/ssh/k8s/direct) and lists the three subcommands
30
+ (open/test/list) with one-liner descriptions.
31
+
32
+ ### Changed
33
+
34
+ - `build_tunnel()` in `dbctl/tunnels/base.py` accepts an optional
35
+ `override_port` parameter; the CLI `tunnel open --port` flag flows
36
+ through to it. The override is applied via `model_copy(update=)` so
37
+ the original config object is not mutated.
38
+
39
+ ## [0.6.3] — 2026-08-03
40
+
41
+ ### Added
42
+
43
+ - **Oracle Database support** — `oracle+oracledb` driver (thin mode, pure
44
+ Python — no Oracle Instant Client needed). `oracledb>=2` added as a
45
+ core dependency. The init wizard offers it in the driver choice list;
46
+ `_default_port` returns 1521 for Oracle. Native-lib hint for Oracle
47
+ Instant Client (`libclntsh` / `libociei` / `libocci`) failure path
48
+ included in `dbctl.db._native_lib_hint`. Healthcheck query convention
49
+ is `SELECT 1 FROM DUAL`.
50
+ - **SQLite support** — `sqlite` driver (built into Python stdlib, no
51
+ extra dependency). File-based: `build_engine` skips host/port/
52
+ username/password injection (just `sqlite:///path`). Config validator
53
+ exempts file-based drivers (sqlite + duckdb) from the credential
54
+ requirement — `username` and `password` are not needed.
55
+ - **DuckDB support** — `duckdb` driver. `duckdb>=1` added as a core
56
+ dependency. Same file-based handling as SQLite (`duckdb:///path` or
57
+ `duckdb:///:memory:`).
58
+ - **`replay_spec.on_conflict`** field — the conflict-handling strategy
59
+ for replay mode. Defaults to `skip` (additive — replay new/changed
60
+ rows without breaking existing entries), unlike `copy_spec` which
61
+ defaults to `error`. Use `truncate` for a full refresh.
62
+ - **`logo_small.png`** added to top of every `docs/*.md` file; README
63
+ gets a centred logo_small footer (big logo stays at top).
64
+
65
+ ### Fixed
66
+
67
+ - **`replay-users` crashed on existing rows** — hardcoded
68
+ `on_conflict=error` meant any PK collision in the target aborted the
69
+ replay. Now uses `replay_spec.on_conflict` (default `skip`), so
70
+ `INSERT IGNORE` / `ON CONFLICT DO NOTHING` handles duplicates cleanly
71
+ and the report shows inserted vs skipped counts correctly.
72
+ - **File-based drivers (sqlite/duckdb) rejected by config validator**
73
+ — required username + password even though the drivers ignore them.
74
+ The validator now exempts `sqlite*` and `duckdb*` from the credential
75
+ requirement.
76
+ - **File-based drivers: `build_engine` injected host:port into the URL**
77
+ — SQLAlchemy rejected `sqlite://:***@localhost:0//path` with an
78
+ ArgumentError. Now skips host/port/username/password for file-based
79
+ drivers; the URL is just `sqlite:///path`.
80
+
8
81
  ## [0.6.2] — 2026-08-03
9
82
 
10
83
  ### Added
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbctl
3
- Version: 0.6.2
3
+ Version: 0.6.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
7
- Keywords: cli,database,mssql,mysql,postgres,ssh,ssm,tunnel
7
+ Keywords: cli,database,duckdb,mssql,mysql,oracle,postgres,sqlite,ssh,ssm,tunnel
8
8
  Classifier: Development Status :: 3 - Alpha
9
9
  Classifier: Environment :: Console
10
10
  Classifier: Intended Audience :: Developers
@@ -16,6 +16,8 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Topic :: Database
17
17
  Requires-Python: >=3.12
18
18
  Requires-Dist: click>=8.1
19
+ Requires-Dist: duckdb>=1
20
+ Requires-Dist: oracledb>=2
19
21
  Requires-Dist: psycopg[binary]>=3.1
20
22
  Requires-Dist: pydantic>=2
21
23
  Requires-Dist: pymysql>=1.1
@@ -85,8 +87,9 @@ your shell history. (For ad-hoc exploration open the tunnel with
85
87
  | `direct` | No tunnel — connect to the upstream host:port directly | none |
86
88
 
87
89
  Each connection declares its SQLAlchemy URL scheme (`postgresql+psycopg`,
88
- `mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`, …), a healthcheck query,
89
- optional introspection (`info`) queries, and a `safety` policy.
90
+ `mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`, `oracle+oracledb`,
91
+ `sqlite`, `duckdb`, …), a healthcheck query, optional introspection
92
+ (`info`) queries, and a `safety` policy.
90
93
 
91
94
  Operations are declared separately in `operations.yaml`. Each operation is a
92
95
  parameterised SQL block (using `$name` placeholders) with declared parameters;
@@ -192,6 +195,9 @@ dbctl pg history # per-connection audit log
192
195
  dbctl pg again # re-run last op on pg
193
196
 
194
197
  dbctl tunnel open pg # hold tunnel for ad-hoc psql
198
+ dbctl tunnel open pg --port 15432 # override the local bind port
199
+ dbctl tunnel test pg # open + healthcheck + close, report OK/FAIL
200
+ dbctl tunnel list # list all connections + tunnel info
195
201
  ```
196
202
 
197
203
  > SQL Server needs an installed ODBC driver on the host (`ODBC Driver 18 for
@@ -53,8 +53,9 @@ your shell history. (For ad-hoc exploration open the tunnel with
53
53
  | `direct` | No tunnel — connect to the upstream host:port directly | none |
54
54
 
55
55
  Each connection declares its SQLAlchemy URL scheme (`postgresql+psycopg`,
56
- `mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`, …), a healthcheck query,
57
- optional introspection (`info`) queries, and a `safety` policy.
56
+ `mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`, `oracle+oracledb`,
57
+ `sqlite`, `duckdb`, …), a healthcheck query, optional introspection
58
+ (`info`) queries, and a `safety` policy.
58
59
 
59
60
  Operations are declared separately in `operations.yaml`. Each operation is a
60
61
  parameterised SQL block (using `$name` placeholders) with declared parameters;
@@ -160,6 +161,9 @@ dbctl pg history # per-connection audit log
160
161
  dbctl pg again # re-run last op on pg
161
162
 
162
163
  dbctl tunnel open pg # hold tunnel for ad-hoc psql
164
+ dbctl tunnel open pg --port 15432 # override the local bind port
165
+ dbctl tunnel test pg # open + healthcheck + close, report OK/FAIL
166
+ dbctl tunnel list # list all connections + tunnel info
163
167
  ```
164
168
 
165
169
  > SQL Server needs an installed ODBC driver on the host (`ODBC Driver 18 for
@@ -1279,13 +1279,36 @@ def history_show(ctx, run_id):
1279
1279
 
1280
1280
  @main.group("tunnel")
1281
1281
  def tunnel_cmd():
1282
- """Hold a tunnel open for ad-hoc work."""
1282
+ """Open, test, or list database tunnels.
1283
+
1284
+ \b
1285
+ Tunnels connect dbctl to databases that aren't directly reachable:
1286
+ - ssm: AWS SSM port-forward through an EC2 bastion
1287
+ - ssh: Classic ssh -N -L port-forward through a bastion
1288
+ - k8s: kubectl port-forward to a Service or Pod
1289
+ - direct: No tunnel — connect to upstream host:port
1290
+
1291
+ \b
1292
+ Subcommands:
1293
+ - tunnel open <conn> [--port N] Hold a tunnel open for ad-hoc work
1294
+ - tunnel test <conn> Open + healthcheck + close, report OK/FAIL
1295
+ - tunnel list List all connections with tunnel info
1296
+ """
1283
1297
 
1284
1298
 
1285
1299
  @tunnel_cmd.command("open")
1286
1300
  @click.argument("name")
1301
+ @click.option(
1302
+ "-p",
1303
+ "--port",
1304
+ "port",
1305
+ type=click.INT,
1306
+ default=None,
1307
+ help="Override the local bind port (even if config says 0 = auto).",
1308
+ )
1287
1309
  @click.pass_context
1288
- def tunnel_open(ctx, name):
1310
+ def tunnel_open(ctx, name, port):
1311
+ """Hold a tunnel open for ad-hoc work (Ctrl-C to close)."""
1289
1312
  from dbctl.connections import resolve
1290
1313
 
1291
1314
  conns, _ = registries(ctx)
@@ -1296,8 +1319,12 @@ def tunnel_open(ctx, name):
1296
1319
  raise SystemExit(2)
1297
1320
  from dbctl.tunnels.base import build_tunnel
1298
1321
 
1299
- tun = build_tunnel(c)
1300
- tun.__enter__()
1322
+ tun = build_tunnel(c, override_port=port)
1323
+ try:
1324
+ tun.__enter__()
1325
+ except RuntimeError as e:
1326
+ err_console.print(f"[red]tunnel error:[/red] {e}")
1327
+ raise SystemExit(3)
1301
1328
  console.print(f"[green]tunnel open:[/green] {tun.local_host}:{tun.local_port} -> {canonical}")
1302
1329
  console.print("[dim]Ctrl-C to close...[/dim]")
1303
1330
  try:
@@ -1309,6 +1336,102 @@ def tunnel_open(ctx, name):
1309
1336
  tun.__exit__(None, None, None)
1310
1337
 
1311
1338
 
1339
+ @tunnel_cmd.command("test")
1340
+ @click.argument("name")
1341
+ @click.pass_context
1342
+ def tunnel_test(ctx, name):
1343
+ """Open the tunnel, run the healthcheck, and close it. Reports OK or FAIL."""
1344
+ from dbctl.connections import resolve
1345
+ from dbctl.db import build_engine, healthcheck
1346
+ from dbctl.tunnels.base import build_tunnel
1347
+
1348
+ conns, _ = registries(ctx)
1349
+ try:
1350
+ canonical, c = resolve(name, conns)
1351
+ except KeyError as e:
1352
+ err_console.print(f"[red]{e}[/red]")
1353
+ raise SystemExit(2)
1354
+
1355
+ tun = build_tunnel(c)
1356
+ started = time.monotonic()
1357
+ try:
1358
+ tun.__enter__()
1359
+ except RuntimeError as e:
1360
+ elapsed = (time.monotonic() - started) * 1000
1361
+ err_console.print(f"[red]FAIL[/red] {canonical} tunnel: {e} ({elapsed:.0f}ms)")
1362
+ raise SystemExit(3)
1363
+
1364
+ try:
1365
+ engine = build_engine(c, tun)
1366
+ except Exception as e: # noqa: BLE001 - DBError, ImportError, etc.
1367
+ tun.__exit__(None, None, None)
1368
+ elapsed = (time.monotonic() - started) * 1000
1369
+ err_console.print(f"[red]FAIL[/red] {canonical} engine: {e} ({elapsed:.0f}ms)")
1370
+ raise SystemExit(4)
1371
+
1372
+ ok, latency_ms, msg = healthcheck(engine, c.healthcheck.query, c.healthcheck.timeout_seconds)
1373
+ tun.__exit__(None, None, None)
1374
+ total_ms = (time.monotonic() - started) * 1000
1375
+
1376
+ if ok:
1377
+ console.print(
1378
+ f"[green]OK[/green] {canonical} via {c.type.value} "
1379
+ f"({tun.local_host}:{tun.local_port}) — {msg} ({latency_ms:.1f}ms, total {total_ms:.0f}ms)"
1380
+ )
1381
+ else:
1382
+ err_console.print(f"[red]FAIL[/red] {canonical} healthcheck: {msg} ({total_ms:.0f}ms)")
1383
+ raise SystemExit(5)
1384
+
1385
+
1386
+ @tunnel_cmd.command("list")
1387
+ @click.pass_context
1388
+ def tunnel_list(ctx):
1389
+ """List all connections with their tunnel type and key parameters."""
1390
+ conns, _ = registries(ctx)
1391
+ if not conns:
1392
+ console.print("[dim]no connections configured[/dim]")
1393
+ return
1394
+
1395
+ from rich.table import Table as _Table
1396
+
1397
+ table = _Table(title="tunnels", header_style="bold cyan")
1398
+ table.add_column("name")
1399
+ table.add_column("type")
1400
+ table.add_column("driver")
1401
+ table.add_column("info")
1402
+
1403
+ for name, c in sorted(conns.items()):
1404
+ ttype = c.type.value
1405
+ driver = c.driver or "(from url)"
1406
+ info_parts = []
1407
+ if ttype == "direct":
1408
+ assert c.direct
1409
+ info_parts.append(f"host={c.direct.host}:{c.direct.port}")
1410
+ elif ttype == "ssm":
1411
+ assert c.ssm
1412
+ if c.ssm.bastion_instance_id:
1413
+ info_parts.append(f"bastion={c.ssm.bastion_instance_id}")
1414
+ elif c.ssm.bastion_tags:
1415
+ info_parts.append(f"bastion_tags={dict(c.ssm.bastion_tags)}")
1416
+ info_parts.append(f"remote={c.ssm.remote_host}:{c.ssm.remote_port}")
1417
+ if c.ssm.region:
1418
+ info_parts.append(f"region={c.ssm.region}")
1419
+ elif ttype == "ssh":
1420
+ assert c.ssh
1421
+ info_parts.append(f"bastion={c.ssh.user}@{c.ssh.host}:{c.ssh.port}")
1422
+ info_parts.append(f"remote={c.ssh.remote_host}:{c.ssh.remote_port}")
1423
+ elif ttype == "k8s":
1424
+ assert c.k8s
1425
+ info_parts.append(f"context={c.k8s.context}")
1426
+ info_parts.append(f"target={c.k8s.target}")
1427
+ if c.k8s.namespace:
1428
+ info_parts.append(f"ns={c.k8s.namespace}")
1429
+ info_parts.append(f"port={c.k8s.remote_port}")
1430
+ table.add_row(name, ttype, driver, ", ".join(info_parts))
1431
+
1432
+ console.print(table)
1433
+
1434
+
1312
1435
  # --------------------------------------------------------------------------- #
1313
1436
  # dashboard + history rendering
1314
1437
  # --------------------------------------------------------------------------- #
@@ -247,16 +247,22 @@ class Connection(BaseModel):
247
247
  if not self.database:
248
248
  raise ValueError("'database' is required (or use 'url:' for a full connection string)")
249
249
 
250
+ # File-based drivers (sqlite, duckdb) have no auth — skip the
251
+ # credential requirement entirely. The username/password fields
252
+ # are accepted (and ignored by the driver) for config-schema
253
+ # compatibility, but none is required.
254
+ _file_based = self.driver.startswith(("sqlite", "duckdb"))
255
+
250
256
  sources = [bool(self.password), bool(self.password_env), self.prompt]
251
257
  if sum(sources) > 1:
252
258
  raise ValueError("'password', 'password_env' and 'prompt' are mutually exclusive")
253
- if not any(sources) and not self.windows_sso:
259
+ if not _file_based and not any(sources) and not self.windows_sso:
254
260
  raise ValueError("set 'password', 'password_env', 'prompt: true', or 'windows_sso: true'")
255
261
  if self.windows_sso and any(sources):
256
262
  raise ValueError("'windows_sso' is mutually exclusive with password/password_env/prompt")
257
263
  if self.windows_sso and not self.driver.startswith("mssql"):
258
264
  raise ValueError("'windows_sso' is only supported with mssql+pyodbc driver")
259
- if not self.windows_sso and not self.username:
265
+ if not _file_based and not self.windows_sso and not self.username:
260
266
  raise ValueError("'username' is required (or set 'windows_sso: true' for mssql SSO)")
261
267
  return self
262
268
 
@@ -339,11 +345,17 @@ class ReplaySpec(BaseModel):
339
345
  ``package.module:callable`` / ``package.module.callable`` resolving to a
340
346
  ``Callable[[dict], dict]``. The callable runs in-process; it must not
341
347
  reach across connections.
348
+
349
+ `on_conflict` defaults to ``skip`` (unlike ``copy`` which defaults to
350
+ ``error``) — a replay is typically additive (replay new/changed rows
351
+ from a source log into a target without breaking existing entries).
352
+ Use ``truncate`` if you want a full refresh instead.
342
353
  """
343
354
 
344
355
  model_config = ConfigDict(extra="forbid")
345
356
  batch_size: int = 10000
346
357
  tables: list[str] | None = None # None = introspect src
358
+ on_conflict: OnConflict = OnConflict.skip # default skip (additive)
347
359
  where: dict[str, str] = Field(default_factory=dict)
348
360
  transform: str = "identity"
349
361
 
@@ -80,14 +80,12 @@ def _connect_args(conn: Connection, timeout: float) -> dict:
80
80
  """Driver-specific connect-time knobs (mainly connect_timeout)."""
81
81
  args: dict = {}
82
82
  driver = _driver_name(conn)
83
- if driver.startswith(("postgresql", "mysql", "mariadb")):
83
+ if driver.startswith(("postgresql", "mysql", "mariadb", "oracle")):
84
84
  args["connect_timeout"] = int(max(1, timeout))
85
+ # sqlite + duckdb are file-based — no connect_timeout; SQLAlchemy
86
+ # ignores it anyway, but we skip it so we don't pass an unknown kwarg
87
+ # to the underlying C library.
85
88
  if conn.windows_sso:
86
- # pyodbc: Trusted_Connection=yes tells the ODBC driver to use the
87
- # current Windows user's credentials (Kerberos / NTLM). The ODBC
88
- # Driver 17+ also supports Authentication=ActiveDirectoryIntegrated
89
- # for Azure AD SSO — use that by setting it explicitly via
90
- # connect_args in your config if needed.
91
89
  args["Trusted_Connection"] = "yes"
92
90
  return args
93
91
 
@@ -99,6 +97,11 @@ def build_engine(conn: Connection, tunnel: Tunnel, *, echo: bool = False) -> Eng
99
97
  tunnel's local bind is NOT injected — the URL's own host:port wins. This
100
98
  is intentional: a user who provides a full URL is taking responsibility
101
99
  for the entire connection string.
100
+
101
+ File-based drivers (``sqlite``, ``duckdb``) never get host/port/username/
102
+ password injected — the URL is just ``driver:///path/to/file``. The
103
+ tunnel's local bind is irrelevant for file-based DBs (the file is
104
+ local), and injecting `host:port` makes SQLAlchemy reject the URL.
102
105
  """
103
106
  driver = _driver_name(conn)
104
107
  _check_driver_available(driver)
@@ -107,6 +110,11 @@ def build_engine(conn: Connection, tunnel: Tunnel, *, echo: bool = False) -> Eng
107
110
 
108
111
  if conn.url:
109
112
  url = make_url(conn.url)
113
+ elif driver.startswith(("sqlite", "duckdb")):
114
+ # File-based: URL is just "sqlite:///path" or "duckdb:///path".
115
+ # No host/port/username/password — those are meaningless for a
116
+ # local file. The database field IS the file path.
117
+ url = URL.create(driver, database=conn.database)
110
118
  else:
111
119
  password = resolve_password(conn)
112
120
  url = URL.create(
@@ -138,6 +146,9 @@ def _check_driver_available(driver: str) -> None:
138
146
  "mysql+pymysql": "pymysql",
139
147
  "mariadb+pymysql": "pymysql",
140
148
  "mssql+pyodbc": "pyodbc",
149
+ "oracle+oracledb": "oracledb",
150
+ "sqlite": "sqlite3", # stdlib — always available
151
+ "duckdb": "duckdb",
141
152
  }
142
153
  pkg = module_map.get(driver)
143
154
  if pkg is None:
@@ -183,6 +194,12 @@ def _native_lib_hint(driver: str, library: str) -> str:
183
194
  "(Debian/Ubuntu: `sudo apt install libpq5`, "
184
195
  "RHEL/Fedora: `sudo dnf install libpq`, macOS: `brew install libpq`)."
185
196
  )
197
+ if "libociei" in (library or "") or "libclntsh" in (library or "") or "libocci" in (library or ""):
198
+ return (
199
+ "missing Oracle Instant Client libs (libclntsh / libociei / libocci); "
200
+ "install Oracle Instant Client (download from oracle.com, or use "
201
+ "`pip install oracledb` with the default Thin mode which needs no native libs)."
202
+ )
186
203
  return ""
187
204
 
188
205
 
@@ -67,7 +67,15 @@ def run_wizard(*, profile: str | None) -> None:
67
67
  driver = click.prompt(
68
68
  "driver (sqlalchemy url scheme)",
69
69
  type=click.Choice(
70
- ["postgresql+psycopg", "mysql+pymysql", "mariadb+pymysql", "mssql+pyodbc"],
70
+ [
71
+ "postgresql+psycopg",
72
+ "mysql+pymysql",
73
+ "mariadb+pymysql",
74
+ "mssql+pyodbc",
75
+ "oracle+oracledb",
76
+ "sqlite",
77
+ "duckdb",
78
+ ],
71
79
  case_sensitive=False,
72
80
  ),
73
81
  default="postgresql+psycopg",
@@ -214,6 +222,8 @@ def _default_port(driver: str) -> int:
214
222
  return 3306
215
223
  if driver.startswith("mssql"):
216
224
  return 1433
225
+ if driver.startswith("oracle"):
226
+ return 1521
217
227
  return 5432
218
228
 
219
229
 
@@ -672,14 +672,14 @@ def run_replay(
672
672
  each row before it lands in the insert batch. ``"identity"`` makes the
673
673
  replay equivalent to a plain copy.
674
674
  """
675
- from dbctl.config import CopySpec, OnConflict
675
+ from dbctl.config import CopySpec
676
676
 
677
677
  # Adapt ReplaySpec → CopySpec so we reuse the copy machinery verbatim.
678
678
  copy_spec = CopySpec(
679
679
  batch_size=spec.batch_size,
680
680
  tables=spec.tables,
681
681
  where=spec.where,
682
- on_conflict=OnConflict.error, # replay is copy-with-transform; default to no skip
682
+ on_conflict=spec.on_conflict, # replay defaults to skip (additive)
683
683
  )
684
684
  transform = _resolve_transform(spec.transform)
685
685
  return run_copy(
@@ -49,24 +49,40 @@ def _terminate(proc: subprocess.Popen) -> None:
49
49
  proc.wait(timeout=5)
50
50
 
51
51
 
52
- def build_tunnel(conn: Connection) -> Tunnel:
52
+ def build_tunnel(conn: Connection, *, override_port: int | None = None) -> Tunnel:
53
+ """Construct a Tunnel for the connection's type.
54
+
55
+ ``override_port`` lets the CLI (e.g. ``tunnel open --port 1234``) pin
56
+ the local bind port even when the config says ``local_port: 0`` (auto).
57
+ For ``direct`` tunnels the override replaces the upstream port (useful
58
+ for pointing at a different port than the config declares).
59
+ """
53
60
  from dbctl.tunnels.direct import DirectTunnel as _Direct
54
- from dbctl.tunnels.k8s import K8sTunnel as _K8s
61
+ from dbctl.tunnels.k8s import K8sTunnel as _K8k
55
62
  from dbctl.tunnels.ssh import SshTunnel as _Ssh
56
63
  from dbctl.tunnels.ssm import SsmTunnel as _Ssm
57
64
 
58
65
  match conn.type.value:
59
66
  case "ssm":
60
67
  assert conn.ssm
68
+ if override_port is not None:
69
+ # Mutate a copy so the original config is untouched.
70
+ conn.ssm = conn.ssm.model_copy(update={"local_port": override_port})
61
71
  return _Ssm(conn.ssm)
62
72
  case "ssh":
63
73
  assert conn.ssh
74
+ if override_port is not None:
75
+ conn.ssh = conn.ssh.model_copy(update={"local_port": override_port})
64
76
  return _Ssh(conn.ssh)
65
77
  case "k8s":
66
78
  assert conn.k8s
67
- return _K8s(conn.k8s)
79
+ if override_port is not None:
80
+ conn.k8s = conn.k8s.model_copy(update={"local_port": override_port})
81
+ return _K8k(conn.k8s)
68
82
  case "direct":
69
83
  assert conn.direct
84
+ if override_port is not None:
85
+ conn.direct = conn.direct.model_copy(update={"port": override_port})
70
86
  return _Direct(conn.direct)
71
87
  case _: # pragma: no cover - exhaustive
72
88
  raise ValueError(f"unknown tunnel type {conn.type!r}")
@@ -39,7 +39,7 @@ overlap with a clear message.
39
39
  | `description` | string | no | shown in the dashboard and `dbctl connections list`. |
40
40
  | `aliases` | list of strings | no | alternates that resolve back to this connection (e.g. `prod` → `db1`). |
41
41
  | `type` | `ssm` \| `ssh` \| `k8s` \| `direct` | **yes** | selects the tunnel implementation. |
42
- | `driver` | string | **yes** | SQLAlchemy URL scheme. Supported: `postgresql+psycopg`, `mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`. Any other SQLAlchemy scheme works as long as its driver is importable. |
42
+ | `driver` | string | **yes** | SQLAlchemy URL scheme. Supported: `postgresql+psycopg`, `mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`, `oracle+oracledb`, `sqlite`, `duckdb`. Any other SQLAlchemy scheme works as long as its driver is importable. |
43
43
  | `database` | string | **yes** | database / catalog name passed to SQLAlchemy. |
44
44
  | `username` | string | **yes** (unless `windows_sso`) | DB user. |
45
45
  | `password` | string | see rule | plaintext DB password (local dev only — don't commit real secrets to YAML). Mutually exclusive with `password_env`, `prompt`, and `windows_sso`. |
@@ -320,6 +320,72 @@ connections:
320
320
  safety: { confirm: false, read_only: false }
321
321
  ```
322
322
 
323
+ ### Oracle Database (thin mode — no native client needed)
324
+
325
+ ```yaml
326
+ connections:
327
+ prod-oracle:
328
+ description: "Production Oracle (oracledb thin mode)"
329
+ type: direct
330
+ driver: oracle+oracledb
331
+ database: ORCLPDB1 # service name (or SID)
332
+ username: app_admin
333
+ password_env: DBCTL_ORACLE_PASSWORD
334
+ direct: { host: db.internal, port: 1521 }
335
+ healthcheck: { query: "SELECT 1 FROM DUAL", timeout_seconds: 10 }
336
+ safety:
337
+ confirm: true
338
+ read_only: false
339
+ ```
340
+
341
+ > `oracledb` defaults to **thin mode** (pure Python, no Oracle Instant
342
+ > Client needed). If you need thick mode (native Oracle client libs), set
343
+ > it via the `url:` field with `thick_mode=true` in the query string.
344
+
345
+ ### Local SQLite database (file-based)
346
+
347
+ ```yaml
348
+ connections:
349
+ local-sqlite:
350
+ description: "Local SQLite database"
351
+ type: direct
352
+ driver: sqlite
353
+ database: /path/to/mydata.db # absolute path to the .db file
354
+ username: "" # sqlite ignores these but config requires one
355
+ password: "" # sqlite ignores
356
+ direct: { host: localhost, port: 0 } # ignored by sqlite; required by config schema
357
+ healthcheck: { query: "SELECT 1" }
358
+ safety:
359
+ confirm: true
360
+ read_only: false
361
+ ```
362
+
363
+ > SQLite and DuckDB are file-based — the `host` / `port` fields are
364
+ > ignored by the driver but `type: direct` still requires a `direct:`
365
+ > block. Use `url:` mode if you prefer:
366
+ > ```yaml
367
+ > url: "sqlite:////absolute/path/to/mydata.db"
368
+ > ```
369
+ > (Note the four slashes for absolute paths in SQLAlchemy's sqlite scheme.)
370
+
371
+ ### Local DuckDB database (file-based)
372
+
373
+ ```yaml
374
+ connections:
375
+ local-duckdb:
376
+ description: "Local DuckDB database"
377
+ type: direct
378
+ driver: duckdb
379
+ database: /path/to/mydata.duckdb # or ":memory:" for in-memory
380
+ username: "" # duckdb ignores
381
+ password: "" # duckdb ignores
382
+ direct: { host: localhost, port: 0 }
383
+ healthcheck: { query: "SELECT 1" }
384
+ safety:
385
+ confirm: true
386
+ read_only: false
387
+ ```
388
+
323
389
  ### CloudNativePG cluster via kubectl port-forward
324
390
 
325
391
  ```yaml
@@ -585,6 +585,15 @@ tunnel open: 127.0.0.1:5433 -> pg
585
585
  Ctrl-C to close...
586
586
  ```
587
587
 
588
+ Override the local port with `--port` (even if the config says `local_port: 0`
589
+ = auto-pick):
590
+
591
+ ```bash
592
+ ▶ uv run dbctl tunnel open pg --port 15432
593
+ tunnel open: 127.0.0.1:15432 -> pg
594
+ Ctrl-C to close...
595
+ ```
596
+
588
597
  In another shell, use that local bind with your favourite client:
589
598
 
590
599
  ```bash
@@ -594,6 +603,28 @@ PGPASSWORD=$DBCTL_PG_PASSWORD psql -h 127.0.0.1 -p 5433 -U app_admin -d app
594
603
  The tunnel is torn down cleanly when you Ctrl-C the `dbctl tunnel open`
595
604
  process — an `atexit` fallback covers hard kills.
596
605
 
606
+ ### `tunnel test` — open + healthcheck + close
607
+
608
+ ```bash
609
+ ▶ uv run dbctl tunnel test pg
610
+ OK pg via direct (127.0.0.1:5433) — ok (31.2ms, total 185ms)
611
+ ```
612
+
613
+ Opens the tunnel, runs the connection's healthcheck query, then closes the
614
+ tunnel. Prints `OK` (green) with latency on success, or `FAIL` (red) with
615
+ a clean error message on failure. Exit codes: 0 success, 2 unknown conn,
616
+ 3 tunnel error, 4 engine error, 5 healthcheck failed.
617
+
618
+ ### `tunnel list` — show all tunnels at a glance
619
+
620
+ ```bash
621
+ ▶ uv run dbctl tunnel list
622
+ ```
623
+
624
+ Lists every configured connection with its tunnel type (`ssm`/`ssh`/`k8s`/
625
+ `direct`), driver, and key parameters (bastion, remote host, port, region,
626
+ context/namespace for k8s).
627
+
597
628
  ---
598
629
 
599
630
  ## 14. Use `dbctl init` to add the next connection
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dbctl"
7
- version = "0.6.2"
7
+ version = "0.6.4"
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.12"
11
11
  license = { text = "MIT" }
12
12
  authors = [{ name = "dbctl contributors" }]
13
- keywords = ["cli", "database", "postgres", "mysql", "mssql", "ssm", "ssh", "tunnel"]
13
+ keywords = ["cli", "database", "postgres", "mysql", "mssql", "oracle", "sqlite", "duckdb", "ssm", "ssh", "tunnel"]
14
14
  classifiers = [
15
15
  "Development Status :: 3 - Alpha",
16
16
  "Environment :: Console",
@@ -31,6 +31,8 @@ dependencies = [
31
31
  "psycopg[binary]>=3.1",
32
32
  "pymysql>=1.1",
33
33
  "pyodbc>=5",
34
+ "oracledb>=2",
35
+ "duckdb>=1",
34
36
  ]
35
37
 
36
38
  [project.optional-dependencies]
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
File without changes
File without changes