airbyte-internal-ops 0.9.1__py3-none-any.whl → 0.10.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.
@@ -0,0 +1,779 @@
1
+ # Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2
+ """MCP tools for connector rollout management.
3
+
4
+ This module provides MCP tools for managing connector rollouts in Airbyte Cloud,
5
+ including finalizing (promoting, rolling back, or canceling) rollouts.
6
+ """
7
+
8
+ # NOTE: We intentionally do NOT use `from __future__ import annotations` here.
9
+ # FastMCP has issues resolving forward references when PEP 563 deferred annotations
10
+ # are used. See: https://github.com/jlowin/fastmcp/issues/905
11
+ # Python 3.12+ supports modern type hint syntax natively, so this is not needed.
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Annotated, Literal
15
+
16
+ from airbyte import constants
17
+ from airbyte.exceptions import PyAirbyteInputError
18
+ from fastmcp import Context, FastMCP
19
+ from fastmcp_extensions import get_mcp_config, mcp_tool, register_mcp_tools
20
+ from pydantic import Field
21
+
22
+ from airbyte_ops_mcp.cloud_admin import api_client
23
+ from airbyte_ops_mcp.cloud_admin.auth import (
24
+ CloudAuthError,
25
+ require_internal_admin_flag_only,
26
+ )
27
+ from airbyte_ops_mcp.cloud_admin.models import (
28
+ ConnectorRolloutFinalizeResult,
29
+ ConnectorRolloutProgressResult,
30
+ ConnectorRolloutStartResult,
31
+ )
32
+ from airbyte_ops_mcp.constants import ServerConfigKey
33
+ from airbyte_ops_mcp.github_api import (
34
+ GitHubAPIError,
35
+ GitHubCommentParseError,
36
+ GitHubUserEmailNotFoundError,
37
+ get_admin_email_from_approval_comment,
38
+ )
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class _ResolvedCloudAuth:
43
+ """Resolved authentication for Airbyte Cloud API calls.
44
+
45
+ Either bearer_token OR (client_id AND client_secret) will be set, not both.
46
+ """
47
+
48
+ bearer_token: str | None = None
49
+ client_id: str | None = None
50
+ client_secret: str | None = None
51
+
52
+
53
+ def _resolve_cloud_auth(ctx: Context) -> _ResolvedCloudAuth:
54
+ """Resolve authentication credentials for Airbyte Cloud API.
55
+
56
+ Credentials are resolved in priority order:
57
+ 1. Bearer token (Authorization header or AIRBYTE_CLOUD_BEARER_TOKEN env var)
58
+ 2. Client credentials (X-Airbyte-Cloud-Client-Id/Secret headers or env vars)
59
+
60
+ Args:
61
+ ctx: FastMCP Context object from the current tool invocation.
62
+
63
+ Returns:
64
+ _ResolvedCloudAuth with either bearer_token or client credentials set.
65
+
66
+ Raises:
67
+ CloudAuthError: If credentials cannot be resolved from headers or env vars.
68
+ """
69
+ # Try bearer token first (preferred, but not required)
70
+ bearer_token = get_mcp_config(ctx, ServerConfigKey.BEARER_TOKEN)
71
+ if bearer_token:
72
+ return _ResolvedCloudAuth(bearer_token=bearer_token)
73
+
74
+ # Fall back to client credentials
75
+ try:
76
+ client_id = get_mcp_config(ctx, ServerConfigKey.CLIENT_ID)
77
+ client_secret = get_mcp_config(ctx, ServerConfigKey.CLIENT_SECRET)
78
+ return _ResolvedCloudAuth(
79
+ client_id=client_id,
80
+ client_secret=client_secret,
81
+ )
82
+ except ValueError as e:
83
+ raise CloudAuthError(
84
+ f"Failed to resolve credentials. Ensure credentials are provided "
85
+ f"via Authorization header (Bearer token), "
86
+ f"HTTP headers (X-Airbyte-Cloud-Client-Id, X-Airbyte-Cloud-Client-Secret), "
87
+ f"or environment variables. Error: {e}"
88
+ ) from e
89
+
90
+
91
+ @mcp_tool(
92
+ destructive=True,
93
+ idempotent=False,
94
+ open_world=True,
95
+ )
96
+ def start_connector_rollout(
97
+ docker_repository: Annotated[
98
+ str,
99
+ Field(description="The docker repository (e.g., 'airbyte/source-pokeapi')"),
100
+ ],
101
+ docker_image_tag: Annotated[
102
+ str,
103
+ Field(description="The docker image tag (e.g., '0.3.48-rc.1')"),
104
+ ],
105
+ actor_definition_id: Annotated[
106
+ str,
107
+ Field(description="The actor definition ID (UUID)"),
108
+ ],
109
+ approval_comment_url: Annotated[
110
+ str,
111
+ Field(
112
+ description="URL to a GitHub comment where an Airbyte contributor has "
113
+ "explicitly requested or authorized starting this rollout. "
114
+ "Must be a valid GitHub comment URL (containing #issuecomment- or #discussion_r). "
115
+ "The admin email is automatically derived from the comment author's GitHub profile."
116
+ ),
117
+ ],
118
+ rollout_strategy: Annotated[
119
+ Literal["manual", "automated", "overridden"],
120
+ Field(
121
+ description="The rollout strategy: "
122
+ "'manual' for manual control of rollout progression, "
123
+ "'automated' for automatic progression based on metrics, "
124
+ "'overridden' for special cases where normal rules are bypassed.",
125
+ default="manual",
126
+ ),
127
+ ] = "manual",
128
+ initial_rollout_pct: Annotated[
129
+ int | None,
130
+ Field(
131
+ description="Initial/step percentage for rollout progression (0-100). "
132
+ "For automated rollouts, this is the percentage increment per step. "
133
+ "For example, 25 means the rollout will advance by 25% each step. "
134
+ "Default is 25% if not specified.",
135
+ default=None,
136
+ ),
137
+ ] = None,
138
+ final_target_rollout_pct: Annotated[
139
+ int | None,
140
+ Field(
141
+ description="Maximum percentage of actors to pin (0-100). "
142
+ "The rollout will not exceed this percentage. "
143
+ "For example, 50 means at most 50% of actors will be pinned to the RC. "
144
+ "Default is 50% if not specified.",
145
+ default=None,
146
+ ),
147
+ ] = None,
148
+ *,
149
+ ctx: Context,
150
+ ) -> ConnectorRolloutStartResult:
151
+ """Start or configure a connector rollout workflow.
152
+
153
+ This tool configures and starts a connector rollout workflow. It can be called
154
+ multiple times while the rollout is in INITIALIZED state to update the configuration
155
+ (strategy, percentages). Once the Temporal workflow starts and the state transitions
156
+ to WORKFLOW_STARTED, the configuration is locked and cannot be changed.
157
+
158
+ **Behavior:**
159
+ - If rollout is INITIALIZED: Updates configuration and starts the workflow
160
+ - If rollout is already started: Returns an error (configuration is locked)
161
+
162
+ **Configuration Parameters:**
163
+ - rollout_strategy: 'manual' (default), 'automated', or 'overridden'
164
+ - initial_rollout_pct: Step size for progression (default: 25%)
165
+ - final_target_rollout_pct: Maximum percentage to pin (default: 50%)
166
+
167
+ **Admin-only operation** - Requires:
168
+ - AIRBYTE_INTERNAL_ADMIN_FLAG=airbyte.io environment variable
169
+ - approval_comment_url parameter (GitHub comment URL with approval from an @airbyte.io user)
170
+
171
+ The admin user email is automatically derived from the approval_comment_url by:
172
+ 1. Fetching the comment from GitHub API to get the author's username
173
+ 2. Fetching the user's profile to get their public email
174
+ 3. Validating the email is an @airbyte.io address
175
+ """
176
+ # Validate admin access (check env var flag)
177
+ try:
178
+ require_internal_admin_flag_only()
179
+ except CloudAuthError as e:
180
+ return ConnectorRolloutStartResult(
181
+ success=False,
182
+ message=f"Admin authentication failed: {e}",
183
+ docker_repository=docker_repository,
184
+ docker_image_tag=docker_image_tag,
185
+ actor_definition_id=actor_definition_id,
186
+ )
187
+
188
+ # Validate approval_comment_url format
189
+ if not approval_comment_url.startswith("https://github.com/"):
190
+ return ConnectorRolloutStartResult(
191
+ success=False,
192
+ message=f"approval_comment_url must be a valid GitHub URL, got: {approval_comment_url}",
193
+ docker_repository=docker_repository,
194
+ docker_image_tag=docker_image_tag,
195
+ actor_definition_id=actor_definition_id,
196
+ )
197
+
198
+ if (
199
+ "#issuecomment-" not in approval_comment_url
200
+ and "#discussion_r" not in approval_comment_url
201
+ ):
202
+ return ConnectorRolloutStartResult(
203
+ success=False,
204
+ message="approval_comment_url must be a GitHub comment URL "
205
+ "(containing #issuecomment- or #discussion_r)",
206
+ docker_repository=docker_repository,
207
+ docker_image_tag=docker_image_tag,
208
+ actor_definition_id=actor_definition_id,
209
+ )
210
+
211
+ # Derive admin email from approval comment URL
212
+ try:
213
+ admin_user_email = get_admin_email_from_approval_comment(approval_comment_url)
214
+ except GitHubCommentParseError as e:
215
+ return ConnectorRolloutStartResult(
216
+ success=False,
217
+ message=f"Failed to parse approval comment URL: {e}",
218
+ docker_repository=docker_repository,
219
+ docker_image_tag=docker_image_tag,
220
+ actor_definition_id=actor_definition_id,
221
+ )
222
+ except GitHubAPIError as e:
223
+ return ConnectorRolloutStartResult(
224
+ success=False,
225
+ message=f"Failed to fetch approval comment from GitHub: {e}",
226
+ docker_repository=docker_repository,
227
+ docker_image_tag=docker_image_tag,
228
+ actor_definition_id=actor_definition_id,
229
+ )
230
+ except GitHubUserEmailNotFoundError as e:
231
+ return ConnectorRolloutStartResult(
232
+ success=False,
233
+ message=str(e),
234
+ docker_repository=docker_repository,
235
+ docker_image_tag=docker_image_tag,
236
+ actor_definition_id=actor_definition_id,
237
+ )
238
+
239
+ # Resolve auth credentials
240
+ try:
241
+ auth = _resolve_cloud_auth(ctx)
242
+ except CloudAuthError as e:
243
+ return ConnectorRolloutStartResult(
244
+ success=False,
245
+ message=f"Failed to resolve credentials: {e}",
246
+ docker_repository=docker_repository,
247
+ docker_image_tag=docker_image_tag,
248
+ actor_definition_id=actor_definition_id,
249
+ )
250
+
251
+ # Get user ID from admin email
252
+ try:
253
+ user_id = api_client.get_user_id_by_email(
254
+ email=admin_user_email,
255
+ config_api_root=constants.CLOUD_CONFIG_API_ROOT,
256
+ client_id=auth.client_id,
257
+ client_secret=auth.client_secret,
258
+ bearer_token=auth.bearer_token,
259
+ )
260
+ except PyAirbyteInputError as e:
261
+ return ConnectorRolloutStartResult(
262
+ success=False,
263
+ message=f"Failed to get user ID for admin email '{admin_user_email}': {e}",
264
+ docker_repository=docker_repository,
265
+ docker_image_tag=docker_image_tag,
266
+ actor_definition_id=actor_definition_id,
267
+ )
268
+
269
+ # Call the API to start the rollout
270
+ try:
271
+ api_client.start_connector_rollout(
272
+ docker_repository=docker_repository,
273
+ docker_image_tag=docker_image_tag,
274
+ actor_definition_id=actor_definition_id,
275
+ updated_by=user_id,
276
+ rollout_strategy=rollout_strategy,
277
+ config_api_root=constants.CLOUD_CONFIG_API_ROOT,
278
+ initial_rollout_pct=initial_rollout_pct,
279
+ final_target_rollout_pct=final_target_rollout_pct,
280
+ client_id=auth.client_id,
281
+ client_secret=auth.client_secret,
282
+ bearer_token=auth.bearer_token,
283
+ )
284
+
285
+ # Build message with configuration details
286
+ config_details = []
287
+ if initial_rollout_pct is not None:
288
+ config_details.append(f"initial_rollout_pct={initial_rollout_pct}%")
289
+ if final_target_rollout_pct is not None:
290
+ config_details.append(
291
+ f"final_target_rollout_pct={final_target_rollout_pct}%"
292
+ )
293
+ config_str = (
294
+ f" Configuration: {', '.join(config_details)}." if config_details else ""
295
+ )
296
+
297
+ return ConnectorRolloutStartResult(
298
+ success=True,
299
+ message=f"Successfully started rollout workflow for "
300
+ f"{docker_repository}:{docker_image_tag}. "
301
+ f"The rollout state has transitioned from INITIALIZED to WORKFLOW_STARTED."
302
+ f"{config_str}",
303
+ docker_repository=docker_repository,
304
+ docker_image_tag=docker_image_tag,
305
+ actor_definition_id=actor_definition_id,
306
+ rollout_strategy=rollout_strategy,
307
+ )
308
+
309
+ except PyAirbyteInputError as e:
310
+ return ConnectorRolloutStartResult(
311
+ success=False,
312
+ message=str(e),
313
+ docker_repository=docker_repository,
314
+ docker_image_tag=docker_image_tag,
315
+ actor_definition_id=actor_definition_id,
316
+ )
317
+ except Exception as e:
318
+ return ConnectorRolloutStartResult(
319
+ success=False,
320
+ message=f"Failed to start connector rollout: {e}",
321
+ docker_repository=docker_repository,
322
+ docker_image_tag=docker_image_tag,
323
+ actor_definition_id=actor_definition_id,
324
+ )
325
+
326
+
327
+ @mcp_tool(
328
+ destructive=True,
329
+ idempotent=False,
330
+ open_world=True,
331
+ )
332
+ def progress_connector_rollout(
333
+ docker_repository: Annotated[
334
+ str,
335
+ Field(description="The docker repository (e.g., 'airbyte/source-pokeapi')"),
336
+ ],
337
+ docker_image_tag: Annotated[
338
+ str,
339
+ Field(description="The docker image tag (e.g., '0.3.48-rc.1')"),
340
+ ],
341
+ actor_definition_id: Annotated[
342
+ str,
343
+ Field(description="The actor definition ID (UUID)"),
344
+ ],
345
+ rollout_id: Annotated[
346
+ str,
347
+ Field(
348
+ description="The rollout ID (UUID). Can be found from query_prod_connector_rollouts."
349
+ ),
350
+ ],
351
+ approval_comment_url: Annotated[
352
+ str,
353
+ Field(
354
+ description="URL to a GitHub comment where an Airbyte contributor has "
355
+ "explicitly requested or authorized this rollout progression. "
356
+ "Must be a valid GitHub comment URL (containing #issuecomment- or #discussion_r). "
357
+ "The admin email is automatically derived from the comment author's GitHub profile."
358
+ ),
359
+ ],
360
+ target_percentage: Annotated[
361
+ int | None,
362
+ Field(
363
+ description="Target percentage of actors to pin to the RC (1-100). "
364
+ "Either target_percentage or actor_ids must be provided.",
365
+ default=None,
366
+ ),
367
+ ] = None,
368
+ actor_ids: Annotated[
369
+ list[str] | None,
370
+ Field(
371
+ description="Specific actor IDs to pin to the RC. "
372
+ "Either target_percentage or actor_ids must be provided.",
373
+ default=None,
374
+ ),
375
+ ] = None,
376
+ *,
377
+ ctx: Context,
378
+ ) -> ConnectorRolloutProgressResult:
379
+ """Progress a connector rollout by pinning actors to the RC version.
380
+
381
+ This tool progresses a connector rollout by either:
382
+ - Setting a target percentage of actors to pin to the RC version
383
+ - Specifying specific actor IDs to pin
384
+
385
+ **Admin-only operation** - Requires:
386
+ - AIRBYTE_INTERNAL_ADMIN_FLAG=airbyte.io environment variable
387
+ - approval_comment_url parameter (GitHub comment URL with approval from an @airbyte.io user)
388
+
389
+ The admin user email is automatically derived from the approval_comment_url.
390
+ """
391
+ # Validate admin access (check env var flag)
392
+ try:
393
+ require_internal_admin_flag_only()
394
+ except CloudAuthError as e:
395
+ return ConnectorRolloutProgressResult(
396
+ success=False,
397
+ message=f"Admin authentication failed: {e}",
398
+ rollout_id=rollout_id,
399
+ docker_repository=docker_repository,
400
+ docker_image_tag=docker_image_tag,
401
+ )
402
+
403
+ # Validate that at least one of target_percentage or actor_ids is provided
404
+ if target_percentage is None and actor_ids is None:
405
+ return ConnectorRolloutProgressResult(
406
+ success=False,
407
+ message="Either target_percentage or actor_ids must be provided",
408
+ rollout_id=rollout_id,
409
+ docker_repository=docker_repository,
410
+ docker_image_tag=docker_image_tag,
411
+ )
412
+
413
+ # Validate approval_comment_url format
414
+ if not approval_comment_url.startswith("https://github.com/"):
415
+ return ConnectorRolloutProgressResult(
416
+ success=False,
417
+ message=f"approval_comment_url must be a valid GitHub URL, got: {approval_comment_url}",
418
+ rollout_id=rollout_id,
419
+ docker_repository=docker_repository,
420
+ docker_image_tag=docker_image_tag,
421
+ )
422
+
423
+ if (
424
+ "#issuecomment-" not in approval_comment_url
425
+ and "#discussion_r" not in approval_comment_url
426
+ ):
427
+ return ConnectorRolloutProgressResult(
428
+ success=False,
429
+ message="approval_comment_url must be a GitHub comment URL "
430
+ "(containing #issuecomment- or #discussion_r)",
431
+ rollout_id=rollout_id,
432
+ docker_repository=docker_repository,
433
+ docker_image_tag=docker_image_tag,
434
+ )
435
+
436
+ # Derive admin email from approval comment URL
437
+ try:
438
+ admin_user_email = get_admin_email_from_approval_comment(approval_comment_url)
439
+ except GitHubCommentParseError as e:
440
+ return ConnectorRolloutProgressResult(
441
+ success=False,
442
+ message=f"Failed to parse approval comment URL: {e}",
443
+ rollout_id=rollout_id,
444
+ docker_repository=docker_repository,
445
+ docker_image_tag=docker_image_tag,
446
+ )
447
+ except GitHubAPIError as e:
448
+ return ConnectorRolloutProgressResult(
449
+ success=False,
450
+ message=f"Failed to fetch approval comment from GitHub: {e}",
451
+ rollout_id=rollout_id,
452
+ docker_repository=docker_repository,
453
+ docker_image_tag=docker_image_tag,
454
+ )
455
+ except GitHubUserEmailNotFoundError as e:
456
+ return ConnectorRolloutProgressResult(
457
+ success=False,
458
+ message=str(e),
459
+ rollout_id=rollout_id,
460
+ docker_repository=docker_repository,
461
+ docker_image_tag=docker_image_tag,
462
+ )
463
+
464
+ # Resolve auth credentials
465
+ try:
466
+ auth = _resolve_cloud_auth(ctx)
467
+ except CloudAuthError as e:
468
+ return ConnectorRolloutProgressResult(
469
+ success=False,
470
+ message=f"Failed to resolve credentials: {e}",
471
+ rollout_id=rollout_id,
472
+ docker_repository=docker_repository,
473
+ docker_image_tag=docker_image_tag,
474
+ )
475
+
476
+ # Get user ID from admin email
477
+ try:
478
+ user_id = api_client.get_user_id_by_email(
479
+ email=admin_user_email,
480
+ config_api_root=constants.CLOUD_CONFIG_API_ROOT,
481
+ client_id=auth.client_id,
482
+ client_secret=auth.client_secret,
483
+ bearer_token=auth.bearer_token,
484
+ )
485
+ except PyAirbyteInputError as e:
486
+ return ConnectorRolloutProgressResult(
487
+ success=False,
488
+ message=f"Failed to get user ID for admin email '{admin_user_email}': {e}",
489
+ rollout_id=rollout_id,
490
+ docker_repository=docker_repository,
491
+ docker_image_tag=docker_image_tag,
492
+ )
493
+
494
+ # Call the API to progress the rollout
495
+ try:
496
+ api_client.progress_connector_rollout(
497
+ docker_repository=docker_repository,
498
+ docker_image_tag=docker_image_tag,
499
+ actor_definition_id=actor_definition_id,
500
+ rollout_id=rollout_id,
501
+ updated_by=user_id,
502
+ config_api_root=constants.CLOUD_CONFIG_API_ROOT,
503
+ target_percentage=target_percentage,
504
+ actor_ids=actor_ids,
505
+ client_id=auth.client_id,
506
+ client_secret=auth.client_secret,
507
+ bearer_token=auth.bearer_token,
508
+ )
509
+
510
+ progress_msg = (
511
+ f"target_percentage={target_percentage}%"
512
+ if target_percentage
513
+ else f"{len(actor_ids) if actor_ids else 0} specific actors"
514
+ )
515
+ return ConnectorRolloutProgressResult(
516
+ success=True,
517
+ message=f"Successfully progressed rollout for "
518
+ f"{docker_repository}:{docker_image_tag} to {progress_msg}.",
519
+ rollout_id=rollout_id,
520
+ docker_repository=docker_repository,
521
+ docker_image_tag=docker_image_tag,
522
+ target_percentage=target_percentage,
523
+ )
524
+
525
+ except PyAirbyteInputError as e:
526
+ return ConnectorRolloutProgressResult(
527
+ success=False,
528
+ message=str(e),
529
+ rollout_id=rollout_id,
530
+ docker_repository=docker_repository,
531
+ docker_image_tag=docker_image_tag,
532
+ )
533
+ except Exception as e:
534
+ return ConnectorRolloutProgressResult(
535
+ success=False,
536
+ message=f"Failed to progress connector rollout: {e}",
537
+ rollout_id=rollout_id,
538
+ docker_repository=docker_repository,
539
+ docker_image_tag=docker_image_tag,
540
+ )
541
+
542
+
543
+ @mcp_tool(
544
+ destructive=True,
545
+ idempotent=False,
546
+ open_world=True,
547
+ )
548
+ def finalize_connector_rollout(
549
+ docker_repository: Annotated[
550
+ str,
551
+ Field(
552
+ description="The docker repository (e.g., 'airbyte/source-youtube-analytics')"
553
+ ),
554
+ ],
555
+ docker_image_tag: Annotated[
556
+ str,
557
+ Field(description="The docker image tag (e.g., '1.2.0-rc.2')"),
558
+ ],
559
+ actor_definition_id: Annotated[
560
+ str,
561
+ Field(description="The actor definition ID (UUID)"),
562
+ ],
563
+ rollout_id: Annotated[
564
+ str,
565
+ Field(
566
+ description="The rollout ID (UUID). Can be found in the 'pin_origin' field "
567
+ "of rollout data from query_prod_actors_by_connector_version."
568
+ ),
569
+ ],
570
+ state: Annotated[
571
+ Literal["succeeded", "failed_rolled_back", "canceled"],
572
+ Field(
573
+ description="The final state for the rollout: "
574
+ "'succeeded' promotes the RC to GA (default version for all users), "
575
+ "'failed_rolled_back' rolls back the RC, "
576
+ "'canceled' cancels the rollout without promotion or rollback."
577
+ ),
578
+ ],
579
+ approval_comment_url: Annotated[
580
+ str,
581
+ Field(
582
+ description="URL to a GitHub comment where an Airbyte contributor has "
583
+ "explicitly requested or authorized this rollout finalization. "
584
+ "Must be a valid GitHub comment URL (containing #issuecomment- or #discussion_r). "
585
+ "The admin email is automatically derived from the comment author's GitHub profile."
586
+ ),
587
+ ],
588
+ error_msg: Annotated[
589
+ str | None,
590
+ Field(
591
+ description="Optional error message for failed/canceled states.",
592
+ default=None,
593
+ ),
594
+ ] = None,
595
+ failed_reason: Annotated[
596
+ str | None,
597
+ Field(
598
+ description="Optional failure reason for failed/canceled states.",
599
+ default=None,
600
+ ),
601
+ ] = None,
602
+ retain_pins_on_cancellation: Annotated[
603
+ bool | None,
604
+ Field(
605
+ description="If True, retain version pins when canceling. "
606
+ "Only applicable when state is 'canceled'.",
607
+ default=None,
608
+ ),
609
+ ] = None,
610
+ *,
611
+ ctx: Context,
612
+ ) -> ConnectorRolloutFinalizeResult:
613
+ """Finalize a connector rollout by promoting, rolling back, or canceling.
614
+
615
+ This tool allows admins to finalize connector rollouts that are in progress.
616
+ Use this after monitoring a rollout and determining it is ready for finalization.
617
+
618
+ **Admin-only operation** - Requires:
619
+ - AIRBYTE_INTERNAL_ADMIN_FLAG=airbyte.io environment variable
620
+ - approval_comment_url parameter (GitHub comment URL with approval from an @airbyte.io user)
621
+
622
+ The admin user email is automatically derived from the approval_comment_url by:
623
+ 1. Fetching the comment from GitHub API to get the author's username
624
+ 2. Fetching the user's profile to get their public email
625
+ 3. Validating the email is an @airbyte.io address
626
+ """
627
+ # Validate admin access (check env var flag)
628
+ try:
629
+ require_internal_admin_flag_only()
630
+ except CloudAuthError as e:
631
+ return ConnectorRolloutFinalizeResult(
632
+ success=False,
633
+ message=f"Admin authentication failed: {e}",
634
+ rollout_id=rollout_id,
635
+ docker_repository=docker_repository,
636
+ docker_image_tag=docker_image_tag,
637
+ )
638
+
639
+ # Validate approval_comment_url format
640
+ if not approval_comment_url.startswith("https://github.com/"):
641
+ return ConnectorRolloutFinalizeResult(
642
+ success=False,
643
+ message=f"approval_comment_url must be a valid GitHub URL, got: {approval_comment_url}",
644
+ rollout_id=rollout_id,
645
+ docker_repository=docker_repository,
646
+ docker_image_tag=docker_image_tag,
647
+ )
648
+
649
+ if (
650
+ "#issuecomment-" not in approval_comment_url
651
+ and "#discussion_r" not in approval_comment_url
652
+ ):
653
+ return ConnectorRolloutFinalizeResult(
654
+ success=False,
655
+ message="approval_comment_url must be a GitHub comment URL "
656
+ "(containing #issuecomment- or #discussion_r)",
657
+ rollout_id=rollout_id,
658
+ docker_repository=docker_repository,
659
+ docker_image_tag=docker_image_tag,
660
+ )
661
+
662
+ # Derive admin email from approval comment URL
663
+ try:
664
+ admin_user_email = get_admin_email_from_approval_comment(approval_comment_url)
665
+ except GitHubCommentParseError as e:
666
+ return ConnectorRolloutFinalizeResult(
667
+ success=False,
668
+ message=f"Failed to parse approval comment URL: {e}",
669
+ rollout_id=rollout_id,
670
+ docker_repository=docker_repository,
671
+ docker_image_tag=docker_image_tag,
672
+ )
673
+ except GitHubAPIError as e:
674
+ return ConnectorRolloutFinalizeResult(
675
+ success=False,
676
+ message=f"Failed to fetch approval comment from GitHub: {e}",
677
+ rollout_id=rollout_id,
678
+ docker_repository=docker_repository,
679
+ docker_image_tag=docker_image_tag,
680
+ )
681
+ except GitHubUserEmailNotFoundError as e:
682
+ return ConnectorRolloutFinalizeResult(
683
+ success=False,
684
+ message=str(e),
685
+ rollout_id=rollout_id,
686
+ docker_repository=docker_repository,
687
+ docker_image_tag=docker_image_tag,
688
+ )
689
+
690
+ # Resolve auth credentials
691
+ try:
692
+ auth = _resolve_cloud_auth(ctx)
693
+ except CloudAuthError as e:
694
+ return ConnectorRolloutFinalizeResult(
695
+ success=False,
696
+ message=f"Failed to resolve credentials: {e}",
697
+ rollout_id=rollout_id,
698
+ docker_repository=docker_repository,
699
+ docker_image_tag=docker_image_tag,
700
+ )
701
+
702
+ # Get user ID from admin email
703
+ try:
704
+ user_id = api_client.get_user_id_by_email(
705
+ email=admin_user_email,
706
+ config_api_root=constants.CLOUD_CONFIG_API_ROOT,
707
+ client_id=auth.client_id,
708
+ client_secret=auth.client_secret,
709
+ bearer_token=auth.bearer_token,
710
+ )
711
+ except PyAirbyteInputError as e:
712
+ return ConnectorRolloutFinalizeResult(
713
+ success=False,
714
+ message=f"Failed to get user ID for admin email '{admin_user_email}': {e}",
715
+ rollout_id=rollout_id,
716
+ docker_repository=docker_repository,
717
+ docker_image_tag=docker_image_tag,
718
+ )
719
+
720
+ # Call the API to finalize the rollout
721
+ try:
722
+ api_client.finalize_connector_rollout(
723
+ docker_repository=docker_repository,
724
+ docker_image_tag=docker_image_tag,
725
+ actor_definition_id=actor_definition_id,
726
+ rollout_id=rollout_id,
727
+ updated_by=user_id,
728
+ state=state,
729
+ config_api_root=constants.CLOUD_CONFIG_API_ROOT,
730
+ client_id=auth.client_id,
731
+ client_secret=auth.client_secret,
732
+ bearer_token=auth.bearer_token,
733
+ error_msg=error_msg,
734
+ failed_reason=failed_reason,
735
+ retain_pins_on_cancellation=retain_pins_on_cancellation,
736
+ )
737
+
738
+ state_descriptions = {
739
+ "succeeded": "promoted to GA (default version for all users)",
740
+ "failed_rolled_back": "rolled back",
741
+ "canceled": "canceled",
742
+ }
743
+ state_desc = state_descriptions.get(state, state)
744
+
745
+ return ConnectorRolloutFinalizeResult(
746
+ success=True,
747
+ message=f"Successfully finalized rollout: {docker_repository}:{docker_image_tag} "
748
+ f"has been {state_desc}.",
749
+ rollout_id=rollout_id,
750
+ docker_repository=docker_repository,
751
+ docker_image_tag=docker_image_tag,
752
+ state=state,
753
+ )
754
+
755
+ except PyAirbyteInputError as e:
756
+ return ConnectorRolloutFinalizeResult(
757
+ success=False,
758
+ message=str(e),
759
+ rollout_id=rollout_id,
760
+ docker_repository=docker_repository,
761
+ docker_image_tag=docker_image_tag,
762
+ )
763
+ except Exception as e:
764
+ return ConnectorRolloutFinalizeResult(
765
+ success=False,
766
+ message=f"Failed to finalize connector rollout: {e}",
767
+ rollout_id=rollout_id,
768
+ docker_repository=docker_repository,
769
+ docker_image_tag=docker_image_tag,
770
+ )
771
+
772
+
773
+ def register_connector_rollout_tools(app: FastMCP) -> None:
774
+ """Register connector rollout tools with the FastMCP app.
775
+
776
+ Args:
777
+ app: FastMCP application instance
778
+ """
779
+ register_mcp_tools(app)