Skip to content

Commit 1a3354d

Browse files
feat: Add safety_mode to custom connector definition deletion (#831)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 1eeda00 commit 1a3354d

File tree

5 files changed

+163
-36
lines changed

5 files changed

+163
-36
lines changed

airbyte/_util/api_util.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1133,8 +1133,58 @@ def delete_custom_yaml_source_definition(
11331133
api_root: str,
11341134
client_id: SecretString,
11351135
client_secret: SecretString,
1136+
safe_mode: bool = True,
11361137
) -> None:
1137-
"""Delete a custom YAML source definition."""
1138+
"""Delete a custom YAML source definition.
1139+
1140+
Args:
1141+
workspace_id: The workspace ID
1142+
definition_id: The definition ID to delete
1143+
api_root: The API root URL
1144+
client_id: OAuth client ID
1145+
client_secret: OAuth client secret
1146+
safe_mode: If True, requires the connector name to either start with "delete:"
1147+
or contain "delete-me" (case insensitive) to prevent accidental deletion.
1148+
Defaults to True.
1149+
1150+
Raises:
1151+
PyAirbyteInputError: If safe_mode is True and the connector name does not meet
1152+
the safety requirements.
1153+
"""
1154+
if safe_mode:
1155+
definition_info = get_custom_yaml_source_definition(
1156+
workspace_id=workspace_id,
1157+
definition_id=definition_id,
1158+
api_root=api_root,
1159+
client_id=client_id,
1160+
client_secret=client_secret,
1161+
)
1162+
connector_name = definition_info.name
1163+
1164+
def is_safe_to_delete(name: str) -> bool:
1165+
name_lower = name.lower()
1166+
return name_lower.startswith("delete:") or "delete-me" in name_lower
1167+
1168+
if not is_safe_to_delete(definition_info.name):
1169+
raise PyAirbyteInputError(
1170+
message=(
1171+
f"Cannot delete custom connector definition '{connector_name}' "
1172+
"with safe_mode enabled. "
1173+
"To authorize deletion, the connector name must either:\n"
1174+
" 1. Start with 'delete:' (case insensitive), OR\n"
1175+
" 2. Contain 'delete-me' (case insensitive)\n\n"
1176+
"Please rename the connector to meet one of these requirements "
1177+
"before attempting deletion."
1178+
),
1179+
context={
1180+
"definition_id": definition_id,
1181+
"connector_name": connector_name,
1182+
"safe_mode": True,
1183+
},
1184+
)
1185+
1186+
# Else proceed with deletion
1187+
11381188
airbyte_instance = get_airbyte_server_instance(
11391189
api_root=api_root,
11401190
client_id=client_id,

airbyte/cloud/connectors.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -410,12 +410,32 @@ def definition_url(self) -> str:
410410
or f"{self.workspace.workspace_url}/settings/{self.connector_type}"
411411
)
412412

413-
def permanently_delete(self) -> None:
414-
"""Permanently delete this custom source definition."""
415-
self.workspace.permanently_delete_custom_source_definition(
416-
self.definition_id,
417-
definition_type=self.definition_type,
418-
)
413+
def permanently_delete(
414+
self,
415+
*,
416+
safe_mode: bool = True,
417+
) -> None:
418+
"""Permanently delete this custom source definition.
419+
420+
Args:
421+
safe_mode: If True, requires the connector name to either start with "delete:"
422+
or contain "delete-me" (case insensitive) to prevent accidental deletion.
423+
Defaults to True.
424+
"""
425+
if self.definition_type == "yaml":
426+
api_util.delete_custom_yaml_source_definition(
427+
workspace_id=self.workspace.workspace_id,
428+
definition_id=self.definition_id,
429+
api_root=self.workspace.api_root,
430+
client_id=self.workspace.client_id,
431+
client_secret=self.workspace.client_secret,
432+
safe_mode=safe_mode,
433+
)
434+
else:
435+
raise NotImplementedError(
436+
"Docker custom source definitions are not yet supported. "
437+
"Only YAML manifest-based custom sources are currently available."
438+
)
419439

420440
def update_definition(
421441
self,

airbyte/cloud/workspaces.py

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -610,29 +610,3 @@ def get_custom_source_definition(
610610
"Docker custom source definitions are not yet supported. "
611611
"Only YAML manifest-based custom sources are currently available."
612612
)
613-
614-
def permanently_delete_custom_source_definition(
615-
self,
616-
definition_id: str,
617-
*,
618-
definition_type: Literal["yaml", "docker"],
619-
) -> None:
620-
"""Permanently delete a custom source definition.
621-
622-
Args:
623-
definition_id: The definition ID to delete
624-
definition_type: Connector type ("yaml" or "docker"). Required.
625-
"""
626-
if definition_type == "yaml":
627-
api_util.delete_custom_yaml_source_definition(
628-
workspace_id=self.workspace_id,
629-
definition_id=definition_id,
630-
api_root=self.api_root,
631-
client_id=self.client_id,
632-
client_secret=self.client_secret,
633-
)
634-
else:
635-
raise NotImplementedError(
636-
"Docker custom source definitions are not yet supported. "
637-
"Only YAML manifest-based custom sources are currently available."
638-
)

airbyte/mcp/cloud_ops.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,9 +591,8 @@ def list_custom_source_definitions() -> list[dict[str, Any]]:
591591
{
592592
"definition_id": d.definition_id,
593593
"name": d.name,
594-
"connector_type": d.connector_type,
595-
"manifest": d.manifest,
596594
"version": d.version,
595+
"connector_builder_project_url": d.connector_builder_project_url,
597596
}
598597
for d in definitions
599598
]
@@ -649,6 +648,39 @@ def update_custom_source_definition(
649648
)
650649

651650

651+
def permanently_delete_custom_source_definition(
652+
definition_id: Annotated[
653+
str,
654+
Field(description="The ID of the custom source definition to delete."),
655+
],
656+
) -> str:
657+
"""Permanently delete a custom YAML source definition from Airbyte Cloud.
658+
659+
IMPORTANT: This operation requires the connector name to either:
660+
1. Start with "delete:" (case insensitive), OR
661+
2. Contain "delete-me" (case insensitive)
662+
663+
If the connector does not meet these requirements, the deletion will be rejected with a
664+
helpful error message. Instruct the user to rename the connector appropriately to authorize
665+
the deletion.
666+
667+
Note: Only YAML (declarative) connectors are currently supported.
668+
Docker-based custom sources are not yet available.
669+
"""
670+
workspace: CloudWorkspace = _get_cloud_workspace()
671+
definition = workspace.get_custom_source_definition(
672+
definition_id=definition_id,
673+
definition_type="yaml",
674+
)
675+
definition_name: str = definition.name # Capture name before deletion
676+
definition.permanently_delete(
677+
safe_mode=True, # Hard-coded safe mode for extra protection when running in LLM agents.
678+
)
679+
return (
680+
f"Successfully deleted custom source definition '{definition_name}' (ID: {definition_id})"
681+
)
682+
683+
652684
def register_cloud_ops_tools(app: FastMCP) -> None:
653685
"""@private Register tools with the FastMCP app.
654686
@@ -668,3 +700,4 @@ def register_cloud_ops_tools(app: FastMCP) -> None:
668700
app.tool(publish_custom_source_definition)
669701
app.tool(list_custom_source_definitions)
670702
app.tool(update_custom_source_definition)
703+
app.tool(permanently_delete_custom_source_definition)

tests/integration_tests/cloud/test_custom_definitions.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,11 @@ def test_publish_custom_yaml_source(
9393
assert updated.manifest["version"] == "0.2.0"
9494

9595
finally:
96-
cloud_workspace.permanently_delete_custom_source_definition(
96+
fetched = cloud_workspace.get_custom_source_definition(
9797
definition_id,
9898
definition_type="yaml",
9999
)
100+
fetched.permanently_delete(safe_mode=False)
100101

101102

102103
@pytest.mark.requires_creds
@@ -118,3 +119,52 @@ def test_yaml_validation_error(
118119
)
119120

120121
assert "type" in str(exc_info.value).lower()
122+
123+
124+
@pytest.mark.requires_creds
125+
@pytest.mark.parametrize(
126+
"name_template,expect_allow",
127+
[
128+
("test-yaml-source-{suffix}", False),
129+
("delete:test-yaml-source-{suffix}", True),
130+
("test-delete-me-yaml-source-{suffix}", True),
131+
],
132+
)
133+
def test_safe_mode_deletion(
134+
cloud_workspace: CloudWorkspace,
135+
name_template: str,
136+
expect_allow: bool,
137+
) -> None:
138+
"""Test safe_mode deletion behavior with different connector names."""
139+
from airbyte._util import text_util
140+
from airbyte.exceptions import PyAirbyteInputError
141+
142+
name = name_template.format(suffix=text_util.generate_random_suffix())
143+
144+
result = cloud_workspace.publish_custom_source_definition(
145+
name=name,
146+
manifest_yaml=TEST_YAML_MANIFEST,
147+
unique=True,
148+
pre_validate=True,
149+
)
150+
151+
definition_id = result.definition_id
152+
153+
definition = cloud_workspace.get_custom_source_definition(
154+
definition_id,
155+
definition_type="yaml",
156+
)
157+
158+
if expect_allow:
159+
definition.permanently_delete(safe_mode=True)
160+
else:
161+
try:
162+
with pytest.raises(PyAirbyteInputError) as exc_info:
163+
definition.permanently_delete(safe_mode=True)
164+
165+
error_message = str(exc_info.value).lower()
166+
assert "safe_mode" in error_message
167+
assert "delete:" in error_message or "delete-me" in error_message
168+
169+
finally:
170+
definition.permanently_delete(safe_mode=False)

0 commit comments

Comments
 (0)