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.
Files changed (56) hide show
  1. {dbctl-0.7.2 → dbctl-0.7.3}/CHANGELOG.md +22 -0
  2. {dbctl-0.7.2 → dbctl-0.7.3}/PKG-INFO +8 -4
  3. {dbctl-0.7.2 → dbctl-0.7.3}/README.md +7 -3
  4. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/cli.py +16 -0
  5. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/config.py +41 -0
  6. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/init.py +41 -1
  7. dbctl-0.7.3/dbctl/tunnels/azure.py +81 -0
  8. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/tunnels/base.py +12 -0
  9. dbctl-0.7.3/dbctl/tunnels/gcp.py +76 -0
  10. {dbctl-0.7.2 → dbctl-0.7.3}/docs/connections.md +102 -1
  11. {dbctl-0.7.2 → dbctl-0.7.3}/pyproject.toml +1 -1
  12. dbctl-0.7.3/tests/test_azure_tunnel.py +284 -0
  13. dbctl-0.7.3/tests/test_gcp_tunnel.py +248 -0
  14. {dbctl-0.7.2 → dbctl-0.7.3}/uv.lock +0 -213
  15. dbctl-0.7.2/tests/test_issue_1.py +0 -580
  16. {dbctl-0.7.2 → dbctl-0.7.3}/.dbctl/connections.yaml +0 -0
  17. {dbctl-0.7.2 → dbctl-0.7.3}/.dbctl/operations.yaml +0 -0
  18. {dbctl-0.7.2 → dbctl-0.7.3}/.github/workflows/ci.yml +0 -0
  19. {dbctl-0.7.2 → dbctl-0.7.3}/.github-local/ci.yml +0 -0
  20. {dbctl-0.7.2 → dbctl-0.7.3}/.gitignore +0 -0
  21. {dbctl-0.7.2 → dbctl-0.7.3}/Makefile +0 -0
  22. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/__init__.py +0 -0
  23. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/__main__.py +0 -0
  24. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/audit.py +0 -0
  25. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/connections.py +0 -0
  26. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/db.py +0 -0
  27. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/execute.py +0 -0
  28. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/multi.py +0 -0
  29. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/operations.py +0 -0
  30. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/refs.py +0 -0
  31. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/reports.py +0 -0
  32. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/runtime.py +0 -0
  33. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/tunnels/__init__.py +0 -0
  34. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/tunnels/direct.py +0 -0
  35. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/tunnels/k8s.py +0 -0
  36. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/tunnels/ssh.py +0 -0
  37. {dbctl-0.7.2 → dbctl-0.7.3}/dbctl/tunnels/ssm.py +0 -0
  38. {dbctl-0.7.2 → dbctl-0.7.3}/docker-compose.yml +0 -0
  39. {dbctl-0.7.2 → dbctl-0.7.3}/docs/ACTION_OUTPUT.md +0 -0
  40. {dbctl-0.7.2 → dbctl-0.7.3}/docs/DESIGN.md +0 -0
  41. {dbctl-0.7.2 → dbctl-0.7.3}/docs/SESSION_STATE.md +0 -0
  42. {dbctl-0.7.2 → dbctl-0.7.3}/docs/logo.png +0 -0
  43. {dbctl-0.7.2 → dbctl-0.7.3}/docs/logo_small.png +0 -0
  44. {dbctl-0.7.2 → dbctl-0.7.3}/docs/operations.md +0 -0
  45. {dbctl-0.7.2 → dbctl-0.7.3}/docs/tutorial.md +0 -0
  46. {dbctl-0.7.2 → dbctl-0.7.3}/seed/mssql.sql +0 -0
  47. {dbctl-0.7.2 → dbctl-0.7.3}/seed/mysql.sql +0 -0
  48. {dbctl-0.7.2 → dbctl-0.7.3}/seed/postgres.sql +0 -0
  49. {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_bastion_tags.py +0 -0
  50. {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_connections_loader.py +0 -0
  51. {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_copy_features.py +0 -0
  52. {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_k8s_tunnel.py +0 -0
  53. {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_refs.py +0 -0
  54. {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_regressions.py +0 -0
  55. {dbctl-0.7.2 → dbctl-0.7.3}/tests/test_smoke.py +0 -0
  56. {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.2
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 `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.
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 `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.
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` | **yes** | selects the tunnel implementation. |
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.2"
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"