airbyte-internal-ops 0.2.3__py3-none-any.whl → 0.3.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.2.3.dist-info → airbyte_internal_ops-0.3.0.dist-info}/METADATA +1 -1
- {airbyte_internal_ops-0.2.3.dist-info → airbyte_internal_ops-0.3.0.dist-info}/RECORD +13 -9
- airbyte_ops_mcp/cli/cloud.py +79 -0
- airbyte_ops_mcp/cloud_admin/api_client.py +463 -69
- airbyte_ops_mcp/constants.py +3 -0
- airbyte_ops_mcp/gcp_logs/__init__.py +18 -0
- airbyte_ops_mcp/gcp_logs/error_lookup.py +383 -0
- airbyte_ops_mcp/github_api.py +264 -0
- airbyte_ops_mcp/mcp/cloud_connector_versions.py +68 -33
- airbyte_ops_mcp/mcp/gcp_logs.py +92 -0
- airbyte_ops_mcp/mcp/server.py +2 -0
- {airbyte_internal_ops-0.2.3.dist-info → airbyte_internal_ops-0.3.0.dist-info}/WHEEL +0 -0
- {airbyte_internal_ops-0.2.3.dist-info → airbyte_internal_ops-0.3.0.dist-info}/entry_points.txt +0 -0
|
@@ -14,37 +14,62 @@ import requests
|
|
|
14
14
|
from airbyte import constants
|
|
15
15
|
from airbyte.exceptions import PyAirbyteInputError
|
|
16
16
|
|
|
17
|
+
from airbyte_ops_mcp import constants as ops_constants
|
|
18
|
+
|
|
17
19
|
# Internal enums for scoped configuration API "magic strings"
|
|
18
20
|
# These values caused issues during development and are now centralized here
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
class _ScopedConfigKey(str, Enum):
|
|
22
|
-
"""Configuration keys used in scoped configuration API.
|
|
24
|
+
"""Configuration keys used in scoped configuration API.
|
|
25
|
+
|
|
26
|
+
Valid values from airbyte-platform-internal:
|
|
27
|
+
oss/airbyte-data/src/main/kotlin/io/airbyte/data/services/shared/ScopedConfigurationKey.kt
|
|
28
|
+
"""
|
|
23
29
|
|
|
24
30
|
CONNECTOR_VERSION = "connector_version"
|
|
31
|
+
NETWORK_SECURITY_TOKEN = "network_security_token"
|
|
32
|
+
PRODUCT_LIMITS = "product_limits"
|
|
25
33
|
|
|
26
34
|
|
|
27
35
|
class _ResourceType(str, Enum):
|
|
28
|
-
"""Resource types for scoped configuration.
|
|
36
|
+
"""Resource types for scoped configuration.
|
|
37
|
+
|
|
38
|
+
Valid values from airbyte-platform-internal:
|
|
39
|
+
oss/airbyte-config/config-models/src/generated/java/io/airbyte/config/ConfigResourceType.java
|
|
40
|
+
"""
|
|
29
41
|
|
|
30
42
|
ACTOR_DEFINITION = "actor_definition"
|
|
43
|
+
USER = "user"
|
|
31
44
|
WORKSPACE = "workspace"
|
|
32
45
|
ORGANIZATION = "organization"
|
|
46
|
+
CONNECTION = "connection"
|
|
47
|
+
SOURCE = "source"
|
|
48
|
+
DESTINATION = "destination"
|
|
33
49
|
|
|
34
50
|
|
|
35
51
|
class _ScopeType(str, Enum):
|
|
36
|
-
"""Scope types for scoped configuration.
|
|
52
|
+
"""Scope types for scoped configuration.
|
|
53
|
+
|
|
54
|
+
Valid values from airbyte-platform-internal:
|
|
55
|
+
oss/airbyte-config/config-models/src/generated/java/io/airbyte/config/ConfigScopeType.java
|
|
56
|
+
"""
|
|
37
57
|
|
|
38
|
-
ACTOR = "actor"
|
|
39
|
-
WORKSPACE = "workspace"
|
|
40
58
|
ORGANIZATION = "organization"
|
|
59
|
+
WORKSPACE = "workspace"
|
|
60
|
+
ACTOR = "actor"
|
|
41
61
|
|
|
42
62
|
|
|
43
63
|
class _OriginType(str, Enum):
|
|
44
|
-
"""Origin types for scoped configuration.
|
|
64
|
+
"""Origin types for scoped configuration.
|
|
65
|
+
|
|
66
|
+
Valid values from airbyte-platform-internal:
|
|
67
|
+
oss/airbyte-config/config-models/src/generated/java/io/airbyte/config/ConfigOriginType.java
|
|
68
|
+
"""
|
|
45
69
|
|
|
46
70
|
USER = "user"
|
|
47
|
-
|
|
71
|
+
BREAKING_CHANGE = "breaking_change"
|
|
72
|
+
CONNECTOR_ROLLOUT = "connector_rollout"
|
|
48
73
|
|
|
49
74
|
|
|
50
75
|
def _get_access_token(
|
|
@@ -132,7 +157,7 @@ def get_user_id_by_email(
|
|
|
132
157
|
json={},
|
|
133
158
|
headers={
|
|
134
159
|
"Authorization": f"Bearer {access_token}",
|
|
135
|
-
"User-Agent":
|
|
160
|
+
"User-Agent": ops_constants.USER_AGENT,
|
|
136
161
|
"Content-Type": "application/json",
|
|
137
162
|
},
|
|
138
163
|
timeout=30,
|
|
@@ -204,7 +229,7 @@ def resolve_connector_version_id(
|
|
|
204
229
|
json=payload,
|
|
205
230
|
headers={
|
|
206
231
|
"Authorization": f"Bearer {access_token}",
|
|
207
|
-
"User-Agent":
|
|
232
|
+
"User-Agent": ops_constants.USER_AGENT,
|
|
208
233
|
"Content-Type": "application/json",
|
|
209
234
|
},
|
|
210
235
|
timeout=30,
|
|
@@ -238,6 +263,130 @@ def resolve_connector_version_id(
|
|
|
238
263
|
return version_id
|
|
239
264
|
|
|
240
265
|
|
|
266
|
+
def _get_scoped_configuration_context(
|
|
267
|
+
actor_definition_id: str,
|
|
268
|
+
scope_type: _ScopeType,
|
|
269
|
+
scope_id: str,
|
|
270
|
+
api_root: str,
|
|
271
|
+
access_token: str,
|
|
272
|
+
) -> dict[str, Any] | None:
|
|
273
|
+
"""Get the active scoped configuration for a single scope level.
|
|
274
|
+
|
|
275
|
+
This is the canonical way to query /scoped_configuration/get_context.
|
|
276
|
+
Used by both the unset path and the multi-scope checking in the set path.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
actor_definition_id: The actor definition ID for the connector
|
|
280
|
+
scope_type: The scope type enum (ACTOR, WORKSPACE, or ORGANIZATION)
|
|
281
|
+
scope_id: The ID for the scope (connector_id, workspace_id, or organization_id)
|
|
282
|
+
api_root: The API root URL
|
|
283
|
+
access_token: Pre-authenticated access token
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
The active configuration dict if an override exists at this scope, or None.
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
PyAirbyteInputError: If the API request fails
|
|
290
|
+
"""
|
|
291
|
+
endpoint = f"{api_root}/scoped_configuration/get_context"
|
|
292
|
+
context_payload = {
|
|
293
|
+
"config_key": _ScopedConfigKey.CONNECTOR_VERSION.value,
|
|
294
|
+
"resource_type": _ResourceType.ACTOR_DEFINITION.value,
|
|
295
|
+
"resource_id": actor_definition_id,
|
|
296
|
+
"scope_type": scope_type.value,
|
|
297
|
+
"scope_id": scope_id,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
response = requests.post(
|
|
301
|
+
endpoint,
|
|
302
|
+
json=context_payload,
|
|
303
|
+
headers={
|
|
304
|
+
"Authorization": f"Bearer {access_token}",
|
|
305
|
+
"User-Agent": ops_constants.USER_AGENT,
|
|
306
|
+
"Content-Type": "application/json",
|
|
307
|
+
},
|
|
308
|
+
timeout=30,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if response.status_code != 200:
|
|
312
|
+
raise PyAirbyteInputError(
|
|
313
|
+
message=f"Failed to get scoped configuration context for {scope_type.value} scope: "
|
|
314
|
+
f"{response.status_code} {response.text}",
|
|
315
|
+
context={
|
|
316
|
+
"endpoint": endpoint,
|
|
317
|
+
"payload": context_payload,
|
|
318
|
+
"scope_type": scope_type.value,
|
|
319
|
+
"status_code": response.status_code,
|
|
320
|
+
"response": response.text,
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
context_data = response.json()
|
|
325
|
+
return context_data.get("activeConfiguration")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_all_scoped_configuration_contexts(
|
|
329
|
+
connector_id: str,
|
|
330
|
+
actor_definition_id: str,
|
|
331
|
+
workspace_id: str,
|
|
332
|
+
organization_id: str,
|
|
333
|
+
api_root: str,
|
|
334
|
+
client_id: str | None = None,
|
|
335
|
+
client_secret: str | None = None,
|
|
336
|
+
bearer_token: str | None = None,
|
|
337
|
+
) -> dict[str, dict[str, Any]]:
|
|
338
|
+
"""Get version override configurations at all scope levels (actor, workspace, organization).
|
|
339
|
+
|
|
340
|
+
This ALWAYS checks all three scope levels to provide a complete picture of any version
|
|
341
|
+
overrides that may affect the connector. All scope IDs are required to ensure comprehensive
|
|
342
|
+
checking - this function will not silently skip any scope.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
connector_id: The ID of the deployed connector (source or destination)
|
|
346
|
+
actor_definition_id: The actor definition ID for the connector
|
|
347
|
+
workspace_id: The workspace ID (required - must always check workspace scope)
|
|
348
|
+
organization_id: The organization ID (required - must always check org scope)
|
|
349
|
+
api_root: The API root URL
|
|
350
|
+
client_id: The Airbyte Cloud client ID (required if no bearer_token)
|
|
351
|
+
client_secret: The Airbyte Cloud client secret (required if no bearer_token)
|
|
352
|
+
bearer_token: Pre-existing bearer token (takes precedence over client credentials)
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
Dictionary containing only the scopes that have active configurations.
|
|
356
|
+
Empty dict if no overrides exist (falsy). Keys are 'actor', 'workspace',
|
|
357
|
+
'organization' - only present if an override exists at that scope.
|
|
358
|
+
|
|
359
|
+
Raises:
|
|
360
|
+
PyAirbyteInputError: If any API request fails
|
|
361
|
+
"""
|
|
362
|
+
access_token = _get_access_token(client_id, client_secret, bearer_token)
|
|
363
|
+
|
|
364
|
+
# Start with empty dict - only add entries for scopes that have active configs
|
|
365
|
+
# This ensures the result is falsy if nothing is set
|
|
366
|
+
results: dict[str, dict[str, Any]] = {}
|
|
367
|
+
|
|
368
|
+
# Always check all three scopes - no optional skipping
|
|
369
|
+
# Using enum values consistently to avoid magic strings
|
|
370
|
+
scopes_to_check: list[tuple[_ScopeType, str]] = [
|
|
371
|
+
(_ScopeType.ACTOR, connector_id),
|
|
372
|
+
(_ScopeType.WORKSPACE, workspace_id),
|
|
373
|
+
(_ScopeType.ORGANIZATION, organization_id),
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
for scope_type_enum, scope_id in scopes_to_check:
|
|
377
|
+
active_config = _get_scoped_configuration_context(
|
|
378
|
+
actor_definition_id=actor_definition_id,
|
|
379
|
+
scope_type=scope_type_enum,
|
|
380
|
+
scope_id=scope_id,
|
|
381
|
+
api_root=api_root,
|
|
382
|
+
access_token=access_token,
|
|
383
|
+
)
|
|
384
|
+
if active_config:
|
|
385
|
+
results[scope_type_enum.value] = active_config
|
|
386
|
+
|
|
387
|
+
return results
|
|
388
|
+
|
|
389
|
+
|
|
241
390
|
def get_connector_version(
|
|
242
391
|
connector_id: str,
|
|
243
392
|
connector_type: Literal["source", "destination"],
|
|
@@ -245,9 +394,14 @@ def get_connector_version(
|
|
|
245
394
|
client_id: str | None = None,
|
|
246
395
|
client_secret: str | None = None,
|
|
247
396
|
bearer_token: str | None = None,
|
|
397
|
+
workspace_id: str | None = None,
|
|
248
398
|
) -> dict[str, Any]:
|
|
249
399
|
"""Get version information for a deployed connector.
|
|
250
400
|
|
|
401
|
+
This function retrieves the current version and override status. If workspace_id is provided,
|
|
402
|
+
it also fetches detailed scoped configuration context at all scope levels (actor, workspace,
|
|
403
|
+
organization) to provide a complete picture of any version pins.
|
|
404
|
+
|
|
251
405
|
Args:
|
|
252
406
|
connector_id: The ID of the deployed connector (source or destination)
|
|
253
407
|
connector_type: Either "source" or "destination"
|
|
@@ -255,11 +409,14 @@ def get_connector_version(
|
|
|
255
409
|
client_id: The Airbyte Cloud client ID (required if no bearer_token)
|
|
256
410
|
client_secret: The Airbyte Cloud client secret (required if no bearer_token)
|
|
257
411
|
bearer_token: Pre-existing bearer token (takes precedence over client credentials)
|
|
412
|
+
workspace_id: Optional workspace ID to enable detailed scope checking
|
|
258
413
|
|
|
259
414
|
Returns:
|
|
260
415
|
Dictionary containing:
|
|
261
416
|
- dockerImageTag: The current version string
|
|
262
417
|
- isVersionOverrideApplied: Boolean indicating if override is active
|
|
418
|
+
- scopedConfigs: (if workspace_id provided) Dict with 'actor', 'workspace', 'organization'
|
|
419
|
+
keys containing the active configuration at each scope level, or None
|
|
263
420
|
|
|
264
421
|
Raises:
|
|
265
422
|
PyAirbyteInputError: If the API request fails
|
|
@@ -271,16 +428,18 @@ def get_connector_version(
|
|
|
271
428
|
if connector_type == "source":
|
|
272
429
|
endpoint = f"{api_root}/actor_definition_versions/get_for_source"
|
|
273
430
|
payload = {"sourceId": connector_id}
|
|
431
|
+
definition_id_key = "sourceDefinitionId"
|
|
274
432
|
else:
|
|
275
433
|
endpoint = f"{api_root}/actor_definition_versions/get_for_destination"
|
|
276
434
|
payload = {"destinationId": connector_id}
|
|
435
|
+
definition_id_key = "destinationDefinitionId"
|
|
277
436
|
|
|
278
437
|
response = requests.post(
|
|
279
438
|
endpoint,
|
|
280
439
|
json=payload,
|
|
281
440
|
headers={
|
|
282
441
|
"Authorization": f"Bearer {access_token}",
|
|
283
|
-
"User-Agent":
|
|
442
|
+
"User-Agent": ops_constants.USER_AGENT,
|
|
284
443
|
"Content-Type": "application/json",
|
|
285
444
|
},
|
|
286
445
|
timeout=30,
|
|
@@ -302,14 +461,65 @@ def get_connector_version(
|
|
|
302
461
|
|
|
303
462
|
data = response.json()
|
|
304
463
|
|
|
305
|
-
|
|
306
|
-
return {
|
|
464
|
+
result: dict[str, Any] = {
|
|
307
465
|
"dockerImageTag": data.get("dockerImageTag", "unknown"),
|
|
308
466
|
"isVersionOverrideApplied": data.get(
|
|
309
467
|
"isVersionOverrideApplied", data.get("isOverrideApplied", False)
|
|
310
468
|
),
|
|
311
469
|
}
|
|
312
470
|
|
|
471
|
+
# If workspace_id is provided, also get detailed scoped configuration context
|
|
472
|
+
if workspace_id:
|
|
473
|
+
# Get actor_definition_id from the connector info
|
|
474
|
+
get_endpoint = f"{api_root}/{connector_type}s/get"
|
|
475
|
+
get_payload: dict[str, str] = {f"{connector_type}Id": connector_id}
|
|
476
|
+
|
|
477
|
+
get_response = requests.post(
|
|
478
|
+
get_endpoint,
|
|
479
|
+
json=get_payload,
|
|
480
|
+
headers={
|
|
481
|
+
"Authorization": f"Bearer {access_token}",
|
|
482
|
+
"User-Agent": ops_constants.USER_AGENT,
|
|
483
|
+
"Content-Type": "application/json",
|
|
484
|
+
},
|
|
485
|
+
timeout=30,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
if get_response.status_code == 200:
|
|
489
|
+
connector_info = get_response.json()
|
|
490
|
+
actor_definition_id = connector_info.get(definition_id_key)
|
|
491
|
+
|
|
492
|
+
if actor_definition_id:
|
|
493
|
+
# Get organization_id from workspace
|
|
494
|
+
workspace_endpoint = f"{api_root}/workspaces/get"
|
|
495
|
+
workspace_response = requests.post(
|
|
496
|
+
workspace_endpoint,
|
|
497
|
+
json={"workspaceId": workspace_id},
|
|
498
|
+
headers={
|
|
499
|
+
"Authorization": f"Bearer {access_token}",
|
|
500
|
+
"User-Agent": ops_constants.USER_AGENT,
|
|
501
|
+
"Content-Type": "application/json",
|
|
502
|
+
},
|
|
503
|
+
timeout=30,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
organization_id = None
|
|
507
|
+
if workspace_response.status_code == 200:
|
|
508
|
+
workspace_data = workspace_response.json()
|
|
509
|
+
organization_id = workspace_data.get("organizationId")
|
|
510
|
+
|
|
511
|
+
if organization_id:
|
|
512
|
+
result["scopedConfigs"] = get_all_scoped_configuration_contexts(
|
|
513
|
+
connector_id=connector_id,
|
|
514
|
+
actor_definition_id=actor_definition_id,
|
|
515
|
+
workspace_id=workspace_id,
|
|
516
|
+
organization_id=organization_id,
|
|
517
|
+
api_root=api_root,
|
|
518
|
+
bearer_token=access_token,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
return result
|
|
522
|
+
|
|
313
523
|
|
|
314
524
|
def set_connector_version_override(
|
|
315
525
|
connector_id: str,
|
|
@@ -327,6 +537,17 @@ def set_connector_version_override(
|
|
|
327
537
|
) -> bool:
|
|
328
538
|
"""Set or clear a version override for a deployed connector.
|
|
329
539
|
|
|
540
|
+
This function checks all three scope levels (actor, workspace, organization) before
|
|
541
|
+
creating a new override to prevent duplicate key constraint violations. If an override
|
|
542
|
+
already exists at the actor scope with a different version, it will be deleted first
|
|
543
|
+
before creating the new one.
|
|
544
|
+
|
|
545
|
+
Note:
|
|
546
|
+
Race condition caveat: The check-then-delete-then-create sequence is not atomic.
|
|
547
|
+
If two concurrent requests attempt to set version overrides for the same connector,
|
|
548
|
+
both could pass the existence check and one could still fail with a duplicate key
|
|
549
|
+
error. The improved 500 error message provides guidance in this case.
|
|
550
|
+
|
|
330
551
|
Args:
|
|
331
552
|
connector_id: The ID of the deployed connector
|
|
332
553
|
connector_type: Either "source" or "destination"
|
|
@@ -375,7 +596,7 @@ def set_connector_version_override(
|
|
|
375
596
|
json=get_payload,
|
|
376
597
|
headers={
|
|
377
598
|
"Authorization": f"Bearer {access_token}",
|
|
378
|
-
"User-Agent":
|
|
599
|
+
"User-Agent": ops_constants.USER_AGENT,
|
|
379
600
|
"Content-Type": "application/json",
|
|
380
601
|
},
|
|
381
602
|
timeout=30,
|
|
@@ -394,43 +615,15 @@ def set_connector_version_override(
|
|
|
394
615
|
message=f"Could not find {definition_id_key} in {connector_type} info",
|
|
395
616
|
)
|
|
396
617
|
|
|
397
|
-
#
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
"scope_id": connector_id,
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
response = requests.post(
|
|
408
|
-
endpoint,
|
|
409
|
-
json=context_payload,
|
|
410
|
-
headers={
|
|
411
|
-
"Authorization": f"Bearer {access_token}",
|
|
412
|
-
"User-Agent": "PyAirbyte Client",
|
|
413
|
-
"Content-Type": "application/json",
|
|
414
|
-
},
|
|
415
|
-
timeout=30,
|
|
618
|
+
# Use the shared helper to get the scoped configuration context at actor scope
|
|
619
|
+
active_config = _get_scoped_configuration_context(
|
|
620
|
+
actor_definition_id=actor_definition_id,
|
|
621
|
+
scope_type=_ScopeType.ACTOR,
|
|
622
|
+
scope_id=connector_id,
|
|
623
|
+
api_root=api_root,
|
|
624
|
+
access_token=access_token,
|
|
416
625
|
)
|
|
417
626
|
|
|
418
|
-
if response.status_code != 200:
|
|
419
|
-
raise PyAirbyteInputError(
|
|
420
|
-
message=f"Failed to get scoped configuration context: {response.status_code} {response.text}",
|
|
421
|
-
context={
|
|
422
|
-
"endpoint": endpoint,
|
|
423
|
-
"payload": context_payload,
|
|
424
|
-
"workspace_id": workspace_id,
|
|
425
|
-
"connector_id": connector_id,
|
|
426
|
-
"status_code": response.status_code,
|
|
427
|
-
"response": response.text,
|
|
428
|
-
},
|
|
429
|
-
)
|
|
430
|
-
|
|
431
|
-
context_data = response.json()
|
|
432
|
-
active_config = context_data.get("activeConfiguration")
|
|
433
|
-
|
|
434
627
|
if not active_config:
|
|
435
628
|
# No override exists, nothing to do
|
|
436
629
|
return False
|
|
@@ -444,7 +637,7 @@ def set_connector_version_override(
|
|
|
444
637
|
json=delete_payload,
|
|
445
638
|
headers={
|
|
446
639
|
"Authorization": f"Bearer {access_token}",
|
|
447
|
-
"User-Agent":
|
|
640
|
+
"User-Agent": ops_constants.USER_AGENT,
|
|
448
641
|
"Content-Type": "application/json",
|
|
449
642
|
},
|
|
450
643
|
timeout=30,
|
|
@@ -475,7 +668,7 @@ def set_connector_version_override(
|
|
|
475
668
|
json=get_payload,
|
|
476
669
|
headers={
|
|
477
670
|
"Authorization": f"Bearer {access_token}",
|
|
478
|
-
"User-Agent":
|
|
671
|
+
"User-Agent": ops_constants.USER_AGENT,
|
|
479
672
|
"Content-Type": "application/json",
|
|
480
673
|
},
|
|
481
674
|
timeout=30,
|
|
@@ -505,35 +698,217 @@ def set_connector_version_override(
|
|
|
505
698
|
bearer_token=bearer_token,
|
|
506
699
|
)
|
|
507
700
|
|
|
508
|
-
# Get
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
701
|
+
# Get organization_id from workspace info for comprehensive scope checking
|
|
702
|
+
# This is REQUIRED - we must always check all three scopes
|
|
703
|
+
workspace_endpoint = f"{api_root}/workspaces/get"
|
|
704
|
+
workspace_payload = {"workspaceId": workspace_id}
|
|
705
|
+
workspace_response = requests.post(
|
|
706
|
+
workspace_endpoint,
|
|
707
|
+
json=workspace_payload,
|
|
708
|
+
headers={
|
|
709
|
+
"Authorization": f"Bearer {access_token}",
|
|
710
|
+
"User-Agent": ops_constants.USER_AGENT,
|
|
711
|
+
"Content-Type": "application/json",
|
|
712
|
+
},
|
|
713
|
+
timeout=30,
|
|
714
|
+
)
|
|
715
|
+
if workspace_response.status_code != 200:
|
|
716
|
+
raise PyAirbyteInputError(
|
|
717
|
+
message=f"Failed to get workspace info: {workspace_response.status_code} {workspace_response.text}. "
|
|
718
|
+
"Workspace info is required to determine organization_id for comprehensive scope checking.",
|
|
719
|
+
context={
|
|
720
|
+
"workspace_id": workspace_id,
|
|
721
|
+
"status_code": workspace_response.status_code,
|
|
722
|
+
"response": workspace_response.text,
|
|
723
|
+
},
|
|
724
|
+
)
|
|
725
|
+
workspace_info = workspace_response.json()
|
|
726
|
+
organization_id = workspace_info.get("organizationId")
|
|
727
|
+
if not organization_id:
|
|
728
|
+
raise PyAirbyteInputError(
|
|
729
|
+
message="Workspace does not have an organization_id. "
|
|
730
|
+
"Organization ID is required for comprehensive scope checking.",
|
|
731
|
+
context={
|
|
732
|
+
"workspace_id": workspace_id,
|
|
733
|
+
"workspace_info": workspace_info,
|
|
734
|
+
},
|
|
517
735
|
)
|
|
518
736
|
|
|
737
|
+
# Check for existing scoped configuration at ALL scope levels BEFORE creating
|
|
738
|
+
# This prevents duplicate key constraint violations (500 errors) and provides
|
|
739
|
+
# clear messaging about where existing pins are set
|
|
740
|
+
all_configs = get_all_scoped_configuration_contexts(
|
|
741
|
+
connector_id=connector_id,
|
|
742
|
+
actor_definition_id=actor_definition_id,
|
|
743
|
+
workspace_id=workspace_id,
|
|
744
|
+
organization_id=organization_id,
|
|
745
|
+
api_root=api_root,
|
|
746
|
+
client_id=client_id,
|
|
747
|
+
client_secret=client_secret,
|
|
748
|
+
bearer_token=bearer_token,
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
# Check actor-level config first (most specific)
|
|
752
|
+
actor_config = all_configs.get(_ScopeType.ACTOR.value)
|
|
753
|
+
if actor_config:
|
|
754
|
+
existing_version_id = actor_config.get("value")
|
|
755
|
+
# API returns snake_case field names
|
|
756
|
+
existing_version_name = actor_config.get("value_name", "unknown")
|
|
757
|
+
|
|
758
|
+
# If already pinned to the same version at actor level, no action needed
|
|
759
|
+
if existing_version_id == version_id:
|
|
760
|
+
# Build detailed message with full context about the existing pin
|
|
761
|
+
# API returns snake_case field names
|
|
762
|
+
existing_description = actor_config.get(
|
|
763
|
+
"description", "No description provided"
|
|
764
|
+
)
|
|
765
|
+
existing_reference_url = actor_config.get(
|
|
766
|
+
"reference_url", "No reference URL"
|
|
767
|
+
)
|
|
768
|
+
existing_origin = actor_config.get("origin", "unknown")
|
|
769
|
+
existing_origin_type = actor_config.get("origin_type", "unknown")
|
|
770
|
+
existing_created_at = actor_config.get("created_at", "unknown")
|
|
771
|
+
existing_updated_at = actor_config.get("updated_at", "unknown")
|
|
772
|
+
existing_scope_name = actor_config.get("scope_name", "unknown")
|
|
773
|
+
existing_resource_name = actor_config.get("resource_name", "unknown")
|
|
774
|
+
|
|
775
|
+
detailed_message = (
|
|
776
|
+
f"Connector is already pinned to version {existing_version_name} "
|
|
777
|
+
f"(version_id: {existing_version_id}) at actor scope.\n\n"
|
|
778
|
+
f"EXISTING PIN DETAILS:\n"
|
|
779
|
+
f" - Config ID: {actor_config.get('id')}\n"
|
|
780
|
+
f" - Pinned Version: {existing_version_name}\n"
|
|
781
|
+
f" - Connector Name: {existing_scope_name}\n"
|
|
782
|
+
f" - Connector Definition: {existing_resource_name}\n"
|
|
783
|
+
f" - Description: {existing_description}\n"
|
|
784
|
+
f" - Reference URL: {existing_reference_url}\n"
|
|
785
|
+
f" - Origin Type: {existing_origin_type}\n"
|
|
786
|
+
f" - Origin: {existing_origin}\n"
|
|
787
|
+
f" - Created At: {existing_created_at}\n"
|
|
788
|
+
f" - Updated At: {existing_updated_at}\n\n"
|
|
789
|
+
f"No action needed. Use unset=True first if you want to re-pin to a different version."
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
raise PyAirbyteInputError(
|
|
793
|
+
message=detailed_message,
|
|
794
|
+
context={
|
|
795
|
+
"connector_id": connector_id,
|
|
796
|
+
"connector_type": connector_type,
|
|
797
|
+
"existing_version": existing_version_name,
|
|
798
|
+
"existing_version_id": existing_version_id,
|
|
799
|
+
"requested_version": version,
|
|
800
|
+
"requested_version_id": version_id,
|
|
801
|
+
"existing_config_id": actor_config.get("id"),
|
|
802
|
+
"existing_description": existing_description,
|
|
803
|
+
"existing_reference_url": existing_reference_url,
|
|
804
|
+
"existing_origin": existing_origin,
|
|
805
|
+
"existing_origin_type": existing_origin_type,
|
|
806
|
+
"existing_created_at": existing_created_at,
|
|
807
|
+
"existing_updated_at": existing_updated_at,
|
|
808
|
+
"scope_level": _ScopeType.ACTOR.value,
|
|
809
|
+
"scope_type_from_api": actor_config.get("scope_type"),
|
|
810
|
+
"scope_id_from_api": actor_config.get("scope_id"),
|
|
811
|
+
"full_existing_config": actor_config,
|
|
812
|
+
},
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
# If pinned to a different version at actor level, delete it first
|
|
816
|
+
# SAFETY GUARD: Only delete if the returned config is truly actor-scoped
|
|
817
|
+
# (not an inherited workspace/org config returned by the API)
|
|
818
|
+
api_scope_type = actor_config.get("scope_type", "").lower()
|
|
819
|
+
api_scope_id = actor_config.get("scope_id", "")
|
|
820
|
+
if api_scope_type != _ScopeType.ACTOR.value or api_scope_id != connector_id:
|
|
821
|
+
raise PyAirbyteInputError(
|
|
822
|
+
message=f"Cannot delete existing config: API returned a config that is not actor-scoped. "
|
|
823
|
+
f"Expected scope_type='{_ScopeType.ACTOR.value}' and scope_id='{connector_id}', "
|
|
824
|
+
f"but got scope_type='{api_scope_type}' and scope_id='{api_scope_id}'. "
|
|
825
|
+
f"This may be an inherited config from workspace/organization level.",
|
|
826
|
+
context={
|
|
827
|
+
"connector_id": connector_id,
|
|
828
|
+
"expected_scope_type": _ScopeType.ACTOR.value,
|
|
829
|
+
"expected_scope_id": connector_id,
|
|
830
|
+
"actual_scope_type": api_scope_type,
|
|
831
|
+
"actual_scope_id": api_scope_id,
|
|
832
|
+
"full_config": actor_config,
|
|
833
|
+
},
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
delete_endpoint = f"{api_root}/scoped_configuration/delete"
|
|
837
|
+
delete_payload = {"scopedConfigurationId": actor_config["id"]}
|
|
838
|
+
|
|
839
|
+
delete_response = requests.post(
|
|
840
|
+
delete_endpoint,
|
|
841
|
+
json=delete_payload,
|
|
842
|
+
headers={
|
|
843
|
+
"Authorization": f"Bearer {access_token}",
|
|
844
|
+
"User-Agent": ops_constants.USER_AGENT,
|
|
845
|
+
"Content-Type": "application/json",
|
|
846
|
+
},
|
|
847
|
+
timeout=30,
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
if delete_response.status_code not in (200, 204):
|
|
851
|
+
raise PyAirbyteInputError(
|
|
852
|
+
message=f"Failed to delete existing actor-level version override before setting new one: "
|
|
853
|
+
f"{delete_response.status_code} {delete_response.text}. "
|
|
854
|
+
f"Connector is currently pinned to {existing_version_name} at actor scope.",
|
|
855
|
+
context={
|
|
856
|
+
"delete_endpoint": delete_endpoint,
|
|
857
|
+
"config_id": actor_config["id"],
|
|
858
|
+
"existing_version": existing_version_name,
|
|
859
|
+
"requested_version": version,
|
|
860
|
+
"status_code": delete_response.status_code,
|
|
861
|
+
"response": delete_response.text,
|
|
862
|
+
"scope_level": _ScopeType.ACTOR.value,
|
|
863
|
+
},
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
# Report if there are pins at workspace or organization level (informational)
|
|
867
|
+
# These won't cause duplicate key errors but users should be aware of them
|
|
868
|
+
workspace_config = all_configs.get(_ScopeType.WORKSPACE.value)
|
|
869
|
+
org_config = all_configs.get(_ScopeType.ORGANIZATION.value)
|
|
870
|
+
inherited_pins: list[str] = []
|
|
871
|
+
if workspace_config:
|
|
872
|
+
ws_version = workspace_config.get("valueName", "unknown")
|
|
873
|
+
inherited_pins.append(f"workspace (version: {ws_version})")
|
|
874
|
+
if org_config:
|
|
875
|
+
org_version = org_config.get("valueName", "unknown")
|
|
876
|
+
inherited_pins.append(f"organization (version: {org_version})")
|
|
877
|
+
|
|
878
|
+
# Get user ID from email - required by API spec
|
|
879
|
+
if not user_email:
|
|
880
|
+
raise PyAirbyteInputError(
|
|
881
|
+
message="user_email is required to set a version override",
|
|
882
|
+
context={
|
|
883
|
+
"connector_id": connector_id,
|
|
884
|
+
"connector_type": connector_type,
|
|
885
|
+
},
|
|
886
|
+
)
|
|
887
|
+
origin = get_user_id_by_email(
|
|
888
|
+
email=user_email,
|
|
889
|
+
api_root=api_root,
|
|
890
|
+
client_id=client_id,
|
|
891
|
+
client_secret=client_secret,
|
|
892
|
+
bearer_token=bearer_token,
|
|
893
|
+
)
|
|
894
|
+
|
|
519
895
|
# Create the override with correct schema
|
|
520
896
|
endpoint = f"{api_root}/scoped_configuration/create"
|
|
521
897
|
|
|
898
|
+
# Build payload with explicit string values for auditability
|
|
899
|
+
# (enum.value ensures we log exactly what we send)
|
|
522
900
|
payload: dict[str, Any] = {
|
|
523
|
-
"config_key": _ScopedConfigKey.CONNECTOR_VERSION,
|
|
524
|
-
"resource_type": _ResourceType.ACTOR_DEFINITION,
|
|
901
|
+
"config_key": _ScopedConfigKey.CONNECTOR_VERSION.value,
|
|
902
|
+
"resource_type": _ResourceType.ACTOR_DEFINITION.value,
|
|
525
903
|
"resource_id": actor_definition_id,
|
|
526
|
-
"scope_type": scope_type,
|
|
904
|
+
"scope_type": scope_type.value,
|
|
527
905
|
"scope_id": connector_id,
|
|
528
906
|
"value": version_id, # Use version ID, not version string
|
|
529
907
|
"description": override_reason,
|
|
530
|
-
"origin_type": _OriginType.USER,
|
|
908
|
+
"origin_type": _OriginType.USER.value,
|
|
909
|
+
"origin": origin,
|
|
531
910
|
}
|
|
532
911
|
|
|
533
|
-
# Add origin (user ID) if available
|
|
534
|
-
if origin:
|
|
535
|
-
payload["origin"] = origin
|
|
536
|
-
|
|
537
912
|
if override_reason_reference_url:
|
|
538
913
|
payload["reference_url"] = override_reason_reference_url
|
|
539
914
|
|
|
@@ -542,15 +917,28 @@ def set_connector_version_override(
|
|
|
542
917
|
json=payload,
|
|
543
918
|
headers={
|
|
544
919
|
"Authorization": f"Bearer {access_token}",
|
|
545
|
-
"User-Agent":
|
|
920
|
+
"User-Agent": ops_constants.USER_AGENT,
|
|
546
921
|
"Content-Type": "application/json",
|
|
547
922
|
},
|
|
548
923
|
timeout=30,
|
|
549
924
|
)
|
|
550
925
|
|
|
551
926
|
if response.status_code not in (200, 201):
|
|
927
|
+
# Provide helpful guidance for 500 errors which often indicate duplicates
|
|
928
|
+
error_message = f"Failed to set version override: {response.status_code} {response.text}"
|
|
929
|
+
if response.status_code == 500:
|
|
930
|
+
error_message += (
|
|
931
|
+
" A 500 error often indicates a duplicate key constraint violation - "
|
|
932
|
+
"the connector may already be pinned. Try using unset=True first, "
|
|
933
|
+
"or use the error lookup tool to get more details on the error ID."
|
|
934
|
+
)
|
|
935
|
+
if inherited_pins:
|
|
936
|
+
error_message += (
|
|
937
|
+
f" Note: Version pins were found at other scope levels: {', '.join(inherited_pins)}. "
|
|
938
|
+
"These inherited pins won't cause duplicate key errors but may affect the connector."
|
|
939
|
+
)
|
|
552
940
|
raise PyAirbyteInputError(
|
|
553
|
-
message=
|
|
941
|
+
message=error_message,
|
|
554
942
|
context={
|
|
555
943
|
"connector_id": connector_id,
|
|
556
944
|
"connector_type": connector_type,
|
|
@@ -561,6 +949,12 @@ def set_connector_version_override(
|
|
|
561
949
|
"actor_definition_id": actor_definition_id,
|
|
562
950
|
"status_code": response.status_code,
|
|
563
951
|
"response": response.text,
|
|
952
|
+
"inherited_pins": inherited_pins if inherited_pins else None,
|
|
953
|
+
"all_scope_configs": {
|
|
954
|
+
k: {"id": v.get("id"), "valueName": v.get("valueName")}
|
|
955
|
+
for k, v in all_configs.items()
|
|
956
|
+
if v
|
|
957
|
+
},
|
|
564
958
|
},
|
|
565
959
|
)
|
|
566
960
|
|