This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new dcb19a9103a Add deferrable mode to AzureContainerInstancesOperator 
(#62772)
dcb19a9103a is described below

commit dcb19a9103aff5f0c2315538ed9eb3374a471561
Author: Akshaykumar <[email protected]>
AuthorDate: Sun May 10 07:34:43 2026 +0530

    Add deferrable mode to AzureContainerInstancesOperator (#62772)
    
    * Add aync support to ACI
    
    * avoid raising AirflowException directly
    
    * Address review
    
    - async credential: store and close _async_credential in close()
    - Remove per-poll state logging in trigger
    - Change default polling_interval from 5s to 30s
    - Simplify finally cleanup block in execute()
    - Add get_async_conn() caching test
    
    * address review comments
    
    * Refactor async hook to use asynccontextmanager
    
    * Fix CI
    
    * ensure proper execution of cleanup
    
    * address review
    
    ---------
    
    Co-authored-by: Akshay <[email protected]>
---
 providers/microsoft/azure/provider.yaml            |   3 +
 .../providers/microsoft/azure/get_provider_info.py |   4 +
 .../microsoft/azure/hooks/container_instance.py    |  96 ++++++-
 .../azure/operators/container_instances.py         |  92 ++++++-
 .../microsoft/azure/triggers/container_instance.py | 130 ++++++++++
 .../azure/hooks/test_container_instance.py         | 265 ++++++++++++++++++-
 .../azure/operators/test_container_instances.py    | 288 +++++++++++++++++++++
 .../azure/triggers/test_container_instance.py      | 248 ++++++++++++++++++
 8 files changed, 1111 insertions(+), 15 deletions(-)

diff --git a/providers/microsoft/azure/provider.yaml 
b/providers/microsoft/azure/provider.yaml
index 72ab88da6fe..76421ba3127 100644
--- a/providers/microsoft/azure/provider.yaml
+++ b/providers/microsoft/azure/provider.yaml
@@ -312,6 +312,9 @@ triggers:
   - integration-name: Microsoft Azure Compute
     python-modules:
       - airflow.providers.microsoft.azure.triggers.compute
+  - integration-name: Microsoft Azure Container Instances
+    python-modules:
+      - airflow.providers.microsoft.azure.triggers.container_instance
   - integration-name: Microsoft Azure Data Factory
     python-modules:
       - airflow.providers.microsoft.azure.triggers.data_factory
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/get_provider_info.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/get_provider_info.py
index 944b296d708..0b312ec09a9 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/get_provider_info.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/get_provider_info.py
@@ -288,6 +288,10 @@ def get_provider_info():
                 "integration-name": "Microsoft Azure Compute",
                 "python-modules": 
["airflow.providers.microsoft.azure.triggers.compute"],
             },
+            {
+                "integration-name": "Microsoft Azure Container Instances",
+                "python-modules": 
["airflow.providers.microsoft.azure.triggers.container_instance"],
+            },
             {
                 "integration-name": "Microsoft Azure Data Factory",
                 "python-modules": 
["airflow.providers.microsoft.azure.triggers.data_factory"],
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/container_instance.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/container_instance.py
index 27941ec53af..bf7e7177298 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/container_instance.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/container_instance.py
@@ -17,17 +17,30 @@
 # under the License.
 from __future__ import annotations
 
+from collections.abc import AsyncGenerator
+from contextlib import asynccontextmanager
 from functools import cached_property
 from typing import TYPE_CHECKING, Any, cast
 
 from azure.common.client_factory import get_client_from_auth_file, 
get_client_from_json_dict
 from azure.core.exceptions import ResourceNotFoundError
 from azure.identity import ClientSecretCredential, DefaultAzureCredential
+from azure.identity.aio import (
+    ClientSecretCredential as AsyncClientSecretCredential,
+    DefaultAzureCredential as AsyncDefaultAzureCredential,
+)
 from azure.mgmt.containerinstance import ContainerInstanceManagementClient
+from azure.mgmt.containerinstance.aio import (
+    ContainerInstanceManagementClient as 
AsyncContainerInstanceManagementClient,
+)
 
+from airflow.providers.common.compat.connection import get_async_connection
 from airflow.providers.common.compat.sdk import AirflowException
 from airflow.providers.microsoft.azure.hooks.base_azure import 
_AZURE_CLOUD_ENVIRONMENTS, AzureBaseHook
-from airflow.providers.microsoft.azure.utils import 
get_sync_default_azure_credential
+from airflow.providers.microsoft.azure.utils import (
+    get_async_default_azure_credential,
+    get_sync_default_azure_credential,
+)
 
 if TYPE_CHECKING:
     from azure.mgmt.containerinstance.models import (
@@ -180,3 +193,84 @@ class AzureContainerInstanceHook(AzureBaseHook):
             return False, str(e)
 
         return True, "Successfully connected to Azure Container Instance."
+
+
+class AzureContainerInstanceAsyncHook(AzureContainerInstanceHook):
+    """
+    An async hook for communicating with Azure Container Instances.
+
+    :param azure_conn_id: :ref:`Azure connection id<howto/connection:azure>` of
+        a service principal which will be used to start the container instance.
+    """
+
+    def __init__(self, azure_conn_id: str = 
AzureContainerInstanceHook.default_conn_name) -> None:
+        super().__init__(azure_conn_id=azure_conn_id)
+
+    @asynccontextmanager
+    async def get_async_conn(self) -> 
AsyncGenerator[AsyncContainerInstanceManagementClient, None]:
+        """Create an async management client bound to a single credential."""
+        conn = await get_async_connection(self.conn_id)
+        tenant = conn.extra_dejson.get("tenantId")
+        subscription_id = cast("str", conn.extra_dejson.get("subscriptionId"))
+
+        credential: AsyncClientSecretCredential | AsyncDefaultAzureCredential
+        if all([conn.login, conn.password, tenant]):
+            credential = AsyncClientSecretCredential(
+                client_id=cast("str", conn.login),
+                client_secret=cast("str", conn.password),
+                tenant_id=cast("str", tenant),
+            )
+        else:
+            managed_identity_client_id = 
conn.extra_dejson.get("managed_identity_client_id")
+            workload_identity_tenant_id = 
conn.extra_dejson.get("workload_identity_tenant_id")
+            credential = get_async_default_azure_credential(
+                managed_identity_client_id=managed_identity_client_id,
+                workload_identity_tenant_id=workload_identity_tenant_id,
+            )
+
+        client = AsyncContainerInstanceManagementClient(
+            credential=credential,
+            subscription_id=subscription_id,
+        )
+        try:
+            yield client
+        finally:
+            await client.close()
+            if hasattr(credential, "close"):
+                await credential.close()
+
+    async def get_state(self, resource_group: str, name: str) -> 
ContainerGroup:  # type: ignore[override]
+        """
+        Get the state of a container group asynchronously.
+
+        :param resource_group: the name of the resource group
+        :param name: the name of the container group
+        :return: ContainerGroup
+        """
+        async with self.get_async_conn() as client:
+            return await client.container_groups.get(resource_group, name)
+
+    async def get_logs(self, resource_group: str, name: str, tail: int = 1000) 
-> list:  # type: ignore[override]
+        """
+        Get the tail from logs of a container group asynchronously.
+
+        :param resource_group: the name of the resource group
+        :param name: the name of the container group
+        :param tail: the size of the tail
+        :return: A list of log messages
+        """
+        async with self.get_async_conn() as client:
+            logs = await client.containers.list_logs(resource_group, name, 
name, tail=tail)
+            if logs.content is None:
+                return [None]
+            return logs.content.splitlines(True)
+
+    async def delete(self, resource_group: str, name: str) -> None:  # type: 
ignore[override]
+        """
+        Delete a container group asynchronously.
+
+        :param resource_group: the name of the resource group
+        :param name: the name of the container group
+        """
+        async with self.get_async_conn() as client:
+            await client.container_groups.begin_delete(resource_group, name)
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/container_instances.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/container_instances.py
index 3bcacfd4c00..3901ff5258e 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/container_instances.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/container_instances.py
@@ -21,6 +21,7 @@ import re
 import time
 from collections import namedtuple
 from collections.abc import Sequence
+from functools import cached_property
 from typing import TYPE_CHECKING, Any, cast
 
 from azure.mgmt.containerinstance.models import (
@@ -42,10 +43,11 @@ from azure.mgmt.containerinstance.models import (
 )
 from msrestazure.azure_exceptions import CloudError
 
-from airflow.providers.common.compat.sdk import AirflowException, 
AirflowTaskTimeout, BaseOperator
+from airflow.providers.common.compat.sdk import AirflowException, 
AirflowTaskTimeout, BaseOperator, conf
 from airflow.providers.microsoft.azure.hooks.container_instance import 
AzureContainerInstanceHook
 from airflow.providers.microsoft.azure.hooks.container_registry import 
AzureContainerRegistryHook
 from airflow.providers.microsoft.azure.hooks.container_volume import 
AzureContainerVolumeHook
+from airflow.providers.microsoft.azure.triggers.container_instance import 
AzureContainerInstanceTrigger
 
 if TYPE_CHECKING:
     from airflow.sdk import Context
@@ -105,6 +107,10 @@ class AzureContainerInstancesOperator(BaseOperator):
     :param diagnostics: Container group diagnostic information (Log Analytics).
     :param priority: Container group priority, Possible values include: 
'Regular', 'Spot'
     :param identity: List of User/System assigned identities for the container 
group.
+    :param deferrable: Run in deferrable mode, releasing the worker slot while 
the container
+        runs. Defaults to ``[operators] default_deferrable`` in 
``airflow.cfg``.
+    :param remove_on_success: Delete the container group after a successful 
run. Default ``True``.
+    :param polling_interval: Seconds between status polls in deferrable mode. 
Default ``30.0``.
 
     **Example**::
 
@@ -181,6 +187,7 @@ class AzureContainerInstancesOperator(BaseOperator):
         gpu: Any | None = None,
         command: list[str] | None = None,
         remove_on_error: bool = True,
+        remove_on_success: bool = True,
         fail_if_exists: bool = True,
         tags: dict[str, str] | None = None,
         xcom_all: bool | None = None,
@@ -193,6 +200,8 @@ class AzureContainerInstancesOperator(BaseOperator):
         diagnostics: ContainerGroupDiagnostics | None = None,
         priority: str | None = "Regular",
         identity: ContainerGroupIdentity | dict | None = None,
+        deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
+        polling_interval: float = 30.0,
         **kwargs,
     ) -> None:
         super().__init__(**kwargs)
@@ -211,8 +220,8 @@ class AzureContainerInstancesOperator(BaseOperator):
         self.gpu = gpu
         self.command = command
         self.remove_on_error = remove_on_error
+        self.remove_on_success = remove_on_success
         self.fail_if_exists = fail_if_exists
-        self._ci_hook: Any = None
         self.tags = tags
         self.xcom_all = xcom_all
         self.os_type = os_type
@@ -242,6 +251,8 @@ class AzureContainerInstancesOperator(BaseOperator):
                 "Please set 'Regular' or 'Spot' as the priority. "
                 f"Found `{self.priority}`."
             )
+        self.deferrable = deferrable
+        self.polling_interval = polling_interval
 
     # helper to accept dict (user-friendly) or ContainerGroupIdentity (SDK 
object)
     @staticmethod
@@ -303,15 +314,17 @@ class AzureContainerInstancesOperator(BaseOperator):
             )
         return identity
 
