airbyte-internal-ops 0.1.10__py3-none-any.whl → 0.2.0__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.10.dist-info → airbyte_internal_ops-0.2.0.dist-info}/METADATA +1 -1
- {airbyte_internal_ops-0.1.10.dist-info → airbyte_internal_ops-0.2.0.dist-info}/RECORD +21 -18
- {airbyte_internal_ops-0.1.10.dist-info → airbyte_internal_ops-0.2.0.dist-info}/entry_points.txt +1 -0
- airbyte_ops_mcp/cli/cloud.py +151 -3
- airbyte_ops_mcp/cloud_admin/auth.py +32 -0
- airbyte_ops_mcp/constants.py +18 -0
- airbyte_ops_mcp/github_actions.py +218 -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/_http_headers.py +198 -0
- airbyte_ops_mcp/mcp/cloud_connector_versions.py +118 -22
- airbyte_ops_mcp/mcp/github.py +2 -21
- airbyte_ops_mcp/mcp/live_tests.py +46 -84
- airbyte_ops_mcp/mcp/prerelease.py +9 -31
- airbyte_ops_mcp/mcp/prod_db_queries.py +67 -24
- airbyte_ops_mcp/mcp/server.py +81 -8
- airbyte_ops_mcp/prod_db_access/db_engine.py +8 -0
- airbyte_ops_mcp/prod_db_access/queries.py +27 -15
- airbyte_ops_mcp/prod_db_access/sql.py +17 -16
- {airbyte_internal_ops-0.1.10.dist-info → airbyte_internal_ops-0.2.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
|
2
|
+
"""Fetch connector test secrets using the PyAirbyte secrets API.
|
|
3
|
+
|
|
4
|
+
This module uses the PyAirbyte GoogleGSMSecretManager to retrieve
|
|
5
|
+
integration test secrets from Google Secret Manager for connectors.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from airbyte_ops_mcp.live_tests.cdk_secrets import get_first_config_from_secrets
|
|
9
|
+
|
|
10
|
+
# Fetch the first config for a connector
|
|
11
|
+
config = get_first_config_from_secrets("source-github")
|
|
12
|
+
if config:
|
|
13
|
+
# Use the config dict
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
Note: Requires GCP credentials with access to the integration testing project.
|
|
17
|
+
The credentials can be provided via:
|
|
18
|
+
- GOOGLE_APPLICATION_CREDENTIALS environment variable
|
|
19
|
+
- GCP_GSM_CREDENTIALS environment variable (JSON string)
|
|
20
|
+
- Application Default Credentials
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
|
|
28
|
+
from airbyte.secrets import GoogleGSMSecretManager
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Default GCP project for integration test secrets
|
|
33
|
+
DEFAULT_GSM_PROJECT = "dataline-integration-testing"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_first_config_from_secrets(
|
|
37
|
+
connector_name: str,
|
|
38
|
+
project: str = DEFAULT_GSM_PROJECT,
|
|
39
|
+
) -> dict | None:
|
|
40
|
+
"""Fetch the first integration test config for a connector from GSM.
|
|
41
|
+
|
|
42
|
+
This function uses the PyAirbyte GoogleGSMSecretManager to fetch secrets
|
|
43
|
+
labeled with the connector name and returns the first one as a parsed dict.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
connector_name: The connector name (e.g., 'source-github').
|
|
47
|
+
project: The GCP project ID containing the secrets.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The parsed config dict, or None if no secrets are found or fetching fails.
|
|
51
|
+
"""
|
|
52
|
+
# Get credentials from environment
|
|
53
|
+
credentials_json: str | None = None
|
|
54
|
+
credentials_path: str | None = None
|
|
55
|
+
|
|
56
|
+
if "GCP_GSM_CREDENTIALS" in os.environ:
|
|
57
|
+
credentials_json = os.environ["GCP_GSM_CREDENTIALS"]
|
|
58
|
+
elif "GOOGLE_APPLICATION_CREDENTIALS" in os.environ:
|
|
59
|
+
credentials_path = os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
|
|
60
|
+
|
|
61
|
+
# If no explicit credentials, GoogleGSMSecretManager will try ADC
|
|
62
|
+
try:
|
|
63
|
+
gsm = GoogleGSMSecretManager(
|
|
64
|
+
project=project,
|
|
65
|
+
credentials_json=credentials_json,
|
|
66
|
+
credentials_path=credentials_path,
|
|
67
|
+
)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.warning(f"Failed to initialize GSM client: {e}")
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
logger.info(f"Fetching integration test config for {connector_name} from GSM")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
# fetch_connector_secret returns the first secret matching the connector label
|
|
76
|
+
secret_handle = gsm.fetch_connector_secret(connector_name)
|
|
77
|
+
# parse_json() calls get_value() internally and parses the result
|
|
78
|
+
config = secret_handle.parse_json()
|
|
79
|
+
logger.info(f"Successfully fetched config for {connector_name}")
|
|
80
|
+
return dict(config) if config else None
|
|
81
|
+
|
|
82
|
+
except StopIteration:
|
|
83
|
+
logger.warning(f"No secrets found for connector {connector_name}")
|
|
84
|
+
return None
|
|
85
|
+
except Exception as e:
|
|
86
|
+
# Log the exception type but not the message (may contain sensitive info)
|
|
87
|
+
logger.warning(
|
|
88
|
+
f"Failed to fetch config for {connector_name}: {type(e).__name__}"
|
|
89
|
+
)
|
|
90
|
+
return None
|
|
@@ -142,6 +142,35 @@ def _format_delta(delta: int) -> str:
|
|
|
142
142
|
return "0"
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
def _get_github_run_url() -> str | None:
|
|
146
|
+
"""Get the URL to the current GitHub Actions workflow run.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The workflow run URL, or None if not running in GitHub Actions.
|
|
150
|
+
"""
|
|
151
|
+
server_url = os.getenv("GITHUB_SERVER_URL")
|
|
152
|
+
repository = os.getenv("GITHUB_REPOSITORY")
|
|
153
|
+
run_id = os.getenv("GITHUB_RUN_ID")
|
|
154
|
+
|
|
155
|
+
if not all([server_url, repository, run_id]):
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
return f"{server_url}/{repository}/actions/runs/{run_id}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _get_github_artifacts_url() -> str | None:
|
|
162
|
+
"""Get the URL to the artifacts section of the current workflow run.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
The artifacts section URL, or None if not running in GitHub Actions.
|
|
166
|
+
"""
|
|
167
|
+
run_url = _get_github_run_url()
|
|
168
|
+
if not run_url:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
return f"{run_url}#artifacts"
|
|
172
|
+
|
|
173
|
+
|
|
145
174
|
def generate_regression_report(
|
|
146
175
|
target_image: str,
|
|
147
176
|
control_image: str,
|
|
@@ -190,6 +219,9 @@ def generate_regression_report(
|
|
|
190
219
|
target_image.rsplit(":", 1)[0] if ":" in target_image else target_image
|
|
191
220
|
)
|
|
192
221
|
|
|
222
|
+
run_url = _get_github_run_url()
|
|
223
|
+
artifacts_url = _get_github_artifacts_url()
|
|
224
|
+
|
|
193
225
|
lines: list[str] = [
|
|
194
226
|
"# Regression Test Report",
|
|
195
227
|
"",
|
|
@@ -200,12 +232,23 @@ def generate_regression_report(
|
|
|
200
232
|
f"- **Control Version:** `{control_version}`",
|
|
201
233
|
f"- **Target Version:** `{target_version}`",
|
|
202
234
|
f"- **Command:** `{command.upper()}`",
|
|
203
|
-
f"- **Artifacts:** `{artifact_name}`",
|
|
204
|
-
"",
|
|
205
|
-
"## Summary",
|
|
206
|
-
"",
|
|
207
235
|
]
|
|
208
236
|
|
|
237
|
+
if run_url:
|
|
238
|
+
lines.append(f"- **Workflow Run:** [View Execution]({run_url})")
|
|
239
|
+
if artifacts_url:
|
|
240
|
+
lines.append(f"- **Artifacts:** [Download `{artifact_name}`]({artifacts_url})")
|
|
241
|
+
else:
|
|
242
|
+
lines.append(f"- **Artifacts:** `{artifact_name}`")
|
|
243
|
+
|
|
244
|
+
lines.extend(
|
|
245
|
+
[
|
|
246
|
+
"",
|
|
247
|
+
"## Summary",
|
|
248
|
+
"",
|
|
249
|
+
]
|
|
250
|
+
)
|
|
251
|
+
|
|
209
252
|
if regression_detected:
|
|
210
253
|
if target_result["success"] and not control_result["success"]:
|
|
211
254
|
lines.append("**Result:** Target succeeded, control failed (improvement)")
|
|
@@ -341,9 +384,16 @@ def get_report_summary(report_path: Path) -> str:
|
|
|
341
384
|
f"regression-test-artifacts-{run_id}" if run_id else "regression-test-artifacts"
|
|
342
385
|
)
|
|
343
386
|
|
|
387
|
+
artifacts_url = _get_github_artifacts_url()
|
|
388
|
+
artifact_link = (
|
|
389
|
+
f"[`{artifact_name}`]({artifacts_url})"
|
|
390
|
+
if artifacts_url
|
|
391
|
+
else f"`{artifact_name}`"
|
|
392
|
+
)
|
|
393
|
+
|
|
344
394
|
return f"""## Regression Test Report
|
|
345
395
|
|
|
346
|
-
Full report available in the **Regression Test Report** check or in artifact
|
|
396
|
+
Full report available in the **Regression Test Report** check or in artifact {artifact_link}.
|
|
347
397
|
|
|
348
398
|
See the Checks tab for the complete report with message counts and execution details.
|
|
349
399
|
"""
|
|
@@ -168,6 +168,9 @@ class ConnectorRunner:
|
|
|
168
168
|
|
|
169
169
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
170
170
|
temp_path = Path(temp_dir)
|
|
171
|
+
# Make temp directory world-readable so non-root container users can access it
|
|
172
|
+
# Many connector images run as non-root users (e.g., 'airbyte' user)
|
|
173
|
+
temp_path.chmod(0o755)
|
|
171
174
|
self._prepare_data_directory(temp_path)
|
|
172
175
|
|
|
173
176
|
docker_cmd = self._build_docker_command(temp_path)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
|
2
|
+
"""HTTP header extraction for Airbyte Cloud credentials.
|
|
3
|
+
|
|
4
|
+
This module provides internal helper functions for extracting Airbyte Cloud
|
|
5
|
+
authentication credentials from HTTP headers when running as an MCP HTTP server.
|
|
6
|
+
This enables per-request credential passing from upstream services like coral-agents.
|
|
7
|
+
|
|
8
|
+
The resolution order for credentials is:
|
|
9
|
+
1. HTTP headers (when running as MCP HTTP server)
|
|
10
|
+
2. Environment variables (fallback)
|
|
11
|
+
|
|
12
|
+
Note: This module is prefixed with "_" to indicate it is internal helper logic
|
|
13
|
+
for the MCP module and should not be imported directly by external code.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from airbyte.cloud.auth import (
|
|
19
|
+
resolve_cloud_api_url,
|
|
20
|
+
resolve_cloud_client_id,
|
|
21
|
+
resolve_cloud_client_secret,
|
|
22
|
+
resolve_cloud_workspace_id,
|
|
23
|
+
)
|
|
24
|
+
from airbyte.secrets.base import SecretString
|
|
25
|
+
from fastmcp.server.dependencies import get_http_headers
|
|
26
|
+
|
|
27
|
+
from airbyte_ops_mcp.constants import (
|
|
28
|
+
HEADER_AIRBYTE_CLOUD_API_URL,
|
|
29
|
+
HEADER_AIRBYTE_CLOUD_CLIENT_ID,
|
|
30
|
+
HEADER_AIRBYTE_CLOUD_CLIENT_SECRET,
|
|
31
|
+
HEADER_AIRBYTE_CLOUD_WORKSPACE_ID,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_header_value(headers: dict[str, str], header_name: str) -> str | None:
|
|
36
|
+
"""Get a header value from a headers dict, case-insensitively.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
headers: Dictionary of HTTP headers.
|
|
40
|
+
header_name: The header name to look for (case-insensitive).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The header value if found, None otherwise.
|
|
44
|
+
"""
|
|
45
|
+
header_name_lower = header_name.lower()
|
|
46
|
+
for key, value in headers.items():
|
|
47
|
+
if key.lower() == header_name_lower:
|
|
48
|
+
return value
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_client_id_from_headers() -> SecretString | None:
|
|
53
|
+
"""Extract client ID from HTTP headers.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The client ID as a SecretString, or None if not found or not in HTTP context.
|
|
57
|
+
"""
|
|
58
|
+
headers = get_http_headers()
|
|
59
|
+
if not headers:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
value = _get_header_value(headers, HEADER_AIRBYTE_CLOUD_CLIENT_ID)
|
|
63
|
+
if value:
|
|
64
|
+
return SecretString(value)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_client_secret_from_headers() -> SecretString | None:
|
|
69
|
+
"""Extract client secret from HTTP headers.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The client secret as a SecretString, or None if not found or not in HTTP context.
|
|
73
|
+
"""
|
|
74
|
+
headers = get_http_headers()
|
|
75
|
+
if not headers:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
value = _get_header_value(headers, HEADER_AIRBYTE_CLOUD_CLIENT_SECRET)
|
|
79
|
+
if value:
|
|
80
|
+
return SecretString(value)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_workspace_id_from_headers() -> str | None:
|
|
85
|
+
"""Extract workspace ID from HTTP headers.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The workspace ID, or None if not found or not in HTTP context.
|
|
89
|
+
"""
|
|
90
|
+
headers = get_http_headers()
|
|
91
|
+
if not headers:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
return _get_header_value(headers, HEADER_AIRBYTE_CLOUD_WORKSPACE_ID)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_api_url_from_headers() -> str | None:
|
|
98
|
+
"""Extract API URL from HTTP headers.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The API URL, or None if not found or not in HTTP context.
|
|
102
|
+
"""
|
|
103
|
+
headers = get_http_headers()
|
|
104
|
+
if not headers:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
return _get_header_value(headers, HEADER_AIRBYTE_CLOUD_API_URL)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def resolve_client_id() -> SecretString:
|
|
111
|
+
"""Resolve client ID from HTTP headers or environment variables.
|
|
112
|
+
|
|
113
|
+
Resolution order:
|
|
114
|
+
1. HTTP header X-Airbyte-Cloud-Client-Id
|
|
115
|
+
2. Environment variable AIRBYTE_CLOUD_CLIENT_ID (via PyAirbyte)
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
The resolved client ID as a SecretString.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
PyAirbyteSecretNotFoundError: If no client ID can be resolved.
|
|
122
|
+
"""
|
|
123
|
+
header_value = get_client_id_from_headers()
|
|
124
|
+
if header_value:
|
|
125
|
+
return header_value
|
|
126
|
+
|
|
127
|
+
return resolve_cloud_client_id()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def resolve_client_secret() -> SecretString:
|
|
131
|
+
"""Resolve client secret from HTTP headers or environment variables.
|
|
132
|
+
|
|
133
|
+
Resolution order:
|
|
134
|
+
1. HTTP header X-Airbyte-Cloud-Client-Secret
|
|
135
|
+
2. Environment variable AIRBYTE_CLOUD_CLIENT_SECRET (via PyAirbyte)
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The resolved client secret as a SecretString.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
PyAirbyteSecretNotFoundError: If no client secret can be resolved.
|
|
142
|
+
"""
|
|
143
|
+
header_value = get_client_secret_from_headers()
|
|
144
|
+
if header_value:
|
|
145
|
+
return header_value
|
|
146
|
+
|
|
147
|
+
return resolve_cloud_client_secret()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def resolve_workspace_id(workspace_id: str | None = None) -> str:
|
|
151
|
+
"""Resolve workspace ID from multiple sources.
|
|
152
|
+
|
|
153
|
+
Resolution order:
|
|
154
|
+
1. Explicit workspace_id parameter (if provided)
|
|
155
|
+
2. HTTP header X-Airbyte-Cloud-Workspace-Id
|
|
156
|
+
3. Environment variable AIRBYTE_CLOUD_WORKSPACE_ID (via PyAirbyte)
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
workspace_id: Optional explicit workspace ID.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The resolved workspace ID.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
PyAirbyteSecretNotFoundError: If no workspace ID can be resolved.
|
|
166
|
+
"""
|
|
167
|
+
if workspace_id is not None:
|
|
168
|
+
return workspace_id
|
|
169
|
+
|
|
170
|
+
header_value = get_workspace_id_from_headers()
|
|
171
|
+
if header_value:
|
|
172
|
+
return header_value
|
|
173
|
+
|
|
174
|
+
return resolve_cloud_workspace_id()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def resolve_api_url(api_url: str | None = None) -> str:
|
|
178
|
+
"""Resolve API URL from multiple sources.
|
|
179
|
+
|
|
180
|
+
Resolution order:
|
|
181
|
+
1. Explicit api_url parameter (if provided)
|
|
182
|
+
2. HTTP header X-Airbyte-Cloud-Api-Url
|
|
183
|
+
3. Environment variable / default (via PyAirbyte)
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
api_url: Optional explicit API URL.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The resolved API URL.
|
|
190
|
+
"""
|
|
191
|
+
if api_url is not None:
|
|
192
|
+
return api_url
|
|
193
|
+
|
|
194
|
+
header_value = get_api_url_from_headers()
|
|
195
|
+
if header_value:
|
|
196
|
+
return header_value
|
|
197
|
+
|
|
198
|
+
return resolve_cloud_api_url()
|
|
@@ -15,10 +15,6 @@ from typing import Annotated, Literal
|
|
|
15
15
|
|
|
16
16
|
from airbyte import constants
|
|
17
17
|
from airbyte.cloud import CloudWorkspace
|
|
18
|
-
from airbyte.cloud.auth import (
|
|
19
|
-
resolve_cloud_client_id,
|
|
20
|
-
resolve_cloud_client_secret,
|
|
21
|
-
)
|
|
22
18
|
from airbyte.exceptions import PyAirbyteInputError
|
|
23
19
|
from fastmcp import FastMCP
|
|
24
20
|
from pydantic import Field
|
|
@@ -26,19 +22,26 @@ from pydantic import Field
|
|
|
26
22
|
from airbyte_ops_mcp.cloud_admin import api_client
|
|
27
23
|
from airbyte_ops_mcp.cloud_admin.auth import (
|
|
28
24
|
CloudAuthError,
|
|
29
|
-
|
|
30
|
-
require_internal_admin,
|
|
25
|
+
require_internal_admin_flag_only,
|
|
31
26
|
)
|
|
32
27
|
from airbyte_ops_mcp.cloud_admin.models import (
|
|
33
28
|
ConnectorVersionInfo,
|
|
34
29
|
VersionOverrideOperationResult,
|
|
35
30
|
)
|
|
31
|
+
from airbyte_ops_mcp.mcp._http_headers import (
|
|
32
|
+
resolve_client_id,
|
|
33
|
+
resolve_client_secret,
|
|
34
|
+
)
|
|
36
35
|
from airbyte_ops_mcp.mcp._mcp_utils import mcp_tool, register_mcp_tools
|
|
37
36
|
|
|
38
37
|
|
|
39
38
|
def _get_workspace(workspace_id: str) -> CloudWorkspace:
|
|
40
39
|
"""Get a CloudWorkspace instance for the specified workspace.
|
|
41
40
|
|
|
41
|
+
Credentials are resolved in priority order:
|
|
42
|
+
1. HTTP headers (X-Airbyte-Cloud-Client-Id, X-Airbyte-Cloud-Client-Secret)
|
|
43
|
+
2. Environment variables (AIRBYTE_CLOUD_CLIENT_ID, AIRBYTE_CLOUD_CLIENT_SECRET)
|
|
44
|
+
|
|
42
45
|
Args:
|
|
43
46
|
workspace_id: The Airbyte Cloud workspace ID (required).
|
|
44
47
|
|
|
@@ -46,19 +49,21 @@ def _get_workspace(workspace_id: str) -> CloudWorkspace:
|
|
|
46
49
|
CloudWorkspace instance configured for the specified workspace.
|
|
47
50
|
|
|
48
51
|
Raises:
|
|
49
|
-
CloudAuthError: If
|
|
52
|
+
CloudAuthError: If credentials cannot be resolved from headers or env vars.
|
|
50
53
|
"""
|
|
51
54
|
try:
|
|
52
55
|
return CloudWorkspace(
|
|
53
56
|
workspace_id=workspace_id,
|
|
54
|
-
client_id=
|
|
55
|
-
client_secret=
|
|
57
|
+
client_id=resolve_client_id(),
|
|
58
|
+
client_secret=resolve_client_secret(),
|
|
56
59
|
api_root=constants.CLOUD_API_ROOT, # Used for workspace operations
|
|
57
60
|
)
|
|
58
61
|
except Exception as e:
|
|
59
62
|
raise CloudAuthError(
|
|
60
|
-
f"Failed to initialize CloudWorkspace. Ensure
|
|
61
|
-
f"
|
|
63
|
+
f"Failed to initialize CloudWorkspace. Ensure credentials are provided "
|
|
64
|
+
f"via HTTP headers (X-Airbyte-Cloud-Client-Id, X-Airbyte-Cloud-Client-Secret) "
|
|
65
|
+
f"or environment variables (AIRBYTE_CLOUD_CLIENT_ID, AIRBYTE_CLOUD_CLIENT_SECRET). "
|
|
66
|
+
f"Error: {e}"
|
|
62
67
|
) from e
|
|
63
68
|
|
|
64
69
|
|
|
@@ -85,8 +90,9 @@ def get_cloud_connector_version(
|
|
|
85
90
|
Returns version details including the current version string and whether
|
|
86
91
|
an override (pin) is applied.
|
|
87
92
|
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
Authentication credentials are resolved in priority order:
|
|
94
|
+
1. HTTP headers: X-Airbyte-Cloud-Client-Id, X-Airbyte-Cloud-Client-Secret
|
|
95
|
+
2. Environment variables: AIRBYTE_CLOUD_CLIENT_ID, AIRBYTE_CLOUD_CLIENT_SECRET
|
|
90
96
|
"""
|
|
91
97
|
try:
|
|
92
98
|
workspace = _get_workspace(workspace_id)
|
|
@@ -163,11 +169,47 @@ def set_cloud_connector_version_override(
|
|
|
163
169
|
default=None,
|
|
164
170
|
),
|
|
165
171
|
],
|
|
172
|
+
admin_user_email: Annotated[
|
|
173
|
+
str | None,
|
|
174
|
+
Field(
|
|
175
|
+
description="Email of the admin user authorizing this operation. "
|
|
176
|
+
"Must be an @airbyte.io email address. Required for authorization.",
|
|
177
|
+
default=None,
|
|
178
|
+
),
|
|
179
|
+
],
|
|
180
|
+
issue_url: Annotated[
|
|
181
|
+
str | None,
|
|
182
|
+
Field(
|
|
183
|
+
description="URL to the GitHub issue providing context for this operation. "
|
|
184
|
+
"Must be a valid GitHub URL (https://github.com/...). Required for authorization.",
|
|
185
|
+
default=None,
|
|
186
|
+
),
|
|
187
|
+
],
|
|
188
|
+
approval_comment_url: Annotated[
|
|
189
|
+
str | None,
|
|
190
|
+
Field(
|
|
191
|
+
description="URL to a GitHub comment where the admin has explicitly "
|
|
192
|
+
"requested or authorized this deployment. Must be a valid GitHub comment URL. "
|
|
193
|
+
"Required for authorization.",
|
|
194
|
+
default=None,
|
|
195
|
+
),
|
|
196
|
+
],
|
|
197
|
+
ai_agent_session_url: Annotated[
|
|
198
|
+
str | None,
|
|
199
|
+
Field(
|
|
200
|
+
description="URL to the AI agent session driving this operation, if applicable. "
|
|
201
|
+
"Provides additional auditability for AI-driven operations.",
|
|
202
|
+
default=None,
|
|
203
|
+
),
|
|
204
|
+
],
|
|
166
205
|
) -> VersionOverrideOperationResult:
|
|
167
206
|
"""Set or clear a version override for a deployed connector.
|
|
168
207
|
|
|
169
|
-
**Admin-only operation** - Requires
|
|
170
|
-
|
|
208
|
+
**Admin-only operation** - Requires:
|
|
209
|
+
- AIRBYTE_INTERNAL_ADMIN_FLAG=airbyte.io environment variable
|
|
210
|
+
- admin_user_email parameter (must be @airbyte.io email)
|
|
211
|
+
- issue_url parameter (GitHub issue URL for context)
|
|
212
|
+
- approval_comment_url parameter (GitHub comment URL with approval)
|
|
171
213
|
|
|
172
214
|
You must specify EXACTLY ONE of `version` OR `unset=True`, but not both.
|
|
173
215
|
When setting a version, `override_reason` is required.
|
|
@@ -177,13 +219,13 @@ def set_cloud_connector_version_override(
|
|
|
177
219
|
- Production versions: Require strong justification mentioning customer/support/investigation
|
|
178
220
|
- Release candidates (-rc): Any admin can pin/unpin RC versions
|
|
179
221
|
|
|
180
|
-
|
|
181
|
-
|
|
222
|
+
Authentication credentials are resolved in priority order:
|
|
223
|
+
1. HTTP headers: X-Airbyte-Cloud-Client-Id, X-Airbyte-Cloud-Client-Secret
|
|
224
|
+
2. Environment variables: AIRBYTE_CLOUD_CLIENT_ID, AIRBYTE_CLOUD_CLIENT_SECRET
|
|
182
225
|
"""
|
|
183
|
-
# Validate admin access
|
|
226
|
+
# Validate admin access (check env var flag)
|
|
184
227
|
try:
|
|
185
|
-
|
|
186
|
-
user_email = get_admin_user_email()
|
|
228
|
+
require_internal_admin_flag_only()
|
|
187
229
|
except CloudAuthError as e:
|
|
188
230
|
return VersionOverrideOperationResult(
|
|
189
231
|
success=False,
|
|
@@ -192,6 +234,60 @@ def set_cloud_connector_version_override(
|
|
|
192
234
|
connector_type=actor_type,
|
|
193
235
|
)
|
|
194
236
|
|
|
237
|
+
# Validate new authorization parameters
|
|
238
|
+
validation_errors: list[str] = []
|
|
239
|
+
|
|
240
|
+
if not admin_user_email:
|
|
241
|
+
validation_errors.append("admin_user_email is required for authorization")
|
|
242
|
+
elif "@airbyte.io" not in admin_user_email:
|
|
243
|
+
validation_errors.append(
|
|
244
|
+
f"admin_user_email must be an @airbyte.io email address, got: {admin_user_email}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if not issue_url:
|
|
248
|
+
validation_errors.append(
|
|
249
|
+
"issue_url is required for authorization (GitHub issue URL)"
|
|
250
|
+
)
|
|
251
|
+
elif not issue_url.startswith("https://github.com/"):
|
|
252
|
+
validation_errors.append(
|
|
253
|
+
f"issue_url must be a valid GitHub URL (https://github.com/...), got: {issue_url}"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if not approval_comment_url:
|
|
257
|
+
validation_errors.append(
|
|
258
|
+
"approval_comment_url is required for authorization (GitHub comment URL)"
|
|
259
|
+
)
|
|
260
|
+
elif not approval_comment_url.startswith("https://github.com/"):
|
|
261
|
+
validation_errors.append(
|
|
262
|
+
f"approval_comment_url must be a valid GitHub URL, got: {approval_comment_url}"
|
|
263
|
+
)
|
|
264
|
+
elif (
|
|
265
|
+
"#issuecomment-" not in approval_comment_url
|
|
266
|
+
and "#discussion_r" not in approval_comment_url
|
|
267
|
+
):
|
|
268
|
+
validation_errors.append(
|
|
269
|
+
"approval_comment_url must be a GitHub comment URL "
|
|
270
|
+
"(containing #issuecomment- or #discussion_r)"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if validation_errors:
|
|
274
|
+
return VersionOverrideOperationResult(
|
|
275
|
+
success=False,
|
|
276
|
+
message="Authorization validation failed: " + "; ".join(validation_errors),
|
|
277
|
+
connector_id=actor_id,
|
|
278
|
+
connector_type=actor_type,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Build enhanced override reason with audit fields (only for 'set' operations)
|
|
282
|
+
enhanced_override_reason = override_reason
|
|
283
|
+
if not unset and override_reason:
|
|
284
|
+
audit_parts = [override_reason]
|
|
285
|
+
audit_parts.append(f"Issue: {issue_url}")
|
|
286
|
+
audit_parts.append(f"Approval: {approval_comment_url}")
|
|
287
|
+
if ai_agent_session_url:
|
|
288
|
+
audit_parts.append(f"AI Session: {ai_agent_session_url}")
|
|
289
|
+
enhanced_override_reason = " | ".join(audit_parts)
|
|
290
|
+
|
|
195
291
|
# Get workspace and current version info
|
|
196
292
|
try:
|
|
197
293
|
workspace = _get_workspace(workspace_id)
|
|
@@ -233,9 +329,9 @@ def set_cloud_connector_version_override(
|
|
|
233
329
|
workspace_id=workspace_id,
|
|
234
330
|
version=version,
|
|
235
331
|
unset=unset,
|
|
236
|
-
override_reason=
|
|
332
|
+
override_reason=enhanced_override_reason,
|
|
237
333
|
override_reason_reference_url=override_reason_reference_url,
|
|
238
|
-
user_email=
|
|
334
|
+
user_email=admin_user_email,
|
|
239
335
|
)
|
|
240
336
|
|
|
241
337
|
# Get updated version info after the operation
|
airbyte_ops_mcp/mcp/github.py
CHANGED
|
@@ -7,7 +7,6 @@ Docker image availability, and other related operations.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
import os
|
|
11
10
|
import re
|
|
12
11
|
from typing import Annotated
|
|
13
12
|
|
|
@@ -15,9 +14,9 @@ import requests
|
|
|
15
14
|
from fastmcp import FastMCP
|
|
16
15
|
from pydantic import BaseModel, Field
|
|
17
16
|
|
|
17
|
+
from airbyte_ops_mcp.github_actions import GITHUB_API_BASE, resolve_github_token
|
|
18
18
|
from airbyte_ops_mcp.mcp._mcp_utils import mcp_tool, register_mcp_tools
|
|
19
19
|
|
|
20
|
-
GITHUB_API_BASE = "https://api.github.com"
|
|
21
20
|
DOCKERHUB_API_BASE = "https://hub.docker.com/v2"
|
|
22
21
|
|
|
23
22
|
|
|
@@ -37,24 +36,6 @@ class WorkflowRunStatus(BaseModel):
|
|
|
37
36
|
jobs_url: str
|
|
38
37
|
|
|
39
38
|
|
|
40
|
-
def _get_github_token() -> str:
|
|
41
|
-
"""Get GitHub token from environment.
|
|
42
|
-
|
|
43
|
-
Returns:
|
|
44
|
-
GitHub token string.
|
|
45
|
-
|
|
46
|
-
Raises:
|
|
47
|
-
ValueError: If GITHUB_TOKEN environment variable is not set.
|
|
48
|
-
"""
|
|
49
|
-
token = os.getenv("GITHUB_TOKEN")
|
|
50
|
-
if not token:
|
|
51
|
-
raise ValueError(
|
|
52
|
-
"GITHUB_TOKEN environment variable is required. "
|
|
53
|
-
"Please set it to a GitHub personal access token."
|
|
54
|
-
)
|
|
55
|
-
return token
|
|
56
|
-
|
|
57
|
-
|
|
58
39
|
def _parse_workflow_url(url: str) -> tuple[str, str, int]:
|
|
59
40
|
"""Parse a GitHub Actions workflow run URL into components.
|
|
60
41
|
|
|
@@ -162,7 +143,7 @@ def check_workflow_status(
|
|
|
162
143
|
)
|
|
163
144
|
|
|
164
145
|
# Guard: Check for required token
|
|
165
|
-
token =
|
|
146
|
+
token = resolve_github_token()
|
|
166
147
|
|
|
167
148
|
# Get workflow run details
|
|
168
149
|
run_data = _get_workflow_run(owner, repo, run_id, token)
|