airbyte-internal-ops 0.1.9__py3-none-any.whl → 0.1.11__py3-none-any.whl
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.
- {airbyte_internal_ops-0.1.9.dist-info → airbyte_internal_ops-0.1.11.dist-info}/METADATA +1 -1
- {airbyte_internal_ops-0.1.9.dist-info → airbyte_internal_ops-0.1.11.dist-info}/RECORD +14 -12
- airbyte_ops_mcp/cli/cloud.py +279 -3
- airbyte_ops_mcp/constants.py +9 -0
- airbyte_ops_mcp/github_actions.py +197 -0
- airbyte_ops_mcp/live_tests/cdk_secrets.py +90 -0
- airbyte_ops_mcp/live_tests/ci_output.py +55 -5
- airbyte_ops_mcp/live_tests/connector_runner.py +3 -0
- airbyte_ops_mcp/mcp/github.py +2 -21
- airbyte_ops_mcp/mcp/live_tests.py +44 -84
- airbyte_ops_mcp/mcp/prerelease.py +9 -31
- airbyte_ops_mcp/prod_db_access/db_engine.py +143 -16
- {airbyte_internal_ops-0.1.9.dist-info → airbyte_internal_ops-0.1.11.dist-info}/WHEEL +0 -0
- {airbyte_internal_ops-0.1.9.dist-info → airbyte_internal_ops-0.1.11.dist-info}/entry_points.txt +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
airbyte_ops_mcp/__init__.py,sha256=HhzURuYr29_UIdMrnWYaZB8ENr_kFkBdm4uqeiIW3Vw,760
|
|
2
2
|
airbyte_ops_mcp/_annotations.py,sha256=MO-SBDnbykxxHDESG7d8rviZZ4WlZgJKv0a8eBqcEzQ,1757
|
|
3
|
-
airbyte_ops_mcp/constants.py,sha256=
|
|
3
|
+
airbyte_ops_mcp/constants.py,sha256=col6-5BUWuIYhbtKmlvSRR8URBoSNExoz94cn4_kujI,2333
|
|
4
4
|
airbyte_ops_mcp/gcp_auth.py,sha256=5k-k145ZoYhHLjyDES8nrA8f8BBihRI0ykrdD1IcfOs,3599
|
|
5
|
+
airbyte_ops_mcp/github_actions.py,sha256=51rHxqTR-1yHPKfZZLKldz8f-4jZbMd71ICF_LQWvCs,5995
|
|
5
6
|
airbyte_ops_mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
7
|
airbyte_ops_mcp/_legacy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
8
|
airbyte_ops_mcp/_legacy/airbyte_ci/README.md,sha256=qEYx4geDR8AEDjrcA303h7Nol-CMDLojxUyiGzQprM8,236
|
|
@@ -351,7 +352,7 @@ airbyte_ops_mcp/cli/__init__.py,sha256=XpL7FyVfgabfBF2JR7u7NwJ2krlYqjd_OwLcWf-Xc
|
|
|
351
352
|
airbyte_ops_mcp/cli/_base.py,sha256=I8tWnyQf0ks4r3J8N8h-5GZxyn37T-55KsbuHnxYlcg,415
|
|
352
353
|
airbyte_ops_mcp/cli/_shared.py,sha256=jg-xMyGzTCGPqKd8VTfE_3kGPIyO_3Kx5sQbG4rPc0Y,1311
|
|
353
354
|
airbyte_ops_mcp/cli/app.py,sha256=SEdBpqFUG2O8zGV5ifwptxrLGFph_dLr66-MX9d69gQ,789
|
|
354
|
-
airbyte_ops_mcp/cli/cloud.py,sha256=
|
|
355
|
+
airbyte_ops_mcp/cli/cloud.py,sha256=idkqBKUlWx9toNGiZy8tVq6MOpQoi4ZWfIRVpdsIdiQ,42494
|
|
355
356
|
airbyte_ops_mcp/cli/gh.py,sha256=91b1AxFXvHQCFyXhrrym-756ZjnMCqvxFdmwCtma1zI,2046
|
|
356
357
|
airbyte_ops_mcp/cli/registry.py,sha256=-yiLJWSslV_qGi6ImXZYfXOJSE4oJBO7yICkyA_RiUo,5792
|
|
357
358
|
airbyte_ops_mcp/cli/repo.py,sha256=G1hoQpH0XYhUH3FFOsia9xabGB0LP9o3XcwBuqvFVo0,16331
|
|
@@ -365,11 +366,12 @@ airbyte_ops_mcp/connection_config_retriever/audit_logging.py,sha256=GjT4dVa0TtvG
|
|
|
365
366
|
airbyte_ops_mcp/connection_config_retriever/retrieval.py,sha256=s6yeCyrboWkUd6KdaheEo87x-rLtQNTL8XeR8O9z2HI,12160
|
|
366
367
|
airbyte_ops_mcp/connection_config_retriever/secrets_resolution.py,sha256=12g0lZzhCzAPl4Iv4eMW6d76mvXjIBGspOnNhywzks4,3644
|
|
367
368
|
airbyte_ops_mcp/live_tests/__init__.py,sha256=qJac67dt6DQCqif39HqeiG3Tr9xrxfP-ala8HsLZKis,1020
|
|
368
|
-
airbyte_ops_mcp/live_tests/
|
|
369
|
+
airbyte_ops_mcp/live_tests/cdk_secrets.py,sha256=TJ0Vbk5jfTvYElREh4fQFHWof0_bIxZfJqT33dDhtrE,3198
|
|
370
|
+
airbyte_ops_mcp/live_tests/ci_output.py,sha256=rrvCVKKShc1iVPMuQJDBqSbsiAHIDpX8SA9j0Uwl_Cg,12718
|
|
369
371
|
airbyte_ops_mcp/live_tests/config.py,sha256=dwWeY0tatdbwl9BqbhZ7EljoZDCtKmGO5fvOAIxeXmA,5873
|
|
370
372
|
airbyte_ops_mcp/live_tests/connection_fetcher.py,sha256=5wIiA0VvCFNEc-fr6Po18gZMX3E5fyPOGf2SuVOqv5U,12799
|
|
371
373
|
airbyte_ops_mcp/live_tests/connection_secret_retriever.py,sha256=DtZYB4Y8CXfUXTFhmzrqzjuEFoICzz5Md3Ol_y9HCq0,4861
|
|
372
|
-
airbyte_ops_mcp/live_tests/connector_runner.py,sha256=
|
|
374
|
+
airbyte_ops_mcp/live_tests/connector_runner.py,sha256=BLy2RY-KLCK9jNmPz5EsPCk55fJ9WlOOaxr_Xw-GOjY,9914
|
|
373
375
|
airbyte_ops_mcp/live_tests/evaluation_modes.py,sha256=lAL6pEDmy_XCC7_m4_NXjt_f6Z8CXeAhMkc0FU8bm_M,1364
|
|
374
376
|
airbyte_ops_mcp/live_tests/http_metrics.py,sha256=oTD7f2MnQOvx4plOxHop2bInQ0-whvuToSsrC7TIM-M,12469
|
|
375
377
|
airbyte_ops_mcp/live_tests/models.py,sha256=brtAT9oO1TwjFcP91dFcu0XcUNqQb-jf7di1zkoVEuo,8782
|
|
@@ -389,25 +391,25 @@ airbyte_ops_mcp/mcp/_mcp_utils.py,sha256=nhztHcoc-_ASPpJfoDBjxjjqEvQM6_QIrhp7F2U
|
|
|
389
391
|
airbyte_ops_mcp/mcp/cloud_connector_versions.py,sha256=XxaS6WBP0sJPRwT7TTPhVH2PzhPqVWMNU5fVdWdxLLk,10361
|
|
390
392
|
airbyte_ops_mcp/mcp/connector_analysis.py,sha256=OC4KrOSkMkKPkOisWnSv96BDDE5TQYHq-Jxa2vtjJpo,298
|
|
391
393
|
airbyte_ops_mcp/mcp/connector_qa.py,sha256=aImpqdnqBPDrz10BS0owsV4kuIU2XdalzgbaGZsbOL0,258
|
|
392
|
-
airbyte_ops_mcp/mcp/github.py,sha256=
|
|
394
|
+
airbyte_ops_mcp/mcp/github.py,sha256=Wum5V99A9vTsjK0YVoE1UOVu75F-M9chg0AnUGkiiT4,7215
|
|
393
395
|
airbyte_ops_mcp/mcp/github_repo_ops.py,sha256=PiERpt8abo20Gz4CfXhrDNlVM4o4FOt5sweZJND2a0s,5314
|
|
394
|
-
airbyte_ops_mcp/mcp/live_tests.py,sha256=
|
|
396
|
+
airbyte_ops_mcp/mcp/live_tests.py,sha256=WnWUeGb_fxf6oBjp1ye51Y2fP-Ld-CDbFnTO-_dnV-Q,17134
|
|
395
397
|
airbyte_ops_mcp/mcp/metadata.py,sha256=fwGW97WknR5lfKcQnFtK6dU87aA6TmLj1NkKyqDAV9g,270
|
|
396
|
-
airbyte_ops_mcp/mcp/prerelease.py,sha256=
|
|
398
|
+
airbyte_ops_mcp/mcp/prerelease.py,sha256=LHLaSd8q0l7boAsVqTXOjFGDxAGsPZdtL3kj5_IOTEE,8852
|
|
397
399
|
airbyte_ops_mcp/mcp/prod_db_queries.py,sha256=RkBVISfkbwML3grWONxYsULRnFEYdqDZVBZIyo6W8xE,14311
|
|
398
400
|
airbyte_ops_mcp/mcp/prompts.py,sha256=mJld9mdPECXYZffWXGSvNs4Xevx3rxqUGNlzGKVC2_s,1599
|
|
399
401
|
airbyte_ops_mcp/mcp/registry.py,sha256=PW-VYUj42qx2pQ_apUkVaoUFq7VgB9zEU7-aGrkSCCw,290
|
|
400
402
|
airbyte_ops_mcp/mcp/server.py,sha256=7zi91xioVTx1q-bEleekZH2c2JnbzDQt_6zxdEwnLbg,2958
|
|
401
403
|
airbyte_ops_mcp/mcp/server_info.py,sha256=Yi4B1auW64QZGBDas5mro_vwTjvrP785TFNSBP7GhRg,2361
|
|
402
404
|
airbyte_ops_mcp/prod_db_access/__init__.py,sha256=5pxouMPY1beyWlB0UwPnbaLTKTHqU6X82rbbgKY2vYU,1069
|
|
403
|
-
airbyte_ops_mcp/prod_db_access/db_engine.py,sha256=
|
|
405
|
+
airbyte_ops_mcp/prod_db_access/db_engine.py,sha256=11xNZTk4I8SKYhsnmE7-LVrkJXN4dCRbBeD1_hj3f-s,9027
|
|
404
406
|
airbyte_ops_mcp/prod_db_access/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
405
407
|
airbyte_ops_mcp/prod_db_access/queries.py,sha256=q7PcI15EGh6jFS9MVB_gZt1a56YvrZV5hnwa5lgU2q0,10844
|
|
406
408
|
airbyte_ops_mcp/prod_db_access/sql.py,sha256=tWQAwMk8DzG8HpLIYglljlReI2oeYulQPsV31ocUJSw,16251
|
|
407
409
|
airbyte_ops_mcp/registry/__init__.py,sha256=iEaPlt9GrnlaLbc__98TguNeZG8wuQu7S-_2QkhHcbA,858
|
|
408
410
|
airbyte_ops_mcp/registry/models.py,sha256=B4L4TKr52wo0xs0CqvCBrpowqjShzVnZ5eTr2-EyhNs,2346
|
|
409
411
|
airbyte_ops_mcp/registry/publish.py,sha256=VoPxsM2_0zJ829orzCRN-kjgcJtuBNyXgW4I9J680ro,12717
|
|
410
|
-
airbyte_internal_ops-0.1.
|
|
411
|
-
airbyte_internal_ops-0.1.
|
|
412
|
-
airbyte_internal_ops-0.1.
|
|
413
|
-
airbyte_internal_ops-0.1.
|
|
412
|
+
airbyte_internal_ops-0.1.11.dist-info/METADATA,sha256=AgQjwFgwAvefxtJxe234AHdr61u1n_FOBy61CU4wYq4,5283
|
|
413
|
+
airbyte_internal_ops-0.1.11.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
414
|
+
airbyte_internal_ops-0.1.11.dist-info/entry_points.txt,sha256=eUgJ9xIy9PlR-CgRbqRMsh1NVp6jz08v9bul9vCYlU4,111
|
|
415
|
+
airbyte_internal_ops-0.1.11.dist-info/RECORD,,
|
airbyte_ops_mcp/cli/cloud.py
CHANGED
|
@@ -13,9 +13,18 @@ Commands:
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import signal
|
|
19
|
+
import socket
|
|
20
|
+
import subprocess
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
16
23
|
from pathlib import Path
|
|
17
24
|
from typing import Annotated, Literal
|
|
18
25
|
|
|
26
|
+
import requests
|
|
27
|
+
import yaml
|
|
19
28
|
from airbyte_cdk.models.connector_metadata import MetadataFile
|
|
20
29
|
from airbyte_cdk.utils.connector_paths import find_connector_root_from_name
|
|
21
30
|
from airbyte_cdk.utils.docker import build_connector_image, verify_docker_installation
|
|
@@ -30,6 +39,13 @@ from airbyte_ops_mcp.cli._shared import (
|
|
|
30
39
|
print_success,
|
|
31
40
|
)
|
|
32
41
|
from airbyte_ops_mcp.cloud_admin.connection_config import fetch_connection_config
|
|
42
|
+
from airbyte_ops_mcp.constants import (
|
|
43
|
+
CLOUD_SQL_INSTANCE,
|
|
44
|
+
CLOUD_SQL_PROXY_PID_FILE,
|
|
45
|
+
DEFAULT_CLOUD_SQL_PROXY_PORT,
|
|
46
|
+
ENV_GCP_PROD_DB_ACCESS_CREDENTIALS,
|
|
47
|
+
)
|
|
48
|
+
from airbyte_ops_mcp.live_tests.cdk_secrets import get_first_config_from_secrets
|
|
33
49
|
from airbyte_ops_mcp.live_tests.ci_output import (
|
|
34
50
|
generate_regression_report,
|
|
35
51
|
get_report_summary,
|
|
@@ -75,6 +91,184 @@ connector_app = App(
|
|
|
75
91
|
)
|
|
76
92
|
cloud_app.command(connector_app)
|
|
77
93
|
|
|
94
|
+
# Create the db sub-app under cloud
|
|
95
|
+
db_app = App(name="db", help="Database operations for Airbyte Cloud Prod DB Replica.")
|
|
96
|
+
cloud_app.command(db_app)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@db_app.command(name="start-proxy")
|
|
100
|
+
def start_proxy(
|
|
101
|
+
port: Annotated[
|
|
102
|
+
int,
|
|
103
|
+
Parameter(help="Port for the Cloud SQL Proxy to listen on."),
|
|
104
|
+
] = DEFAULT_CLOUD_SQL_PROXY_PORT,
|
|
105
|
+
daemon: Annotated[
|
|
106
|
+
bool,
|
|
107
|
+
Parameter(
|
|
108
|
+
help="Run as daemon in background (default). Use --no-daemon for foreground."
|
|
109
|
+
),
|
|
110
|
+
] = True,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Start the Cloud SQL Proxy for database access.
|
|
113
|
+
|
|
114
|
+
This command starts the Cloud SQL Auth Proxy to enable connections to the
|
|
115
|
+
Airbyte Cloud Prod DB Replica. The proxy is required for database query tools.
|
|
116
|
+
|
|
117
|
+
By default, runs as a daemon (background process). Use --no-daemon to run in
|
|
118
|
+
foreground mode where you can see logs and stop with Ctrl+C.
|
|
119
|
+
|
|
120
|
+
Credentials are read from the GCP_PROD_DB_ACCESS_CREDENTIALS environment variable,
|
|
121
|
+
which should contain the service account JSON credentials.
|
|
122
|
+
|
|
123
|
+
After starting the proxy, set these environment variables to use database tools:
|
|
124
|
+
export USE_CLOUD_SQL_PROXY=1
|
|
125
|
+
export DB_PORT={port}
|
|
126
|
+
|
|
127
|
+
Example:
|
|
128
|
+
airbyte-ops cloud db start-proxy
|
|
129
|
+
airbyte-ops cloud db start-proxy --port 15432
|
|
130
|
+
airbyte-ops cloud db start-proxy --no-daemon
|
|
131
|
+
"""
|
|
132
|
+
# Check if proxy is already running on the requested port (idempotency)
|
|
133
|
+
try:
|
|
134
|
+
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
|
135
|
+
# Something is already listening on this port
|
|
136
|
+
pid_file = Path(CLOUD_SQL_PROXY_PID_FILE)
|
|
137
|
+
pid_info = ""
|
|
138
|
+
if pid_file.exists():
|
|
139
|
+
pid_info = f" (PID: {pid_file.read_text().strip()})"
|
|
140
|
+
print_success(
|
|
141
|
+
f"Cloud SQL Proxy is already running on port {port}{pid_info}"
|
|
142
|
+
)
|
|
143
|
+
print_success("")
|
|
144
|
+
print_success("To use database tools, set these environment variables:")
|
|
145
|
+
print_success(" export USE_CLOUD_SQL_PROXY=1")
|
|
146
|
+
print_success(f" export DB_PORT={port}")
|
|
147
|
+
return
|
|
148
|
+
except (OSError, TimeoutError, ConnectionRefusedError):
|
|
149
|
+
pass # Port not in use, proceed with starting proxy
|
|
150
|
+
|
|
151
|
+
# Check if cloud-sql-proxy is installed
|
|
152
|
+
proxy_path = shutil.which("cloud-sql-proxy")
|
|
153
|
+
if not proxy_path:
|
|
154
|
+
exit_with_error(
|
|
155
|
+
"cloud-sql-proxy not found in PATH. "
|
|
156
|
+
"Install it from: https://cloud.google.com/sql/docs/mysql/sql-proxy"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Get credentials from environment
|
|
160
|
+
creds_json = os.getenv(ENV_GCP_PROD_DB_ACCESS_CREDENTIALS)
|
|
161
|
+
if not creds_json:
|
|
162
|
+
exit_with_error(
|
|
163
|
+
f"{ENV_GCP_PROD_DB_ACCESS_CREDENTIALS} environment variable is not set. "
|
|
164
|
+
"This should contain the GCP service account JSON credentials."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Build the command using --json-credentials to avoid writing to disk
|
|
168
|
+
cmd = [
|
|
169
|
+
proxy_path,
|
|
170
|
+
CLOUD_SQL_INSTANCE,
|
|
171
|
+
f"--port={port}",
|
|
172
|
+
f"--json-credentials={creds_json}",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
print_success(f"Starting Cloud SQL Proxy on port {port}...")
|
|
176
|
+
print_success(f"Instance: {CLOUD_SQL_INSTANCE}")
|
|
177
|
+
print_success("")
|
|
178
|
+
print_success("To use database tools, set these environment variables:")
|
|
179
|
+
print_success(" export USE_CLOUD_SQL_PROXY=1")
|
|
180
|
+
print_success(f" export DB_PORT={port}")
|
|
181
|
+
print_success("")
|
|
182
|
+
|
|
183
|
+
if daemon:
|
|
184
|
+
# Run in background (daemon mode) with log file for diagnostics
|
|
185
|
+
log_file_path = Path("/tmp/airbyte-cloud-sql-proxy.log")
|
|
186
|
+
log_file = log_file_path.open("ab")
|
|
187
|
+
process = subprocess.Popen(
|
|
188
|
+
cmd,
|
|
189
|
+
stdout=subprocess.DEVNULL,
|
|
190
|
+
stderr=log_file,
|
|
191
|
+
start_new_session=True,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Brief wait to verify the process started successfully
|
|
195
|
+
time.sleep(0.5)
|
|
196
|
+
if process.poll() is not None:
|
|
197
|
+
# Process exited immediately - read any error output
|
|
198
|
+
log_file.close()
|
|
199
|
+
error_output = ""
|
|
200
|
+
if log_file_path.exists():
|
|
201
|
+
error_output = log_file_path.read_text()[-1000:] # Last 1000 chars
|
|
202
|
+
exit_with_error(
|
|
203
|
+
f"Cloud SQL Proxy failed to start (exit code: {process.returncode}).\n"
|
|
204
|
+
f"Check logs at {log_file_path}\n"
|
|
205
|
+
f"Recent output: {error_output}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Write PID to file for stop-proxy command
|
|
209
|
+
pid_file = Path(CLOUD_SQL_PROXY_PID_FILE)
|
|
210
|
+
pid_file.write_text(str(process.pid))
|
|
211
|
+
print_success(f"Cloud SQL Proxy started as daemon (PID: {process.pid})")
|
|
212
|
+
print_success(f"Logs: {log_file_path}")
|
|
213
|
+
print_success("To stop: airbyte-ops cloud db stop-proxy")
|
|
214
|
+
else:
|
|
215
|
+
# Run in foreground - replace current process
|
|
216
|
+
# Signals (Ctrl+C) will be handled directly by the cloud-sql-proxy process
|
|
217
|
+
print_success("Running in foreground. Press Ctrl+C to stop the proxy.")
|
|
218
|
+
print_success("")
|
|
219
|
+
os.execv(proxy_path, cmd)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@db_app.command(name="stop-proxy")
|
|
223
|
+
def stop_proxy() -> None:
|
|
224
|
+
"""Stop the Cloud SQL Proxy daemon.
|
|
225
|
+
|
|
226
|
+
This command stops a Cloud SQL Proxy that was started with 'start-proxy'.
|
|
227
|
+
It reads the PID from the PID file and sends a SIGTERM signal to stop the process.
|
|
228
|
+
|
|
229
|
+
Example:
|
|
230
|
+
airbyte-ops cloud db stop-proxy
|
|
231
|
+
"""
|
|
232
|
+
pid_file = Path(CLOUD_SQL_PROXY_PID_FILE)
|
|
233
|
+
|
|
234
|
+
if not pid_file.exists():
|
|
235
|
+
exit_with_error(
|
|
236
|
+
f"PID file not found at {CLOUD_SQL_PROXY_PID_FILE}. "
|
|
237
|
+
"No Cloud SQL Proxy daemon appears to be running."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
pid_str = pid_file.read_text().strip()
|
|
241
|
+
if not pid_str.isdigit():
|
|
242
|
+
pid_file.unlink()
|
|
243
|
+
exit_with_error(f"Invalid PID in {CLOUD_SQL_PROXY_PID_FILE}: {pid_str}")
|
|
244
|
+
|
|
245
|
+
pid = int(pid_str)
|
|
246
|
+
|
|
247
|
+
# Check if process is still running
|
|
248
|
+
try:
|
|
249
|
+
os.kill(pid, 0) # Signal 0 just checks if process exists
|
|
250
|
+
except ProcessLookupError:
|
|
251
|
+
pid_file.unlink()
|
|
252
|
+
print_success(
|
|
253
|
+
f"Cloud SQL Proxy (PID: {pid}) is not running. Cleaned up PID file."
|
|
254
|
+
)
|
|
255
|
+
return
|
|
256
|
+
except PermissionError:
|
|
257
|
+
exit_with_error(f"Permission denied to check process {pid}.")
|
|
258
|
+
|
|
259
|
+
# Send SIGTERM to stop the process
|
|
260
|
+
try:
|
|
261
|
+
os.kill(pid, signal.SIGTERM)
|
|
262
|
+
print_success(f"Sent SIGTERM to Cloud SQL Proxy (PID: {pid}).")
|
|
263
|
+
except ProcessLookupError:
|
|
264
|
+
print_success(f"Cloud SQL Proxy (PID: {pid}) already stopped.")
|
|
265
|
+
except PermissionError:
|
|
266
|
+
exit_with_error(f"Permission denied to stop process {pid}.")
|
|
267
|
+
|
|
268
|
+
# Clean up PID file
|
|
269
|
+
pid_file.unlink(missing_ok=True)
|
|
270
|
+
print_success("Cloud SQL Proxy stopped.")
|
|
271
|
+
|
|
78
272
|
|
|
79
273
|
@connector_app.command(name="get-version-info")
|
|
80
274
|
def get_version_info(
|
|
@@ -317,6 +511,49 @@ def _build_connector_image_from_source(
|
|
|
317
511
|
return built_image
|
|
318
512
|
|
|
319
513
|
|
|
514
|
+
def _fetch_control_image_from_metadata(connector_name: str) -> str | None:
|
|
515
|
+
"""Fetch the current released connector image from metadata.yaml on main branch.
|
|
516
|
+
|
|
517
|
+
This fetches the connector's metadata.yaml from the airbyte monorepo's master branch
|
|
518
|
+
and extracts the dockerRepository and dockerImageTag to construct the control image.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
connector_name: The connector name (e.g., 'source-github').
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
The full connector image with tag (e.g., 'airbyte/source-github:1.0.0'),
|
|
525
|
+
or None if the metadata could not be fetched or parsed.
|
|
526
|
+
"""
|
|
527
|
+
metadata_url = (
|
|
528
|
+
f"https://raw.githubusercontent.com/airbytehq/airbyte/master/"
|
|
529
|
+
f"airbyte-integrations/connectors/{connector_name}/metadata.yaml"
|
|
530
|
+
)
|
|
531
|
+
response = requests.get(metadata_url, timeout=30)
|
|
532
|
+
if not response.ok:
|
|
533
|
+
print_error(
|
|
534
|
+
f"Failed to fetch metadata for {connector_name}: "
|
|
535
|
+
f"HTTP {response.status_code} from {metadata_url}"
|
|
536
|
+
)
|
|
537
|
+
return None
|
|
538
|
+
|
|
539
|
+
metadata = yaml.safe_load(response.text)
|
|
540
|
+
if not isinstance(metadata, dict):
|
|
541
|
+
print_error(f"Invalid metadata format for {connector_name}: expected dict")
|
|
542
|
+
return None
|
|
543
|
+
|
|
544
|
+
data = metadata.get("data", {})
|
|
545
|
+
docker_repository = data.get("dockerRepository")
|
|
546
|
+
docker_image_tag = data.get("dockerImageTag")
|
|
547
|
+
|
|
548
|
+
if not docker_repository or not docker_image_tag:
|
|
549
|
+
print_error(
|
|
550
|
+
f"Could not find dockerRepository/dockerImageTag in metadata for {connector_name}"
|
|
551
|
+
)
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
return f"{docker_repository}:{docker_image_tag}"
|
|
555
|
+
|
|
556
|
+
|
|
320
557
|
@connector_app.command(name="live-test")
|
|
321
558
|
def live_test(
|
|
322
559
|
connector_image: Annotated[
|
|
@@ -716,10 +953,48 @@ def regression_test(
|
|
|
716
953
|
if not resolved_control_image and connection_data.connector_image:
|
|
717
954
|
resolved_control_image = connection_data.connector_image
|
|
718
955
|
print_success(f"Auto-detected control image: {resolved_control_image}")
|
|
956
|
+
elif config_path:
|
|
957
|
+
config_file = Path(config_path)
|
|
958
|
+
catalog_file = Path(catalog_path) if catalog_path else None
|
|
959
|
+
elif connector_name:
|
|
960
|
+
# Fallback: fetch integration test secrets from GSM using PyAirbyte API
|
|
961
|
+
print_success(
|
|
962
|
+
f"No connection_id or config_path provided. "
|
|
963
|
+
f"Attempting to fetch integration test config from GSM for {connector_name}..."
|
|
964
|
+
)
|
|
965
|
+
gsm_config = get_first_config_from_secrets(connector_name)
|
|
966
|
+
if gsm_config:
|
|
967
|
+
# Write config to a temp file (not in output_path to avoid artifact upload)
|
|
968
|
+
gsm_config_dir = Path(
|
|
969
|
+
tempfile.mkdtemp(prefix=f"gsm-config-{connector_name}-")
|
|
970
|
+
)
|
|
971
|
+
gsm_config_dir.chmod(0o700)
|
|
972
|
+
gsm_config_file = gsm_config_dir / "config.json"
|
|
973
|
+
gsm_config_file.write_text(json.dumps(gsm_config, indent=2))
|
|
974
|
+
gsm_config_file.chmod(0o600)
|
|
975
|
+
config_file = gsm_config_file
|
|
976
|
+
catalog_file = None
|
|
977
|
+
print_success(
|
|
978
|
+
f"Fetched integration test config from GSM for {connector_name}"
|
|
979
|
+
)
|
|
980
|
+
else:
|
|
981
|
+
print_error(
|
|
982
|
+
f"Failed to fetch integration test config from GSM for {connector_name}."
|
|
983
|
+
)
|
|
984
|
+
config_file = None
|
|
985
|
+
catalog_file = None
|
|
719
986
|
else:
|
|
720
|
-
config_file =
|
|
987
|
+
config_file = None
|
|
721
988
|
catalog_file = Path(catalog_path) if catalog_path else None
|
|
722
989
|
|
|
990
|
+
# Auto-detect control_image from metadata.yaml if connector_name is provided
|
|
991
|
+
if not resolved_control_image and connector_name:
|
|
992
|
+
resolved_control_image = _fetch_control_image_from_metadata(connector_name)
|
|
993
|
+
if resolved_control_image:
|
|
994
|
+
print_success(
|
|
995
|
+
f"Auto-detected control image from metadata.yaml: {resolved_control_image}"
|
|
996
|
+
)
|
|
997
|
+
|
|
723
998
|
# Validate that we have both images
|
|
724
999
|
if not resolved_target_image:
|
|
725
1000
|
write_github_output("success", False)
|
|
@@ -733,8 +1008,9 @@ def regression_test(
|
|
|
733
1008
|
write_github_output("success", False)
|
|
734
1009
|
write_github_output("error", "No control image specified")
|
|
735
1010
|
exit_with_error(
|
|
736
|
-
"You must provide one of the following: a control_image
|
|
737
|
-
"for a connection that has an associated connector image
|
|
1011
|
+
"You must provide one of the following: a control_image, a connection_id "
|
|
1012
|
+
"for a connection that has an associated connector image, or a connector_name "
|
|
1013
|
+
"to auto-detect the control image from the airbyte repo's metadata.yaml."
|
|
738
1014
|
)
|
|
739
1015
|
|
|
740
1016
|
# Pull images if they weren't just built locally
|
airbyte_ops_mcp/constants.py
CHANGED
|
@@ -27,6 +27,15 @@ EXPECTED_ADMIN_EMAIL_DOMAIN = "@airbyte.io"
|
|
|
27
27
|
GCP_PROJECT_NAME = "prod-ab-cloud-proj"
|
|
28
28
|
"""The GCP project name for Airbyte Cloud production."""
|
|
29
29
|
|
|
30
|
+
CLOUD_SQL_INSTANCE = "prod-ab-cloud-proj:us-west3:prod-pgsql-replica"
|
|
31
|
+
"""The Cloud SQL instance connection name for the Prod DB Replica."""
|
|
32
|
+
|
|
33
|
+
DEFAULT_CLOUD_SQL_PROXY_PORT = 15432
|
|
34
|
+
"""Default port for Cloud SQL Proxy connections."""
|
|
35
|
+
|
|
36
|
+
CLOUD_SQL_PROXY_PID_FILE = "/tmp/airbyte-cloud-sql-proxy.pid"
|
|
37
|
+
"""PID file for tracking the Cloud SQL Proxy process."""
|
|
38
|
+
|
|
30
39
|
CLOUD_REGISTRY_URL = (
|
|
31
40
|
"https://connectors.airbyte.com/files/registries/v0/cloud_registry.json"
|
|
32
41
|
)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
|
2
|
+
"""GitHub Actions API utilities.
|
|
3
|
+
|
|
4
|
+
This module provides core utilities for interacting with GitHub Actions workflows,
|
|
5
|
+
including workflow dispatch, run discovery, and authentication. These utilities
|
|
6
|
+
are used by MCP tools but are not MCP-specific.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
GITHUB_API_BASE = "https://api.github.com"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_github_token(preferred_env_vars: list[str] | None = None) -> str:
|
|
22
|
+
"""Resolve GitHub token from environment variables.
|
|
23
|
+
|
|
24
|
+
Checks environment variables in order of preference, returning the first
|
|
25
|
+
non-empty value found.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
preferred_env_vars: List of environment variable names to check in order.
|
|
29
|
+
Defaults to ["GITHUB_CI_WORKFLOW_TRIGGER_PAT", "GITHUB_TOKEN"].
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
GitHub token string.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: If no GitHub token is found in any of the specified env vars.
|
|
36
|
+
"""
|
|
37
|
+
if preferred_env_vars is None:
|
|
38
|
+
preferred_env_vars = ["GITHUB_CI_WORKFLOW_TRIGGER_PAT", "GITHUB_TOKEN"]
|
|
39
|
+
|
|
40
|
+
for env_var in preferred_env_vars:
|
|
41
|
+
token = os.getenv(env_var)
|
|
42
|
+
if token:
|
|
43
|
+
return token
|
|
44
|
+
|
|
45
|
+
env_var_list = ", ".join(preferred_env_vars)
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"No GitHub token found. Set one of: {env_var_list} environment variable."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class WorkflowDispatchResult:
|
|
53
|
+
"""Result of triggering a workflow dispatch."""
|
|
54
|
+
|
|
55
|
+
workflow_url: str
|
|
56
|
+
"""URL to the workflow file (e.g., .../actions/workflows/my-workflow.yml)"""
|
|
57
|
+
|
|
58
|
+
run_id: int | None = None
|
|
59
|
+
"""GitHub Actions run ID, if discovered"""
|
|
60
|
+
|
|
61
|
+
run_url: str | None = None
|
|
62
|
+
"""Direct URL to the workflow run, if discovered"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def find_workflow_run(
|
|
66
|
+
owner: str,
|
|
67
|
+
repo: str,
|
|
68
|
+
workflow_file: str,
|
|
69
|
+
ref: str,
|
|
70
|
+
token: str,
|
|
71
|
+
created_after: datetime,
|
|
72
|
+
max_wait_seconds: float = 5.0,
|
|
73
|
+
) -> tuple[int, str] | None:
|
|
74
|
+
"""Find a workflow run that was created after a given time.
|
|
75
|
+
|
|
76
|
+
This is used to find the run that was just triggered via workflow_dispatch.
|
|
77
|
+
Polls for up to max_wait_seconds to handle GitHub API eventual consistency.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
owner: Repository owner
|
|
81
|
+
repo: Repository name
|
|
82
|
+
workflow_file: Workflow file name
|
|
83
|
+
ref: Git ref the workflow was triggered on
|
|
84
|
+
token: GitHub API token
|
|
85
|
+
created_after: Only consider runs created after this time
|
|
86
|
+
max_wait_seconds: Maximum time to wait for run to appear (default 5 seconds)
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Tuple of (run_id, run_url) if found, None otherwise.
|
|
90
|
+
"""
|
|
91
|
+
url = (
|
|
92
|
+
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/actions/workflows/{workflow_file}/runs"
|
|
93
|
+
)
|
|
94
|
+
headers = {
|
|
95
|
+
"Authorization": f"Bearer {token}",
|
|
96
|
+
"Accept": "application/vnd.github+json",
|
|
97
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
98
|
+
}
|
|
99
|
+
params = {
|
|
100
|
+
"branch": ref,
|
|
101
|
+
"event": "workflow_dispatch",
|
|
102
|
+
"per_page": 5,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Add a small buffer to handle timestamp precision differences between
|
|
106
|
+
# local time and GitHub's created_at (which has second resolution)
|
|
107
|
+
search_after = created_after - timedelta(seconds=2)
|
|
108
|
+
|
|
109
|
+
deadline = time.monotonic() + max_wait_seconds
|
|
110
|
+
attempt = 0
|
|
111
|
+
|
|
112
|
+
while time.monotonic() < deadline:
|
|
113
|
+
if attempt > 0:
|
|
114
|
+
time.sleep(1.0)
|
|
115
|
+
attempt += 1
|
|
116
|
+
|
|
117
|
+
response = requests.get(url, headers=headers, params=params, timeout=30)
|
|
118
|
+
if not response.ok:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
data = response.json()
|
|
122
|
+
runs = data.get("workflow_runs", [])
|
|
123
|
+
|
|
124
|
+
for run in runs:
|
|
125
|
+
run_created_at = datetime.fromisoformat(
|
|
126
|
+
run["created_at"].replace("Z", "+00:00")
|
|
127
|
+
)
|
|
128
|
+
if run_created_at >= search_after:
|
|
129
|
+
return run["id"], run["html_url"]
|
|
130
|
+
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def trigger_workflow_dispatch(
|
|
135
|
+
owner: str,
|
|
136
|
+
repo: str,
|
|
137
|
+
workflow_file: str,
|
|
138
|
+
ref: str,
|
|
139
|
+
inputs: dict,
|
|
140
|
+
token: str,
|
|
141
|
+
find_run: bool = True,
|
|
142
|
+
max_wait_seconds: float = 5.0,
|
|
143
|
+
) -> WorkflowDispatchResult:
|
|
144
|
+
"""Trigger a GitHub Actions workflow via workflow_dispatch.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
owner: Repository owner (e.g., "airbytehq")
|
|
148
|
+
repo: Repository name (e.g., "airbyte-ops-mcp")
|
|
149
|
+
workflow_file: Workflow file name (e.g., "connector-live-test.yml")
|
|
150
|
+
ref: Git ref to run the workflow on (branch name)
|
|
151
|
+
inputs: Workflow inputs dictionary
|
|
152
|
+
token: GitHub API token
|
|
153
|
+
find_run: Whether to attempt to find the run after dispatch (default True)
|
|
154
|
+
max_wait_seconds: Maximum time to wait for run discovery (default 5 seconds)
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
WorkflowDispatchResult with workflow URL and optionally run ID/URL.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
requests.HTTPError: If API request fails.
|
|
161
|
+
"""
|
|
162
|
+
dispatch_time = datetime.now(tz=datetime.now().astimezone().tzinfo)
|
|
163
|
+
|
|
164
|
+
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/actions/workflows/{workflow_file}/dispatches"
|
|
165
|
+
headers = {
|
|
166
|
+
"Authorization": f"Bearer {token}",
|
|
167
|
+
"Accept": "application/vnd.github+json",
|
|
168
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
169
|
+
}
|
|
170
|
+
payload = {
|
|
171
|
+
"ref": ref,
|
|
172
|
+
"inputs": inputs,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
|
176
|
+
response.raise_for_status()
|
|
177
|
+
|
|
178
|
+
workflow_url = (
|
|
179
|
+
f"https://github.com/{owner}/{repo}/actions/workflows/{workflow_file}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if not find_run:
|
|
183
|
+
return WorkflowDispatchResult(workflow_url=workflow_url)
|
|
184
|
+
|
|
185
|
+
# Best-effort lookup of the run that was just triggered
|
|
186
|
+
run_info = find_workflow_run(
|
|
187
|
+
owner, repo, workflow_file, ref, token, dispatch_time, max_wait_seconds
|
|
188
|
+
)
|
|
189
|
+
if run_info:
|
|
190
|
+
run_id, run_url = run_info
|
|
191
|
+
return WorkflowDispatchResult(
|
|
192
|
+
workflow_url=workflow_url,
|
|
193
|
+
run_id=run_id,
|
|
194
|
+
run_url=run_url,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return WorkflowDispatchResult(workflow_url=workflow_url)
|