+    @cached_property
+    def hook(self) -> AzureContainerInstanceHook:
+        return AzureContainerInstanceHook(azure_conn_id=self.ci_conn_id)
+
     def execute(self, context: Context) -> int:
         # Check name again in case it was templated.
         self._check_name(self.name)
 
-        self._ci_hook = 
AzureContainerInstanceHook(azure_conn_id=self.ci_conn_id)
-
         if self.fail_if_exists:
             self.log.info("Testing if container group already exists")
-            if self._ci_hook.exists(self.resource_group, self.name):
+            if self.hook.exists(self.resource_group, self.name):
                 raise AirflowException("Container group exists")
 
         if self.registry_conn_id:
@@ -340,6 +353,7 @@ class AzureContainerInstancesOperator(BaseOperator):
             volume_mounts.append(VolumeMount(name=mount_name, 
mount_path=mount_path, read_only=read_only))
 
         exit_code = 1
+        _cleanup = True
         try:
             self.log.info("Starting container group with %.1f cpu %.1f mem", 
self.cpu, self.memory_in_gb)
             if self.gpu:
@@ -381,13 +395,26 @@ class AzureContainerInstancesOperator(BaseOperator):
                 identity=self.identity,
             )
 
-            self._ci_hook.create_or_update(self.resource_group, self.name, 
container_group)
+            self.hook.create_or_update(self.resource_group, self.name, 
container_group)
 
             self.log.info("Container group started %s/%s", 
self.resource_group, self.name)
 
+            if self.deferrable:
+                _cleanup = False
+                self.defer(
+                    trigger=AzureContainerInstanceTrigger(
+                        resource_group=self.resource_group,
+                        name=self.name,
+                        ci_conn_id=self.ci_conn_id,
+                        polling_interval=self.polling_interval,
+                    ),
+                    method_name=self.execute_complete.__name__,
+                    timeout=self.execution_timeout,
+                )
+
             exit_code = self._monitor_logging(self.resource_group, self.name)
             if self.xcom_all is not None:
