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.
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/CLAUDE.md +6 -1
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/PKG-INFO +1 -1
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/auth_local.py +1 -1
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/db/__init__.py +2 -0
- buildai_cli-0.3.55/cli/commands/db/broker.py +60 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/db/common.py +13 -20
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/db/query.py +10 -4
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/context.py +108 -34
- buildai_cli-0.3.55/cli/db_broker.py +632 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/ops_init.py +36 -9
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/pyproject.toml +1 -1
- buildai_cli-0.3.53/cli/auth_proxy.py +0 -99
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/.gitignore +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/AGENTS.md +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/buildai_bootstrap.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/__init__.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/_has_core.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/__init__.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/api_proxy.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/auth.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/db/migrate.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/db/schema.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/db/status.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/dev.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/doctor.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/commands/gigcamera.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/config.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/console.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/guard.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/internal_api.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/main.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/nl_query/__init__.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/nl_query/dataset_tools.py +0 -0
- {buildai_cli-0.3.53 → buildai_cli-0.3.55}/cli/output.py +0 -0
- {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.
|
|
@@ -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}="
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
|
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
|
-
|
|
244
|
-
|
|
319
|
+
config = resolve_broker_config(settings)
|
|
320
|
+
conn = await connect_via_broker(config)
|
|
245
321
|
try:
|
|
246
|
-
await
|
|
247
|
-
|
|
248
|
-
yield db.conn
|
|
322
|
+
await _stamp_internal_inspection_session_contract(conn)
|
|
323
|
+
yield conn
|
|
249
324
|
finally:
|
|
250
|
-
await
|
|
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)
|
|
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[
|
|
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
|
|
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
|
-
|
|
342
|
-
db =
|
|
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
|
|
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
|
|
132
|
-
effective_user =
|
|
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
|
|
162
|
-
os.environ[f"ALLOYDB_RUNTIME_IMPERSONATE_SA_{env_prefix}"] =
|
|
163
|
-
|
|
164
|
-
)
|
|
165
|
-
|
|
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()
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|