diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index 3f72bcfbc..e151fb68f 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -1382,6 +1382,340 @@ def is_safe_to_delete(name: str) -> bool: ) +def create_custom_docker_source_definition( + name: str, + docker_repository: str, + docker_image_tag: str, + *, + workspace_id: str, + documentation_url: str | None = None, + api_root: str, + client_id: SecretString, + client_secret: SecretString, +) -> models.DefinitionResponse: + """Create a custom Docker source definition.""" + airbyte_instance = get_airbyte_server_instance( + api_root=api_root, + client_id=client_id, + client_secret=client_secret, + ) + + request_body = models.CreateDefinitionRequest( + name=name, + docker_repository=docker_repository, + docker_image_tag=docker_image_tag, + documentation_url=documentation_url, + ) + request = api.CreateSourceDefinitionRequest( + workspace_id=workspace_id, + create_definition_request=request_body, + ) + response = airbyte_instance.source_definitions.create_source_definition(request) + if response.definition_response is None: + raise AirbyteError( + message="Failed to create custom Docker source definition", + context={"name": name, "workspace_id": workspace_id}, + ) + return response.definition_response + + +def list_custom_docker_source_definitions( + workspace_id: str, + *, + api_root: str, + client_id: SecretString, + client_secret: SecretString, +) -> list[models.DefinitionResponse]: + """List all custom Docker source definitions in a workspace.""" + airbyte_instance = get_airbyte_server_instance( + api_root=api_root, + client_id=client_id, + client_secret=client_secret, + ) + + request = api.ListSourceDefinitionsRequest( + workspace_id=workspace_id, + ) + response = airbyte_instance.source_definitions.list_source_definitions(request) + if not status_ok(response.status_code) or response.definitions_response is None: + raise AirbyteError( + message="Failed to list custom Docker source definitions", + context={ + "workspace_id": workspace_id, + "response": response, + }, + ) + return response.definitions_response.data + + +def get_custom_docker_source_definition( + workspace_id: str, + definition_id: str, + *, + api_root: str, + client_id: SecretString, + client_secret: SecretString, +) -> models.DefinitionResponse: + """Get a specific custom Docker source definition.""" + airbyte_instance = get_airbyte_server_instance( + api_root=api_root, + client_id=client_id, + client_secret=client_secret, + ) + + request = api.GetSourceDefinitionRequest( + workspace_id=workspace_id, + definition_id=definition_id, + ) + response = airbyte_instance.source_definitions.get_source_definition(request) + if not status_ok(response.status_code) or response.definition_response is None: + raise AirbyteError( + message="Failed to get custom Docker source definition", + context={ + "workspace_id": workspace_id, + "definition_id": definition_id, + "response": response, + }, + ) + return response.definition_response + + +def update_custom_docker_source_definition( + workspace_id: str, + definition_id: str, + *, + name: str, + docker_image_tag: str, + api_root: str, + client_id: SecretString, + client_secret: SecretString, +) -> models.DefinitionResponse: + """Update a custom Docker source definition.""" + airbyte_instance = get_airbyte_server_instance( + api_root=api_root, + client_id=client_id, + client_secret=client_secret, + ) + + request_body = models.UpdateDefinitionRequest( + name=name, + docker_image_tag=docker_image_tag, + ) + request = api.UpdateSourceDefinitionRequest( + workspace_id=workspace_id, + definition_id=definition_id, + update_definition_request=request_body, + ) + response = airbyte_instance.source_definitions.update_source_definition(request) + if not status_ok(response.status_code) or response.definition_response is None: + raise AirbyteError( + message="Failed to update custom Docker source definition", + context={ + "workspace_id": workspace_id, + "definition_id": definition_id, + "response": response, + }, + ) + return response.definition_response + + +def delete_custom_docker_source_definition( + workspace_id: str, + definition_id: str, + *, + api_root: str, + client_id: SecretString, + client_secret: SecretString, +) -> None: + """Delete a custom Docker source definition.""" + airbyte_instance = get_airbyte_server_instance( + api_root=api_root, + client_id=client_id, + client_secret=client_secret, + ) + + request = api.DeleteSourceDefinitionRequest( + workspace_id=workspace_id, + definition_id=definition_id, + ) + response = airbyte_instance.source_definitions.delete_source_definition(request) + if not status_ok(response.status_code): + raise AirbyteError( + message="Failed to delete custom Docker source definition", + context={ + "workspace_id": workspace_id, + "definition_id": definition_id, + }, + ) + + +def create_custom_docker_destination_definition( + name: str, + docker_repository: str, + docker_image_tag: str, + *, + workspace_id: str, + documentation_url: str | None = None, + api_root: str, + client_id: SecretString, + client_secret: SecretString, +) -> models.DefinitionResponse: + """Create a custom Docker destination definition.""" + airbyte_instance = get_airbyte_server_instance( + api_root=api_root, + client_id=client_id, + client_secret=client_secret, + ) + + request_body = models.CreateDefinitionRequest( + name=name, + docker_repository=docker_repository, + docker_image_tag=docker_image_tag, + documentation_url=documentation_url, + ) + request = api.CreateDestinationDefinitionRequest( + workspace_id=workspace_id, + create_definition_request=request_body, + ) + response = airbyte_instance.destination_definitions.create_destination_definition(request) + if response.definition_response is None: + raise AirbyteError( + message="Failed to create custom Docker destination definition", + context={"name": name, "workspace_id": workspace_id}, + ) + return response.definition_response + + +def list_custom_docker_destination_definitions( + workspace_id: str, + *, + api_root: str, + client_id: SecretString, + client_secret: SecretString, +) -> list[models.DefinitionResponse]: + """List all custom Docker destination definitions in a workspace.""" + airbyte_instance = get_airbyte_server_instance( + api_root=api_root, + client_id=client_id, + client_secret=client_secret, + ) + + request = api.ListDestinationDefinitionsRequest( + workspace_id=workspace_id, + ) + response = airbyte_instance.destination_definitions.list_destination_definitions(request) + if not status_ok(response.status_code) or response.definitions_response is None: + raise AirbyteError( + message="Failed to list custom Docker destination definitions", + context={ + "workspace_id": workspace_id, + "response": response, + }, + ) + return response.definitions_response.data + + +def get_custom_docker_destination_definition( + workspace_id: str, + definition_id: str, + *, + api_root: str, + client_id: SecretString, + client_secret: SecretString, +) -> models.DefinitionResponse: + """Get a specific custom Docker destination definition.""" + airbyte_instance = get_airbyte_server_instance( + api_root=api_root, + client_id=client_id, + client_secret=client_secret, + ) + + request = api.GetDestinationDefinitionRequest( + workspace_id=workspace_id, + definition_id=definition_id, + ) + response = airbyte_instance.destination_definitions.get_destination_definition(request) + if not status_ok(response.status_code) or response.definition_response is None: + raise AirbyteError( + message="Failed to get custom Docker destination definition", + context={ + "workspace_id": workspace_id, + "definition_id": definition_id, + "response": response, + }, + ) + return response.definition_response + + +def update_custom_docker_destination_definition( + workspace_id: str, + definition_id: str, + *, + name: str, + docker_image_tag: str, + api_root: str, + client_id: SecretString, + client_secret: SecretString, +) -> models.DefinitionResponse: + """Update a custom Docker destination definition.""" + airbyte_instance = get_airbyte_server_instance( + api_root=api_root, + client_id=client_id, + client_secret=client_secret, + ) + + request_body = models.UpdateDefinitionRequest( + name=name, + docker_image_tag=docker_image_tag, + ) + request = api.UpdateDestinationDefinitionRequest( + workspace_id=workspace_id, + definition_id=definition_id, + update_definition_request=request_body, + ) + response = airbyte_instance.destination_definitions.update_destination_definition(request) + if not status_ok(response.status_code) or response.definition_response is None: + raise AirbyteError( + message="Failed to update custom Docker destination definition", + context={ + "workspace_id": workspace_id, + "definition_id": definition_id, + "response": response, + }, + ) + return response.definition_response + + +def delete_custom_docker_destination_definition( + workspace_id: str, + definition_id: str, + *, + api_root: str, + client_id: SecretString, + client_secret: SecretString, +) -> None: + """Delete a custom Docker destination definition.""" + airbyte_instance = get_airbyte_server_instance( + api_root=api_root, + client_id=client_id, + client_secret=client_secret, + ) + + request = api.DeleteDestinationDefinitionRequest( + workspace_id=workspace_id, + definition_id=definition_id, + ) + response = airbyte_instance.destination_definitions.delete_destination_definition(request) + if not status_ok(response.status_code): + raise AirbyteError( + message="Failed to delete custom Docker destination definition", + context={ + "workspace_id": workspace_id, + "definition_id": definition_id, + }, + ) + + def get_connector_builder_project_for_definition_id( *, workspace_id: str, diff --git a/airbyte/cloud/connectors.py b/airbyte/cloud/connectors.py index d1edd85c4..2595c15b2 100644 --- a/airbyte/cloud/connectors.py +++ b/airbyte/cloud/connectors.py @@ -691,3 +691,117 @@ def deploy_source( workspace=self.workspace, source_response=result, ) + + +class CloudCustomDestinationDefinition: + """A custom destination connector definition in Airbyte Cloud. + + Currently only supports Docker-based custom destinations. + """ + + def __init__( + self, + workspace: CloudWorkspace, + definition_id: str, + ) -> None: + """Initialize a custom destination definition object.""" + self.workspace = workspace + self.definition_id = definition_id + self._definition_info: api_models.DefinitionResponse | None = None + + def _fetch_definition_info(self) -> api_models.DefinitionResponse: + """Fetch definition info from the API.""" + return api_util.get_custom_docker_destination_definition( + workspace_id=self.workspace.workspace_id, + definition_id=self.definition_id, + api_root=self.workspace.api_root, + client_id=self.workspace.client_id, + client_secret=self.workspace.client_secret, + ) + + @property + def name(self) -> str: + """Get the display name of the custom connector definition.""" + if not self._definition_info: + self._definition_info = self._fetch_definition_info() + return self._definition_info.name + + @property + def docker_repository(self) -> str: + """Get the Docker repository.""" + if not self._definition_info: + self._definition_info = self._fetch_definition_info() + return self._definition_info.docker_repository + + @property + def docker_image_tag(self) -> str: + """Get the Docker image tag.""" + if not self._definition_info: + self._definition_info = self._fetch_definition_info() + return self._definition_info.docker_image_tag + + @property + def documentation_url(self) -> str | None: + """Get the documentation URL.""" + if not self._definition_info: + self._definition_info = self._fetch_definition_info() + return self._definition_info.documentation_url + + @property + def definition_url(self) -> str: + """Get the web URL of the custom destination definition.""" + return ( + f"{self.workspace.workspace_url}/settings/custom-connectors/" + f"destinations/{self.definition_id}" + ) + + def permanently_delete(self) -> None: + """Permanently delete this custom destination definition.""" + self.workspace.permanently_delete_custom_destination_definition(self.definition_id) + + def update_definition( + self, + *, + name: str, + docker_tag: str, + ) -> CloudCustomDestinationDefinition: + """Update this custom destination definition. + + Args: + name: New display name + docker_tag: New Docker tag + + Returns: + Updated CloudCustomDestinationDefinition object + """ + result = api_util.update_custom_docker_destination_definition( + workspace_id=self.workspace.workspace_id, + definition_id=self.definition_id, + name=name, + docker_image_tag=docker_tag, + api_root=self.workspace.api_root, + client_id=self.workspace.client_id, + client_secret=self.workspace.client_secret, + ) + return CloudCustomDestinationDefinition._from_docker_response(self.workspace, result) + + def __repr__(self) -> str: + """String representation.""" + return ( + f"CloudCustomDestinationDefinition(definition_id={self.definition_id}, " + f"name={self.name}, docker_repository={self.docker_repository})" + ) + + @classmethod + def _from_docker_response( + cls, + workspace: CloudWorkspace, + response: api_models.DefinitionResponse, + ) -> CloudCustomDestinationDefinition: + """Internal factory method.""" + result = cls( + workspace=workspace, + definition_id=response.id, + ) + result._definition_info = response # noqa: SLF001 + return result diff --git a/airbyte/cloud/workspaces.py b/airbyte/cloud/workspaces.py index af201e4c9..02131aef8 100644 --- a/airbyte/cloud/workspaces.py +++ b/airbyte/cloud/workspaces.py @@ -46,6 +46,7 @@ from airbyte._util.api_util import get_web_url_root from airbyte.cloud.connections import CloudConnection from airbyte.cloud.connectors import ( + CloudCustomDestinationDefinition, CloudDestination, CloudSource, CustomCloudSourceDefinition, @@ -610,3 +611,110 @@ def get_custom_source_definition( "Docker custom source definitions are not yet supported. " "Only YAML manifest-based custom sources are currently available." ) + + def publish_custom_destination_definition( + self, + name: str, + *, + docker_image: str, + docker_tag: str, + documentation_url: str | None = None, + unique: bool = True, + ) -> CloudCustomDestinationDefinition: + """Publish a custom destination connector definition. + + Currently only Docker-based destinations are supported. + + Args: + name: Display name for the connector definition + docker_image: Docker repository (e.g., 'airbyte/destination-custom') + docker_tag: Docker image tag (e.g., '1.0.0') + documentation_url: Optional URL to connector documentation + unique: Whether to enforce name uniqueness + + Returns: + CloudCustomDestinationDefinition object representing the created definition + """ + if unique: + existing = self.list_custom_destination_definitions(name=name) + if existing: + raise exc.AirbyteDuplicateResourcesError( + resource_type="custom_destination_definition", + resource_name=name, + ) + + result = api_util.create_custom_docker_destination_definition( + name=name, + docker_repository=docker_image, + docker_image_tag=docker_tag, + workspace_id=self.workspace_id, + documentation_url=documentation_url, + api_root=self.api_root, + client_id=self.client_id, + client_secret=self.client_secret, + ) + return CloudCustomDestinationDefinition._from_docker_response(self, result) # noqa: SLF001 + + def list_custom_destination_definitions( + self, + *, + name: str | None = None, + ) -> list[CloudCustomDestinationDefinition]: + """List custom destination connector definitions. + + Args: + name: Filter by exact name match + + Returns: + List of CloudCustomDestinationDefinition objects + """ + definitions = api_util.list_custom_docker_destination_definitions( + workspace_id=self.workspace_id, + api_root=self.api_root, + client_id=self.client_id, + client_secret=self.client_secret, + ) + + return [ + CloudCustomDestinationDefinition._from_docker_response(self, d) # noqa: SLF001 + for d in definitions + if name is None or d.name == name + ] + + def get_custom_destination_definition( + self, + definition_id: str, + ) -> CloudCustomDestinationDefinition: + """Get a specific custom destination definition by ID. + + Args: + definition_id: The definition ID + + Returns: + CloudCustomDestinationDefinition object + """ + result = api_util.get_custom_docker_destination_definition( + workspace_id=self.workspace_id, + definition_id=definition_id, + api_root=self.api_root, + client_id=self.client_id, + client_secret=self.client_secret, + ) + return CloudCustomDestinationDefinition._from_docker_response(self, result) # noqa: SLF001 + + def permanently_delete_custom_destination_definition( + self, + definition_id: str, + ) -> None: + """Permanently delete a custom destination definition. + + Args: + definition_id: The definition ID to delete + """ + api_util.delete_custom_docker_destination_definition( + workspace_id=self.workspace_id, + definition_id=definition_id, + api_root=self.api_root, + client_id=self.client_id, + client_secret=self.client_secret, + ) diff --git a/airbyte/mcp/cloud_ops.py b/airbyte/mcp/cloud_ops.py index 1cba32d63..9186153cd 100644 --- a/airbyte/mcp/cloud_ops.py +++ b/airbyte/mcp/cloud_ops.py @@ -14,7 +14,7 @@ resolve_cloud_client_secret, resolve_cloud_workspace_id, ) -from airbyte.cloud.connectors import CustomCloudSourceDefinition +from airbyte.cloud.connectors import CloudCustomDestinationDefinition, CustomCloudSourceDefinition from airbyte.cloud.workspaces import CloudWorkspace from airbyte.destinations.util import get_noop_destination from airbyte.mcp._tool_utils import ( @@ -63,6 +63,23 @@ class CloudConnectionResult(BaseModel): """ID of the destination used by this connection.""" +class CustomDestinationDefinitionResult(BaseModel): + """Information about a custom destination definition in Airbyte Cloud.""" + + definition_id: str + """The definition ID.""" + name: str + """Display name of the custom destination definition.""" + docker_repository: str + """Docker repository (e.g., 'airbyte/destination-custom').""" + docker_image_tag: str + """Docker image tag (e.g., '1.0.0').""" + documentation_url: str | None = None + """Optional URL to connector documentation.""" + definition_url: str + """Web URL for managing this definition in Airbyte Cloud.""" + + def _get_cloud_workspace(workspace_id: str | None = None) -> CloudWorkspace: """Get an authenticated CloudWorkspace. @@ -1001,6 +1018,196 @@ def permanently_delete_custom_source_definition( ) +# ============================================================================= +# Custom Destination Definition Tools (Docker-based) +# ============================================================================= + + +def _get_custom_destination_definition_result( + definition: CloudCustomDestinationDefinition, +) -> CustomDestinationDefinitionResult: + """Convert a CloudCustomDestinationDefinition to a result model.""" + return CustomDestinationDefinitionResult( + definition_id=definition.definition_id, + name=definition.name, + docker_repository=definition.docker_repository, + docker_image_tag=definition.docker_image_tag, + documentation_url=definition.documentation_url, + definition_url=definition.definition_url, + ) + + +@mcp_tool( + domain="cloud", + open_world=True, +) +def publish_custom_destination_definition( + name: Annotated[ + str, + Field(description="The name for the custom destination connector definition."), + ], + *, + docker_image: Annotated[ + str, + Field(description="Docker repository (e.g., 'airbyte/destination-custom')."), + ], + docker_tag: Annotated[ + str, + Field(description="Docker image tag (e.g., '1.0.0')."), + ], + documentation_url: Annotated[ + str | None, + Field( + description="Optional URL to connector documentation.", + default=None, + ), + ] = None, + unique: Annotated[ + bool, + Field( + description="Whether to require a unique name.", + default=True, + ), + ] = True, + workspace_id: Annotated[ + str | None, + Field( + description="Workspace ID. Defaults to AIRBYTE_CLOUD_WORKSPACE_ID env var.", + default=None, + ), + ] = None, +) -> CustomDestinationDefinitionResult | str: + """Publish a custom Docker destination connector definition to Airbyte Cloud. + + Creates a new custom destination definition using a Docker image. + Only Docker-based custom destinations are currently supported. + """ + try: + workspace: CloudWorkspace = _get_cloud_workspace(workspace_id) + custom_destination = workspace.publish_custom_destination_definition( + name=name, + docker_image=docker_image, + docker_tag=docker_tag, + documentation_url=documentation_url, + unique=unique, + ) + except Exception as ex: + return f"Failed to publish custom destination definition '{name}': {ex}" + else: + register_guid_created_in_session(custom_destination.definition_id) + return _get_custom_destination_definition_result(custom_destination) + + +@mcp_tool( + domain="cloud", + read_only=True, + idempotent=True, + open_world=True, +) +def list_custom_destination_definitions( + *, + workspace_id: Annotated[ + str | None, + Field( + description="Workspace ID. Defaults to AIRBYTE_CLOUD_WORKSPACE_ID env var.", + default=None, + ), + ], +) -> list[CustomDestinationDefinitionResult]: + """List custom Docker destination definitions in the Airbyte Cloud workspace. + + Returns all custom destination definitions. Only Docker-based custom + destinations are currently supported. + """ + workspace: CloudWorkspace = _get_cloud_workspace(workspace_id) + definitions = workspace.list_custom_destination_definitions() + + return [_get_custom_destination_definition_result(d) for d in definitions] + + +@mcp_tool( + domain="cloud", + open_world=True, +) +def update_custom_destination_definition( + definition_id: Annotated[ + str, + Field(description="The ID of the custom destination definition to update."), + ], + *, + name: Annotated[ + str, + Field(description="New display name for the destination definition."), + ], + docker_tag: Annotated[ + str, + Field(description="New Docker image tag (e.g., '1.0.1')."), + ], + workspace_id: Annotated[ + str | None, + Field( + description="Workspace ID. Defaults to AIRBYTE_CLOUD_WORKSPACE_ID env var.", + default=None, + ), + ] = None, +) -> CustomDestinationDefinitionResult | str: + """Update a custom Docker destination definition in Airbyte Cloud. + + Updates the name and/or Docker tag of an existing custom destination definition. + Only Docker-based custom destinations are currently supported. + """ + check_guid_created_in_session(definition_id) + try: + workspace: CloudWorkspace = _get_cloud_workspace(workspace_id) + definition = workspace.get_custom_destination_definition(definition_id=definition_id) + updated = definition.update_definition( + name=name, + docker_tag=docker_tag, + ) + except Exception as ex: + return f"Failed to update custom destination definition '{definition_id}': {ex}" + else: + return _get_custom_destination_definition_result(updated) + + +@mcp_tool( + domain="cloud", + destructive=True, + open_world=True, +) +def permanently_delete_custom_destination_definition( + definition_id: Annotated[ + str, + Field(description="The ID of the custom destination definition to delete."), + ], + *, + workspace_id: Annotated[ + str | None, + Field( + description="Workspace ID. Defaults to AIRBYTE_CLOUD_WORKSPACE_ID env var.", + default=None, + ), + ], +) -> str: + """Permanently delete a custom Docker destination definition from Airbyte Cloud. + + IMPORTANT: This operation requires the definition to have been created in the + current session. Definitions created outside this session cannot be deleted + for safety reasons. + + Only Docker-based custom destinations are currently supported. + """ + check_guid_created_in_session(definition_id) + workspace: CloudWorkspace = _get_cloud_workspace(workspace_id) + definition = workspace.get_custom_destination_definition(definition_id=definition_id) + definition_name: str = definition.name # Capture name before deletion + definition.permanently_delete() + return ( + f"Successfully deleted custom destination definition '{definition_name}' " + f"(ID: {definition_id})" + ) + + @mcp_tool( domain="cloud", open_world=True,