airbyte-internal-ops 0.1.3__py3-none-any.whl → 0.1.4__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.
Files changed (31) hide show
  1. {airbyte_internal_ops-0.1.3.dist-info → airbyte_internal_ops-0.1.4.dist-info}/METADATA +8 -5
  2. {airbyte_internal_ops-0.1.3.dist-info → airbyte_internal_ops-0.1.4.dist-info}/RECORD +31 -11
  3. airbyte_ops_mcp/_legacy/airbyte_ci/connector_pipelines/airbyte_ci/connectors/test/steps/common.py +1 -1
  4. airbyte_ops_mcp/cli/cloud.py +309 -38
  5. airbyte_ops_mcp/cloud_admin/connection_config.py +131 -0
  6. airbyte_ops_mcp/live_tests/__init__.py +16 -0
  7. airbyte_ops_mcp/live_tests/_connection_retriever/__init__.py +35 -0
  8. airbyte_ops_mcp/live_tests/_connection_retriever/audit_logging.py +88 -0
  9. airbyte_ops_mcp/live_tests/_connection_retriever/consts.py +33 -0
  10. airbyte_ops_mcp/live_tests/_connection_retriever/db_access.py +82 -0
  11. airbyte_ops_mcp/live_tests/_connection_retriever/retrieval.py +391 -0
  12. airbyte_ops_mcp/live_tests/_connection_retriever/secrets_resolution.py +130 -0
  13. airbyte_ops_mcp/live_tests/config.py +190 -0
  14. airbyte_ops_mcp/live_tests/connection_fetcher.py +159 -2
  15. airbyte_ops_mcp/live_tests/connection_secret_retriever.py +173 -0
  16. airbyte_ops_mcp/live_tests/evaluation_modes.py +45 -0
  17. airbyte_ops_mcp/live_tests/http_metrics.py +81 -0
  18. airbyte_ops_mcp/live_tests/message_cache/__init__.py +15 -0
  19. airbyte_ops_mcp/live_tests/message_cache/duckdb_cache.py +415 -0
  20. airbyte_ops_mcp/live_tests/obfuscation.py +126 -0
  21. airbyte_ops_mcp/live_tests/regression/__init__.py +29 -0
  22. airbyte_ops_mcp/live_tests/regression/comparators.py +466 -0
  23. airbyte_ops_mcp/live_tests/schema_generation.py +154 -0
  24. airbyte_ops_mcp/live_tests/validation/__init__.py +43 -0
  25. airbyte_ops_mcp/live_tests/validation/catalog_validators.py +389 -0
  26. airbyte_ops_mcp/live_tests/validation/record_validators.py +227 -0
  27. airbyte_ops_mcp/mcp/_mcp_utils.py +3 -0
  28. airbyte_ops_mcp/mcp/live_tests.py +500 -0
  29. airbyte_ops_mcp/mcp/server.py +3 -0
  30. {airbyte_internal_ops-0.1.3.dist-info → airbyte_internal_ops-0.1.4.dist-info}/WHEEL +0 -0
  31. {airbyte_internal_ops-0.1.3.dist-info → airbyte_internal_ops-0.1.4.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,500 @@
1
+ # Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2
+ """MCP tools for live connection tests.
3
+
4
+ This module provides MCP tools for triggering live validation and regression tests
5
+ on Airbyte Cloud connections via GitHub Actions workflows. Tests run asynchronously
6
+ in GitHub Actions and results can be polled via workflow status.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ import uuid
14
+ from datetime import datetime
15
+ from enum import Enum
16
+ from typing import Annotated, Any
17
+
18
+ import requests
19
+ from airbyte.cloud import CloudWorkspace
20
+ from fastmcp import FastMCP
21
+ from pydantic import BaseModel, Field
22
+
23
+ from airbyte_ops_mcp.mcp._mcp_utils import ToolDomain, mcp_tool, register_mcp_tools
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # =============================================================================
28
+ # GitHub Workflow Configuration
29
+ # =============================================================================
30
+
31
+ GITHUB_API_BASE = "https://api.github.com"
32
+ LIVE_TEST_REPO_OWNER = "airbytehq"
33
+ LIVE_TEST_REPO_NAME = "airbyte-ops-mcp"
34
+ LIVE_TEST_DEFAULT_BRANCH = "main"
35
+ LIVE_TEST_WORKFLOW_FILE = "connector-live-test.yml"
36
+ REGRESSION_TEST_WORKFLOW_FILE = "connector-regression-test.yml"
37
+
38
+
39
+ # =============================================================================
40
+ # GitHub API Helper Functions
41
+ # =============================================================================
42
+
43
+
44
+ def _get_github_token() -> str:
45
+ """Get GitHub token from environment.
46
+
47
+ Checks for tokens in order of specificity:
48
+ 1. GITHUB_CI_WORKFLOW_TRIGGER_PAT (general workflow triggering)
49
+ 2. GITHUB_TOKEN (fallback)
50
+
51
+ Returns:
52
+ GitHub token string.
53
+
54
+ Raises:
55
+ ValueError: If no GitHub token environment variable is set.
56
+ """
57
+ token = os.getenv("GITHUB_CI_WORKFLOW_TRIGGER_PAT") or os.getenv("GITHUB_TOKEN")
58
+ if not token:
59
+ raise ValueError(
60
+ "No GitHub token found. Set GITHUB_CI_WORKFLOW_TRIGGER_PAT or GITHUB_TOKEN "
61
+ "environment variable with 'actions:write' permission."
62
+ )
63
+ return token
64
+
65
+
66
+ def _trigger_workflow_dispatch(
67
+ owner: str,
68
+ repo: str,
69
+ workflow_file: str,
70
+ ref: str,
71
+ inputs: dict[str, Any],
72
+ token: str,
73
+ ) -> str:
74
+ """Trigger a GitHub Actions workflow via workflow_dispatch.
75
+
76
+ Args:
77
+ owner: Repository owner (e.g., "airbytehq")
78
+ repo: Repository name (e.g., "airbyte-ops-mcp")
79
+ workflow_file: Workflow file name (e.g., "connector-live-test.yml")
80
+ ref: Git ref to run the workflow on (branch name)
81
+ inputs: Workflow inputs dictionary
82
+ token: GitHub API token
83
+
84
+ Returns:
85
+ URL to view workflow runs.
86
+
87
+ Raises:
88
+ requests.HTTPError: If API request fails.
89
+ """
90
+ url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/actions/workflows/{workflow_file}/dispatches"
91
+ headers = {
92
+ "Authorization": f"Bearer {token}",
93
+ "Accept": "application/vnd.github+json",
94
+ "X-GitHub-Api-Version": "2022-11-28",
95
+ }
96
+ payload = {
97
+ "ref": ref,
98
+ "inputs": inputs,
99
+ }
100
+
101
+ response = requests.post(url, headers=headers, json=payload, timeout=30)
102
+ response.raise_for_status()
103
+
104
+ # workflow_dispatch returns 204 No Content on success
105
+ # Return URL to view workflow runs
106
+ return f"https://github.com/{owner}/{repo}/actions/workflows/{workflow_file}"
107
+
108
+
109
+ def _get_workflow_run_status(
110
+ owner: str,
111
+ repo: str,
112
+ run_id: int,
113
+ token: str,
114
+ ) -> dict[str, Any]:
115
+ """Get workflow run details from GitHub API.
116
+
117
+ Args:
118
+ owner: Repository owner (e.g., "airbytehq")
119
+ repo: Repository name (e.g., "airbyte-ops-mcp")
120
+ run_id: Workflow run ID
121
+ token: GitHub API token
122
+
123
+ Returns:
124
+ Workflow run data dictionary.
125
+
126
+ Raises:
127
+ ValueError: If workflow run not found.
128
+ requests.HTTPError: If API request fails.
129
+ """
130
+ url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/actions/runs/{run_id}"
131
+ headers = {
132
+ "Authorization": f"Bearer {token}",
133
+ "Accept": "application/vnd.github+json",
134
+ "X-GitHub-Api-Version": "2022-11-28",
135
+ }
136
+
137
+ response = requests.get(url, headers=headers, timeout=30)
138
+ if response.status_code == 404:
139
+ raise ValueError(f"Workflow run {owner}/{repo}/actions/runs/{run_id} not found")
140
+ response.raise_for_status()
141
+
142
+ return response.json()
143
+
144
+
145
+ # =============================================================================
146
+ # Pydantic Models for Test Results
147
+ # =============================================================================
148
+
149
+
150
+ class TestRunStatus(str, Enum):
151
+ """Status of a test run."""
152
+
153
+ QUEUED = "queued"
154
+ RUNNING = "running"
155
+ SUCCEEDED = "succeeded"
156
+ FAILED = "failed"
157
+
158
+
159
+ class TestPhaseStatus(str, Enum):
160
+ """Status of a test phase (live or regression)."""
161
+
162
+ PENDING = "pending"
163
+ RUNNING = "running"
164
+ PASSED = "passed"
165
+ FAILED = "failed"
166
+ SKIPPED = "skipped"
167
+
168
+
169
+ class ValidationResultModel(BaseModel):
170
+ """Result of a single validation check."""
171
+
172
+ name: str = Field(description="Name of the validation check")
173
+ passed: bool = Field(description="Whether the validation passed")
174
+ message: str = Field(description="Human-readable result message")
175
+ errors: list[str] = Field(
176
+ default_factory=list,
177
+ description="List of error messages if validation failed",
178
+ )
179
+
180
+
181
+ class StreamComparisonResultModel(BaseModel):
182
+ """Result of comparing a single stream between control and target."""
183
+
184
+ stream_name: str = Field(description="Name of the stream")
185
+ passed: bool = Field(description="Whether all comparisons passed")
186
+ control_record_count: int = Field(description="Number of records in control")
187
+ target_record_count: int = Field(description="Number of records in target")
188
+ missing_pks: list[str] = Field(
189
+ default_factory=list,
190
+ description="Primary keys present in control but missing in target",
191
+ )
192
+ differing_records: int = Field(
193
+ default=0,
194
+ description="Number of records that differ between control and target",
195
+ )
196
+ message: str = Field(description="Human-readable comparison summary")
197
+
198
+
199
+ class LivePhaseResult(BaseModel):
200
+ """Results from the live test phase."""
201
+
202
+ status: TestPhaseStatus = Field(description="Status of the live phase")
203
+ catalog_validations: list[ValidationResultModel] = Field(
204
+ default_factory=list,
205
+ description="Results of catalog validation checks",
206
+ )
207
+ record_validations: list[ValidationResultModel] = Field(
208
+ default_factory=list,
209
+ description="Results of record validation checks",
210
+ )
211
+ record_count: int = Field(
212
+ default=0,
213
+ description="Total number of records read",
214
+ )
215
+ error_message: str | None = Field(
216
+ default=None,
217
+ description="Error message if the phase failed",
218
+ )
219
+
220
+
221
+ class RegressionPhaseResult(BaseModel):
222
+ """Results from the regression test phase."""
223
+
224
+ status: TestPhaseStatus = Field(description="Status of the regression phase")
225
+ baseline_version: str | None = Field(
226
+ default=None,
227
+ description="Version of the baseline (control) connector",
228
+ )
229
+ stream_comparisons: list[StreamComparisonResultModel] = Field(
230
+ default_factory=list,
231
+ description="Per-stream comparison results",
232
+ )
233
+ error_message: str | None = Field(
234
+ default=None,
235
+ description="Error message if the phase failed",
236
+ )
237
+
238
+
239
+ class LiveConnectionTestResult(BaseModel):
240
+ """Complete result of a live connection test run."""
241
+
242
+ run_id: str = Field(description="Unique identifier for this test run")
243
+ connection_id: str = Field(description="The connection being tested")
244
+ workspace_id: str = Field(description="The workspace containing the connection")
245
+ status: TestRunStatus = Field(description="Overall status of the test run")
246
+ target_version: str | None = Field(
247
+ default=None,
248
+ description="Version of the target connector being tested",
249
+ )
250
+ baseline_version: str | None = Field(
251
+ default=None,
252
+ description="Version of the baseline connector (if regression ran)",
253
+ )
254
+ evaluation_mode: str = Field(
255
+ default="diagnostic",
256
+ description="Evaluation mode used (diagnostic or strict)",
257
+ )
258
+ skip_regression_tests: bool = Field(
259
+ default=False,
260
+ description="Whether regression tests were skipped by request",
261
+ )
262
+ live_phase: LivePhaseResult | None = Field(
263
+ default=None,
264
+ description="Results from the live test phase",
265
+ )
266
+ regression_phase: RegressionPhaseResult | None = Field(
267
+ default=None,
268
+ description="Results from the regression test phase",
269
+ )
270
+ artifacts: dict[str, str] = Field(
271
+ default_factory=dict,
272
+ description="Paths to generated artifacts (JSONL, DuckDB, HAR files)",
273
+ )
274
+ human_summary: str = Field(
275
+ default="",
276
+ description="Human-readable summary of the test results",
277
+ )
278
+ started_at: datetime | None = Field(
279
+ default=None,
280
+ description="When the test run started",
281
+ )
282
+ completed_at: datetime | None = Field(
283
+ default=None,
284
+ description="When the test run completed",
285
+ )
286
+ test_description: str | None = Field(
287
+ default=None,
288
+ description="Optional description/context for this test run",
289
+ )
290
+
291
+
292
+ class RunLiveConnectionTestsResponse(BaseModel):
293
+ """Response from starting a live connection test via GitHub Actions workflow."""
294
+
295
+ run_id: str = Field(description="Unique identifier for the test run")
296
+ status: TestRunStatus = Field(description="Initial status of the test run")
297
+ message: str = Field(description="Human-readable status message")
298
+ workflow_url: str | None = Field(
299
+ default=None,
300
+ description="URL to view the GitHub Actions workflow runs",
301
+ )
302
+
303
+
304
+ # =============================================================================
305
+ # MCP Tools
306
+ # =============================================================================
307
+
308
+
309
+ @mcp_tool(
310
+ ToolDomain.LIVE_TESTS,
311
+ read_only=False,
312
+ idempotent=False,
313
+ open_world=True,
314
+ )
315
+ def run_live_connection_tests(
316
+ connection_id: Annotated[str, "The Airbyte Cloud connection ID to test"],
317
+ command: Annotated[
318
+ str,
319
+ "Airbyte command to run: 'spec', 'check', 'discover', or 'read'",
320
+ ] = "check",
321
+ workspace_id: Annotated[
322
+ str | None,
323
+ "Optional Airbyte Cloud workspace ID. If provided, validates that the connection "
324
+ "belongs to this workspace before triggering tests. If omitted, no validation is done.",
325
+ ] = None,
326
+ skip_regression_tests: Annotated[
327
+ bool,
328
+ "If True, run only live tests (connector-live-test workflow). "
329
+ "If False, run regression tests comparing target vs control versions "
330
+ "(connector-regression-test workflow).",
331
+ ] = True,
332
+ connector_image: Annotated[
333
+ str | None,
334
+ "Optional connector image with tag for live tests (e.g., 'airbyte/source-github:1.0.0'). "
335
+ "If not provided, auto-detected from connection. Only used when skip_regression_tests=True.",
336
+ ] = None,
337
+ target_image: Annotated[
338
+ str | None,
339
+ "Target connector image (new version) with tag for regression tests "
340
+ "(e.g., 'airbyte/source-github:2.0.0'). Optional if connector_name is provided. "
341
+ "Only used when skip_regression_tests=False.",
342
+ ] = None,
343
+ control_image: Annotated[
344
+ str | None,
345
+ "Control connector image (baseline version) with tag for regression tests "
346
+ "(e.g., 'airbyte/source-github:1.0.0'). Optional if connection_id is provided "
347
+ "(auto-detected from connection). Only used when skip_regression_tests=False.",
348
+ ] = None,
349
+ connector_name: Annotated[
350
+ str | None,
351
+ "Connector name to build target image from source for regression tests "
352
+ "(e.g., 'source-pokeapi'). If provided, builds the target image locally. "
353
+ "Only used when skip_regression_tests=False.",
354
+ ] = None,
355
+ ) -> RunLiveConnectionTestsResponse:
356
+ """Start a live connection test run via GitHub Actions workflow.
357
+
358
+ This tool triggers either the live-test or regression-test workflow depending
359
+ on the skip_regression_tests parameter:
360
+
361
+ - skip_regression_tests=True (default): Triggers connector-live-test workflow.
362
+ Runs the specified command against the connection and validates the output.
363
+
364
+ - skip_regression_tests=False: Triggers connector-regression-test workflow.
365
+ Compares the target connector version against a control (baseline) version.
366
+ For regression tests, provide either target_image or connector_name to specify
367
+ the target version.
368
+
369
+ Returns immediately with a run_id and workflow URL. Check the workflow URL
370
+ to monitor progress and view results.
371
+
372
+ Requires GITHUB_CI_WORKFLOW_TRIGGER_PAT or GITHUB_TOKEN environment variable
373
+ with 'actions:write' permission.
374
+ """
375
+ # Generate a unique run ID for tracking
376
+ run_id = str(uuid.uuid4())
377
+
378
+ # Get GitHub token
379
+ try:
380
+ token = _get_github_token()
381
+ except ValueError as e:
382
+ return RunLiveConnectionTestsResponse(
383
+ run_id=run_id,
384
+ status=TestRunStatus.FAILED,
385
+ message=str(e),
386
+ workflow_url=None,
387
+ )
388
+
389
+ # Validate workspace membership if workspace_id is provided
390
+ if workspace_id:
391
+ try:
392
+ workspace = CloudWorkspace(workspace_id=workspace_id)
393
+ # This will raise an exception if the connection doesn't belong to the workspace
394
+ workspace.get_connection(connection_id)
395
+ except Exception as e:
396
+ return RunLiveConnectionTestsResponse(
397
+ run_id=run_id,
398
+ status=TestRunStatus.FAILED,
399
+ message=f"Connection {connection_id} validation failed for workspace {workspace_id}: {e}",
400
+ workflow_url=None,
401
+ )
402
+
403
+ if skip_regression_tests:
404
+ # Live test workflow
405
+ workflow_inputs: dict[str, str] = {
406
+ "connection_id": connection_id,
407
+ "command": command,
408
+ }
409
+ if connector_image:
410
+ workflow_inputs["connector_image"] = connector_image
411
+
412
+ try:
413
+ workflow_url = _trigger_workflow_dispatch(
414
+ owner=LIVE_TEST_REPO_OWNER,
415
+ repo=LIVE_TEST_REPO_NAME,
416
+ workflow_file=LIVE_TEST_WORKFLOW_FILE,
417
+ ref=LIVE_TEST_DEFAULT_BRANCH,
418
+ inputs=workflow_inputs,
419
+ token=token,
420
+ )
421
+ except Exception as e:
422
+ logger.exception("Failed to trigger live test workflow")
423
+ return RunLiveConnectionTestsResponse(
424
+ run_id=run_id,
425
+ status=TestRunStatus.FAILED,
426
+ message=f"Failed to trigger live-test workflow: {e}",
427
+ workflow_url=None,
428
+ )
429
+
430
+ return RunLiveConnectionTestsResponse(
431
+ run_id=run_id,
432
+ status=TestRunStatus.QUEUED,
433
+ message=f"Live-test workflow triggered for connection {connection_id}. "
434
+ f"View progress at: {workflow_url}",
435
+ workflow_url=workflow_url,
436
+ )
437
+
438
+ # Regression test workflow (skip_regression_tests=False)
439
+ # Validate that we have enough info to run regression tests
440
+ if not target_image and not connector_name:
441
+ return RunLiveConnectionTestsResponse(
442
+ run_id=run_id,
443
+ status=TestRunStatus.FAILED,
444
+ message=(
445
+ "For regression tests (skip_regression_tests=False), provide either "
446
+ "target_image or connector_name so the workflow can determine the target image."
447
+ ),
448
+ workflow_url=None,
449
+ )
450
+
451
+ workflow_inputs = {
452
+ "connection_id": connection_id,
453
+ "command": command,
454
+ }
455
+ if target_image:
456
+ workflow_inputs["target_image"] = target_image
457
+ if control_image:
458
+ workflow_inputs["control_image"] = control_image
459
+ if connector_name:
460
+ workflow_inputs["connector_name"] = connector_name
461
+
462
+ try:
463
+ workflow_url = _trigger_workflow_dispatch(
464
+ owner=LIVE_TEST_REPO_OWNER,
465
+ repo=LIVE_TEST_REPO_NAME,
466
+ workflow_file=REGRESSION_TEST_WORKFLOW_FILE,
467
+ ref=LIVE_TEST_DEFAULT_BRANCH,
468
+ inputs=workflow_inputs,
469
+ token=token,
470
+ )
471
+ except Exception as e:
472
+ logger.exception("Failed to trigger regression test workflow")
473
+ return RunLiveConnectionTestsResponse(
474
+ run_id=run_id,
475
+ status=TestRunStatus.FAILED,
476
+ message=f"Failed to trigger regression-test workflow: {e}",
477
+ workflow_url=None,
478
+ )
479
+
480
+ return RunLiveConnectionTestsResponse(
481
+ run_id=run_id,
482
+ status=TestRunStatus.QUEUED,
483
+ message=f"Regression-test workflow triggered for connection {connection_id}. "
484
+ f"View progress at: {workflow_url}",
485
+ workflow_url=workflow_url,
486
+ )
487
+
488
+
489
+ # =============================================================================
490
+ # Registration
491
+ # =============================================================================
492
+
493
+
494
+ def register_live_tests_tools(app: FastMCP) -> None:
495
+ """Register live tests tools with the FastMCP app.
496
+
497
+ Args:
498
+ app: FastMCP application instance
499
+ """
500
+ register_mcp_tools(app, domain=ToolDomain.LIVE_TESTS)
@@ -17,6 +17,7 @@ from airbyte_ops_mcp.mcp.cloud_connector_versions import (
17
17
  )
18
18
  from airbyte_ops_mcp.mcp.github import register_github_tools
19
19
  from airbyte_ops_mcp.mcp.github_repo_ops import register_github_repo_ops_tools
20
+ from airbyte_ops_mcp.mcp.live_tests import register_live_tests_tools
20
21
  from airbyte_ops_mcp.mcp.prerelease import register_prerelease_tools
21
22
  from airbyte_ops_mcp.mcp.prompts import register_prompts
22
23
  from airbyte_ops_mcp.mcp.server_info import register_server_info_resources
@@ -32,6 +33,7 @@ def register_server_assets(app: FastMCP) -> None:
32
33
  - REPO: GitHub repository operations
33
34
  - CLOUD: Cloud connector version management
34
35
  - PROMPTS: Prompt templates for common workflows
36
+ - LIVE_TESTS: Live connection validation and regression tests
35
37
  - REGISTRY: Connector registry operations (future)
36
38
  - METADATA: Connector metadata operations (future)
37
39
  - QA: Connector quality assurance (future)
@@ -46,6 +48,7 @@ def register_server_assets(app: FastMCP) -> None:
46
48
  register_prerelease_tools(app)
47
49
  register_cloud_connector_version_tools(app)
48
50
  register_prompts(app)
51
+ register_live_tests_tools(app)
49
52
 
50
53
 
51
54
  register_server_assets(app)