-                logs = self._ci_hook.get_logs(self.resource_group, self.name)
+                logs = self.hook.get_logs(self.resource_group, self.name)
                 if logs is None:
                     context["ti"].xcom_push(key="logs", value=[])
                 else:
@@ -407,16 +434,57 @@ class AzureContainerInstancesOperator(BaseOperator):
             raise AirflowException("Could not start container group")
 
         finally:
-            if exit_code == 0 or self.remove_on_error:
-                self.on_kill()
+            if _cleanup:
+                if exit_code == 0 and self.remove_on_success:
+                    self.on_kill()
+                elif exit_code != 0 and self.remove_on_error:
+                    self.on_kill()
 
     def on_kill(self) -> None:
         self.log.info("Deleting container group")
         try:
-            self._ci_hook.delete(self.resource_group, self.name)
+            self.hook.delete(self.resource_group, self.name)
         except Exception:
             self.log.exception("Could not delete container group")
 
+    def execute_complete(self, context: Context, event: dict[str, Any] | None) 
-> int:
+        """
+        Handle the trigger event after deferral.
+
+        Called by the Triggerer when the container reaches a terminal state.
+        Raises on failure; returns the exit code on success.
+        """
+        if event is None:
+            raise ValueError("Trigger error: event is None")
+
+        exit_code: int = event.get("exit_code", 1)
+
+        if event["status"] == "error":
+            if self.remove_on_error:
+                self.on_kill()
+            raise RuntimeError(
+                event.get(
+                    "message",
+                    f"Container group {self.resource_group}/{self.name} failed 
with exit code {exit_code}",
+                )
+            )
+
+        try:
+            if self.xcom_all is not None:
+                logs = self.hook.get_logs(self.resource_group, self.name)
+                if logs is None:
+                    context["ti"].xcom_push(key="logs", value=[])
+                elif self.xcom_all:
+                    context["ti"].xcom_push(key="logs", value=logs)
+                else:
+                    context["ti"].xcom_push(key="logs", value=logs[-1:])
+
+            self.log.info("Container had exit code: %s", exit_code)
+            return exit_code
+        finally:
+            if self.remove_on_success:
+                self.on_kill()
+
     def _monitor_logging(self, resource_group: str, name: str) -> int:
         last_state = None
         last_message_logged = None
@@ -424,7 +492,7 @@ class AzureContainerInstancesOperator(BaseOperator):
 
         while True:
             try:
-                cg_state = self._ci_hook.get_state(resource_group, name)
+                cg_state = self.hook.get_state(resource_group, name)
                 instance_view = cg_state.containers[0].instance_view
                 # If there is no instance view, we show the provisioning state
                 if instance_view is not None:
@@ -449,7 +517,7 @@ class AzureContainerInstancesOperator(BaseOperator):
 
                 if state in ["Running", "Terminated", "Succeeded"]:
                     try:
-                        logs = self._ci_hook.get_logs(resource_group, name)
+                        logs = self.hook.get_logs(resource_group, name)
                         if logs and logs[0] is None:
                             self.log.error("Container log is broken, marking 
as failed.")
                             return 1
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/triggers/container_instance.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/triggers/container_instance.py
new file mode 100644
index 00000000000..db855d1b0e3
--- /dev/null
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/triggers/container_instance.py
@@ -0,0 +1,130 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncIterator
+from typing import Any
+
+from airflow.providers.microsoft.azure.hooks.container_instance import 
AzureContainerInstanceAsyncHook
+from airflow.triggers.base import BaseTrigger, TriggerEvent
+
+TERMINAL_STATES = frozenset({"Terminated", "Succeeded", "Failed", "Unhealthy"})
+SUCCESS_STATES = frozenset({"Terminated", "Succeeded"})
+
+
+class AzureContainerInstanceTrigger(BaseTrigger):
+    """
+    Poll an Azure Container Instance until it reaches a terminal state.
+
+    :param resource_group: the name of the resource group
+    :param name: the name of the container group
+    :param ci_conn_id: connection id of the Azure service principal
+    :param polling_interval: time in seconds between state polls
+    """
+
+    def __init__(
+        self,
+        resource_group: str,
+        name: str,
+        ci_conn_id: str,
+        polling_interval: float = 30.0,
+    ) -> None:
+        super().__init__()
+        self.resource_group = resource_group
+        self.name = name
+        self.ci_conn_id = ci_conn_id
+        self.polling_interval = polling_interval
+
+    def serialize(self) -> tuple[str, dict[str, Any]]:
+        """Serialize trigger arguments and classpath."""
+        return (
+            
"airflow.providers.microsoft.azure.triggers.container_instance.AzureContainerInstanceTrigger",
+            {
+                "resource_group": self.resource_group,
+                "name": self.name,
+                "ci_conn_id": self.ci_conn_id,
+                "polling_interval": self.polling_interval,
+            },
+        )
+
+    async def run(self) -> AsyncIterator[TriggerEvent]:
+        """Poll ACI until a terminal state is reached, then yield a 
TriggerEvent."""
+        hook = AzureContainerInstanceAsyncHook(azure_conn_id=self.ci_conn_id)
+        try:
+            async with hook.get_async_conn() as client:
+                while True:
+                    cg_state = await 
client.container_groups.get(self.resource_group, self.name)
+                    instance_view = cg_state.containers[0].instance_view
+
+                    if instance_view is not None:
+                        c_state = instance_view.current_state
+                        state = c_state.state
+                        exit_code = c_state.exit_code
+                        detail_status = c_state.detail_status
+                    else:
+                        prov = cg_state.provisioning_state
+                        if prov in ("Failed", "Unhealthy"):
+                            state = prov
+                            exit_code = 1
+                            detail_status = prov
+                        else:
+                            await asyncio.sleep(self.polling_interval)
+                            continue
+
+                    if state in TERMINAL_STATES:
+                        if state in SUCCESS_STATES and exit_code == 0:
+                            yield TriggerEvent(
+                                {
+                                    "status": "success",
+                                    "exit_code": exit_code,
+                                    "detail_status": detail_status,
+                                    "resource_group": self.resource_group,
+                                    "name": self.name,
+                                }
+                            )
+                        else:
+                            yield TriggerEvent(
+                                {
+                                    "status": "error",
+                                    "exit_code": exit_code,
+                                    "detail_status": detail_status,
+                                    "resource_group": self.resource_group,
+                                    "name": self.name,
+                                    "message": (
+                                        f"Container group 
{self.resource_group}/{self.name} "
+                                        f"reached state {state!r} with exit 
code {exit_code} "
+                                        f"({detail_status})"
+                                    ),
+                                }
+                            )
+                        return
+
+                    await asyncio.sleep(self.polling_interval)
+        except asyncio.CancelledError:
+            raise
+        except Exception as e:
+            yield TriggerEvent(
+                {
+                    "status": "error",
+                    "message": str(e),
+                    "resource_group": self.resource_group,
+                    "name": self.name,
+                    "exit_code": 1,
+                    "detail_status": "",
+                }
+            )
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_container_instance.py
 
b/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_container_instance.py
index 0bebf4d7afc..33df9b1d992 100644
--- 
a/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_container_instance.py
+++ 
b/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_container_instance.py
@@ -17,19 +17,27 @@
 # under the License.
 from __future__ import annotations
 
-from unittest.mock import MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 from azure.core.exceptions import ResourceNotFoundError
 from azure.identity import AzureAuthorityHosts
+from azure.identity.aio import ClientSecretCredential as 
AsyncClientSecretCredential
+from azure.mgmt.containerinstance.aio import (
+    ContainerInstanceManagementClient as 
AsyncContainerInstanceManagementClient,
+)
 from azure.mgmt.containerinstance.models import (
+    ContainerGroup,
     Logs,
     ResourceRequests,
     ResourceRequirements,
 )
 
 from airflow.models import Connection
-from airflow.providers.microsoft.azure.hooks.container_instance import 
AzureContainerInstanceHook
+from airflow.providers.microsoft.azure.hooks.container_instance import (
+    AzureContainerInstanceAsyncHook,
+    AzureContainerInstanceHook,
+)
 
 
 @pytest.fixture
@@ -252,3 +260,256 @@ class TestAzureContainerInstanceHookCloudEnvironment:
             base_url=expected_base_url,
             credential_scopes=expected_scopes,
         )
+
+
[email protected]
+def async_conn_with_credentials(create_mock_connection):
+    return create_mock_connection(
+        Connection(
+            conn_id="azure_aci_async_test",
+            conn_type="azure_container_instance",
+            login="client-id",
+            password="client-secret",
+            extra={
+                "tenantId": "tenant-id",
+                "subscriptionId": "subscription-id",
+            },
+        )
+    )
+
+
[email protected]
+def async_conn_without_credentials(create_mock_connection):
+    return create_mock_connection(
+        Connection(
+            conn_id="azure_aci_async_no_creds",
+            conn_type="azure_container_instance",
+            extra={"subscriptionId": "subscription-id"},
+        )
+    )
+
+
+class TestAzureContainerInstanceAsyncHook:
+    @patch(
+        
"airflow.providers.microsoft.azure.hooks.container_instance.AsyncContainerInstanceManagementClient"
+    )
+    
@patch("airflow.providers.microsoft.azure.hooks.container_instance.AsyncClientSecretCredential")
+    @pytest.mark.asyncio
+    async def test_get_async_conn_with_client_secret(
+        self,
+        mock_credential_cls,
+        mock_client_cls,
+        async_conn_with_credentials,
+    ):
+        mock_credential = MagicMock(spec=AsyncClientSecretCredential)
+        mock_credential.close = AsyncMock()
+        mock_credential_cls.return_value = mock_credential
+        mock_client_instance = 
MagicMock(spec=AsyncContainerInstanceManagementClient)
+        mock_client_instance.close = AsyncMock()
+        mock_client_cls.return_value = mock_client_instance
+
+        hook = 
AzureContainerInstanceAsyncHook(azure_conn_id=async_conn_with_credentials.conn_id)
+        async with hook.get_async_conn() as conn:
+            mock_credential_cls.assert_called_once_with(
+                client_id="client-id",
+                client_secret="client-secret",
+                tenant_id="tenant-id",
+            )
+            mock_client_cls.assert_called_once_with(
+                credential=mock_credential,
+                subscription_id="subscription-id",
+            )
+            assert conn is mock_client_instance
+
+    @patch(
+        
"airflow.providers.microsoft.azure.hooks.container_instance.AsyncContainerInstanceManagementClient"
+    )
+    
@patch("airflow.providers.microsoft.azure.hooks.container_instance.get_async_default_azure_credential")
+    @pytest.mark.asyncio
+    async def test_get_async_conn_with_default_credential(
+        self,
+        mock_default_cred,
+        mock_client_cls,
+        async_conn_without_credentials,
+    ):
+        mock_credential = MagicMock()
+        mock_credential.close = AsyncMock()
+        mock_default_cred.return_value = mock_credential
+        mock_client_instance = 
MagicMock(spec=AsyncContainerInstanceManagementClient)
+        mock_client_instance.close = AsyncMock()
+        mock_client_cls.return_value = mock_client_instance
+
+        hook = 
AzureContainerInstanceAsyncHook(azure_conn_id=async_conn_without_credentials.conn_id)
+        async with hook.get_async_conn() as conn:
+            mock_default_cred.assert_called_once_with(
+                managed_identity_client_id=None,
+                workload_identity_tenant_id=None,
+            )
+            assert conn is mock_client_instance
+
+    @patch(
+        
"airflow.providers.microsoft.azure.hooks.container_instance.AsyncContainerInstanceManagementClient"
+    )
+    
@patch("airflow.providers.microsoft.azure.hooks.container_instance.AsyncClientSecretCredential")
+    @pytest.mark.asyncio
+    async def test_get_async_conn_closes_client_and_credential(
+        self,
+        mock_credential_cls,
+        mock_client_cls,
+        async_conn_with_credentials,
+    ):
+        mock_credential = AsyncMock(spec=AsyncClientSecretCredential)
+        mock_credential_cls.return_value = mock_credential
+        mock_client_instance = 
AsyncMock(spec=AsyncContainerInstanceManagementClient)
+        mock_client_cls.return_value = mock_client_instance
+
+        hook = 
AzureContainerInstanceAsyncHook(azure_conn_id=async_conn_with_credentials.conn_id)
+        async with hook.get_async_conn() as conn:
+            assert conn is mock_client_instance
+
+        mock_client_instance.close.assert_called_once()
+        mock_credential.close.assert_called_once()
+
+    @patch(
+        
"airflow.providers.microsoft.azure.hooks.container_instance.AsyncContainerInstanceManagementClient"
+    )
+    
@patch("airflow.providers.microsoft.azure.hooks.container_instance.get_async_default_azure_credential")
+    @pytest.mark.asyncio
+    async def test_get_async_conn_skips_credential_close_when_not_closeable(
+        self,
+        mock_default_cred,
+        mock_client_cls,
+        async_conn_without_credentials,
+    ):
+        # Credential without a close method (spec=[] removes all attributes)
+        mock_credential = MagicMock(spec=[])
+        mock_default_cred.return_value = mock_credential
+        mock_client_instance = 
AsyncMock(spec=AsyncContainerInstanceManagementClient)
+        mock_client_cls.return_value = mock_client_instance
+
+        hook = 
AzureContainerInstanceAsyncHook(azure_conn_id=async_conn_without_credentials.conn_id)
+        async with hook.get_async_conn():
+            pass
+
+        mock_client_instance.close.assert_called_once()
+        assert not hasattr(mock_credential, "close")
+
+    @patch(
+        
"airflow.providers.microsoft.azure.hooks.container_instance.AsyncContainerInstanceManagementClient"
+    )
+    
@patch("airflow.providers.microsoft.azure.hooks.container_instance.AsyncClientSecretCredential")
+    @pytest.mark.asyncio
+    async def test_get_async_conn_closes_on_exception(
+        self,
+        mock_credential_cls,
+        mock_client_cls,
+        async_conn_with_credentials,
+    ):
+        mock_credential = AsyncMock(spec=AsyncClientSecretCredential)
+        mock_credential_cls.return_value = mock_credential
+        mock_client_instance = 
AsyncMock(spec=AsyncContainerInstanceManagementClient)
+        mock_client_cls.return_value = mock_client_instance
+
+        hook = 
AzureContainerInstanceAsyncHook(azure_conn_id=async_conn_with_credentials.conn_id)
+        with pytest.raises(RuntimeError, match="boom"):
+            async with hook.get_async_conn():
+                raise RuntimeError("boom")
+
+        mock_client_instance.close.assert_called_once()
+        mock_credential.close.assert_called_once()
+
+    @patch(
+        
"airflow.providers.microsoft.azure.hooks.container_instance.AsyncContainerInstanceManagementClient"
+    )
+    
@patch("airflow.providers.microsoft.azure.hooks.container_instance.AsyncClientSecretCredential")
+    @pytest.mark.asyncio
+    async def test_get_state(
+        self,
+        mock_credential_cls,
+        mock_client_cls,
+        async_conn_with_credentials,
+    ):
+        mock_credential_cls.return_value = 
AsyncMock(spec=AsyncClientSecretCredential)
+        mock_cg = MagicMock(spec=ContainerGroup)
+        mock_client = MagicMock()
+        mock_client.close = AsyncMock()
+        mock_client.container_groups.get = AsyncMock(return_value=mock_cg)
+        mock_client_cls.return_value = mock_client
+
+        hook = 
AzureContainerInstanceAsyncHook(azure_conn_id=async_conn_with_credentials.conn_id)
+        result = await hook.get_state("my-rg", "my-container")
+
+        mock_client.container_groups.get.assert_called_once_with("my-rg", 
"my-container")
+        assert result is mock_cg
+
+    @patch(
+        
"airflow.providers.microsoft.azure.hooks.container_instance.AsyncContainerInstanceManagementClient"
+    )
+    
@patch("airflow.providers.microsoft.azure.hooks.container_instance.AsyncClientSecretCredential")
+    @pytest.mark.asyncio
+    async def test_get_logs(
+        self,
+        mock_credential_cls,
+        mock_client_cls,
+        async_conn_with_credentials,
+    ):
+        mock_credential_cls.return_value = 
AsyncMock(spec=AsyncClientSecretCredential)
+        mock_logs = MagicMock(spec=Logs)
+        mock_logs.content = "line1\nline2\n"
+        mock_client = MagicMock()
+        mock_client.close = AsyncMock()
+        mock_client.containers.list_logs = AsyncMock(return_value=mock_logs)
+        mock_client_cls.return_value = mock_client
+
+        hook = 
AzureContainerInstanceAsyncHook(azure_conn_id=async_conn_with_credentials.conn_id)
+        result = await hook.get_logs("my-rg", "my-container")
+
+        mock_client.containers.list_logs.assert_called_once_with(
+            "my-rg", "my-container", "my-container", tail=1000
+        )
+        assert result == ["line1\n", "line2\n"]
+
+    @patch(
+        
"airflow.providers.microsoft.azure.hooks.container_instance.AsyncContainerInstanceManagementClient"
+    )
+    
@patch("airflow.providers.microsoft.azure.hooks.container_instance.AsyncClientSecretCredential")
+    @pytest.mark.asyncio
+    async def test_get_logs_returns_none_sentinel_when_content_is_none(
+        self,
+        mock_credential_cls,
+        mock_client_cls,
+        async_conn_with_credentials,
+    ):
+        mock_credential_cls.return_value = 
AsyncMock(spec=AsyncClientSecretCredential)
+        mock_logs = MagicMock(spec=Logs)
+        mock_logs.content = None
+        mock_client = MagicMock()
+        mock_client.close = AsyncMock()
+        mock_client.containers.list_logs = AsyncMock(return_value=mock_logs)
+        mock_client_cls.return_value = mock_client
+
+        hook = 
AzureContainerInstanceAsyncHook(azure_conn_id=async_conn_with_credentials.conn_id)
+        result = await hook.get_logs("my-rg", "my-container")
+        assert result == [None]
+
+    @patch(
+        
"airflow.providers.microsoft.azure.hooks.container_instance.AsyncContainerInstanceManagementClient"
+    )
+    
@patch("airflow.providers.microsoft.azure.hooks.container_instance.AsyncClientSecretCredential")
+    @pytest.mark.asyncio
+    async def test_delete(
+        self,
+        mock_credential_cls,
+        mock_client_cls,
+        async_conn_with_credentials,
+    ):
+        mock_credential_cls.return_value = 
AsyncMock(spec=AsyncClientSecretCredential)
+        mock_client = MagicMock()
+        mock_client.close = AsyncMock()
+        mock_client.container_groups.begin_delete = AsyncMock()
+        mock_client_cls.return_value = mock_client
+
+        hook = 
AzureContainerInstanceAsyncHook(azure_conn_id=async_conn_with_credentials.conn_id)
+        await hook.delete("my-rg", "my-container")
+
+        
mock_client.container_groups.begin_delete.assert_called_once_with("my-rg", 
"my-container")
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/operators/test_container_instances.py
 
b/providers/microsoft/azure/tests/unit/microsoft/azure/operators/test_container_instances.py
index 9325b318bce..d21070de0c3 100644
--- 
a/providers/microsoft/azure/tests/unit/microsoft/azure/operators/test_container_instances.py
+++ 
b/providers/microsoft/azure/tests/unit/microsoft/azure/operators/test_container_instances.py
@@ -32,8 +32,10 @@ from azure.mgmt.containerinstance.models import (
     Event,
 )
 
+from airflow.exceptions import TaskDeferred
 from airflow.providers.common.compat.sdk import AirflowException
 from airflow.providers.microsoft.azure.operators.container_instances import 
AzureContainerInstancesOperator
+from airflow.providers.microsoft.azure.triggers.container_instance import 
AzureContainerInstanceTrigger
 
 from tests_common.test_utils.compat import Context
 
@@ -642,6 +644,292 @@ class TestACIOperator:
         # user_assigned_identities should contain the resource id as a key
         assert resource_id in (called_cg.identity.user_assigned_identities or 
{})
 
+    @mock.patch(
+        
"airflow.providers.microsoft.azure.operators.container_instances.AzureContainerInstanceHook",
+        autospec=True,
+    )
+    def test_execute_deferrable_defers_unconditionally(self, aci_mock):
+        """When deferrable=True, defer() is called immediately after 
create_or_update."""
+        aci_mock.return_value.exists.return_value = False
+
+        aci = AzureContainerInstancesOperator(
+            ci_conn_id="azure_default",
+            registry_conn_id=None,
+            resource_group="resource-group",
+            name="container-name",
+            image="container-image",
+            region="region",
+            task_id="task",
+            deferrable=True,
+            polling_interval=10.0,
+        )
+        with pytest.raises(TaskDeferred) as exc_info:
+            aci.execute(None)
+
+        assert aci_mock.return_value.create_or_update.call_count == 1
+        assert isinstance(exc_info.value.trigger, 
AzureContainerInstanceTrigger)
+        assert exc_info.value.trigger.resource_group == "resource-group"
+        assert exc_info.value.trigger.name == "container-name"
+        assert exc_info.value.trigger.polling_interval == 10.0
+        assert exc_info.value.method_name == "execute_complete"
+        assert aci_mock.return_value.delete.call_count == 0
+        assert aci_mock.return_value.get_state.call_count == 0
+
+    @mock.patch(
+        
"airflow.providers.microsoft.azure.operators.container_instances.AzureContainerInstanceHook",
+        autospec=True,
+    )
+    def test_execute_complete_success(self, aci_mock):
+        """execute_complete succeeds, does not raise, and deletes the 
container group."""
+        aci_mock.return_value.get_logs.return_value = None
+
+        aci = AzureContainerInstancesOperator(
+            ci_conn_id="azure_default",
+            registry_conn_id=None,
+            resource_group="resource-group",
+            name="container-name",
+            image="container-image",
+            region="region",
+            task_id="task",
+            deferrable=True,
+            remove_on_success=True,
+        )
+        result = aci.execute_complete(
+            context=None,
+            event={
+                "status": "success",
+                "exit_code": 0,
+                "detail_status": "Completed",
+                "resource_group": "resource-group",
+                "name": "container-name",
+            },
+        )
+        assert result == 0
+        assert aci_mock.return_value.delete.call_count == 1
+
+    @mock.patch(
+        
"airflow.providers.microsoft.azure.operators.container_instances.AzureContainerInstanceHook",
+        autospec=True,
+    )
+    def test_execute_complete_success_with_remove_on_success_false(self, 
aci_mock):
+        """execute_complete with remove_on_success=False should NOT delete the 
container."""
+        aci_mock.return_value.get_logs.return_value = None
+
+        aci = AzureContainerInstancesOperator(
+            ci_conn_id="azure_default",
+            registry_conn_id=None,
+            resource_group="resource-group",
+            name="container-name",
+            image="container-image",
+            region="region",
+            task_id="task",
+            deferrable=True,
+            remove_on_success=False,
+        )
+        aci.execute_complete(
+            context=None,
+            event={
+                "status": "success",
+                "exit_code": 0,
+                "detail_status": "Completed",
+                "resource_group": "resource-group",
+                "name": "container-name",
+            },
+        )
+        assert aci_mock.return_value.delete.call_count == 0
+
+    @mock.patch(
+        
"airflow.providers.microsoft.azure.operators.container_instances.AzureContainerInstanceHook",
+        autospec=True,
+    )
+    def test_execute_complete_error_event_raises(self, aci_mock):
+        """execute_complete raises RuntimeError when event has status=error."""
+        aci = AzureContainerInstancesOperator(
+            ci_conn_id="azure_default",
+            registry_conn_id=None,
+            resource_group="resource-group",
+            name="container-name",
+            image="container-image",
+            region="region",
+            task_id="task",
+            deferrable=True,
+            remove_on_error=False,
+        )
+        with pytest.raises(RuntimeError, match="Container group failed"):
+            aci.execute_complete(
+                context=None,
+                event={
+                    "status": "error",
+                    "exit_code": 1,
+                    "detail_status": "OOMKilled",
+                    "message": "Container group failed with exit code 1",
+                    "resource_group": "resource-group",
+                    "name": "container-name",
+                },
+            )
+
+    @mock.patch(
+        
"airflow.providers.microsoft.azure.operators.container_instances.AzureContainerInstanceHook",
+        autospec=True,
+    )
+    def test_execute_complete_error_event_removes_on_error(self, aci_mock):
+        """execute_complete with remove_on_error=True deletes the container on 
error."""
+        aci = AzureContainerInstancesOperator(
+            ci_conn_id="azure_default",
+            registry_conn_id=None,
+            resource_group="resource-group",
+            name="container-name",
+            image="container-image",
+            region="region",
+            task_id="task",
+            deferrable=True,
+            remove_on_error=True,
+        )
+        with pytest.raises(RuntimeError):
+            aci.execute_complete(
+                context=None,
+                event={
+                    "status": "error",
+                    "exit_code": 1,
+                    "detail_status": "Failed",
+                    "message": "Container group failed with exit code 1",
+                    "resource_group": "resource-group",
+                    "name": "container-name",
+                },
+            )
+        assert aci_mock.return_value.delete.call_count == 1
+
+    @mock.patch(
+        
"airflow.providers.microsoft.azure.operators.container_instances.AzureContainerInstanceHook",
+        autospec=True,
+    )
+    def test_execute_complete_none_event_raises(self, aci_mock):
+        """execute_complete raises ValueError when event is None."""
+        aci = AzureContainerInstancesOperator(
+            ci_conn_id="azure_default",
+            registry_conn_id=None,
+            resource_group="resource-group",
+            name="container-name",
+            image="container-image",
+            region="region",
+            task_id="task",
+            deferrable=True,
+        )
+        with pytest.raises(ValueError, match="event is None"):
+            aci.execute_complete(context=None, event=None)
+
+    @mock.patch(
+        
"airflow.providers.microsoft.azure.operators.container_instances.AzureContainerInstanceHook",
+        autospec=True,
+    )
+    def test_execute_complete_pushes_all_logs_to_xcom(self, aci_mock):
+        """execute_complete pushes all logs to XCom when xcom_all=True."""
+        aci_mock.return_value.get_logs.return_value = ["line1\n", "line2\n"]
+
+        aci = AzureContainerInstancesOperator(
+            ci_conn_id="azure_default",
+            registry_conn_id=None,
+            resource_group="resource-group",
+            name="container-name",
+            image="container-image",
+            region="region",
+            task_id="task",
+            deferrable=True,
+            xcom_all=True,
+            remove_on_success=False,
+        )
+        context = Context(ti=XcomMock())
+        aci.execute_complete(
+            context=context,
+            event={
+                "status": "success",
+                "exit_code": 0,
+                "detail_status": "Completed",
+                "resource_group": "resource-group",
+                "name": "container-name",
+            },
+        )
+        assert context["ti"].xcom_pull(key="logs") == ["line1\n", "line2\n"]
+
+    @mock.patch(
+        
"airflow.providers.microsoft.azure.operators.container_instances.AzureContainerInstanceHook",
+        autospec=True,
+    )
+    def test_execute_complete_pushes_last_log_to_xcom(self, aci_mock):
+        """execute_complete pushes only last log line to XCom when 
xcom_all=False."""
+        aci_mock.return_value.get_logs.return_value = ["line1\n", "line2\n"]
+
+        aci = AzureContainerInstancesOperator(
+            ci_conn_id="azure_default",
+            registry_conn_id=None,
+            resource_group="resource-group",
+            name="container-name",
+            image="container-image",
+            region="region",
+            task_id="task",
+            deferrable=True,
+            xcom_all=False,
+            remove_on_success=False,
+        )
+        context = Context(ti=XcomMock())
+        aci.execute_complete(
+            context=context,
+            event={
+                "status": "success",
+                "exit_code": 0,
+                "detail_status": "Completed",
+                "resource_group": "resource-group",
+                "name": "container-name",
+            },
+        )
+        assert context["ti"].xcom_pull(key="logs") == ["line2\n"]
+
+    @mock.patch(
+        
"airflow.providers.microsoft.azure.operators.container_instances.AzureContainerInstanceHook",
+        autospec=True,
+    )
+    def test_execute_remove_on_success_false_sync_path(self, aci_mock):
+        """Non-deferrable: remove_on_success=False should NOT delete the 
container on success."""
+        terminated_cg = make_mock_container(state="Terminated", exit_code=0, 
detail_status="test")
+        aci_mock.return_value.get_state.return_value = terminated_cg
+        aci_mock.return_value.exists.return_value = False
+
+        aci = AzureContainerInstancesOperator(
+            ci_conn_id=None,
+            registry_conn_id=None,
+            resource_group="resource-group",
+            name="container-name",
+            image="container-image",
+            region="region",
+            task_id="task",
+            remove_on_success=False,
+        )
+        aci.execute(None)
+        assert aci_mock.return_value.delete.call_count == 0
+
+    @mock.patch(
+        
"airflow.providers.microsoft.azure.operators.container_instances.AzureContainerInstanceHook",
+        autospec=True,
+    )
+    def test_execute_remove_on_success_true_sync_path(self, aci_mock):
+        """Non-deferrable: remove_on_success=True (default) deletes the 
container on success."""
+        terminated_cg = make_mock_container(state="Terminated", exit_code=0, 
detail_status="test")
+        aci_mock.return_value.get_state.return_value = terminated_cg
+        aci_mock.return_value.exists.return_value = False
+
+        aci = AzureContainerInstancesOperator(
+            ci_conn_id=None,
+            registry_conn_id=None,
+            resource_group="resource-group",
+            name="container-name",
+            image="container-image",
+            region="region",
+            task_id="task",
+            remove_on_success=True,
+        )
+        aci.execute(None)
+        assert aci_mock.return_value.delete.call_count == 1
+
 
 class XcomMock:
     def __init__(self) -> None:
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/triggers/test_container_instance.py
 
b/providers/microsoft/azure/tests/unit/microsoft/azure/triggers/test_container_instance.py
new file mode 100644
index 00000000000..e4d26060ed2
--- /dev/null
+++ 
b/providers/microsoft/azure/tests/unit/microsoft/azure/triggers/test_container_instance.py
@@ -0,0 +1,248 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+from unittest import mock
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from azure.mgmt.containerinstance.models import (
+    Container,
+    ContainerGroup,
+    ContainerPropertiesInstanceView,
+    ContainerState,
+)
+
+from airflow.providers.microsoft.azure.triggers.container_instance import 
AzureContainerInstanceTrigger
+from airflow.triggers.base import TriggerEvent
+
+RESOURCE_GROUP = "test-resource-group"
+CONTAINER_NAME = "test-container"
+CI_CONN_ID = "azure_container_instance_test"
+POLLING_INTERVAL = 5.0
+
+MODULE = "airflow.providers.microsoft.azure.triggers.container_instance"
+
+
+def _make_cg_state(state: str, exit_code: int = 0, detail_status: str = 
"test") -> ContainerGroup:
+    """Build a minimal fake ContainerGroup using real SDK models (read-only 
attrs use type: ignore)."""
+    container_state = ContainerState()
+    container_state.state = state  # type: ignore[assignment]
+    container_state.exit_code = exit_code  # type: ignore[assignment]
+    container_state.detail_status = detail_status  # type: ignore[assignment]
+    instance_view = ContainerPropertiesInstanceView()
+    instance_view.current_state = container_state  # type: ignore[assignment]
+    container = Container(name="test", image="test", resources="test")  # 
type: ignore[arg-type]
+    container.instance_view = instance_view  # type: ignore[assignment]
+    cg = ContainerGroup(containers=[container], os_type="Linux")
+    cg.provisioning_state = state  # type: ignore[assignment]
+    return cg
+
+
+def _make_provisioning_state(state: str) -> ContainerGroup:
+    """Build a ContainerGroup where instance_view is None (provisioning 
phase)."""
+    container = Container(name="test", image="test", resources="test")  # 
type: ignore[arg-type]
+    container.instance_view = None  # type: ignore[assignment]
+    cg = ContainerGroup(containers=[container], os_type="Linux")
+    cg.provisioning_state = state  # type: ignore[assignment]
+    return cg
+
+
+def _make_mock_hook(mock_client):
+    """Create a mock hook whose get_async_conn yields the given mock client."""
+    mock_hook = MagicMock()
+
+    @asynccontextmanager
+    async def _get_async_conn():
+        yield mock_client
+
+    mock_hook.get_async_conn = _get_async_conn
+    return mock_hook
+
+
+class TestAzureContainerInstanceTrigger:
+    def test_serialize(self):
+        trigger = AzureContainerInstanceTrigger(
+            resource_group=RESOURCE_GROUP,
+            name=CONTAINER_NAME,
+            ci_conn_id=CI_CONN_ID,
+            polling_interval=POLLING_INTERVAL,
+        )
+        classpath, kwargs = trigger.serialize()
+
+        assert classpath == (
+            
"airflow.providers.microsoft.azure.triggers.container_instance.AzureContainerInstanceTrigger"
+        )
+        assert kwargs == {
+            "resource_group": RESOURCE_GROUP,
+            "name": CONTAINER_NAME,
+            "ci_conn_id": CI_CONN_ID,
+            "polling_interval": POLLING_INTERVAL,
+        }
+
+    @pytest.mark.asyncio
+    @mock.patch(f"{MODULE}.AzureContainerInstanceAsyncHook")
+    async def test_run_yields_success_on_terminated_with_exit_code_zero(self, 
mock_hook_cls):
+        mock_client = MagicMock()
+        mock_client.container_groups.get = 
AsyncMock(return_value=_make_cg_state("Terminated", exit_code=0))
+        mock_hook_cls.return_value = _make_mock_hook(mock_client)
+
+        trigger = AzureContainerInstanceTrigger(
+            resource_group=RESOURCE_GROUP,
+            name=CONTAINER_NAME,
+            ci_conn_id=CI_CONN_ID,
+            polling_interval=POLLING_INTERVAL,
+        )
+        events = [event async for event in trigger.run()]
+
+        assert len(events) == 1
+        assert isinstance(events[0], TriggerEvent)
+        assert events[0].payload["status"] == "success"
+        assert events[0].payload["exit_code"] == 0
+
+    @pytest.mark.asyncio
+    @mock.patch(f"{MODULE}.AzureContainerInstanceAsyncHook")
+    async def test_run_yields_success_on_succeeded_state(self, mock_hook_cls):
+        mock_client = MagicMock()
+        mock_client.container_groups.get = 
AsyncMock(return_value=_make_cg_state("Succeeded", exit_code=0))
+        mock_hook_cls.return_value = _make_mock_hook(mock_client)
+
+        trigger = AzureContainerInstanceTrigger(
+            resource_group=RESOURCE_GROUP,
+            name=CONTAINER_NAME,
+            ci_conn_id=CI_CONN_ID,
+        )
+        events = [event async for event in trigger.run()]
+
+        assert events[0].payload["status"] == "success"
+
+    @pytest.mark.asyncio
+    @mock.patch(f"{MODULE}.AzureContainerInstanceAsyncHook")
+    async def test_run_yields_error_on_terminated_with_nonzero_exit_code(self, 
mock_hook_cls):
+        mock_client = MagicMock()
+        mock_client.container_groups.get = 
AsyncMock(return_value=_make_cg_state("Terminated", exit_code=1))
+        mock_hook_cls.return_value = _make_mock_hook(mock_client)
+
+        trigger = AzureContainerInstanceTrigger(
+            resource_group=RESOURCE_GROUP,
+            name=CONTAINER_NAME,
+            ci_conn_id=CI_CONN_ID,
+        )
+        events = [event async for event in trigger.run()]
+
+        assert events[0].payload["status"] == "error"
+        assert events[0].payload["exit_code"] == 1
+
+    @pytest.mark.asyncio
+    @mock.patch(f"{MODULE}.AzureContainerInstanceAsyncHook")
+    @pytest.mark.parametrize("terminal_state", ["Failed", "Unhealthy"])
+    async def test_run_yields_error_on_failed_states(self, mock_hook_cls, 
terminal_state):
+        mock_client = MagicMock()
+        mock_client.container_groups.get = 
AsyncMock(return_value=_make_cg_state(terminal_state, exit_code=1))
+        mock_hook_cls.return_value = _make_mock_hook(mock_client)
+
+        trigger = AzureContainerInstanceTrigger(
+            resource_group=RESOURCE_GROUP,
+            name=CONTAINER_NAME,
+            ci_conn_id=CI_CONN_ID,
+        )
+        events = [event async for event in trigger.run()]
+
+        assert events[0].payload["status"] == "error"
+
+    @pytest.mark.asyncio
+    @mock.patch(f"{MODULE}.asyncio.sleep", new_callable=AsyncMock)
+    @mock.patch(f"{MODULE}.AzureContainerInstanceAsyncHook")
+    async def test_run_polls_through_non_terminal_states(self, mock_hook_cls, 
mock_sleep):
+        mock_client = MagicMock()
+        mock_client.container_groups.get = AsyncMock(
+            side_effect=[
+                _make_provisioning_state("Creating"),
+                _make_cg_state("Running", exit_code=0),
+                _make_cg_state("Terminated", exit_code=0),
+            ]
+        )
+        mock_hook_cls.return_value = _make_mock_hook(mock_client)
+
+        trigger = AzureContainerInstanceTrigger(
+            resource_group=RESOURCE_GROUP,
+            name=CONTAINER_NAME,
+            ci_conn_id=CI_CONN_ID,
+            polling_interval=POLLING_INTERVAL,
+        )
+        events = [event async for event in trigger.run()]
+
+        assert mock_client.container_groups.get.call_count == 3
+        assert mock_sleep.call_count == 2
+        mock_sleep.assert_called_with(POLLING_INTERVAL)
+        assert events[0].payload["status"] == "success"
+
+    @pytest.mark.asyncio
+    @mock.patch(f"{MODULE}.AzureContainerInstanceAsyncHook")
+    async def test_run_yields_error_event_on_exception(self, mock_hook_cls):
+        mock_client = MagicMock()
+        mock_client.container_groups.get = 
AsyncMock(side_effect=Exception("Azure API error"))
+        mock_hook_cls.return_value = _make_mock_hook(mock_client)
+
+        trigger = AzureContainerInstanceTrigger(
+            resource_group=RESOURCE_GROUP,
+            name=CONTAINER_NAME,
+            ci_conn_id=CI_CONN_ID,
+        )
+        events = [event async for event in trigger.run()]
+
+        assert len(events) == 1
+        assert events[0].payload["status"] == "error"
+        assert "Azure API error" in events[0].payload["message"]
+
+    @pytest.mark.asyncio
+    @mock.patch(f"{MODULE}.asyncio.sleep", new_callable=AsyncMock)
+    @mock.patch(f"{MODULE}.AzureContainerInstanceAsyncHook")
+    async def test_run_provisioning_state_does_not_yield_until_terminal(self, 
mock_hook_cls, mock_sleep):
+        """Container in 'Creating' (no instance_view) should keep polling."""
+        mock_client = MagicMock()
+        mock_client.container_groups.get = AsyncMock(
+            side_effect=[
+                _make_provisioning_state("Creating"),
+                _make_cg_state("Terminated", exit_code=0),
+            ]
+        )
+        mock_hook_cls.return_value = _make_mock_hook(mock_client)
+
+        trigger = AzureContainerInstanceTrigger(
+            resource_group=RESOURCE_GROUP,
+            name=CONTAINER_NAME,
+            ci_conn_id=CI_CONN_ID,
+        )
+        events = [event async for event in trigger.run()]
+
+        assert mock_client.container_groups.get.call_count == 2
+        assert events[0].payload["status"] == "success"
+
+    def test_serialize_deserialize_roundtrip(self):
+        original = AzureContainerInstanceTrigger(
+            resource_group=RESOURCE_GROUP,
+            name=CONTAINER_NAME,
+            ci_conn_id=CI_CONN_ID,
+            polling_interval=10.0,
+        )
+        classpath, kwargs = original.serialize()
+        reconstructed = AzureContainerInstanceTrigger(**kwargs)
+        _, kwargs2 = reconstructed.serialize()
+        assert kwargs == kwargs2

Reply via email to