buildai-cli 0.3.61__tar.gz → 0.3.63__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.61 → buildai_cli-0.3.63}/PKG-INFO +1 -1
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/auth_local.py +21 -6
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/gigcamera.py +224 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/context.py +7 -4
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/db_broker.py +140 -6
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/pyproject.toml +1 -1
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/.gitignore +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/AGENTS.md +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/CLAUDE.md +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/buildai_bootstrap.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/__init__.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/_has_core.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/__init__.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/api_proxy.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/auth.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/__init__.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/broker.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/common.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/migrate.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/query.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/schema.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/status.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/dev.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/doctor.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/processing.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/config.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/console.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/guard.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/internal_api.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/main.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/nl_query/__init__.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/nl_query/dataset_tools.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/ops_init.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/output.py +0 -0
- {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/pagination.py +0 -0
|
@@ -353,12 +353,27 @@ def _fixed_targets_for_environment(
|
|
|
353
353
|
def _run_command(argv: list[str], *, timeout: int = 20) -> CommandResult:
|
|
354
354
|
"""Run one external command with bounded time and normalized output fields."""
|
|
355
355
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
356
|
+
try:
|
|
357
|
+
completed = subprocess.run(
|
|
358
|
+
argv,
|
|
359
|
+
capture_output=True,
|
|
360
|
+
text=True,
|
|
361
|
+
timeout=timeout,
|
|
362
|
+
)
|
|
363
|
+
except FileNotFoundError as exc:
|
|
364
|
+
return CommandResult(
|
|
365
|
+
argv=tuple(argv),
|
|
366
|
+
returncode=127,
|
|
367
|
+
stdout="",
|
|
368
|
+
stderr=f"{argv[0]} is not installed or is not on PATH: {exc}",
|
|
369
|
+
)
|
|
370
|
+
except subprocess.TimeoutExpired as exc:
|
|
371
|
+
return CommandResult(
|
|
372
|
+
argv=tuple(argv),
|
|
373
|
+
returncode=124,
|
|
374
|
+
stdout=(exc.stdout or "").strip() if isinstance(exc.stdout, str) else "",
|
|
375
|
+
stderr=(exc.stderr or "").strip() if isinstance(exc.stderr, str) else "Command timed out.",
|
|
376
|
+
)
|
|
362
377
|
return CommandResult(
|
|
363
378
|
argv=tuple(argv),
|
|
364
379
|
returncode=completed.returncode,
|
|
@@ -5,12 +5,15 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import hashlib
|
|
7
7
|
from datetime import date
|
|
8
|
+
from decimal import Decimal
|
|
8
9
|
from typing import Literal
|
|
9
10
|
from uuid import UUID
|
|
10
11
|
|
|
11
12
|
import typer
|
|
12
13
|
|
|
14
|
+
from cli.console import error
|
|
13
15
|
from cli.context import get_cli_context, get_inspection_connection
|
|
16
|
+
from cli.db_broker import BrokerConnectionError
|
|
14
17
|
from cli.ops_init import init_ops_context
|
|
15
18
|
from cli.output import Format, format_option, render
|
|
16
19
|
|
|
@@ -106,6 +109,150 @@ def _idempotency_key(base: str, *, run_suffix: str | None) -> str:
|
|
|
106
109
|
return f"{base}:{suffix}" if suffix else base
|
|
107
110
|
|
|
108
111
|
|
|
112
|
+
async def _stale_consolidated_source_rows(
|
|
113
|
+
db: object,
|
|
114
|
+
*,
|
|
115
|
+
day_ist: date,
|
|
116
|
+
source_ids: list[UUID] | None,
|
|
117
|
+
) -> list[dict[str, object]]:
|
|
118
|
+
"""List approved source rows not actually covered by same-day consolidation."""
|
|
119
|
+
|
|
120
|
+
rows = await db.fetch(
|
|
121
|
+
"""
|
|
122
|
+
WITH source_rows AS (
|
|
123
|
+
SELECT source.*
|
|
124
|
+
FROM finance.payouts source
|
|
125
|
+
WHERE COALESCE(source.metadata->>'gigcamera_surface', 'false') = 'true'
|
|
126
|
+
AND source.metadata->>'payout_day_ist' = $1::text
|
|
127
|
+
AND COALESCE(source.metadata->>'payout_kind', 'participant_earnings') IN (
|
|
128
|
+
'participant_earnings',
|
|
129
|
+
'participant_referral_bonus'
|
|
130
|
+
)
|
|
131
|
+
AND source.status = 'approved'
|
|
132
|
+
AND ($2::uuid[] IS NULL OR source.id = ANY($2::uuid[]))
|
|
133
|
+
AND NOT EXISTS (
|
|
134
|
+
SELECT 1
|
|
135
|
+
FROM finance.disbursements source_disbursement
|
|
136
|
+
WHERE source_disbursement.payout_id = source.id
|
|
137
|
+
AND source_disbursement.status IN ('settled', 'pending', 'sent', 'processing')
|
|
138
|
+
)
|
|
139
|
+
),
|
|
140
|
+
stale_sources AS (
|
|
141
|
+
SELECT DISTINCT ON (source.id)
|
|
142
|
+
source.id AS source_id,
|
|
143
|
+
source.metadata->>'person_display_name' AS person_display_name,
|
|
144
|
+
source.metadata->>'person_id' AS person_id,
|
|
145
|
+
COALESCE(
|
|
146
|
+
source.metadata->>'payout_kind',
|
|
147
|
+
'participant_earnings'
|
|
148
|
+
) AS source_kind,
|
|
149
|
+
source.net_amount AS source_amount,
|
|
150
|
+
source.metadata->>'gigcamera_provider_state' AS source_provider_state,
|
|
151
|
+
source.metadata->>'gigcamera_reason' AS source_provider_reason,
|
|
152
|
+
consolidated.id AS consolidated_id,
|
|
153
|
+
consolidated.status AS consolidated_status,
|
|
154
|
+
consolidated.net_amount AS consolidated_amount,
|
|
155
|
+
consolidated.metadata->>'worker_amount_inr' AS consolidated_worker_amount,
|
|
156
|
+
consolidated.metadata->>'referral_amount_inr' AS consolidated_referral_amount,
|
|
157
|
+
consolidated.metadata->'source_payout_ids' AS consolidated_source_ids,
|
|
158
|
+
latest.status AS consolidated_disbursement_status,
|
|
159
|
+
latest.reconciled_at AS consolidated_reconciled_at
|
|
160
|
+
FROM source_rows source
|
|
161
|
+
JOIN finance.payouts consolidated
|
|
162
|
+
ON consolidated.org_id = source.org_id
|
|
163
|
+
AND COALESCE(consolidated.metadata->>'gigcamera_surface', 'false') = 'true'
|
|
164
|
+
AND consolidated.metadata->>'payout_kind' = 'participant_consolidated'
|
|
165
|
+
AND consolidated.metadata->>'person_id' = source.metadata->>'person_id'
|
|
166
|
+
AND consolidated.metadata->>'payout_day_ist' = source.metadata->>'payout_day_ist'
|
|
167
|
+
AND consolidated.status NOT IN ('voided', 'skipped')
|
|
168
|
+
LEFT JOIN LATERAL (
|
|
169
|
+
SELECT status, reconciled_at
|
|
170
|
+
FROM finance.disbursements disbursement
|
|
171
|
+
WHERE disbursement.payout_id = consolidated.id
|
|
172
|
+
ORDER BY attempt_index DESC, created_at DESC
|
|
173
|
+
LIMIT 1
|
|
174
|
+
) latest ON true
|
|
175
|
+
WHERE NOT (
|
|
176
|
+
COALESCE(consolidated.metadata->'source_payout_ids', '[]'::jsonb)
|
|
177
|
+
? source.id::text
|
|
178
|
+
)
|
|
179
|
+
AND NOT EXISTS (
|
|
180
|
+
SELECT 1
|
|
181
|
+
FROM finance.payouts explicit_consolidated
|
|
182
|
+
WHERE explicit_consolidated.org_id = source.org_id
|
|
183
|
+
AND COALESCE(
|
|
184
|
+
explicit_consolidated.metadata->>'gigcamera_surface',
|
|
185
|
+
'false'
|
|
186
|
+
) = 'true'
|
|
187
|
+
AND explicit_consolidated.metadata->>'payout_kind'
|
|
188
|
+
= 'participant_consolidated'
|
|
189
|
+
AND explicit_consolidated.metadata->>'person_id'
|
|
190
|
+
= source.metadata->>'person_id'
|
|
191
|
+
AND explicit_consolidated.metadata->>'payout_day_ist'
|
|
192
|
+
= source.metadata->>'payout_day_ist'
|
|
193
|
+
AND explicit_consolidated.status NOT IN ('voided', 'skipped')
|
|
194
|
+
AND COALESCE(
|
|
195
|
+
explicit_consolidated.metadata->'source_payout_ids',
|
|
196
|
+
'[]'::jsonb
|
|
197
|
+
) ? source.id::text
|
|
198
|
+
)
|
|
199
|
+
ORDER BY source.id, consolidated.created_at DESC, consolidated.id DESC
|
|
200
|
+
)
|
|
201
|
+
SELECT *
|
|
202
|
+
FROM stale_sources
|
|
203
|
+
ORDER BY person_display_name, source_id
|
|
204
|
+
""",
|
|
205
|
+
day_ist.isoformat(),
|
|
206
|
+
source_ids,
|
|
207
|
+
)
|
|
208
|
+
return [dict(row) for row in rows]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def _clear_false_consolidated_submit_blocks(
|
|
212
|
+
db: object,
|
|
213
|
+
*,
|
|
214
|
+
source_ids: list[UUID],
|
|
215
|
+
) -> list[UUID]:
|
|
216
|
+
"""Clear only metadata written by the old false consolidation submit guard."""
|
|
217
|
+
|
|
218
|
+
if not source_ids:
|
|
219
|
+
return []
|
|
220
|
+
rows = await db.fetch(
|
|
221
|
+
"""
|
|
222
|
+
UPDATE finance.payouts payout
|
|
223
|
+
SET metadata = jsonb_strip_nulls(
|
|
224
|
+
(
|
|
225
|
+
COALESCE(payout.metadata, '{}'::jsonb)
|
|
226
|
+
- 'gigcamera_provider_state'
|
|
227
|
+
- 'gigcamera_provider_review_needed'
|
|
228
|
+
- 'gigcamera_reason'
|
|
229
|
+
- 'gigcamera_provider_status'
|
|
230
|
+
- 'gigcamera_provider_status_code'
|
|
231
|
+
- 'gigcamera_retry_policy'
|
|
232
|
+
- 'gigcamera_retry_available'
|
|
233
|
+
- 'gigcamera_retry_guidance'
|
|
234
|
+
)
|
|
235
|
+
|| jsonb_build_object(
|
|
236
|
+
'gigcamera_consolidation_repair',
|
|
237
|
+
jsonb_build_object(
|
|
238
|
+
'kind', 'cleared_false_consolidated_submit_block',
|
|
239
|
+
'cleared_at', now()::text
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
),
|
|
243
|
+
updated_at = now()
|
|
244
|
+
WHERE payout.id = ANY($1::uuid[])
|
|
245
|
+
AND payout.status = 'approved'
|
|
246
|
+
AND payout.metadata->>'gigcamera_provider_state' = 'blocked_destination'
|
|
247
|
+
AND COALESCE(payout.metadata->>'gigcamera_reason', '')
|
|
248
|
+
LIKE 'this row has been consolidated into payout %; submit the consolidated row instead'
|
|
249
|
+
RETURNING payout.id
|
|
250
|
+
""",
|
|
251
|
+
source_ids,
|
|
252
|
+
)
|
|
253
|
+
return [UUID(str(row["id"])) for row in rows]
|
|
254
|
+
|
|
255
|
+
|
|
109
256
|
async def _resolve_program_version_id(
|
|
110
257
|
conn: object,
|
|
111
258
|
*,
|
|
@@ -213,6 +360,83 @@ async def _delete_stale_recaps(conn: object, *, program_version_id: UUID) -> Non
|
|
|
213
360
|
)
|
|
214
361
|
|
|
215
362
|
|
|
363
|
+
@app.command("stale-consolidation-sources")
|
|
364
|
+
def stale_consolidation_sources(
|
|
365
|
+
ctx: typer.Context,
|
|
366
|
+
day: str = typer.Option(..., "--day", help="IST payout day to inspect (YYYY-MM-DD)."),
|
|
367
|
+
source_id: list[str] | None = typer.Option(
|
|
368
|
+
None,
|
|
369
|
+
"--source-id",
|
|
370
|
+
help="Optional source payout id to constrain the repair; repeatable.",
|
|
371
|
+
),
|
|
372
|
+
write: bool = typer.Option(
|
|
373
|
+
False,
|
|
374
|
+
"--write",
|
|
375
|
+
help=(
|
|
376
|
+
"Clear bogus blocked-destination metadata on affected source rows. "
|
|
377
|
+
"Does not submit payments."
|
|
378
|
+
),
|
|
379
|
+
),
|
|
380
|
+
format: Format = format_option(),
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Audit stale source rows left outside settled participant-day consolidation."""
|
|
383
|
+
|
|
384
|
+
parsed_day = _parse_ist_day(day, field_name="--day")
|
|
385
|
+
parsed_source_ids = [UUID(value) for value in source_id or []] or None
|
|
386
|
+
settings = _settings_for_command(ctx, write=write)
|
|
387
|
+
_require_internal_admin_for_write(ctx, write=write)
|
|
388
|
+
|
|
389
|
+
async def run() -> None:
|
|
390
|
+
async with get_inspection_connection(settings) as conn:
|
|
391
|
+
rows = await _stale_consolidated_source_rows(
|
|
392
|
+
conn,
|
|
393
|
+
day_ist=parsed_day,
|
|
394
|
+
source_ids=parsed_source_ids,
|
|
395
|
+
)
|
|
396
|
+
cleared_ids: list[UUID] = []
|
|
397
|
+
if write:
|
|
398
|
+
async with conn.transaction():
|
|
399
|
+
cleared_ids = await _clear_false_consolidated_submit_blocks(
|
|
400
|
+
conn,
|
|
401
|
+
source_ids=[UUID(str(row["source_id"])) for row in rows],
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
delta_ids = [str(row["source_id"]) for row in rows]
|
|
405
|
+
block_repair_candidate_ids = [
|
|
406
|
+
str(row["source_id"])
|
|
407
|
+
for row in rows
|
|
408
|
+
if row.get("source_provider_state") == "blocked_destination"
|
|
409
|
+
and str(row.get("source_provider_reason") or "").startswith(
|
|
410
|
+
"this row has been consolidated into payout "
|
|
411
|
+
)
|
|
412
|
+
]
|
|
413
|
+
delta_total = sum(
|
|
414
|
+
(row["source_amount"] for row in rows),
|
|
415
|
+
Decimal("0"),
|
|
416
|
+
).quantize(Decimal("0.01"))
|
|
417
|
+
render(
|
|
418
|
+
{
|
|
419
|
+
"dry_run": not write,
|
|
420
|
+
"day": parsed_day.isoformat(),
|
|
421
|
+
"delta_payout_count": len(delta_ids),
|
|
422
|
+
"delta_payout_total_inr": delta_total,
|
|
423
|
+
"delta_payout_ids": delta_ids,
|
|
424
|
+
"blocked_destination_repair_candidate_count": len(block_repair_candidate_ids),
|
|
425
|
+
"blocked_destination_repair_candidate_ids": block_repair_candidate_ids,
|
|
426
|
+
"cleared_blocked_destination_count": len(cleared_ids),
|
|
427
|
+
"cleared_blocked_destination_ids": [str(value) for value in cleared_ids],
|
|
428
|
+
"rows": rows,
|
|
429
|
+
},
|
|
430
|
+
format=format,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
asyncio.run(run())
|
|
435
|
+
except BrokerConnectionError as exc:
|
|
436
|
+
error(str(exc))
|
|
437
|
+
raise typer.Exit(1) from None
|
|
438
|
+
|
|
439
|
+
|
|
216
440
|
@app.command("vlm-purge-stale-recaps")
|
|
217
441
|
def vlm_purge_stale_recaps(
|
|
218
442
|
ctx: typer.Context,
|
|
@@ -220,10 +220,13 @@ def resolve_broker_config(
|
|
|
220
220
|
|
|
221
221
|
resolved_profile = resolve_cli_profile(profile)
|
|
222
222
|
db_identity_profile = db_identity_profile_for_cli_profile(resolved_profile)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
if resolved_profile in _ADMIN_DB_IDENTITY_PROFILES:
|
|
224
|
+
config = resolve_admin_connection_config(settings) or _fixed_profile_connection_config(
|
|
225
|
+
settings,
|
|
226
|
+
profile=db_identity_profile,
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
config = _fixed_profile_connection_config(settings, profile=db_identity_profile)
|
|
227
230
|
|
|
228
231
|
if config is None:
|
|
229
232
|
if resolved_profile in _ADMIN_DB_IDENTITY_PROFILES:
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import ctypes
|
|
6
6
|
import hashlib
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
@@ -20,6 +20,11 @@ from typing import Any, Iterator
|
|
|
20
20
|
import asyncpg
|
|
21
21
|
from infra.settings import Settings
|
|
22
22
|
|
|
23
|
+
if os.name == "nt":
|
|
24
|
+
import msvcrt
|
|
25
|
+
else:
|
|
26
|
+
import fcntl
|
|
27
|
+
|
|
23
28
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
24
29
|
LEGACY_STATE_DIR = REPO_ROOT / ".buildai" / "db-brokers"
|
|
25
30
|
STATE_DIR = Path(os.getenv("BUILDAI_DB_BROKER_STATE_DIR", Path.home() / ".buildai" / "db-brokers"))
|
|
@@ -192,6 +197,9 @@ def proxy_is_listening(*, host: str, port: int) -> bool:
|
|
|
192
197
|
def process_is_running(pid: int) -> bool:
|
|
193
198
|
"""Return whether the broker PID still exists."""
|
|
194
199
|
|
|
200
|
+
if os.name == "nt":
|
|
201
|
+
return _windows_process_is_running(pid)
|
|
202
|
+
|
|
195
203
|
try:
|
|
196
204
|
os.kill(pid, 0)
|
|
197
205
|
except ProcessLookupError:
|
|
@@ -201,18 +209,59 @@ def process_is_running(pid: int) -> bool:
|
|
|
201
209
|
return True
|
|
202
210
|
|
|
203
211
|
|
|
212
|
+
def _windows_process_is_running(pid: int) -> bool:
|
|
213
|
+
"""Check a Windows process handle without sending a signal to the process."""
|
|
214
|
+
|
|
215
|
+
if pid <= 0:
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
synchronize = 0x00100000
|
|
219
|
+
wait_timeout = 0x00000102
|
|
220
|
+
kernel32 = ctypes.windll.kernel32
|
|
221
|
+
handle = kernel32.OpenProcess(synchronize, False, int(pid))
|
|
222
|
+
if not handle:
|
|
223
|
+
return False
|
|
224
|
+
try:
|
|
225
|
+
return kernel32.WaitForSingleObject(handle, 0) == wait_timeout
|
|
226
|
+
finally:
|
|
227
|
+
kernel32.CloseHandle(handle)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _lock_file(handle: Any) -> None:
|
|
231
|
+
"""Acquire an exclusive machine-local lock for the opened lock file."""
|
|
232
|
+
|
|
233
|
+
if os.name == "nt":
|
|
234
|
+
handle.seek(0)
|
|
235
|
+
handle.write(b"\0")
|
|
236
|
+
handle.flush()
|
|
237
|
+
handle.seek(0)
|
|
238
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
239
|
+
return
|
|
240
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _unlock_file(handle: Any) -> None:
|
|
244
|
+
"""Release a lock previously acquired by ``_lock_file``."""
|
|
245
|
+
|
|
246
|
+
if os.name == "nt":
|
|
247
|
+
handle.seek(0)
|
|
248
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
249
|
+
return
|
|
250
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
251
|
+
|
|
252
|
+
|
|
204
253
|
@contextmanager
|
|
205
254
|
def _broker_lock(config: BrokerConfig) -> Iterator[None]:
|
|
206
255
|
"""Serialize local broker startup for one profile identity on this machine."""
|
|
207
256
|
|
|
208
257
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
209
258
|
lock_path = STATE_DIR / f"{config.identity}.lock"
|
|
210
|
-
with lock_path.open("
|
|
211
|
-
|
|
259
|
+
with lock_path.open("a+b") as handle:
|
|
260
|
+
_lock_file(handle)
|
|
212
261
|
try:
|
|
213
262
|
yield
|
|
214
263
|
finally:
|
|
215
|
-
|
|
264
|
+
_unlock_file(handle)
|
|
216
265
|
|
|
217
266
|
|
|
218
267
|
def _state_paths(config: BrokerConfig) -> tuple[Path, ...]:
|
|
@@ -286,6 +335,9 @@ def _write_broker_state(config: BrokerConfig, state: BrokerState) -> None:
|
|
|
286
335
|
def _process_command(pid: int) -> list[str]:
|
|
287
336
|
"""Return argv-like command text for a local PID when the OS exposes it."""
|
|
288
337
|
|
|
338
|
+
if os.name == "nt":
|
|
339
|
+
return _windows_process_command(pid)
|
|
340
|
+
|
|
289
341
|
try:
|
|
290
342
|
completed = subprocess.run(
|
|
291
343
|
["ps", "-p", str(pid), "-o", "command="],
|
|
@@ -307,9 +359,44 @@ def _process_command(pid: int) -> list[str]:
|
|
|
307
359
|
return raw.split()
|
|
308
360
|
|
|
309
361
|
|
|
362
|
+
def _windows_process_command(pid: int) -> list[str]:
|
|
363
|
+
"""Return command-line tokens for a Windows process when PowerShell exposes it."""
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
completed = subprocess.run(
|
|
367
|
+
[
|
|
368
|
+
"powershell",
|
|
369
|
+
"-NoProfile",
|
|
370
|
+
"-Command",
|
|
371
|
+
(
|
|
372
|
+
"$p = Get-CimInstance Win32_Process -Filter "
|
|
373
|
+
f"'ProcessId = {int(pid)}'; if ($p) {{ $p.CommandLine }}"
|
|
374
|
+
),
|
|
375
|
+
],
|
|
376
|
+
capture_output=True,
|
|
377
|
+
text=True,
|
|
378
|
+
timeout=2,
|
|
379
|
+
check=False,
|
|
380
|
+
)
|
|
381
|
+
except Exception:
|
|
382
|
+
return []
|
|
383
|
+
if completed.returncode != 0:
|
|
384
|
+
return []
|
|
385
|
+
raw = completed.stdout.strip()
|
|
386
|
+
if not raw:
|
|
387
|
+
return []
|
|
388
|
+
try:
|
|
389
|
+
return shlex.split(raw, posix=False)
|
|
390
|
+
except ValueError:
|
|
391
|
+
return raw.split()
|
|
392
|
+
|
|
393
|
+
|
|
310
394
|
def _listening_pids(*, host: str, port: int) -> list[int]:
|
|
311
395
|
"""Return local PIDs listening on a TCP port when OS tools expose them."""
|
|
312
396
|
|
|
397
|
+
if os.name == "nt":
|
|
398
|
+
return _windows_listening_pids(host=host, port=port)
|
|
399
|
+
|
|
313
400
|
if shutil.which("lsof") is not None:
|
|
314
401
|
try:
|
|
315
402
|
completed = subprocess.run(
|
|
@@ -355,6 +442,41 @@ def _listening_pids(*, host: str, port: int) -> list[int]:
|
|
|
355
442
|
return []
|
|
356
443
|
|
|
357
444
|
|
|
445
|
+
def _windows_listening_pids(*, host: str, port: int) -> list[int]:
|
|
446
|
+
"""Return Windows PIDs listening on a local TCP port via netstat."""
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
completed = subprocess.run(
|
|
450
|
+
["netstat", "-ano", "-p", "tcp"],
|
|
451
|
+
capture_output=True,
|
|
452
|
+
text=True,
|
|
453
|
+
timeout=2,
|
|
454
|
+
check=False,
|
|
455
|
+
)
|
|
456
|
+
except Exception:
|
|
457
|
+
return []
|
|
458
|
+
if completed.returncode != 0:
|
|
459
|
+
return []
|
|
460
|
+
|
|
461
|
+
pids: list[int] = []
|
|
462
|
+
port_suffix = f":{port}"
|
|
463
|
+
for line in completed.stdout.splitlines():
|
|
464
|
+
parts = line.split()
|
|
465
|
+
if len(parts) < 5 or parts[0].upper() != "TCP":
|
|
466
|
+
continue
|
|
467
|
+
local_address = parts[1]
|
|
468
|
+
state = parts[3].upper()
|
|
469
|
+
if state != "LISTENING" or not local_address.endswith(port_suffix):
|
|
470
|
+
continue
|
|
471
|
+
if host not in ("0.0.0.0", "::", "localhost") and not local_address.startswith(host):
|
|
472
|
+
continue
|
|
473
|
+
try:
|
|
474
|
+
pids.append(int(parts[-1]))
|
|
475
|
+
except ValueError:
|
|
476
|
+
continue
|
|
477
|
+
return pids
|
|
478
|
+
|
|
479
|
+
|
|
358
480
|
def _listener_processes(*, host: str, port: int) -> list[ListenerProcess]:
|
|
359
481
|
"""Return process metadata for listeners on the broker endpoint."""
|
|
360
482
|
|
|
@@ -382,7 +504,8 @@ def _is_matching_proxy_process(process: ListenerProcess, config: BrokerConfig) -
|
|
|
382
504
|
command = process.command
|
|
383
505
|
if not command:
|
|
384
506
|
return False
|
|
385
|
-
|
|
507
|
+
executable_name = Path(command[0].strip('"')).name.lower()
|
|
508
|
+
if executable_name not in {"alloydb-auth-proxy", "alloydb-auth-proxy.exe"}:
|
|
386
509
|
return False
|
|
387
510
|
if config.instance_uri not in command:
|
|
388
511
|
return False
|
|
@@ -435,6 +558,17 @@ def _broker_command(config: BrokerConfig, *, proxy_binary: str) -> list[str]:
|
|
|
435
558
|
return command
|
|
436
559
|
|
|
437
560
|
|
|
561
|
+
def _proxy_popen_kwargs() -> dict[str, Any]:
|
|
562
|
+
"""Return process-detach kwargs appropriate for the current platform."""
|
|
563
|
+
|
|
564
|
+
if os.name != "nt":
|
|
565
|
+
return {"start_new_session": True}
|
|
566
|
+
creationflags = 0
|
|
567
|
+
creationflags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
568
|
+
creationflags |= getattr(subprocess, "DETACHED_PROCESS", 0)
|
|
569
|
+
return {"creationflags": creationflags}
|
|
570
|
+
|
|
571
|
+
|
|
438
572
|
def _recent_broker_log(config: BrokerConfig, *, max_bytes: int = 12000) -> str:
|
|
439
573
|
"""Return the recent proxy log text that explains connection resets."""
|
|
440
574
|
|
|
@@ -565,8 +699,8 @@ def ensure_broker(config: BrokerConfig) -> BrokerState:
|
|
|
565
699
|
command,
|
|
566
700
|
stdout=log_file,
|
|
567
701
|
stderr=log_file,
|
|
568
|
-
start_new_session=True,
|
|
569
702
|
env=os.environ.copy(),
|
|
703
|
+
**_proxy_popen_kwargs(),
|
|
570
704
|
)
|
|
571
705
|
|
|
572
706
|
deadline = time.monotonic() + 15.0
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|