dbctl 0.7.2__tar.gz → 0.7.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dbctl-0.7.2 → dbctl-0.7.3}/CHANGELOG.md +22 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/PKG-INFO +8 -4
- {dbctl-0.7.2 → dbctl-0.7.3}/README.md +7 -3
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/cli.py +16 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/config.py +41 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/init.py +41 -1
- dbctl-0.7.3/dbctl/tunnels/azure.py +81 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/tunnels/base.py +12 -0
- dbctl-0.7.3/dbctl/tunnels/gcp.py +76 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/docs/connections.md +102 -1
- {dbctl-0.7.2 → dbctl-0.7.3}/pyproject.toml +1 -1
- dbctl-0.7.3/tests/test_azure_tunnel.py +284 -0
- dbctl-0.7.3/tests/test_gcp_tunnel.py +248 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/uv.lock +0 -213
- dbctl-0.7.2/tests/test_issue_1.py +0 -580
- {dbctl-0.7.2 → dbctl-0.7.3}/.dbctl/connections.yaml +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/.dbctl/operations.yaml +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/.github/workflows/ci.yml +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/.github-local/ci.yml +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/.gitignore +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/Makefile +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/__init__.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/__main__.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/audit.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/connections.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/db.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/execute.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/multi.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/operations.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/refs.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/reports.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/runtime.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/tunnels/__init__.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/tunnels/direct.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/tunnels/k8s.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/tunnels/ssh.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/tunnels/ssm.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/docker-compose.yml +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/docs/ACTION_OUTPUT.md +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/docs/DESIGN.md +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/docs/SESSION_STATE.md +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/docs/logo.png +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/docs/logo_small.png +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/docs/operations.md +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/docs/tutorial.md +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/seed/mssql.sql +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/seed/mysql.sql +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/seed/postgres.sql +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_bastion_tags.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_connections_loader.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_copy_features.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_k8s_tunnel.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_refs.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_regressions.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_smoke.py +0 -0
- {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_sso_cache.py +0 -0
|
@@ -5,6 +5,28 @@ 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.3] — 2026-08-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`azure` tunnel type** — reaches a VM through Azure Bastion via
|
|
13
|
+
`az network bastion tunnel` (requires the Bastion resource on the
|
|
14
|
+
Standard SKU). New `AzureBastionTunnel` connection block
|
|
15
|
+
(`resource_group` / `bastion_name` / `target_resource_id` /
|
|
16
|
+
`subscription` / `remote_port` / `local_port`), same lifecycle
|
|
17
|
+
(subprocess + `atexit` cleanup, `local_port: 0` auto-pick) as the
|
|
18
|
+
existing `ssm` / `ssh` / `k8s` tunnels. Shells out to the `az` CLI —
|
|
19
|
+
no Azure SDK dependency.
|
|
20
|
+
- **`gcp` tunnel type** — reaches a Compute Engine instance through
|
|
21
|
+
Identity-Aware Proxy via `gcloud compute start-iap-tunnel` (requires
|
|
22
|
+
IAP TCP forwarding on the instance's network and the
|
|
23
|
+
`roles/iap.tunnelResourceAccessor` IAM role). New `GcpIapTunnel`
|
|
24
|
+
connection block (`project` / `zone` / `instance` / `remote_port` /
|
|
25
|
+
`local_port`). Shells out to the `gcloud` CLI — no GCP SDK dependency.
|
|
26
|
+
- Both new types are wired into `dbctl doctor` (reports `az`/`gcloud` on
|
|
27
|
+
`PATH`, only flagged `required` when a configured connection actually
|
|
28
|
+
uses them), `dbctl tunnel list`, and the `dbctl init` wizard.
|
|
29
|
+
|
|
8
30
|
## [0.7.2] — 2026-08-04
|
|
9
31
|
|
|
10
32
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dbctl
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.3
|
|
4
4
|
Summary: Generic CLI to monitor, control, and administer multiple databases via SSM, SSH, or direct connection.
|
|
5
5
|
Author: dbctl contributors
|
|
6
6
|
License: MIT
|
|
@@ -85,6 +85,8 @@ your shell history. (For ad-hoc exploration open the tunnel with
|
|
|
85
85
|
| `ssh` | Classic `ssh -N -L` port-forward through a bastion | `ssh` CLI on PATH, an SSH key file |
|
|
86
86
|
| `k8s` | `kubectl port-forward` to a Service / Pod in a cluster | `kubectl` CLI on PATH, a valid kubeconfig |
|
|
87
87
|
| `direct` | No tunnel — connect to the upstream host:port directly | none |
|
|
88
|
+
| `azure` | Azure Bastion tunnel to a VM (`az network bastion tunnel`) | `az` CLI on PATH, active `az login` session, Bastion on Standard SKU |
|
|
89
|
+
| `gcp` | IAP TCP tunnel to a Compute Engine instance (`gcloud compute start-iap-tunnel`) | `gcloud` CLI on PATH, active `gcloud auth login` session |
|
|
88
90
|
|
|
89
91
|
Each connection declares its SQLAlchemy URL scheme (`postgresql+psycopg`,
|
|
90
92
|
`mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`, `oracle+oracledb`,
|
|
@@ -145,9 +147,11 @@ uv pip install -e .
|
|
|
145
147
|
uv run dbctl --help
|
|
146
148
|
```
|
|
147
149
|
|
|
148
|
-
The `aws` and `
|
|
149
|
-
|
|
150
|
-
|
|
150
|
+
The `aws`, `ssh`, `az`, and `gcloud` binaries are expected on `PATH` (only
|
|
151
|
+
the ones your configured tunnel types actually need — `dbctl doctor` tells
|
|
152
|
+
you which). No `boto3`, no `paramiko`, no Azure/GCP SDKs — dbctl always
|
|
153
|
+
shells out so you keep your existing SSO session, key agents, and MFA
|
|
154
|
+
flows.
|
|
151
155
|
|
|
152
156
|
## Quick start with the bundled docker-compose fleet
|
|
153
157
|
|
|
@@ -51,6 +51,8 @@ your shell history. (For ad-hoc exploration open the tunnel with
|
|
|
51
51
|
| `ssh` | Classic `ssh -N -L` port-forward through a bastion | `ssh` CLI on PATH, an SSH key file |
|
|
52
52
|
| `k8s` | `kubectl port-forward` to a Service / Pod in a cluster | `kubectl` CLI on PATH, a valid kubeconfig |
|
|
53
53
|
| `direct` | No tunnel — connect to the upstream host:port directly | none |
|
|
54
|
+
| `azure` | Azure Bastion tunnel to a VM (`az network bastion tunnel`) | `az` CLI on PATH, active `az login` session, Bastion on Standard SKU |
|
|
55
|
+
| `gcp` | IAP TCP tunnel to a Compute Engine instance (`gcloud compute start-iap-tunnel`) | `gcloud` CLI on PATH, active `gcloud auth login` session |
|
|
54
56
|
|
|
55
57
|
Each connection declares its SQLAlchemy URL scheme (`postgresql+psycopg`,
|
|
56
58
|
`mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`, `oracle+oracledb`,
|
|
@@ -111,9 +113,11 @@ uv pip install -e .
|
|
|
111
113
|
uv run dbctl --help
|
|
112
114
|
```
|
|
113
115
|
|
|
114
|
-
The `aws` and `
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
The `aws`, `ssh`, `az`, and `gcloud` binaries are expected on `PATH` (only
|
|
117
|
+
the ones your configured tunnel types actually need — `dbctl doctor` tells
|
|
118
|
+
you which). No `boto3`, no `paramiko`, no Azure/GCP SDKs — dbctl always
|
|
119
|
+
shells out so you keep your existing SSO session, key agents, and MFA
|
|
120
|
+
flows.
|
|
117
121
|
|
|
118
122
|
## Quick start with the bundled docker-compose fleet
|
|
119
123
|
|
|
@@ -1289,6 +1289,8 @@ def _doctor_deps(ctx, conns) -> None:
|
|
|
1289
1289
|
("kubectl", TunnelType.k8s, "install: https://kubernetes.io/docs/tasks/tools/"),
|
|
1290
1290
|
("aws", TunnelType.ssm, "install: `pip install awscli` or your OS package"),
|
|
1291
1291
|
("ssh", TunnelType.ssh, "install: OpenSSH client (openssh-clients / openssh-client)"),
|
|
1292
|
+
("az", TunnelType.azure, "install: https://learn.microsoft.com/cli/azure/install-azure-cli"),
|
|
1293
|
+
("gcloud", TunnelType.gcp, "install: https://cloud.google.com/sdk/docs/install"),
|
|
1292
1294
|
]
|
|
1293
1295
|
|
|
1294
1296
|
dep_table = Table(title="optional dependencies", header_style="bold cyan")
|
|
@@ -1367,6 +1369,8 @@ def tunnel_cmd():
|
|
|
1367
1369
|
- ssh: Classic ssh -N -L port-forward through a bastion
|
|
1368
1370
|
- k8s: kubectl port-forward to a Service or Pod
|
|
1369
1371
|
- direct: No tunnel — connect to upstream host:port
|
|
1372
|
+
- azure: Azure Bastion tunnel to a VM (az network bastion tunnel)
|
|
1373
|
+
- gcp: GCP IAP TCP tunnel to a Compute Engine instance (gcloud compute start-iap-tunnel)
|
|
1370
1374
|
|
|
1371
1375
|
\b
|
|
1372
1376
|
Subcommands:
|
|
@@ -1507,6 +1511,18 @@ def tunnel_list(ctx):
|
|
|
1507
1511
|
if c.k8s.namespace:
|
|
1508
1512
|
info_parts.append(f"ns={c.k8s.namespace}")
|
|
1509
1513
|
info_parts.append(f"port={c.k8s.remote_port}")
|
|
1514
|
+
elif ttype == "azure":
|
|
1515
|
+
assert c.azure
|
|
1516
|
+
info_parts.append(f"bastion={c.azure.bastion_name}")
|
|
1517
|
+
info_parts.append(f"target={c.azure.target_resource_id}")
|
|
1518
|
+
info_parts.append(f"port={c.azure.remote_port}")
|
|
1519
|
+
elif ttype == "gcp":
|
|
1520
|
+
assert c.gcp
|
|
1521
|
+
info_parts.append(f"instance={c.gcp.instance}")
|
|
1522
|
+
info_parts.append(f"zone={c.gcp.zone}")
|
|
1523
|
+
if c.gcp.project:
|
|
1524
|
+
info_parts.append(f"project={c.gcp.project}")
|
|
1525
|
+
info_parts.append(f"port={c.gcp.remote_port}")
|
|
1510
1526
|
table.add_row(name, ttype, driver, ", ".join(info_parts))
|
|
1511
1527
|
|
|
1512
1528
|
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
|
|
@@ -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
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Azure Bastion tunnel via the ``az`` CLI subprocess."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from dbctl.config import AzureBastionTunnel as _Conn
|
|
10
|
+
from dbctl.tunnels.base import _terminate, find_free_port, wait_local_open
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AzureBastionTunnel:
|
|
14
|
+
def __init__(self, conn: _Conn) -> None:
|
|
15
|
+
self.conn = conn
|
|
16
|
+
self.local_host = "127.0.0.1"
|
|
17
|
+
self.local_port = conn.local_port or find_free_port()
|
|
18
|
+
self._proc: subprocess.Popen | None = None
|
|
19
|
+
|
|
20
|
+
def __enter__(self) -> int:
|
|
21
|
+
# On Windows the Azure CLI installs as `az.cmd`, not `az.exe` —
|
|
22
|
+
# subprocess.Popen(["az", ...]) without shell=True raises
|
|
23
|
+
# FileNotFoundError even though `az` is genuinely on PATH, because
|
|
24
|
+
# CreateProcess won't resolve a bare command name to a .cmd/.bat
|
|
25
|
+
# launcher. Resolving the full path via shutil.which first fixes
|
|
26
|
+
# this on Windows and is a no-op on Linux/macOS (real executable).
|
|
27
|
+
cmd = [
|
|
28
|
+
shutil.which("az") or "az",
|
|
29
|
+
"network",
|
|
30
|
+
"bastion",
|
|
31
|
+
"tunnel",
|
|
32
|
+
"--name",
|
|
33
|
+
self.conn.bastion_name,
|
|
34
|
+
"--resource-group",
|
|
35
|
+
self.conn.resource_group,
|
|
36
|
+
"--target-resource-id",
|
|
37
|
+
self.conn.target_resource_id,
|
|
38
|
+
"--resource-port",
|
|
39
|
+
str(self.conn.remote_port),
|
|
40
|
+
"--port",
|
|
41
|
+
str(self.local_port),
|
|
42
|
+
]
|
|
43
|
+
if self.conn.subscription:
|
|
44
|
+
cmd += ["--subscription", self.conn.subscription]
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
self._proc = subprocess.Popen(
|
|
48
|
+
cmd,
|
|
49
|
+
stdout=subprocess.DEVNULL,
|
|
50
|
+
stderr=subprocess.PIPE,
|
|
51
|
+
stdin=subprocess.DEVNULL,
|
|
52
|
+
)
|
|
53
|
+
except FileNotFoundError as e:
|
|
54
|
+
raise RuntimeError(
|
|
55
|
+
"the `az` CLI was not found on PATH — install the Azure CLI "
|
|
56
|
+
"(https://learn.microsoft.com/cli/azure/install-azure-cli) before "
|
|
57
|
+
"opening an azure tunnel"
|
|
58
|
+
) from e
|
|
59
|
+
atexit.register(self._cleanup)
|
|
60
|
+
|
|
61
|
+
if not wait_local_open(self.local_port, timeout=30.0):
|
|
62
|
+
stderr = ""
|
|
63
|
+
if self._proc and self._proc.poll() is not None:
|
|
64
|
+
err = self._proc.stderr
|
|
65
|
+
if err:
|
|
66
|
+
stderr = err.read().decode("utf-8", "replace")
|
|
67
|
+
self._cleanup()
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
f"Azure Bastion tunnel did not come up on 127.0.0.1:{self.local_port} "
|
|
70
|
+
f"(bastion={self.conn.bastion_name}, target={self.conn.target_resource_id}). "
|
|
71
|
+
f"az stderr: {stderr.strip()[:500]}"
|
|
72
|
+
)
|
|
73
|
+
return self.local_port
|
|
74
|
+
|
|
75
|
+
def __exit__(self, *exc) -> None:
|
|
76
|
+
self._cleanup()
|
|
77
|
+
|
|
78
|
+
def _cleanup(self) -> None:
|
|
79
|
+
if self._proc is not None:
|
|
80
|
+
_terminate(self._proc)
|
|
81
|
+
self._proc = None
|
|
@@ -62,7 +62,9 @@ def build_tunnel(conn: Connection, *, override_port: int | None = None) -> Tunne
|
|
|
62
62
|
it's the right place for that lazy resolution to happen.
|
|
63
63
|
"""
|
|
64
64
|
from dbctl.refs import resolve_connection
|
|
65
|
+
from dbctl.tunnels.azure import AzureBastionTunnel as _Azure
|
|
65
66
|
from dbctl.tunnels.direct import DirectTunnel as _Direct
|
|
67
|
+
from dbctl.tunnels.gcp import GcpIapTunnel as _Gcp
|
|
66
68
|
from dbctl.tunnels.k8s import K8sTunnel as _K8k
|
|
67
69
|
from dbctl.tunnels.ssh import SshTunnel as _Ssh
|
|
68
70
|
from dbctl.tunnels.ssm import SsmTunnel as _Ssm
|
|
@@ -91,6 +93,16 @@ def build_tunnel(conn: Connection, *, override_port: int | None = None) -> Tunne
|
|
|
91
93
|
if override_port is not None:
|
|
92
94
|
conn.direct = conn.direct.model_copy(update={"port": override_port})
|
|
93
95
|
return _Direct(conn.direct)
|
|
96
|
+
case "azure":
|
|
97
|
+
assert conn.azure
|
|
98
|
+
if override_port is not None:
|
|
99
|
+
conn.azure = conn.azure.model_copy(update={"local_port": override_port})
|
|
100
|
+
return _Azure(conn.azure)
|
|
101
|
+
case "gcp":
|
|
102
|
+
assert conn.gcp
|
|
103
|
+
if override_port is not None:
|
|
104
|
+
conn.gcp = conn.gcp.model_copy(update={"local_port": override_port})
|
|
105
|
+
return _Gcp(conn.gcp)
|
|
94
106
|
case _: # pragma: no cover - exhaustive
|
|
95
107
|
raise ValueError(f"unknown tunnel type {conn.type!r}")
|
|
96
108
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""GCP Identity-Aware Proxy (IAP) TCP tunnel via the ``gcloud`` CLI subprocess."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from dbctl.config import GcpIapTunnel as _Conn
|
|
10
|
+
from dbctl.tunnels.base import _terminate, find_free_port, wait_local_open
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GcpIapTunnel:
|
|
14
|
+
def __init__(self, conn: _Conn) -> None:
|
|
15
|
+
self.conn = conn
|
|
16
|
+
self.local_host = "127.0.0.1"
|
|
17
|
+
self.local_port = conn.local_port or find_free_port()
|
|
18
|
+
self._proc: subprocess.Popen | None = None
|
|
19
|
+
|
|
20
|
+
def __enter__(self) -> int:
|
|
21
|
+
# On Windows the Google Cloud SDK installs as `gcloud.cmd`, not
|
|
22
|
+
# `gcloud.exe` — subprocess.Popen(["gcloud", ...]) without
|
|
23
|
+
# shell=True raises FileNotFoundError even though `gcloud` is
|
|
24
|
+
# genuinely on PATH, because CreateProcess won't resolve a bare
|
|
25
|
+
# command name to a .cmd/.bat launcher. Resolving the full path
|
|
26
|
+
# via shutil.which first fixes this on Windows and is a no-op on
|
|
27
|
+
# Linux/macOS (real executable).
|
|
28
|
+
cmd = [
|
|
29
|
+
shutil.which("gcloud") or "gcloud",
|
|
30
|
+
"compute",
|
|
31
|
+
"start-iap-tunnel",
|
|
32
|
+
self.conn.instance,
|
|
33
|
+
str(self.conn.remote_port),
|
|
34
|
+
f"--local-host-port=localhost:{self.local_port}",
|
|
35
|
+
"--zone",
|
|
36
|
+
self.conn.zone,
|
|
37
|
+
]
|
|
38
|
+
if self.conn.project:
|
|
39
|
+
cmd.append(f"--project={self.conn.project}")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
self._proc = subprocess.Popen(
|
|
43
|
+
cmd,
|
|
44
|
+
stdout=subprocess.DEVNULL,
|
|
45
|
+
stderr=subprocess.PIPE,
|
|
46
|
+
stdin=subprocess.DEVNULL,
|
|
47
|
+
)
|
|
48
|
+
except FileNotFoundError as e:
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
"the `gcloud` CLI was not found on PATH — install the Google Cloud "
|
|
51
|
+
"CLI (https://cloud.google.com/sdk/docs/install) before opening a "
|
|
52
|
+
"gcp tunnel"
|
|
53
|
+
) from e
|
|
54
|
+
atexit.register(self._cleanup)
|
|
55
|
+
|
|
56
|
+
if not wait_local_open(self.local_port, timeout=30.0):
|
|
57
|
+
stderr = ""
|
|
58
|
+
if self._proc and self._proc.poll() is not None:
|
|
59
|
+
err = self._proc.stderr
|
|
60
|
+
if err:
|
|
61
|
+
stderr = err.read().decode("utf-8", "replace")
|
|
62
|
+
self._cleanup()
|
|
63
|
+
raise RuntimeError(
|
|
64
|
+
f"GCP IAP tunnel did not come up on 127.0.0.1:{self.local_port} "
|
|
65
|
+
f"(instance={self.conn.instance}, zone={self.conn.zone}). "
|
|
66
|
+
f"gcloud stderr: {stderr.strip()[:500]}"
|
|
67
|
+
)
|
|
68
|
+
return self.local_port
|
|
69
|
+
|
|
70
|
+
def __exit__(self, *exc) -> None:
|
|
71
|
+
self._cleanup()
|
|
72
|
+
|
|
73
|
+
def _cleanup(self) -> None:
|
|
74
|
+
if self._proc is not None:
|
|
75
|
+
_terminate(self._proc)
|
|
76
|
+
self._proc = None
|
|
@@ -38,7 +38,7 @@ overlap with a clear message.
|
|
|
38
38
|
|-----------------|-------------------------------------------|----------|-------|
|
|
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
|
-
| `type` | `ssm` \| `ssh` \| `k8s` \| `direct`
|
|
41
|
+
| `type` | `ssm` \| `ssh` \| `k8s` \| `direct` \| `azure` \| `gcp` | **yes** | selects the tunnel implementation. |
|
|
42
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. |
|
|
@@ -50,6 +50,8 @@ overlap with a clear message.
|
|
|
50
50
|
| `ssh` | [`SshTunnel`](#ssh-block) | yes if `type: ssh` | tunnel params. |
|
|
51
51
|
| `k8s` | [`K8sTunnel`](#k8s-block) | yes if `type: k8s` | tunnel params. |
|
|
52
52
|
| `direct` | [`DirectTunnel`](#direct-block) | yes if `type: direct` | upstream params (no tunnel). |
|
|
53
|
+
| `azure` | [`AzureBastionTunnel`](#azure-block) | yes if `type: azure` | tunnel params. |
|
|
54
|
+
| `gcp` | [`GcpIapTunnel`](#gcp-block) | yes if `type: gcp` | tunnel params. |
|
|
53
55
|
| `healthcheck` | [`Healthcheck`](#healthcheck-block) | no | `SELECT 1` by default. |
|
|
54
56
|
| `info` | list of [`InfoQuery`](#info-query) | no | named introspection queries that `dbctl <conn> info <name>` can run. |
|
|
55
57
|
| `safety` | [`Safety`](#safety-block) | no | gates DML. |
|
|
@@ -256,6 +258,54 @@ the database without a bastion.
|
|
|
256
258
|
> driver, install `ODBC Driver 18 for SQL Server` (or similar) on your
|
|
257
259
|
> system first.
|
|
258
260
|
|
|
261
|
+
## `azure` block
|
|
262
|
+
|
|
263
|
+
Azure Bastion tunnel to a VM via `az network bastion tunnel`. The `az` CLI
|
|
264
|
+
on your `PATH` is invoked as a subprocess; your existing `az login` session
|
|
265
|
+
is used directly. Requires an Azure Bastion resource on the **Standard**
|
|
266
|
+
SKU (the Basic SKU doesn't support the native-client tunnel command) in the
|
|
267
|
+
target VM's VNet.
|
|
268
|
+
|
|
269
|
+
```yaml
|
|
270
|
+
azure:
|
|
271
|
+
resource_group: prod-rg
|
|
272
|
+
bastion_name: prod-bastion
|
|
273
|
+
target_resource_id: /subscriptions/xxxx/resourceGroups/prod-rg/providers/Microsoft.Compute/virtualMachines/prod-db-vm
|
|
274
|
+
subscription: prod # optional; az CLI --subscription (name or id)
|
|
275
|
+
remote_port: 5432 # port on the target VM to forward
|
|
276
|
+
local_port: 0 # 0 = dbctl picks a free local port
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
- `target_resource_id` is the full ARM resource id of the target VM (the
|
|
280
|
+
bastion's target, not the bastion itself).
|
|
281
|
+
- `local_port: 0` makes `dbctl` discover a free port itself before invoking
|
|
282
|
+
`az`, the same as the `ssm` / `ssh` / `k8s` tunnels.
|
|
283
|
+
- The subprocess is terminated cleanly on exit; an `atexit` fallback covers
|
|
284
|
+
hard crashes.
|
|
285
|
+
|
|
286
|
+
## `gcp` block
|
|
287
|
+
|
|
288
|
+
GCP Identity-Aware Proxy (IAP) TCP tunnel to a Compute Engine instance via
|
|
289
|
+
`gcloud compute start-iap-tunnel`. The `gcloud` CLI on your `PATH` is
|
|
290
|
+
invoked as a subprocess; your existing `gcloud auth login` session is used
|
|
291
|
+
directly. Requires IAP TCP forwarding enabled for the target instance's
|
|
292
|
+
network and the caller to hold the `roles/iap.tunnelResourceAccessor` IAM
|
|
293
|
+
role (directly or via a broader role).
|
|
294
|
+
|
|
295
|
+
```yaml
|
|
296
|
+
gcp:
|
|
297
|
+
project: my-gcp-project # optional; blank = gcloud's active project
|
|
298
|
+
zone: europe-west1-b
|
|
299
|
+
instance: prod-db-vm
|
|
300
|
+
remote_port: 5432 # port on the instance to forward
|
|
301
|
+
local_port: 0 # 0 = dbctl picks a free local port
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
- `local_port: 0` makes `dbctl` discover a free port itself before invoking
|
|
305
|
+
`gcloud`, the same as the other tunnel types.
|
|
306
|
+
- The subprocess is terminated cleanly on exit; an `atexit` fallback covers
|
|
307
|
+
hard crashes.
|
|
308
|
+
|
|
259
309
|
## `healthcheck` block
|
|
260
310
|
|
|
261
311
|
```yaml
|
|
@@ -479,6 +529,57 @@ connections:
|
|
|
479
529
|
allowed_operations: []
|
|
480
530
|
```
|
|
481
531
|
|
|
532
|
+
### Azure Postgres VM via Azure Bastion
|
|
533
|
+
|
|
534
|
+
```yaml
|
|
535
|
+
connections:
|
|
536
|
+
azure-prod-pg:
|
|
537
|
+
description: "Production Postgres VM reached via Azure Bastion"
|
|
538
|
+
aliases: [prod]
|
|
539
|
+
type: azure
|
|
540
|
+
driver: postgresql+psycopg
|
|
541
|
+
database: app
|
|
542
|
+
username: app_admin
|
|
543
|
+
password_env: DBCTL_AZURE_PROD_PG_PASSWORD
|
|
544
|
+
azure:
|
|
545
|
+
resource_group: prod-rg
|
|
546
|
+
bastion_name: prod-bastion
|
|
547
|
+
target_resource_id: /subscriptions/xxxx/resourceGroups/prod-rg/providers/Microsoft.Compute/virtualMachines/prod-db-vm
|
|
548
|
+
subscription: prod
|
|
549
|
+
remote_port: 5432
|
|
550
|
+
local_port: 0
|
|
551
|
+
healthcheck: { query: "SELECT 1" }
|
|
552
|
+
safety:
|
|
553
|
+
confirm: true
|
|
554
|
+
read_only: false
|
|
555
|
+
allowed_operations: []
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### GCP Cloud SQL-adjacent VM via IAP tunnel
|
|
559
|
+
|
|
560
|
+
```yaml
|
|
561
|
+
connections:
|
|
562
|
+
gcp-prod-pg:
|
|
563
|
+
description: "Production Postgres VM reached via GCP IAP tunnel"
|
|
564
|
+
aliases: [prod]
|
|
565
|
+
type: gcp
|
|
566
|
+
driver: postgresql+psycopg
|
|
567
|
+
database: app
|
|
568
|
+
username: app_admin
|
|
569
|
+
password_env: DBCTL_GCP_PROD_PG_PASSWORD
|
|
570
|
+
gcp:
|
|
571
|
+
project: my-gcp-project
|
|
572
|
+
zone: europe-west1-b
|
|
573
|
+
instance: prod-db-vm
|
|
574
|
+
remote_port: 5432
|
|
575
|
+
local_port: 0
|
|
576
|
+
healthcheck: { query: "SELECT 1" }
|
|
577
|
+
safety:
|
|
578
|
+
confirm: true
|
|
579
|
+
read_only: false
|
|
580
|
+
allowed_operations: []
|
|
581
|
+
```
|
|
582
|
+
|
|
482
583
|
### SQL Server with Windows SSO (Integrated Security)
|
|
483
584
|
|
|
484
585
|
```yaml
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dbctl"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.3"
|
|
8
8
|
description = "Generic CLI to monitor, control, and administer multiple databases via SSM, SSH, or direct connection."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|