Skip to content

Commit f7ba17f

Browse files
aaronsteersCopilotdevin-ai-integration[bot]
authored
feat: allow cloud deletes for all object types (with name-based safety protections) (#877)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 2cfd4c0 commit f7ba17f

File tree

7 files changed

+614
-261
lines changed

7 files changed

+614
-261
lines changed

airbyte/_util/api_util.py

Lines changed: 160 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -538,13 +538,57 @@ def get_source(
538538
def delete_source(
539539
source_id: str,
540540
*,
541+
source_name: str | None = None,
541542
api_root: str,
542543
client_id: SecretString,
543544
client_secret: SecretString,
544545
workspace_id: str | None = None,
546+
safe_mode: bool = True,
545547
) -> None:
546-
"""Delete a source."""
548+
"""Delete a source.
549+
550+
Args:
551+
source_id: The source ID to delete
552+
source_name: Optional source name. If not provided and safe_mode is enabled,
553+
the source name will be fetched from the API to perform safety checks.
554+
api_root: The API root URL
555+
client_id: OAuth client ID
556+
client_secret: OAuth client secret
557+
workspace_id: The workspace ID (not currently used)
558+
safe_mode: If True, requires the source name to contain "delete-me" or "deleteme"
559+
(case insensitive) to prevent accidental deletion. Defaults to True.
560+
561+
Raises:
562+
PyAirbyteInputError: If safe_mode is True and the source name does not meet
563+
the safety requirements.
564+
"""
547565
_ = workspace_id # Not used (yet)
566+
567+
if safe_mode:
568+
if source_name is None:
569+
source_info = get_source(
570+
source_id=source_id,
571+
api_root=api_root,
572+
client_id=client_id,
573+
client_secret=client_secret,
574+
)
575+
source_name = source_info.name
576+
577+
if not _is_safe_name_to_delete(source_name):
578+
raise PyAirbyteInputError(
579+
message=(
580+
f"Cannot delete source '{source_name}' with safe_mode enabled. "
581+
"To authorize deletion, the source name must contain 'delete-me' or 'deleteme' "
582+
"(case insensitive).\n\n"
583+
"Please rename the source to meet this requirement before attempting deletion."
584+
),
585+
context={
586+
"source_id": source_id,
587+
"source_name": source_name,
588+
"safe_mode": True,
589+
},
590+
)
591+
548592
airbyte_instance = get_airbyte_server_instance(
549593
client_id=client_id,
550594
client_secret=client_secret,
@@ -700,7 +744,7 @@ def get_destination(
700744
# the destination API response is of the wrong type.
701745
# https://github.com/airbytehq/pyairbyte/issues/320
702746
raw_response: dict[str, Any] = json.loads(response.raw_response.text)
703-
raw_configuration: dict[str, Any] = raw_response["configuration"]
747+
raw_configuration: dict[str, Any] | None = raw_response.get("configuration")
704748

705749
destination_type = raw_response.get("destinationType")
706750
destination_mapping = {
@@ -710,7 +754,7 @@ def get_destination(
710754
"duckdb": models.DestinationDuckdb,
711755
}
712756

713-
if destination_type in destination_mapping:
757+
if destination_type in destination_mapping and raw_configuration is not None:
714758
response.destination_response.configuration = destination_mapping[
715759
destination_type # pyrefly: ignore[index-error]
716760
](**raw_configuration)
@@ -726,13 +770,58 @@ def get_destination(
726770
def delete_destination(
727771
destination_id: str,
728772
*,
773+
destination_name: str | None = None,
729774
api_root: str,
730775
client_id: SecretString,
731776
client_secret: SecretString,
732777
workspace_id: str | None = None,
778+
safe_mode: bool = True,
733779
) -> None:
734-
"""Delete a destination."""
780+
"""Delete a destination.
781+
782+
Args:
783+
destination_id: The destination ID to delete
784+
destination_name: Optional destination name. If not provided and safe_mode is enabled,
785+
the destination name will be fetched from the API to perform safety checks.
786+
api_root: The API root URL
787+
client_id: OAuth client ID
788+
client_secret: OAuth client secret
789+
workspace_id: The workspace ID (not currently used)
790+
safe_mode: If True, requires the destination name to contain "delete-me" or "deleteme"
791+
(case insensitive) to prevent accidental deletion. Defaults to True.
792+
793+
Raises:
794+
PyAirbyteInputError: If safe_mode is True and the destination name does not meet
795+
the safety requirements.
796+
"""
735797
_ = workspace_id # Not used (yet)
798+
799+
if safe_mode:
800+
if destination_name is None:
801+
destination_info = get_destination(
802+
destination_id=destination_id,
803+
api_root=api_root,
804+
client_id=client_id,
805+
client_secret=client_secret,
806+
)
807+
destination_name = destination_info.name
808+
809+
if not _is_safe_name_to_delete(destination_name):
810+
raise PyAirbyteInputError(
811+
message=(
812+
f"Cannot delete destination '{destination_name}' with safe_mode enabled. "
813+
"To authorize deletion, the destination name must contain 'delete-me' or "
814+
"'deleteme' (case insensitive).\n\n"
815+
"Please rename the destination to meet this requirement "
816+
"before attempting deletion."
817+
),
818+
context={
819+
"destination_id": destination_id,
820+
"destination_name": destination_name,
821+
"safe_mode": True,
822+
},
823+
)
824+
736825
airbyte_instance = get_airbyte_server_instance(
737826
client_id=client_id,
738827
client_secret=client_secret,
@@ -902,13 +991,74 @@ def get_connection_by_name(
902991
return found[0]
903992

904993

994+
def _is_safe_name_to_delete(name: str) -> bool:
995+
"""Check if a name is safe to delete.
996+
997+
Requires the name to contain either "delete-me" or "deleteme" (case insensitive).
998+
"""
999+
name_lower = name.lower()
1000+
return any(
1001+
{
1002+
"delete-me" in name_lower,
1003+
"deleteme" in name_lower,
1004+
}
1005+
)
1006+
1007+
9051008
def delete_connection(
9061009
connection_id: str,
1010+
connection_name: str | None = None,
1011+
*,
9071012
api_root: str,
9081013
workspace_id: str,
9091014
client_id: SecretString,
9101015
client_secret: SecretString,
1016+
safe_mode: bool = True,
9111017
) -> None:
1018+
"""Delete a connection.
1019+
1020+
Args:
1021+
connection_id: The connection ID to delete
1022+
connection_name: Optional connection name. If not provided and safe_mode is enabled,
1023+
the connection name will be fetched from the API to perform safety checks.
1024+
api_root: The API root URL
1025+
workspace_id: The workspace ID
1026+
client_id: OAuth client ID
1027+
client_secret: OAuth client secret
1028+
safe_mode: If True, requires the connection name to contain "delete-me" or "deleteme"
1029+
(case insensitive) to prevent accidental deletion. Defaults to True.
1030+
1031+
Raises:
1032+
PyAirbyteInputError: If safe_mode is True and the connection name does not meet
1033+
the safety requirements.
1034+
"""
1035+
if safe_mode:
1036+
if connection_name is None:
1037+
connection_info = get_connection(
1038+
workspace_id=workspace_id,
1039+
connection_id=connection_id,
1040+
api_root=api_root,
1041+
client_id=client_id,
1042+
client_secret=client_secret,
1043+
)
1044+
connection_name = connection_info.name
1045+
1046+
if not _is_safe_name_to_delete(connection_name):
1047+
raise PyAirbyteInputError(
1048+
message=(
1049+
f"Cannot delete connection '{connection_name}' with safe_mode enabled. "
1050+
"To authorize deletion, the connection name must contain 'delete-me' or "
1051+
"'deleteme' (case insensitive).\n\n"
1052+
"Please rename the connection to meet this requirement "
1053+
"before attempting deletion."
1054+
),
1055+
context={
1056+
"connection_id": connection_id,
1057+
"connection_name": connection_name,
1058+
"safe_mode": True,
1059+
},
1060+
)
1061+
9121062
_ = workspace_id # Not used (yet)
9131063
airbyte_instance = get_airbyte_server_instance(
9141064
client_id=client_id,
@@ -1317,9 +1467,8 @@ def delete_custom_yaml_source_definition(
13171467
api_root: The API root URL
13181468
client_id: OAuth client ID
13191469
client_secret: OAuth client secret
1320-
safe_mode: If True, requires the connector name to either start with "delete:"
1321-
or contain "delete-me" (case insensitive) to prevent accidental deletion.
1322-
Defaults to True.
1470+
safe_mode: If True, requires the connector name to contain "delete-me" or "deleteme"
1471+
(case insensitive) to prevent accidental deletion. Defaults to True.
13231472
13241473
Raises:
13251474
PyAirbyteInputError: If safe_mode is True and the connector name does not meet
@@ -1335,19 +1484,14 @@ def delete_custom_yaml_source_definition(
13351484
)
13361485
connector_name = definition_info.name
13371486

1338-
def is_safe_to_delete(name: str) -> bool:
1339-
name_lower = name.lower()
1340-
return name_lower.startswith("delete:") or "delete-me" in name_lower
1341-
1342-
if not is_safe_to_delete(definition_info.name):
1487+
if not _is_safe_name_to_delete(definition_info.name):
13431488
raise PyAirbyteInputError(
13441489
message=(
13451490
f"Cannot delete custom connector definition '{connector_name}' "
13461491
"with safe_mode enabled. "
1347-
"To authorize deletion, the connector name must either:\n"
1348-
" 1. Start with 'delete:' (case insensitive), OR\n"
1349-
" 2. Contain 'delete-me' (case insensitive)\n\n"
1350-
"Please rename the connector to meet one of these requirements "
1492+
"To authorize deletion, the connector name must contain 'delete-me' or "
1493+
"'deleteme' (case insensitive).\n\n"
1494+
"Please rename the connector to meet this requirement "
13511495
"before attempting deletion."
13521496
),
13531497
context={

airbyte/cloud/connectors.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -500,9 +500,8 @@ def permanently_delete(
500500
"""Permanently delete this custom source definition.
501501
502502
Args:
503-
safe_mode: If True, requires the connector name to either start with "delete:"
504-
or contain "delete-me" (case insensitive) to prevent accidental deletion.
505-
Defaults to True.
503+
safe_mode: If True, requires the connector name to contain "delete-me" or "deleteme"
504+
(case insensitive) to prevent accidental deletion. Defaults to True.
506505
"""
507506
if self.definition_type == "yaml":
508507
api_util.delete_custom_yaml_source_definition(

airbyte/cloud/workspaces.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,17 @@ def deploy_destination(
250250
def permanently_delete_source(
251251
self,
252252
source: str | CloudSource,
253+
*,
254+
safe_mode: bool = True,
253255
) -> None:
254256
"""Delete a source from the workspace.
255257
256258
You can pass either the source ID `str` or a deployed `Source` object.
259+
260+
Args:
261+
source: The source ID or CloudSource object to delete
262+
safe_mode: If True, requires the source name to contain "delete-me" or "deleteme"
263+
(case insensitive) to prevent accidental deletion. Defaults to True.
257264
"""
258265
if not isinstance(source, (str, CloudSource)):
259266
raise exc.PyAirbyteInputError(
@@ -263,20 +270,29 @@ def permanently_delete_source(
263270

264271
api_util.delete_source(
265272
source_id=source.connector_id if isinstance(source, CloudSource) else source,
273+
source_name=source.name if isinstance(source, CloudSource) else None,
266274
api_root=self.api_root,
267275
client_id=self.client_id,
268276
client_secret=self.client_secret,
277+
safe_mode=safe_mode,
269278
)
270279

271280
# Deploy and delete destinations
272281

273282
def permanently_delete_destination(
274283
self,
275284
destination: str | CloudDestination,
285+
*,
286+
safe_mode: bool = True,
276287
) -> None:
277288
"""Delete a deployed destination from the workspace.
278289
279290
You can pass either the `Cache` class or the deployed destination ID as a `str`.
291+
292+
Args:
293+
destination: The destination ID or CloudDestination object to delete
294+
safe_mode: If True, requires the destination name to contain "delete-me" or "deleteme"
295+
(case insensitive) to prevent accidental deletion. Defaults to True.
280296
"""
281297
if not isinstance(destination, (str, CloudDestination)):
282298
raise exc.PyAirbyteInputError(
@@ -288,9 +304,13 @@ def permanently_delete_destination(
288304
destination_id=(
289305
destination if isinstance(destination, str) else destination.destination_id
290306
),
307+
destination_name=(
308+
destination.name if isinstance(destination, CloudDestination) else None
309+
),
291310
api_root=self.api_root,
292311
client_id=self.client_id,
293312
client_secret=self.client_secret,
313+
safe_mode=safe_mode,
294314
)
295315

296316
# Deploy and delete connections
@@ -351,8 +371,19 @@ def permanently_delete_connection(
351371
*,
352372
cascade_delete_source: bool = False,
353373
cascade_delete_destination: bool = False,
374+
safe_mode: bool = True,
354375
) -> None:
355-
"""Delete a deployed connection from the workspace."""
376+
"""Delete a deployed connection from the workspace.
377+
378+
Args:
379+
connection: The connection ID or CloudConnection object to delete
380+
cascade_delete_source: If True, also delete the source after deleting the connection
381+
cascade_delete_destination: If True, also delete the destination after deleting
382+
the connection
383+
safe_mode: If True, requires the connection name to contain "delete-me" or "deleteme"
384+
(case insensitive) to prevent accidental deletion. Defaults to True. Also applies
385+
to cascade deletes.
386+
"""
356387
if connection is None:
357388
raise ValueError("No connection ID provided.")
358389

@@ -364,16 +395,24 @@ def permanently_delete_connection(
364395

365396
api_util.delete_connection(
366397
connection_id=connection.connection_id,
398+
connection_name=connection.name,
367399
api_root=self.api_root,
368400
workspace_id=self.workspace_id,
369401
client_id=self.client_id,
370402
client_secret=self.client_secret,
403+
safe_mode=safe_mode,
371404
)
372405

373406
if cascade_delete_source:
374-
self.permanently_delete_source(source=connection.source_id)
407+
self.permanently_delete_source(
408+
source=connection.source_id,
409+
safe_mode=safe_mode,
410+
)
375411
if cascade_delete_destination:
376-
self.permanently_delete_destination(destination=connection.destination_id)
412+
self.permanently_delete_destination(
413+
destination=connection.destination_id,
414+
safe_mode=safe_mode,
415+
)
377416

378417
# List sources, destinations, and connections
379418

airbyte/mcp/_tool_utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ def register_guid_created_in_session(guid: str) -> None:
5050
def check_guid_created_in_session(guid: str) -> None:
5151
"""Check if a GUID was created in this session.
5252
53+
This is a no-op if AIRBYTE_CLOUD_MCP_SAFE_MODE is set to "0".
54+
5355
Raises SafeModeError if the GUID was not created in this session and
5456
AIRBYTE_CLOUD_MCP_SAFE_MODE is set to 1.
5557

0 commit comments

Comments
 (0)