buildai-cli 0.3.53__tar.gz → 0.3.55__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 (35) hide show
  1. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/CLAUDE.md +6 -1
  2. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/PKG-INFO +1 -1
  3. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/auth_local.py +1 -1
  4. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/db/__init__.py +2 -0
  5. buildai_cli-0.3.55/cli/commands/db/broker.py +60 -0
  6. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/db/common.py +13 -20
  7. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/db/query.py +10 -4
  8. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/context.py +108 -34
  9. buildai_cli-0.3.55/cli/db_broker.py +632 -0
  10. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/ops_init.py +36 -9
  11. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/pyproject.toml +1 -1
  12. buildai_cli-0.3.53/cli/auth_proxy.py +0 -99
  13. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/.gitignore +0 -0
  14. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/AGENTS.md +0 -0
  15. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/buildai_bootstrap.py +0 -0
  16. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/__init__.py +0 -0
  17. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/_has_core.py +0 -0
  18. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/__init__.py +0 -0
  19. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/api_proxy.py +0 -0
  20. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/auth.py +0 -0
  21. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/db/migrate.py +0 -0
  22. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/db/schema.py +0 -0
  23. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/db/status.py +0 -0
  24. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/dev.py +0 -0
  25. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/doctor.py +0 -0
  26. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/gigcamera.py +0 -0
  27. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/config.py +0 -0
  28. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/console.py +0 -0
  29. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/guard.py +0 -0
  30. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/internal_api.py +0 -0
  31. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/main.py +0 -0
  32. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/nl_query/__init__.py +0 -0
  33. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/nl_query/dataset_tools.py +0 -0
  34. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/output.py +0 -0
  35. {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/pagination.py +0 -0
@@ -7,13 +7,14 @@ Typer-based CLI with two modes: standalone (PyPI, API-backed) and workspace (rep
7
7
  | Mode | Install | Auth | DB Access |
8
8
  |------|---------|------|-----------|
9
9
  | Standalone (`buildai`) | `uv tool install buildai-cli` | API key / JWT | Through API |
10
- | Workspace (`uv run buildai`) | Repo-local editable install | IAM / password | Direct via `db` |
10
+ | Workspace (`uv run buildai`) | Repo-local editable install | IAM / password | Direct via `db`, through the profile-keyed local broker |
11
11
 
12
12
  ## Key Commands
13
13
 
14
14
  ```bash
15
15
  buildai auth whoami # API auth inspection
16
16
  buildai db query "SELECT count(*) FROM core.clips" # DB-direct
17
+ buildai db broker status # Active local DB broker state
17
18
  buildai db schema tables # Schema introspection
18
19
  buildai db schema describe core.clips # Table details
19
20
  buildai db --write migrate all # Run migrations
@@ -23,6 +24,10 @@ buildai db status # Migration status
23
24
  ## Guards
24
25
 
25
26
  - `db` subcommands require workspace install + gcloud IAM.
27
+ - `db` inspection/migration commands use a profile-keyed `alloydb-auth-proxy`
28
+ broker owned by the local machine, with state in `~/.buildai/db-brokers` so
29
+ all worktrees reuse the same listener; use `buildai db broker status|ensure|stop`
30
+ for that local transport.
26
31
  - Writes require `--write` flag.
27
32
  - Production migrations prompt for confirmation.
28
33
  - Worktree app/runtime targeting comes from explicit env vars and the repo Makefile, not a saved CLI context layer.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buildai-cli
3
- Version: 0.3.53
3
+ Version: 0.3.55
4
4
  Summary: Build AI CLI (Typer)
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27.0
@@ -98,7 +98,7 @@ class ResolvedLocalAuthProfile:
98
98
  exports.extend(
99
99
  [
100
100
  f'export ALLOYDB_RUNTIME_IMPERSONATE_SA_{suffix}="{self.target_service_account}"',
101
- f'export ALLOYDB_USE_AUTH_PROXY_{suffix}="false"',
101
+ f'export ALLOYDB_USE_AUTH_PROXY_{suffix}="true"',
102
102
  ]
103
103
  )
104
104
  elif self.name == "db-admin-local" and self.target_db_user and self.target_service_account:
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
+ from . import broker as broker_mod
7
8
  from . import migrate as migrate_mod
8
9
  from . import query as query_mod
9
10
  from . import schema as schema_mod
@@ -18,4 +19,5 @@ app = typer.Typer(
18
19
  app.command("query")(query_mod.query)
19
20
  app.command("status")(status_mod.status)
20
21
  app.command("migrate")(migrate_mod.migrate)
22
+ app.add_typer(broker_mod.app, name="broker")
21
23
  app.add_typer(schema_mod.app, name="schema")
@@ -0,0 +1,60 @@
1
+ """Local AlloyDB broker commands for the DB CLI surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from cli.context import resolve_broker_config
8
+ from cli.db_broker import BrokerConfig, broker_status, ensure_broker, stop_broker
9
+ from cli.output import Format, format_option, render
10
+
11
+ app = typer.Typer(
12
+ name="broker",
13
+ help="Inspect and manage the profile-keyed local AlloyDB broker.",
14
+ no_args_is_help=True,
15
+ )
16
+
17
+
18
+ def _config_from_context(ctx: typer.Context) -> BrokerConfig:
19
+ """Resolve the active DB command context into a broker config."""
20
+
21
+ settings = ctx.obj["settings"]
22
+ profile = ctx.obj.get("cli_profile")
23
+ return resolve_broker_config(settings, profile=profile)
24
+
25
+
26
+ @app.command("status")
27
+ def status(
28
+ ctx: typer.Context,
29
+ format: Format = format_option(),
30
+ ) -> None:
31
+ """Show whether the active profile broker is alive and listening."""
32
+
33
+ config = _config_from_context(ctx)
34
+ render(broker_status(config), format=format)
35
+
36
+
37
+ @app.command("ensure")
38
+ def ensure(
39
+ ctx: typer.Context,
40
+ format: Format = format_option(),
41
+ ) -> None:
42
+ """Start or reuse the active profile broker, then print its status."""
43
+
44
+ config = _config_from_context(ctx)
45
+ ensure_broker(config)
46
+ render(broker_status(config), format=format)
47
+
48
+
49
+ @app.command("stop")
50
+ def stop(
51
+ ctx: typer.Context,
52
+ format: Format = format_option(),
53
+ ) -> None:
54
+ """Stop the active profile broker if this workspace owns it."""
55
+
56
+ config = _config_from_context(ctx)
57
+ stopped = stop_broker(config)
58
+ payload = broker_status(config)
59
+ payload["stopped"] = stopped
60
+ render(payload, format=format)
@@ -9,6 +9,12 @@ from typing import Any
9
9
  import asyncpg
10
10
  from infra.settings import Settings
11
11
 
12
+ from cli.db_broker import (
13
+ BrokeredDatabase,
14
+ broker_config_for_identity,
15
+ open_brokered_database,
16
+ )
17
+
12
18
  MIGRATIONS_SA_EMAIL = "buildai-migrations-sa@data-470400.iam.gserviceaccount.com"
13
19
  MIGRATIONS_SA_DB_USER = "buildai-migrations-sa@data-470400.iam"
14
20
  REPO_ROOT = Path(__file__).resolve().parents[5]
@@ -62,35 +68,22 @@ def migration_label(item: Any) -> str:
62
68
  return str(item)
63
69
 
64
70
 
65
- async def get_migrations_connection(settings: Settings):
71
+ async def get_migrations_connection(settings: Settings) -> BrokeredDatabase:
66
72
  """Open the canonical migrations identity for reviewed DDL work."""
67
73
 
68
- from infra.database import Database
69
-
70
74
  settings.validate_environment_access()
71
75
 
72
76
  if not settings.alloydb_instance_uri:
73
77
  raise RuntimeError("AlloyDB instance URI is not configured for this environment.")
74
78
 
75
- ip_type = "PRIVATE" if settings.use_private_ip else "PUBLIC"
76
- db = Database(
77
- instance_uri=settings.alloydb_instance_uri,
78
- database=settings.db_name,
79
- user=MIGRATIONS_SA_DB_USER,
79
+ config = broker_config_for_identity(
80
+ settings,
81
+ profile="migrations-local",
82
+ db_user=MIGRATIONS_SA_DB_USER,
80
83
  use_iam_auth=True,
81
- ip_type=ip_type,
82
- auth_proxy_host=(
83
- settings.alloydb_auth_proxy_host if settings.effective_use_alloydb_auth_proxy else None
84
- ),
85
- auth_proxy_port=(
86
- settings.effective_alloydb_auth_proxy_port
87
- if settings.effective_use_alloydb_auth_proxy
88
- else None
89
- ),
90
- impersonate_sa=MIGRATIONS_SA_EMAIL,
84
+ target_service_account=MIGRATIONS_SA_EMAIL,
91
85
  )
92
- await db.connect()
93
- return db
86
+ return await open_brokered_database(config)
94
87
 
95
88
 
96
89
  async def get_table_row_count(
@@ -17,6 +17,7 @@ QUERY_HINTS = {
17
17
  "public._migrations": "Tip: Use 'buildai db migrate' to inspect migration state.",
18
18
  "core.clips": None,
19
19
  }
20
+ _QUERY_WRITE_PROFILES = frozenset({"internal_admin", "db-admin-local"})
20
21
 
21
22
 
22
23
  def _show_query_hints(sql: str) -> None:
@@ -117,7 +118,10 @@ def query(
117
118
  ] = False,
118
119
  write: Annotated[
119
120
  bool,
120
- typer.Option("--write", help="Allow write queries (requires internal_admin profile)"),
121
+ typer.Option(
122
+ "--write",
123
+ help="Allow write queries (requires internal_admin or db-admin-local profile)",
124
+ ),
121
125
  ] = False,
122
126
  ) -> None:
123
127
  """Execute SQL against the selected database lane in inspection mode."""
@@ -130,11 +134,13 @@ def query(
130
134
  cli_profile = (ctx.obj or {}).get("cli_profile", "internal_viewer")
131
135
 
132
136
  if is_write and not write:
133
- error("Write query blocked. Re-run with --write and an internal_admin profile.")
137
+ error(
138
+ "Write query blocked. Re-run with --write and an admin-capable DB profile."
139
+ )
134
140
  raise typer.Exit(1)
135
141
 
136
- if is_write and cli_profile != "internal_admin":
137
- error("Write query requires --profile internal_admin.")
142
+ if is_write and cli_profile not in _QUERY_WRITE_PROFILES:
143
+ error("Write query requires --profile internal_admin or --profile db-admin-local.")
138
144
  raise typer.Exit(1)
139
145
 
140
146
  if settings.is_production and is_ddl:
@@ -28,7 +28,15 @@ import asyncpg
28
28
  from dal import scopes as dal_scopes
29
29
  from infra.settings import Settings, get_settings
30
30
 
31
- from infra import Database, get_logger
31
+ from cli.auth_local import resolve_sanctioned_profile
32
+ from cli.db_broker import (
33
+ BrokerConfig,
34
+ BrokeredDatabase,
35
+ broker_config_for_identity,
36
+ connect_via_broker,
37
+ open_brokered_database,
38
+ )
39
+ from infra import get_logger
32
40
 
33
41
  if TYPE_CHECKING:
34
42
  from dal.context import Context
@@ -72,6 +80,10 @@ _UNRESTRICTED_CLI_PROFILES = frozenset(
72
80
  "agent",
73
81
  }
74
82
  )
83
+ DB_IDENTITY_PROFILE_ALIASES: dict[str, str] = {
84
+ "internal_admin": "db-admin-local",
85
+ }
86
+ _ADMIN_DB_IDENTITY_PROFILES = frozenset({"internal_admin", "db-admin-local"})
75
87
 
76
88
 
77
89
  def scopes_for_cli_profile(profile: str) -> frozenset[str]:
@@ -82,6 +94,12 @@ def scopes_for_cli_profile(profile: str) -> frozenset[str]:
82
94
  return resolved
83
95
 
84
96
 
97
+ def db_identity_profile_for_cli_profile(profile: str) -> str:
98
+ """Return the DB identity profile backing one CLI-facing profile."""
99
+
100
+ return DB_IDENTITY_PROFILE_ALIASES.get(profile, profile)
101
+
102
+
85
103
  @dataclass(frozen=True)
86
104
  class AdminConnectionConfig:
87
105
  """Describe the canonical admin connection the ops-plane CLI should prefer.
@@ -166,7 +184,72 @@ def resolve_admin_connection_config(
166
184
  return None
167
185
 
168
186
 
169
- async def open_admin_database(settings: Settings) -> Database:
187
+ def _fixed_profile_connection_config(
188
+ settings: Settings,
189
+ *,
190
+ profile: str,
191
+ ) -> AdminConnectionConfig | None:
192
+ """Resolve a sanctioned local auth profile into a DB login contract."""
193
+
194
+ try:
195
+ resolved = resolve_sanctioned_profile(
196
+ profile,
197
+ environment=settings.app_env.value,
198
+ )
199
+ except ValueError:
200
+ return None
201
+
202
+ if not resolved.db_access or not resolved.target_db_user or not resolved.target_service_account:
203
+ return None
204
+
205
+ return AdminConnectionConfig(
206
+ user=resolved.target_db_user,
207
+ use_iam_auth=True,
208
+ impersonate_sa=resolved.target_service_account,
209
+ )
210
+
211
+
212
+ def resolve_broker_config(
213
+ settings: Settings,
214
+ *,
215
+ profile: str | None = None,
216
+ ) -> BrokerConfig:
217
+ """Resolve the CLI profile and settings into one local DB broker identity."""
218
+
219
+ from cli.config import resolve_cli_profile
220
+
221
+ resolved_profile = resolve_cli_profile(profile)
222
+ db_identity_profile = db_identity_profile_for_cli_profile(resolved_profile)
223
+ config = _fixed_profile_connection_config(settings, profile=db_identity_profile)
224
+
225
+ if config is None and resolved_profile in _ADMIN_DB_IDENTITY_PROFILES:
226
+ config = resolve_admin_connection_config(settings)
227
+
228
+ if config is None:
229
+ if resolved_profile in _ADMIN_DB_IDENTITY_PROFILES:
230
+ raise RuntimeError(
231
+ f"Profile '{resolved_profile}' requires admin DB credentials. "
232
+ "Set ALLOYDB_ADMIN_IAM_USER_* / ALLOYDB_ADMIN_IMPERSONATE_SA_* "
233
+ "or use the sanctioned db-admin-local profile."
234
+ )
235
+ config = AdminConnectionConfig(
236
+ user=settings.effective_db_user,
237
+ use_iam_auth=settings.effective_use_iam_auth,
238
+ impersonate_sa=settings.effective_alloydb_runtime_impersonate_sa or None,
239
+ password=settings.effective_alloydb_password,
240
+ )
241
+
242
+ return broker_config_for_identity(
243
+ settings,
244
+ profile=resolved_profile,
245
+ db_user=config.user,
246
+ use_iam_auth=config.use_iam_auth,
247
+ target_service_account=config.impersonate_sa,
248
+ password=config.password,
249
+ )
250
+
251
+
252
+ async def open_admin_database(settings: Settings) -> BrokeredDatabase:
170
253
  """Open the canonical admin DB connection for privileged ops-plane access.
171
254
 
172
255
  Commands that need deterministic production introspection should use this
@@ -180,33 +263,26 @@ async def open_admin_database(settings: Settings) -> Database:
180
263
  if not settings.alloydb_instance_uri:
181
264
  raise RuntimeError("AlloyDB instance URI is not configured for this environment.")
182
265
 
183
- config = resolve_admin_connection_config(settings)
266
+ config = resolve_admin_connection_config(settings) or _fixed_profile_connection_config(
267
+ settings,
268
+ profile="db-admin-local",
269
+ )
184
270
  if config is None:
185
271
  raise RuntimeError(
186
272
  "Admin credentials not configured for this environment. "
187
- "Set ALLOYDB_ADMIN_IAM_USER_* / ALLOYDB_ADMIN_IMPERSONATE_SA_*."
273
+ "Set ALLOYDB_ADMIN_IAM_USER_* / ALLOYDB_ADMIN_IMPERSONATE_SA_* "
274
+ "or use the sanctioned db-admin-local profile."
188
275
  )
189
276
 
190
- ip_type = "PRIVATE" if settings.use_private_ip else "PUBLIC"
191
- db = Database(
192
- instance_uri=settings.alloydb_instance_uri,
193
- database=settings.db_name,
194
- user=config.user,
195
- password=config.password,
277
+ broker_config = broker_config_for_identity(
278
+ settings,
279
+ profile="db-admin-local",
280
+ db_user=config.user,
196
281
  use_iam_auth=config.use_iam_auth,
197
- ip_type=ip_type,
198
- auth_proxy_host=(
199
- settings.alloydb_auth_proxy_host if settings.effective_use_alloydb_auth_proxy else None
200
- ),
201
- auth_proxy_port=(
202
- settings.effective_alloydb_auth_proxy_port
203
- if settings.effective_use_alloydb_auth_proxy
204
- else None
205
- ),
206
- impersonate_sa=config.impersonate_sa,
282
+ target_service_account=config.impersonate_sa,
283
+ password=config.password,
207
284
  )
208
- await db.connect()
209
- return db
285
+ return await open_brokered_database(broker_config)
210
286
 
211
287
 
212
288
  @asynccontextmanager
@@ -240,14 +316,13 @@ async def get_inspection_connection(
240
316
  await _stamp_internal_inspection_session_contract(conn)
241
317
  yield conn
242
318
  else:
243
- # Development/Production: use AlloyDB via infra.Database
244
- db = Database.from_settings(settings)
319
+ config = resolve_broker_config(settings)
320
+ conn = await connect_via_broker(config)
245
321
  try:
246
- await db.connect()
247
- await _stamp_internal_inspection_session_contract(db.conn)
248
- yield db.conn
322
+ await _stamp_internal_inspection_session_contract(conn)
323
+ yield conn
249
324
  finally:
250
- await db.close()
325
+ await conn.close()
251
326
 
252
327
 
253
328
  @asynccontextmanager
@@ -283,7 +358,7 @@ async def _local_connection(
283
358
  - DB_PORT (default: 5432)
284
359
  - DB_USER (falls back to settings.db_user)
285
360
  - DB_PASSWORD (falls back to "postgres")
286
- - DB_NAME (falls back to settings.db_name) - FIX: respect env var for CI
361
+ - DB_NAME (falls back to settings.db_name)
287
362
  """
288
363
  conn = await asyncpg.connect(
289
364
  host=os.getenv("DB_HOST", "localhost"),
@@ -304,7 +379,7 @@ async def get_cli_context(
304
379
  settings: Settings | None = None,
305
380
  *,
306
381
  profile: str | None = None,
307
- ) -> AsyncGenerator[tuple[Database | None, "Context"], None]:
382
+ ) -> AsyncGenerator[tuple[BrokeredDatabase | None, "Context"], None]:
308
383
  """
309
384
  Get a database connection and canonical DAL context for CLI commands.
310
385
 
@@ -317,7 +392,7 @@ async def get_cli_context(
317
392
  Yields:
318
393
  Tuple of (Database instance or None, Context for DAL operations)
319
394
  - For test/CI: Database is None, Context wraps the raw connection
320
- - For dev/prod: Database is the infra.Database instance
395
+ - For dev/prod: Database is a brokered Database-like wrapper
321
396
 
322
397
  Usage:
323
398
  async with get_cli_context(settings) as (db, ctx):
@@ -338,10 +413,9 @@ async def get_cli_context(
338
413
  ctx = Context.for_cli(conn, scopes=scopes_for_cli_profile(resolved_profile))
339
414
  yield None, ctx
340
415
  else:
341
- # Development/Production: use infra.Database
342
- db = Database.from_settings(settings)
416
+ broker_config = resolve_broker_config(settings, profile=resolved_profile)
417
+ db = await open_brokered_database(broker_config)
343
418
  try:
344
- await db.connect()
345
419
  await _stamp_scoped_cli_session_contract(db.conn, profile=resolved_profile)
346
420
  ctx = Context.for_cli(db, scopes=scopes_for_cli_profile(resolved_profile))
347
421
  yield db, ctx
@@ -0,0 +1,632 @@
1
+ """Profile-keyed local AlloyDB broker for CLI database operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import shlex
11
+ import shutil
12
+ import socket
13
+ import subprocess
14
+ import time
15
+ from contextlib import contextmanager
16
+ from dataclasses import asdict, dataclass
17
+ from pathlib import Path
18
+ from typing import Any, Iterator
19
+
20
+ import asyncpg
21
+ from infra.settings import Settings
22
+
23
+ REPO_ROOT = Path(__file__).resolve().parents[3]
24
+ LEGACY_STATE_DIR = REPO_ROOT / ".buildai" / "db-brokers"
25
+ STATE_DIR = Path(os.getenv("BUILDAI_DB_BROKER_STATE_DIR", Path.home() / ".buildai" / "db-brokers"))
26
+ _PROFILE_PORT_OFFSETS = {
27
+ "engineers-dev": 0,
28
+ "internal_viewer": 1,
29
+ "internal_admin": 21,
30
+ "migrations-local": 10,
31
+ "db-admin-local": 20,
32
+ }
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class BrokerConfig:
37
+ """Complete identity and endpoint contract for one local DB broker."""
38
+
39
+ environment: str
40
+ profile: str
41
+ instance_uri: str
42
+ database: str
43
+ db_user: str
44
+ use_iam_auth: bool
45
+ target_service_account: str | None
46
+ host: str
47
+ port: int
48
+ use_private_ip: bool
49
+ password: str = ""
50
+
51
+ @property
52
+ def identity(self) -> str:
53
+ """Return the stable identity hash for state files and diagnostics."""
54
+
55
+ payload = {
56
+ "environment": self.environment,
57
+ "profile": self.profile,
58
+ "instance_uri": self.instance_uri,
59
+ "database": self.database,
60
+ "db_user": self.db_user,
61
+ "use_iam_auth": self.use_iam_auth,
62
+ "target_service_account": self.target_service_account,
63
+ "host": self.host,
64
+ "port": self.port,
65
+ "use_private_ip": self.use_private_ip,
66
+ }
67
+ raw = json.dumps(payload, sort_keys=True, separators=(",", ":"))
68
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
69
+
70
+ @property
71
+ def state_path(self) -> Path:
72
+ """Return the broker state file path for this identity."""
73
+
74
+ return STATE_DIR / f"{self.identity}.json"
75
+
76
+ @property
77
+ def log_path(self) -> Path:
78
+ """Return the broker log path for this identity."""
79
+
80
+ return STATE_DIR / f"{self.identity}.log"
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class BrokerState:
85
+ """Persist the child process and identity that own one broker listener."""
86
+
87
+ pid: int
88
+ config: dict[str, Any]
89
+ command: list[str]
90
+ log_path: str
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class ListenerProcess:
95
+ """Describe one local process already bound to a broker TCP endpoint."""
96
+
97
+ pid: int
98
+ command: list[str]
99
+
100
+
101
+ @dataclass
102
+ class BrokeredDatabase:
103
+ """Expose the small Database-like surface migration commands require."""
104
+
105
+ config: BrokerConfig
106
+ conn: asyncpg.Connection
107
+
108
+ async def fetch(self, query: str, *args: Any, timeout: float | None = None) -> list[Any]:
109
+ """Run ``fetch`` through the brokered connection."""
110
+
111
+ result = await self.conn.fetch(query, *args, timeout=timeout)
112
+ return list(result)
113
+
114
+ async def fetchrow(self, query: str, *args: Any, timeout: float | None = None) -> Any | None:
115
+ """Run ``fetchrow`` through the brokered connection."""
116
+
117
+ return await self.conn.fetchrow(query, *args, timeout=timeout)
118
+
119
+ async def fetchval(self, query: str, *args: Any, timeout: float | None = None) -> Any:
120
+ """Run ``fetchval`` through the brokered connection."""
121
+
122
+ return await self.conn.fetchval(query, *args, timeout=timeout)
123
+
124
+ async def execute(self, query: str, *args: Any, timeout: float | None = None) -> str:
125
+ """Run ``execute`` through the brokered connection."""
126
+
127
+ return str(await self.conn.execute(query, *args, timeout=timeout))
128
+
129
+ async def executemany(
130
+ self,
131
+ query: str,
132
+ args: list[tuple[Any, ...]],
133
+ timeout: float | None = None,
134
+ ) -> None:
135
+ """Run ``executemany`` through the brokered connection."""
136
+
137
+ await self.conn.executemany(query, args, timeout=timeout)
138
+
139
+ def transaction(self) -> Any:
140
+ """Return the asyncpg transaction context for this connection."""
141
+
142
+ return self.conn.transaction()
143
+
144
+ async def close(self) -> None:
145
+ """Close the underlying local asyncpg connection."""
146
+
147
+ await self.conn.close()
148
+
149
+
150
+ async def _init_broker_connection(conn: asyncpg.Connection) -> None:
151
+ """Register codecs expected by DAL code using brokered CLI connections."""
152
+
153
+ await conn.set_type_codec(
154
+ "jsonb",
155
+ encoder=json.dumps,
156
+ decoder=json.loads,
157
+ schema="pg_catalog",
158
+ )
159
+ await conn.set_type_codec(
160
+ "json",
161
+ encoder=json.dumps,
162
+ decoder=json.loads,
163
+ schema="pg_catalog",
164
+ )
165
+
166
+
167
+ def default_proxy_binary() -> str | None:
168
+ """Return the preferred AlloyDB Auth Proxy binary path if available."""
169
+
170
+ on_path = shutil.which("alloydb-auth-proxy")
171
+ if on_path:
172
+ return on_path
173
+ local_bin = Path.home() / ".local" / "bin" / "alloydb-auth-proxy"
174
+ if local_bin.is_file():
175
+ return str(local_bin)
176
+ return None
177
+
178
+
179
+ def proxy_is_listening(*, host: str, port: int) -> bool:
180
+ """Return True when a TCP listener is present at the local broker endpoint."""
181
+
182
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
183
+ sock.settimeout(0.25)
184
+ return sock.connect_ex((host, port)) == 0
185
+
186
+
187
+ def process_is_running(pid: int) -> bool:
188
+ """Return whether the broker PID still exists."""
189
+
190
+ try:
191
+ os.kill(pid, 0)
192
+ except ProcessLookupError:
193
+ return False
194
+ except PermissionError:
195
+ return True
196
+ return True
197
+
198
+
199
+ @contextmanager
200
+ def _broker_lock(config: BrokerConfig) -> Iterator[None]:
201
+ """Serialize local broker startup for one profile identity on this machine."""
202
+
203
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
204
+ lock_path = STATE_DIR / f"{config.identity}.lock"
205
+ with lock_path.open("w", encoding="utf-8") as handle:
206
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
207
+ try:
208
+ yield
209
+ finally:
210
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
211
+
212
+
213
+ def _state_paths(config: BrokerConfig) -> tuple[Path, ...]:
214
+ """Return current and legacy broker state paths for one identity."""
215
+
216
+ paths = [config.state_path]
217
+ legacy_path = LEGACY_STATE_DIR / f"{config.identity}.json"
218
+ if legacy_path != config.state_path:
219
+ paths.append(legacy_path)
220
+ return tuple(paths)
221
+
222
+
223
+ def _read_broker_state_file(path: Path) -> BrokerState | None:
224
+ """Read one broker state file without trusting malformed stale content."""
225
+
226
+ try:
227
+ data = json.loads(path.read_text(encoding="utf-8"))
228
+ return BrokerState(
229
+ pid=int(data["pid"]),
230
+ config=dict(data["config"]),
231
+ command=list(data["command"]),
232
+ log_path=str(data["log_path"]),
233
+ )
234
+ except Exception:
235
+ return None
236
+
237
+
238
+ def _unlink_state(config: BrokerConfig) -> None:
239
+ """Remove current and legacy state files for a stale broker identity."""
240
+
241
+ for path in _state_paths(config):
242
+ try:
243
+ path.unlink()
244
+ except FileNotFoundError:
245
+ pass
246
+
247
+
248
+ def _state_payload(config: BrokerConfig, *, pid: int, command: list[str]) -> BrokerState:
249
+ """Build a serializable broker state object."""
250
+
251
+ return BrokerState(
252
+ pid=pid,
253
+ config=asdict(config),
254
+ command=command,
255
+ log_path=str(config.log_path),
256
+ )
257
+
258
+
259
+ def read_broker_state(config: BrokerConfig) -> BrokerState | None:
260
+ """Read the state file for a broker identity when present and valid."""
261
+
262
+ for path in _state_paths(config):
263
+ state = _read_broker_state_file(path)
264
+ if state is not None:
265
+ return state
266
+ return None
267
+
268
+
269
+ def _write_broker_state(config: BrokerConfig, state: BrokerState) -> None:
270
+ """Atomically write the broker state file."""
271
+
272
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
273
+ tmp_path = config.state_path.with_suffix(".tmp")
274
+ tmp_path.write_text(
275
+ json.dumps(asdict(state), indent=2, sort_keys=True) + "\n",
276
+ encoding="utf-8",
277
+ )
278
+ os.replace(tmp_path, config.state_path)
279
+
280
+
281
+ def _process_command(pid: int) -> list[str]:
282
+ """Return argv-like command text for a local PID when the OS exposes it."""
283
+
284
+ try:
285
+ completed = subprocess.run(
286
+ ["ps", "-p", str(pid), "-o", "command="],
287
+ capture_output=True,
288
+ text=True,
289
+ timeout=2,
290
+ check=False,
291
+ )
292
+ except Exception:
293
+ return []
294
+ if completed.returncode != 0:
295
+ return []
296
+ raw = completed.stdout.strip()
297
+ if not raw:
298
+ return []
299
+ try:
300
+ return shlex.split(raw)
301
+ except ValueError:
302
+ return raw.split()
303
+
304
+
305
+ def _listening_pids(*, host: str, port: int) -> list[int]:
306
+ """Return local PIDs listening on a TCP port when OS tools expose them."""
307
+
308
+ if shutil.which("lsof") is not None:
309
+ try:
310
+ completed = subprocess.run(
311
+ ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
312
+ capture_output=True,
313
+ text=True,
314
+ timeout=2,
315
+ check=False,
316
+ )
317
+ except Exception:
318
+ completed = None
319
+ if completed is not None and completed.returncode == 0:
320
+ pids: list[int] = []
321
+ for line in completed.stdout.splitlines():
322
+ try:
323
+ pids.append(int(line.strip()))
324
+ except ValueError:
325
+ continue
326
+ if pids:
327
+ return pids
328
+
329
+ if shutil.which("ss") is not None:
330
+ try:
331
+ completed = subprocess.run(
332
+ ["ss", "-H", "-ltnp", f"sport = :{port}"],
333
+ capture_output=True,
334
+ text=True,
335
+ timeout=2,
336
+ check=False,
337
+ )
338
+ except Exception:
339
+ completed = None
340
+ if completed is not None and completed.returncode == 0:
341
+ candidates: list[int] = []
342
+ for line in completed.stdout.splitlines():
343
+ if f":{port}" not in line:
344
+ continue
345
+ candidates.extend(int(pid) for pid in re.findall(r"pid=(\d+)", line))
346
+ if candidates:
347
+ return candidates
348
+
349
+ del host
350
+ return []
351
+
352
+
353
+ def _listener_processes(*, host: str, port: int) -> list[ListenerProcess]:
354
+ """Return process metadata for listeners on the broker endpoint."""
355
+
356
+ return [
357
+ ListenerProcess(pid=pid, command=_process_command(pid))
358
+ for pid in _listening_pids(host=host, port=port)
359
+ ]
360
+
361
+
362
+ def _flag_value(command: list[str], flag: str) -> str | None:
363
+ """Return the value passed to a command-line flag in split or equals form."""
364
+
365
+ for index, value in enumerate(command):
366
+ if value == flag and index + 1 < len(command):
367
+ return command[index + 1]
368
+ prefix = f"{flag}="
369
+ if value.startswith(prefix):
370
+ return value[len(prefix) :]
371
+ return None
372
+
373
+
374
+ def _is_matching_proxy_process(process: ListenerProcess, config: BrokerConfig) -> bool:
375
+ """Return True when a listener command is the broker this profile needs."""
376
+
377
+ command = process.command
378
+ if not command:
379
+ return False
380
+ if Path(command[0]).name != "alloydb-auth-proxy":
381
+ return False
382
+ if config.instance_uri not in command:
383
+ return False
384
+ if _flag_value(command, "--address") != config.host:
385
+ return False
386
+ if _flag_value(command, "--port") != str(config.port):
387
+ return False
388
+ if ("--auto-iam-authn" in command) != config.use_iam_auth:
389
+ return False
390
+ impersonate = _flag_value(command, "--impersonate-service-account")
391
+ if impersonate != config.target_service_account:
392
+ return False
393
+ if not config.use_private_ip and "--public-ip" not in command:
394
+ return False
395
+ if config.use_private_ip and "--public-ip" in command:
396
+ return False
397
+ return True
398
+
399
+
400
+ def _adopt_matching_listener(config: BrokerConfig) -> BrokerState | None:
401
+ """Persist state for an already-running broker from another worktree."""
402
+
403
+ for process in _listener_processes(host=config.host, port=config.port):
404
+ if _is_matching_proxy_process(process, config):
405
+ state = _state_payload(config, pid=process.pid, command=process.command)
406
+ _write_broker_state(config, state)
407
+ return state
408
+ return None
409
+
410
+
411
+ def _broker_command(config: BrokerConfig, *, proxy_binary: str) -> list[str]:
412
+ """Build the AlloyDB Auth Proxy command for one broker config."""
413
+
414
+ command = [
415
+ proxy_binary,
416
+ config.instance_uri,
417
+ "--address",
418
+ config.host,
419
+ "--port",
420
+ str(config.port),
421
+ "--disable-built-in-telemetry",
422
+ "--quiet",
423
+ ]
424
+ if config.use_iam_auth:
425
+ command.append("--auto-iam-authn")
426
+ if config.target_service_account:
427
+ command.append(f"--impersonate-service-account={config.target_service_account}")
428
+ if not config.use_private_ip:
429
+ command.append("--public-ip")
430
+ return command
431
+
432
+
433
+ def ensure_broker(config: BrokerConfig) -> BrokerState:
434
+ """Start or reuse the profile-keyed local AlloyDB broker."""
435
+
436
+ with _broker_lock(config):
437
+ state = read_broker_state(config)
438
+ if state:
439
+ alive = process_is_running(state.pid)
440
+ listening = proxy_is_listening(host=config.host, port=config.port)
441
+ if alive and listening:
442
+ _write_broker_state(config, state)
443
+ return state
444
+ if alive:
445
+ raise RuntimeError(
446
+ f"Broker process {state.pid} for {config.profile} exists, but "
447
+ f"{config.host}:{config.port} is not accepting connections. "
448
+ f"Inspect {state.log_path} or run `buildai db broker stop`."
449
+ )
450
+ _unlink_state(config)
451
+
452
+ adopted = _adopt_matching_listener(config)
453
+ if adopted is not None:
454
+ return adopted
455
+
456
+ if proxy_is_listening(host=config.host, port=config.port):
457
+ raise RuntimeError(
458
+ f"{config.host}:{config.port} is already listening, but not for the expected "
459
+ f"{config.profile} broker identity. Stop that process or choose another broker port."
460
+ )
461
+
462
+ proxy_binary = default_proxy_binary()
463
+ if proxy_binary is None:
464
+ raise RuntimeError(
465
+ "alloydb-auth-proxy is not installed. Install it before using brokered DB access."
466
+ )
467
+ if not config.instance_uri:
468
+ raise RuntimeError("AlloyDB instance URI is not configured for this environment.")
469
+
470
+ command = _broker_command(config, proxy_binary=proxy_binary)
471
+ with config.log_path.open("ab") as log_file:
472
+ process = subprocess.Popen(
473
+ command,
474
+ stdout=log_file,
475
+ stderr=log_file,
476
+ start_new_session=True,
477
+ env=os.environ.copy(),
478
+ )
479
+
480
+ deadline = time.monotonic() + 15.0
481
+ while time.monotonic() < deadline:
482
+ if process.poll() is not None:
483
+ adopted = _adopt_matching_listener(config)
484
+ if adopted is not None:
485
+ return adopted
486
+ break
487
+ if proxy_is_listening(host=config.host, port=config.port):
488
+ state = _state_payload(config, pid=process.pid, command=command)
489
+ _write_broker_state(config, state)
490
+ return state
491
+ time.sleep(0.25)
492
+
493
+ if process.poll() is not None:
494
+ raise RuntimeError(
495
+ f"AlloyDB broker process exited with code {process.returncode}. "
496
+ f"Broker log: {config.log_path}"
497
+ )
498
+
499
+ raise RuntimeError(
500
+ f"Timed out waiting for AlloyDB broker on {config.host}:{config.port}. "
501
+ f"Broker log: {config.log_path}"
502
+ )
503
+
504
+
505
+ def stop_broker(config: BrokerConfig) -> bool:
506
+ """Stop the machine-local broker for one identity."""
507
+
508
+ with _broker_lock(config):
509
+ state = read_broker_state(config) or _adopt_matching_listener(config)
510
+ if state is None:
511
+ return False
512
+
513
+ def stopped() -> bool:
514
+ return not process_is_running(state.pid) and not proxy_is_listening(
515
+ host=config.host,
516
+ port=config.port,
517
+ )
518
+
519
+ if process_is_running(state.pid):
520
+ os.kill(state.pid, 15)
521
+ deadline = time.monotonic() + 5.0
522
+ while time.monotonic() < deadline:
523
+ if stopped():
524
+ break
525
+ time.sleep(0.1)
526
+
527
+ if not stopped():
528
+ try:
529
+ os.kill(state.pid, 9)
530
+ except ProcessLookupError:
531
+ pass
532
+
533
+ deadline = time.monotonic() + 2.0
534
+ while time.monotonic() < deadline:
535
+ if stopped():
536
+ break
537
+ time.sleep(0.1)
538
+
539
+ if not stopped():
540
+ raise RuntimeError(
541
+ f"Failed to stop AlloyDB broker process {state.pid} for {config.profile}. "
542
+ f"State file left in place: {config.state_path}"
543
+ )
544
+
545
+ _unlink_state(config)
546
+ return True
547
+
548
+
549
+ def broker_status(config: BrokerConfig) -> dict[str, Any]:
550
+ """Return a human and JSON friendly status payload for one broker."""
551
+
552
+ state = read_broker_state(config)
553
+ if state is None and proxy_is_listening(host=config.host, port=config.port):
554
+ with _broker_lock(config):
555
+ state = read_broker_state(config) or _adopt_matching_listener(config)
556
+ pid = state.pid if state else None
557
+ alive = process_is_running(pid) if pid is not None else False
558
+ listening = proxy_is_listening(host=config.host, port=config.port)
559
+ return {
560
+ "environment": config.environment,
561
+ "profile": config.profile,
562
+ "database": config.database,
563
+ "db_user": config.db_user,
564
+ "instance_uri": config.instance_uri,
565
+ "host": config.host,
566
+ "port": config.port,
567
+ "state_path": str(config.state_path),
568
+ "log_path": state.log_path if state else str(config.log_path),
569
+ "pid": pid,
570
+ "process_alive": alive,
571
+ "listening": listening,
572
+ "healthy": bool(state and alive and listening),
573
+ }
574
+
575
+
576
+ def _profile_port(settings: Settings, profile: str, *, db_user: str) -> int:
577
+ """Return a deterministic local port for an environment/profile/user tuple."""
578
+
579
+ base = settings.effective_alloydb_auth_proxy_port
580
+ if profile in _PROFILE_PORT_OFFSETS:
581
+ return base + _PROFILE_PORT_OFFSETS[profile]
582
+ digest = hashlib.sha256(f"{profile}:{db_user}".encode("utf-8")).digest()
583
+ return base + 100 + int.from_bytes(digest[:2], "big") % 200
584
+
585
+
586
+ def broker_config_for_identity(
587
+ settings: Settings,
588
+ *,
589
+ profile: str,
590
+ db_user: str,
591
+ use_iam_auth: bool,
592
+ target_service_account: str | None,
593
+ password: str = "",
594
+ ) -> BrokerConfig:
595
+ """Create a broker config for an explicit DB identity."""
596
+
597
+ return BrokerConfig(
598
+ environment=settings.app_env.value,
599
+ profile=profile,
600
+ instance_uri=settings.alloydb_instance_uri,
601
+ database=settings.db_name,
602
+ db_user=db_user,
603
+ use_iam_auth=use_iam_auth,
604
+ target_service_account=target_service_account,
605
+ host=settings.alloydb_auth_proxy_host,
606
+ port=_profile_port(settings, profile, db_user=db_user),
607
+ use_private_ip=settings.use_private_ip,
608
+ password=password,
609
+ )
610
+
611
+
612
+ async def connect_via_broker(config: BrokerConfig) -> asyncpg.Connection:
613
+ """Ensure the broker is running and return a local asyncpg connection."""
614
+
615
+ ensure_broker(config)
616
+ conn = await asyncpg.connect(
617
+ host=config.host,
618
+ port=config.port,
619
+ user=config.db_user,
620
+ password=config.password if config.password else None,
621
+ database=config.database,
622
+ ssl=False,
623
+ statement_cache_size=0,
624
+ )
625
+ await _init_broker_connection(conn)
626
+ return conn
627
+
628
+
629
+ async def open_brokered_database(config: BrokerConfig) -> BrokeredDatabase:
630
+ """Open one brokered connection behind a Database-like wrapper."""
631
+
632
+ return BrokeredDatabase(config=config, conn=await connect_via_broker(config))
@@ -61,6 +61,12 @@ def _apply_default_db_target(*, env_prefix: str) -> None:
61
61
  os.environ.setdefault(f"ALLOYDB_IAM_AUTH_{env_prefix}", "true")
62
62
 
63
63
 
64
+ def _short_service_account(email: str) -> str:
65
+ """Return a compact service-account label for CLI banners."""
66
+
67
+ return email.split("@", 1)[0]
68
+
69
+
64
70
  def init_ops_context(ctx: typer.Context):
65
71
  """Heavy DB/auth/observability init — called lazily by ops-plane commands.
66
72
 
@@ -80,6 +86,8 @@ def init_ops_context(ctx: typer.Context):
80
86
  from infra.auth import AuthError, get_effective_user_for_iam, validate_auth_config
81
87
  from infra.settings import Environment, ExecutionContext, Settings, get_settings
82
88
 
89
+ from cli.auth_local import resolve_sanctioned_profile
90
+ from cli.context import db_identity_profile_for_cli_profile
83
91
  from infra import init_observability
84
92
 
85
93
  verbose = ctx.obj.get("_verbose", False)
@@ -114,6 +122,26 @@ def init_ops_context(ctx: typer.Context):
114
122
  auth_info: list[str] = []
115
123
  env_prefix = _auth_env_prefix(settings.app_env)
116
124
  _apply_default_db_target(env_prefix=env_prefix)
125
+ settings = Settings(app_env=app_env, execution_context=ExecutionContext.HUMAN)
126
+
127
+ try:
128
+ db_identity_profile = db_identity_profile_for_cli_profile(profile)
129
+ profile_contract = resolve_sanctioned_profile(
130
+ db_identity_profile,
131
+ environment=settings.app_env.value,
132
+ )
133
+ except ValueError:
134
+ profile_contract = None
135
+ profile_db_user = (
136
+ profile_contract.target_db_user
137
+ if profile_contract is not None and profile_contract.db_access
138
+ else None
139
+ )
140
+ profile_impersonate_sa = (
141
+ profile_contract.target_service_account
142
+ if profile_contract is not None and profile_contract.db_access
143
+ else None
144
+ )
117
145
 
118
146
  default_user = settings.effective_db_user
119
147
  default_use_iam = settings.effective_use_iam_auth
@@ -121,15 +149,15 @@ def init_ops_context(ctx: typer.Context):
121
149
 
122
150
  if auth_flag is not None:
123
151
  use_iam_auth = auth_flag == AuthMethod.IAM
124
- elif profile == "engineers-dev" and not ctx.obj.get("allow_write"):
152
+ elif profile_db_user and profile_impersonate_sa:
125
153
  use_iam_auth = True
126
154
  else:
127
155
  use_iam_auth = default_use_iam
128
156
 
129
157
  if user_flag is not None:
130
158
  effective_user = user_flag
131
- elif profile == "engineers-dev" and not ctx.obj.get("allow_write"):
132
- effective_user = "engineers-dev-sa@data-470400.iam"
159
+ elif profile_db_user:
160
+ effective_user = profile_db_user
133
161
  elif use_iam_auth:
134
162
  try:
135
163
  effective_user = get_effective_user_for_iam(None, default_user)
@@ -158,12 +186,11 @@ def init_ops_context(ctx: typer.Context):
158
186
 
159
187
  os.environ[f"ALLOYDB_USER_{env_prefix}"] = effective_user
160
188
  auth_info.append(f"user={effective_user}")
161
- if profile == "engineers-dev" and not ctx.obj.get("allow_write"):
162
- os.environ[f"ALLOYDB_RUNTIME_IMPERSONATE_SA_{env_prefix}"] = (
163
- "engineers-dev-sa@data-470400.iam.gserviceaccount.com"
164
- )
165
- os.environ[f"ALLOYDB_USE_AUTH_PROXY_{env_prefix}"] = "false"
166
- auth_info.append("impersonate=engineers-dev-sa")
189
+ if profile_impersonate_sa:
190
+ os.environ[f"ALLOYDB_RUNTIME_IMPERSONATE_SA_{env_prefix}"] = profile_impersonate_sa
191
+ os.environ[f"ALLOYDB_USE_AUTH_PROXY_{env_prefix}"] = "true"
192
+ auth_info.append(f"impersonate={_short_service_account(profile_impersonate_sa)}")
193
+ auth_info.append("broker=alloydb-auth-proxy")
167
194
 
168
195
  # Reload settings with overrides
169
196
  get_settings.cache_clear()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "buildai-cli"
7
- version = "0.3.53"
7
+ version = "0.3.55"
8
8
  description = "Build AI CLI (Typer)"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -1,99 +0,0 @@
1
- """Helpers for the sanctioned local AlloyDB Auth Proxy lane."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import shutil
7
- import socket
8
- import subprocess
9
- import time
10
- from pathlib import Path
11
-
12
- from infra.settings import Settings
13
-
14
-
15
- def _default_proxy_binary() -> str | None:
16
- """Return the preferred AlloyDB Auth Proxy binary path if available."""
17
-
18
- on_path = shutil.which("alloydb-auth-proxy")
19
- if on_path:
20
- return on_path
21
- local_bin = Path.home() / ".local" / "bin" / "alloydb-auth-proxy"
22
- if local_bin.is_file():
23
- return str(local_bin)
24
- return None
25
-
26
-
27
- def proxy_is_listening(*, host: str, port: int) -> bool:
28
- """Return True when a TCP listener is already present on the proxy endpoint."""
29
-
30
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
31
- sock.settimeout(0.25)
32
- return sock.connect_ex((host, port)) == 0
33
-
34
-
35
- def ensure_alloydb_auth_proxy(
36
- *,
37
- settings: Settings,
38
- target_service_account: str,
39
- ) -> None:
40
- """Start the sanctioned AlloyDB Auth Proxy if it is not already running.
41
-
42
- The engineer DB lane should use one explicit transport path across CLI,
43
- `psql`, and desktop tools. If the proxy is already listening, leave it
44
- alone. Otherwise start it in the background and wait briefly for the port
45
- to come up.
46
- """
47
-
48
- if not settings.effective_use_alloydb_auth_proxy:
49
- return
50
-
51
- host = settings.alloydb_auth_proxy_host
52
- port = settings.effective_alloydb_auth_proxy_port
53
- if proxy_is_listening(host=host, port=port):
54
- return
55
-
56
- proxy_binary = _default_proxy_binary()
57
- if proxy_binary is None:
58
- raise RuntimeError(
59
- "alloydb-auth-proxy is not installed. Re-run ./scripts/setup/setup.py to provision "
60
- "the canonical local DB transport."
61
- )
62
-
63
- if not settings.alloydb_instance_uri:
64
- raise RuntimeError(
65
- "AlloyDB instance URI is not configured, so the local Auth Proxy cannot start."
66
- )
67
-
68
- command = [
69
- proxy_binary,
70
- settings.alloydb_instance_uri,
71
- "--address",
72
- host,
73
- "--port",
74
- str(port),
75
- "--auto-iam-authn",
76
- f"--impersonate-service-account={target_service_account}",
77
- "--disable-built-in-telemetry",
78
- ]
79
- if not settings.use_private_ip:
80
- command.append("--public-ip")
81
-
82
- subprocess.Popen(
83
- command,
84
- stdout=subprocess.DEVNULL,
85
- stderr=subprocess.DEVNULL,
86
- start_new_session=True,
87
- env=os.environ.copy(),
88
- )
89
-
90
- deadline = time.monotonic() + 10.0
91
- while time.monotonic() < deadline:
92
- if proxy_is_listening(host=host, port=port):
93
- return
94
- time.sleep(0.25)
95
-
96
- raise RuntimeError(
97
- f"Timed out waiting for alloydb-auth-proxy on {host}:{port}. "
98
- "Run `uv run buildai dev db info` to inspect the expected proxy recipe."
99
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes