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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: airbyte-internal-ops
3
- Version: 0.1.9
3
+ Version: 0.1.11
4
4
  Summary: MCP and API interfaces that let the agents do the admin work
5
5
  Author-email: Aaron Steers <aj@airbyte.io>
6
6
  Keywords: admin,airbyte,api,mcp
@@ -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=419-AlRfwbbxeEEV9lhmXhpTUjsSdzJpfcuL_MZZtXM,1982
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=OTc8o_MIw5DGGPnQ2kiUT98GCXS2I95vvsSN9_d7LVo,32077
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/ci_output.py,sha256=NQATOGid0OCbLEl2NOtGK4cHLL5OxXhjmtanBjIlCyE,11369
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=fGE_TCii9zhC3pbyBupJ3JVkuxOWB59Q1DgigcF3q04,9707
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=opmVWRwjNs_jeWejv8wHOtb-3J09hOlqxg98GCzmFLo,7627
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=eHfgNkEcY1OKzYiJmkxwOluLPiFHIdP4nm_H1H0MXDg,17940
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=08G6ogRqEauQyDxFLirUVaYeU3exAJd-DJn_8fXCNXg,9450
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=ia1KcuQOXi3Qhy_MnxYmccCBJ4rAt_d4nVDjcyzje6o,4289
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.9.dist-info/METADATA,sha256=1tBlf96RtcJNsKARaZt4711QiVISWcAkex4OLBlUmjk,5282
411
- airbyte_internal_ops-0.1.9.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
412
- airbyte_internal_ops-0.1.9.dist-info/entry_points.txt,sha256=eUgJ9xIy9PlR-CgRbqRMsh1NVp6jz08v9bul9vCYlU4,111
413
- airbyte_internal_ops-0.1.9.dist-info/RECORD,,
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,,
@@ -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 = Path(config_path) if config_path else None
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 or a connection_id "
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
@@ -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)