dbctl
dbctl is a small Python CLI for monitoring, controlling, and administering
multiple databases through a single declarative config. It is built for
operators who manage a database explosion — the same application deployed
across several environments (dev / test / int / prod) and several tenants
(Germany / USA / India / …) — where administering a row means opening a GUI
client, navigating a connection tree, opening a tunnel, and clicking through
to the right schema. dbctl replaces that multi-click flow with one command:
dbctl prod-de increase-quota alice 10 --apply # bump a user's quota, in prod, Germany
dbctl prod-us increase-quota alice 10 --apply # same op, different tenant
dbctl int-in doctor # healthcheck integration India
Every connection's tunnel (ssm / ssh / k8s / direct), credentials,
safety policy, and introspection queries live in one versioned
~/.dbctl/connections.yaml; every common SQL action lives in one
~/.dbctl/operations.yaml. dbctl synthesises one Click subcommand per
connection × operation, opens the tunnel, runs the SQL with bind parameters,
writes an audit-log entry, and tears the tunnel down — without you ever
leaving the terminal.
Why not DBeaver / a GUI client?
| GUI client (DBeaver, etc.) | dbctl |
|---|---|
| Click through a connection tree every time | dbctl <conn> <op> — one command |
| Re-establish SSO / SSH per session, often by hand | Tunnels auto-opened from YAML, SSO re-used |
| Ad-hoc SQL with no audit trail | Every run appended to ~/.dbctl/history.jsonl (secrets redacted) |
| "What can I run here?" is whatever you remember | Operations are declared + versioned; --help lists them |
DML is one Ctrl-Enter away |
DML is dry-run by default until --apply; --yes skips the prompt |
| No cross-DB diff | dbctl diff user-count prod-de int-de |
dbctl is not a schema browser or a query playground — it deliberately
has no ad-hoc query command. Declaring operations in YAML keeps "what can be
run against this DB" discoverable from a versioned file instead of buried in
your shell history. (For ad-hoc exploration open the tunnel with
dbctl tunnel open <conn> and point your favourite client at the local bind.)
How it reaches a database
| type | how | external deps |
|---|---|---|
ssm |
AWS SSM port-forward through an EC2 bastion to a private host | aws CLI on PATH, active SSO session |
ssh |
Classic ssh -N -L port-forward through a bastion |
ssh CLI on PATH, an SSH key file |
k8s |
kubectl port-forward to a Service / Pod in a cluster |
kubectl CLI on PATH, a valid kubeconfig |
direct |
No tunnel — connect to the upstream host:port directly | none |
Each connection declares its SQLAlchemy URL scheme (postgresql+psycopg,
mysql+pymysql, mariadb+pymysql, mssql+pyodbc, …), a healthcheck query,
optional introspection (info) queries, and a safety policy.
Operations are declared separately in operations.yaml. Each operation is a
parameterised SQL block (using $name placeholders) with declared parameters;
dbctl builds one Click subcommand per operation, so every connection
automatically exposes a subcommand for every declared single-DB operation.
dbctl pg add-user stephen 12 --apply
dbctl pg list-users 20
dbctl pg info row_counts
dbctl diff user-count pg my
dbctl diff compare-quotas pg my Daily
dbctl doctor
dbctl history list
A query-only operation runs immediately. A DML operation (mode: execute,
upsert, or script) on a connection with safety.confirm: true runs as a
dry-run by default — it shows the resolved SQL + bound values and writes
nothing, until you pass --apply. --yes skips the final [y/N] confirmation.
Every run is appended to ~/.dbctl/history.jsonl (with secret-typed
parameters redacted), and dbctl <conn> again re-runs the last operation with
the same params.
Why "the operations are declarative"
There is no ad-hoc query command. To run SQL through dbctl you must
declare an operation in operations.yaml first — its name, its parameters
(with types, descriptions, positional vs keyword), its SQL, and its mode
(execute / fetch / fetch_one / script / upsert for single-DB;
diff / compare / sync for multi-DB). The CLI then synthesises a Click
subcommand per operation, so:
- the
--helpfor<conn> <op>is generated from the declared parameters; - parameters are type-checked before the SQL is ever rendered;
- secret-typed parameters are identified for audit redaction;
- the audit log is queryable by operation name (
dbctl history list).
This keeps "what can be run against this DB" discoverable from a versioned config file, instead of buried in shell history.
Install (uv)
uv is the recommended workflow:
# Install dbctl as a tool (all dialect drivers ship as dependencies)
uv tool install ./dbctl
# Or hack locally:
cd dbctl
uv sync --extra dev
uv pip install -e .
uv run dbctl --help
The aws and ssh binaries are expected on PATH. No boto3, no
paramiko — dbctl always shells out so you keep your existing SSO session,
key agents, and MFA flows.
Quick start with the bundled docker-compose fleet
First time here? Follow the step-by-step tutorial instead — it walks through install, the dashboard, single-DB operations, the audit log, multi-DB diff, writing your own operation, and pointing
dbctlat real AWS RDS via SSM.
docker-compose.yml brings up three databases (postgres on :5433, mysql on
:3307, mssql on :1434) with the same four-table schema (users, quotas,
usage, logs) and slightly different sample data — perfect for trying the
multi-connection diff commands.
docker compose up -d
# install the sample config (uses plaintext passwords out of the box for dev;
# swap to `password_env: VARNAME` or `prompt: true` for real environments)
mkdir -p ~/.dbctl
cp .dbctl/connections.yaml ~/.dbctl/connections.yaml
cp .dbctl/operations.yaml ~/.dbctl/operations.yaml
dbctl # dashboard
dbctl doctor # healthcheck all three
dbctl pg health
dbctl pg info row_counts
dbctl pg list-users 10
dbctl pg add-user stephen 12 --show-sql # dry-run (prints SQL)
dbctl pg add-user stephen 12 --apply --yes # commit (no prompt)
dbctl pg increase-quota alice 10 --apply -y # +10% on alice's quotas
dbctl diff user-count pg my # multi-DB diff
dbctl diff compare-quotas pg my Daily
dbctl pg history # per-connection audit log
dbctl pg again # re-run last op on pg
dbctl tunnel open pg # hold tunnel for ad-hoc psql
SQL Server needs an installed ODBC driver on the host (
ODBC Driver 18 for SQL Server). The samplemsconnection isread_only: trueand is only used for thediffoperations against the smalleruserstable.
Config layout
~/.dbctl/
├── connections.yaml # named connections (ssm / ssh / direct)
├── operations.yaml # parameterised SQL operations (single + multi)
└── history.jsonl # audit log (one JSON event per run)
~/.dbctl/profiles/<name>/ # switch config dir with --profile <name>
dbctl init walks you through adding a new connection interactively and
writes it back to ~/.dbctl/connections.yaml — it will test the tunnel +
healthcheck before saving (for direct connections).
Loader resilience
Each connection is validated independently, not as one big
ConnectionsFile. A mis-configured connection (e.g. a reference template
with all password sources commented out, or a typo'd driver) is skipped with
a one-line warning on stderr — the rest of the registry still loads so the
dashboard, --help, and the good connections keep working:
connections.yaml: 1 invalid connection skipped:
pg-ssm: set 'password', 'password_env' or 'prompt: true' for each connection
dbctl operations validate checks operations.yaml the same way.
Adding an operation
Operations are global — every connection sees them. Declare one:
# ~/.dbctl/operations.yaml
operations:
reset-quota:
description: "Reset a user's daily quota back to their yearly baseline"
scope: single
mode: execute
confirm: true
parameters:
- { name: name, type: string, required: true, position: 1, description: "User name" }
- { name: floor, type: integer, default: 10, position: 2, description: "Don't reset below this floor" }
sql: |
UPDATE users
SET quota_daily = GREATEST($floor, quota_yearly / 365)
WHERE name = $name
Then:
dbctl pg reset-quota alice --show-sql # preview
dbctl pg reset-quota alice --apply --yes # commit
$name and $floor are rewritten to SQLAlchemy bind-params (:name,
:floor) — they are always parameterised, never string-interpolated, so SQL
injection through a parameter value is not possible. The Postgres cast idiom
$name::type is rewritten to the portable CAST(:name AS type) form so the
same operation YAML works cross-dialect:
sql: |
SELECT level, COUNT(*) AS events
FROM logs
WHERE created_at >= $since::timestamp # → CAST(:since AS timestamp)
AND created_at < $until::timestamp
GROUP BY level
(For dynamic identifiers such as table names, write one operation per table
for now; ${var} identifier interpolation is on the v2 roadmap.)
See docs/operations.md for the full parameter and
mode reference.
Adding a connection
Edit ~/.dbctl/connections.yaml, or run dbctl init:
connections:
prod-pg:
description: "Production main Postgres (private RDS, SSM tunnel)"
aliases: [prod]
type: ssm
driver: postgresql+psycopg
database: app
username: app_admin
password_env: DBCTL_PROD_PG_PASSWORD
ssm:
region: eu-west-1
profile: prod # AWS SSO profile; tokens cached in ~/.aws/cache
bastion_instance_id: i-0abcd1234 # or use bastion_tags: { Name: bastion-prod }
remote_host: mydb.xxxx.eu-west-1.rds.amazonaws.com
remote_port: 5432
local_port: 0 # 0 = auto-pick a free port
healthcheck: { query: "SELECT 1" }
info:
- name: row_counts
query: "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10"
safety:
confirm: true # dry-run-by-default for DML
read_only: false
allowed_operations: [] # empty = any
See docs/connections.md for the full reference.
Safety model
Each connection has a safety block:
| field | effect |
|---|---|
confirm: true |
DML ops print resolved SQL + dry-run unless --apply is passed. --yes skips the final prompt. |
read_only: true |
Blocks every DML op (execute / upsert / script) entirely. |
allowed_operations |
Whitelist of operation names; empty list = all allowed. |
dbctl confirms before opening the transaction — saying N at the
prompt leaves the database untouched.
DB password sources are mutually exclusive — pick one per connection:
| source | use |
|---|---|
password: "…" |
plaintext (local dev only). Don't commit real secrets. |
password_env: VAR |
read from the named environment variable. Recommended for any shared/CI host. |
prompt: true |
prompt for the DB password interactively each run. Good for break-glass access. |
Secret-typed operation parameters (type: secret) are redacted in the
audit log regardless of which DB password source the connection uses.
Shell completion
dbctl --install-completion bash # prints the snippet
echo 'eval "$(_DBCTL_COMPLETE=bash_source dbctl)"' >> ~/.bashrc
# similarly for zsh / fish
Completion then covers connection names, operation names, and (for multi-DB
verbs) dbctl diff <TAB> lists the available operations.
Project layout
dbctl/
├── pyproject.toml # uv / hatchling; ruff + pytest + mypy config
├── docker-compose.yml # postgres :5433, mysql :3307, mssql :1434
├── seed/ # four-table schema + sample data for the fleet
├── .dbctl/ # sample connections.yaml + operations.yaml
├── docs/
│ ├── connections.md # connections.yaml reference
│ ├── operations.md # operations.yaml reference
│ └── DESIGN.md # architecture and design decisions
└── dbctl/
├── cli.py # dynamic groups + per-op Click commands
├── config.py # pydantic v2 models
├── connections.py # loader + alias resolution + "did you mean?"
├── operations.py # loader + scope filter
├── tunnels/{ssm,ssh,direct}.py
├── db.py # URL builder + password resolution + healthcheck
├── execute.py # $name → :name, mode routing, bind_params
├── multi.py # per-role engine open + query runner for diffs
├── reports.py # rich tables / json / csv / yaml + diff rendering
├── audit.py # history.jsonl
├── runtime.py # opened_conn() ctx-mgr (tunnel+engine+healthcheck)
└── init.py # dbctl init wizard
Development
uv sync --extra dev
uv run ruff check dbctl tests # lint
uv run pytest tests/ # ~60 unit tests against in-memory SQLite
uv run mypy dbctl # type-check (pre-existing debt; non-blocking in CI)
The unit tests use an in-memory SQLite database so they run without docker.
For a live end-to-end smoke test, docker compose up -d and follow the
quick start above.
Roadmap
mode: upsert(autoload target table, dialect-awareON CONFLICT/ON DUPLICATE KEY)mode: script(multi-statement support beyond single-statement passthrough)- Multi-file operations loader (
.dbctl/operations/<name>.yaml) - Dynamic identifier interpolation (
${var}) for table/column names in diff ops dbctl compareanddbctl syncmodes (the framework is ready; sample ops not shipped)- Tag-based operation filtering (
dbctl pg --tag user-mgmt <op>) - SSH agent / MFA support surfacing through the existing
sshsubprocess
License
MIT
Metadata
Release files for dbctl 0.5.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| dbctl-0.5.3.tar.gz | 145.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dbctl-0.5.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 186.5 kB
Release files / dbctl-0.5.3.tar.gz
| Download URL | dbctl-0.5.3.tar.gz |
|---|---|
| Size | 145.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
caf87982162a380f7505779ea82e78e754ee5c297addcd9f130bf3d46973fc8a
|
|
BLAKE2b-256 checksum How to use checksums |
5989065735769901cad43340282b27ac60d0f5ede59ef5573bc7d8324cc65f23
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|
Release files / dbctl-0.5.3-py3-none-any.whl
| Download URL | dbctl-0.5.3-py3-none-any.whl |
|---|---|
| Size | 41.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ea428cac4cfc408024b0b71c79447656a595fa3fe4a254f396bde614d1c0e25c
|
|
BLAKE2b-256 checksum How to use checksums |
db8e9c005e7d04de97c71d0f1a61302a52be4d3ece5a717500690a952acd6d3d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|