Skip to content

Post-provision hook always fails: postconfig calls CosmosClient.close(), which azure-cosmos 4.9.0 does not have #1589

Open

Description

@paullizer

Issue

On Development (be01d94) and paullizer-react-v2-ui (4f77838), the post-provision hook fails on every run. Post-deployment configuration saves and verifies the settings, then crashes on its last line:

AttributeError: 'CosmosClient' object has no attribute 'close'

Both hook variants, pwsh and POSIX, treat the non-zero exit as a failure. The hook stops before [4/4] Restarting web service, and azd up / azd provision reports the deployment as failed.

This was introduced by #1488 (b9a063a, merged 2026-09-17). main (f5bcae7) is not affected.

Root cause

azure-cosmos is pinned to 4.9.0 in deployers/bicep/requirements.txt and application/single_app/requirements.txt. The sync azure.cosmos.CosmosClient in 4.9.0 has no close() method. It releases its HTTP transport only through the context-manager protocol:

# azure/cosmos/cosmos_client.py (azure-cosmos 4.9.0)
def __enter__(self):
    self.client_connection.pipeline_client.__enter__()
    return self

def __exit__(self, *args):
    return self.client_connection.pipeline_client.__exit__(*args)

The deployer calls client.close() in four places:

File Line Path
deployers/bicep/postconfig.py 570 Success path; runs at the end of every post-provision run
deployers/bicep/deployment_cosmos.py 64 CosmosHttpResponseError after the client was constructed
deployers/bicep/deployment_cosmos.py 75 ClientAuthenticationError after the client was constructed
deployers/bicep/deployment_cosmos.py 79 ServiceRequestError after the client was constructed

The tests did not catch it. test_deployment_cosmos_access.py::test_failed_read_closes_client_and_redacts_error and test_deployment_configuration.py::test_real_postconfig_entrypoint_writes_once_and_initializes_search fake the client with a bare Mock(), which accepts any attribute. Both assert close.assert_called_once(), so they lock the bug in.

Steps to Reproduce

  1. Check out Development or paullizer-react-v2-ui.
  2. Run azd up or azd provision with post-configuration enabled (var_configureApplication=true, the default). Alternatively, run azd hooks run postprovision -e <env> against an existing environment.
  3. Watch step [3/4] Running post-deployment configuration....

Expected Behavior

postconfig.py exits 0 after Settings saved and verified. The hook then restarts the web app in [4/4] and reports success.

Actual Behavior

The settings are saved and verified, and then postconfig.py exits 1. Excerpt from a SimpleChat POC environment on 2026-09-30, reordered because stdout and stderr interleave:

[3/4] Running post-deployment configuration...
Found existing app_setting document
Settings saved and verified. Search indexes created: none (already exist).
Restart the web service to activate any changed Redis session configuration.
Traceback (most recent call last):
  File "...\deployers\bicep\postconfig.py", line 570, in <module>
    client.close()
AttributeError: 'CosmosClient' object has no attribute 'close'
Post-deployment configuration failed.
ERROR: failed running hook postprovision, 'postprovision' hook failed with exit code: '1'

Impact

  • Every azd up / azd provision with post-configuration enabled reports failure, even though the infrastructure deployment and the settings save succeeded. CI/CD pipelines that run azd fail.
  • The web app is not restarted. Redis and session configuration that postconfig writes does not take effect until someone restarts the app manually.
  • In create_deployment_cosmos_client, if the client is constructed but the list_databases() probe fails, the close() call raises AttributeError inside the except block. That traceback replaces the network or RBAC guidance in the intended RuntimeError.

Workaround

If the hook output contains Settings saved and verified, the configuration was applied. Restart the app yourself:

az webapp restart --name <web-app> --resource-group <resource-group> --subscription <subscription-id>

Proposed fix (verified)

  • Add close_cosmos_client(client) to deployment_cosmos.py. It releases the transport with client.__exit__(None, None, None), the only release mechanism the pinned sync client provides.
  • Use the helper in the three error paths and at the end of postconfig.py.
  • Fake the client with MagicMock(spec=CosmosClient) in the two tests. A call to a method the real client lacks, such as close(), then fails the test.
  • Add a test that runs the helper against the real CosmosClient.__exit__.
  • Add a static guard to the postconfig test.

All five files are identical on Development and paullizer-react-v2-ui, so the same patch applies to both. Apply it with git apply:

diff --git a/deployers/bicep/deployment_cosmos.py b/deployers/bicep/deployment_cosmos.py
index b3567b42..1f64eec8 100644
--- a/deployers/bicep/deployment_cosmos.py
+++ b/deployers/bicep/deployment_cosmos.py
@@ -27,6 +27,11 @@ def run_cli(arguments):
     return json.loads(result.stdout)
 
 
+def close_cosmos_client(client):
+    # The pinned azure-cosmos sync client has no close(); __exit__ releases its transport.
+    client.__exit__(None, None, None)
+
+
 def create_deployment_cosmos_client(environment=None):
     environment = os.environ if environment is None else environment
     subscription = environment.get("AZURE_SUBSCRIPTION_ID") or environment.get("var_subscriptionId")
@@ -61,7 +66,7 @@ def create_deployment_cosmos_client(environment=None):
         return client
     except CosmosHttpResponseError as error:
         if client is not None:
-            client.close()
+            close_cosmos_client(client)
         message = str(error).lower()
         if "firewall" in message or "public internet" in message:
             detail = "Cosmos network access is blocked. Use a network-connected runner or an approved firewall rule."
@@ -72,11 +77,11 @@ def create_deployment_cosmos_client(environment=None):
         raise RuntimeError(detail + " No firewall rules were changed.") from None
     except ClientAuthenticationError:
         if client is not None:
-            client.close()
+            close_cosmos_client(client)
         raise RuntimeError("Azure CLI authentication failed. Sign in to the deployment tenant and retry.") from None
     except ServiceRequestError:
         if client is not None:
-            client.close()
+            close_cosmos_client(client)
         raise RuntimeError("Cosmos endpoint could not be reached. Check DNS and runner network connectivity.") from None
 
 
diff --git a/deployers/bicep/postconfig.py b/deployers/bicep/postconfig.py
index f4b1bd8c..f7bfdc1b 100644
--- a/deployers/bicep/postconfig.py
+++ b/deployers/bicep/postconfig.py
@@ -14,7 +14,7 @@ from deployment_configuration import (
     load_deployment_environment,
     persist_settings,
 )
-from deployment_cosmos import create_deployment_cosmos_client
+from deployment_cosmos import close_cosmos_client, create_deployment_cosmos_client
 
 load_deployment_environment()
 credential = AzureCliCredential(tenant_id=os.environ["AZURE_TENANT_ID"])
@@ -567,4 +567,4 @@ created_indexes = configure_search(item, credential)
 response = persist_settings(container, original_item, item, credential)
 print(f"Settings saved and verified. Search indexes created: {', '.join(created_indexes) or 'none (already exist)'}.")
 print("Restart the web service to activate any changed Redis session configuration.")
-client.close()
+close_cosmos_client(client)
diff --git a/functional_tests/test_deployment_configuration.py b/functional_tests/test_deployment_configuration.py
index 639a86e5..bf267e5c 100644
--- a/functional_tests/test_deployment_configuration.py
+++ b/functional_tests/test_deployment_configuration.py
@@ -12,7 +12,7 @@ import json
 import os
 from pathlib import Path
 from types import SimpleNamespace
-from unittest.mock import Mock
+from unittest.mock import MagicMock, Mock
 import runpy
 import shutil
 import subprocess
@@ -21,6 +21,7 @@ import sys
 import pytest
 import yaml
 from azure.core.exceptions import HttpResponseError, ResourceNotFoundError
+from azure.cosmos import CosmosClient
 
 from test_app_settings_store_consistency import FakeCosmos, FakeRedis
 
@@ -31,6 +32,11 @@ SPEC = importlib.util.spec_from_file_location(
 )
 MODULE = importlib.util.module_from_spec(SPEC)
 SPEC.loader.exec_module(MODULE)
+COSMOS_SPEC = importlib.util.spec_from_file_location(
+    "deployment_cosmos", ROOT / "deployers" / "bicep" / "deployment_cosmos.py",
+)
+COSMOS_ACCESS = importlib.util.module_from_spec(COSMOS_SPEC)
+COSMOS_SPEC.loader.exec_module(COSMOS_ACCESS)
 
 
 @pytest.fixture
@@ -313,9 +319,12 @@ def test_real_postconfig_entrypoint_writes_once_and_initializes_search(monkeypat
     monkeypatch.setattr(MODULE, "load_deployment_environment", lambda: environment)
     search = FakeSearch()
     monkeypatch.setattr(MODULE, "configure_search", lambda *_: MODULE.ensure_search_indexes(search))
-    cosmos = Mock()
+    cosmos = MagicMock(spec=CosmosClient)
     cosmos.get_database_client.return_value.get_container_client.return_value = world.cosmos
-    access_module = SimpleNamespace(create_deployment_cosmos_client=lambda: cosmos)
+    access_module = SimpleNamespace(
+        create_deployment_cosmos_client=lambda: cosmos,
+        close_cosmos_client=COSMOS_ACCESS.close_cosmos_client,
+    )
     monkeypatch.setitem(sys.modules, "deployment_cosmos", access_module)
     monkeypatch.setitem(sys.modules, "deployment_configuration", MODULE)
     runpy.run_path(str(ROOT / "deployers" / "bicep" / "postconfig.py"), run_name="__main__")
@@ -324,7 +333,7 @@ def test_real_postconfig_entrypoint_writes_once_and_initializes_search(monkeypat
     assert world.cosmos.document["redis_url"] == "cache.redis.azure.net"
     assert json.loads(world.redis.raw)["document"] == world.cosmos.document
     assert set(search.indexes) == {"simplechat-user-index", "simplechat-group-index", "simplechat-public-index"}
-    cosmos.close.assert_called_once()
+    cosmos.__exit__.assert_called_once_with(None, None, None)
 
 
 @pytest.mark.parametrize("failure", [False, True])
diff --git a/functional_tests/test_deployment_cosmos_access.py b/functional_tests/test_deployment_cosmos_access.py
index a64af9e3..114f622e 100644
--- a/functional_tests/test_deployment_cosmos_access.py
+++ b/functional_tests/test_deployment_cosmos_access.py
@@ -8,7 +8,7 @@ Validate credential choice and that failed probes never mutate firewalls.
 
 import importlib.util
 from pathlib import Path
-from unittest.mock import Mock, patch
+from unittest.mock import MagicMock, Mock, patch
 
 import pytest
 from azure.cosmos.exceptions import CosmosHttpResponseError
@@ -104,12 +104,20 @@ def test_key_mode_retrieves_key_only_for_target_account(inputs):
 
 def test_failed_read_closes_client_and_redacts_error(inputs):
     account = {"documentEndpoint": "https://account.documents.azure.com"}
-    client = Mock()
+    # spec= makes the fake reject methods the real client lacks, such as close().
+    client = MagicMock(spec=MODULE.azure_cosmos.CosmosClient)
     client.list_databases.side_effect = CosmosHttpResponseError(status_code=401, message="sensitive-provider-response")
     with patch.object(MODULE, "run_cli", return_value=account), patch.object(
         MODULE, "AzureCliCredential"
     ), patch.object(MODULE.azure_cosmos, "CosmosClient", return_value=client):
-        with pytest.raises(RuntimeError) as error:
+        with pytest.raises(RuntimeError, match="authentication or data-plane RBAC failed") as error:
             MODULE.create_deployment_cosmos_client(inputs)
     assert "sensitive-provider-response" not in str(error.value)
-    client.close.assert_called_once()
\ No newline at end of file
+    client.__exit__.assert_called_once_with(None, None, None)
+
+
+def test_close_uses_the_real_client_transport_exit():
+    client = object.__new__(MODULE.azure_cosmos.CosmosClient)
+    client.client_connection = MagicMock()
+    MODULE.close_cosmos_client(client)
+    client.client_connection.pipeline_client.__exit__.assert_called_once_with(None, None, None)
\ No newline at end of file
diff --git a/functional_tests/test_postconfig_azurecli_credential.py b/functional_tests/test_postconfig_azurecli_credential.py
index f4652bb2..31cd241c 100644
--- a/functional_tests/test_postconfig_azurecli_credential.py
+++ b/functional_tests/test_postconfig_azurecli_credential.py
@@ -38,6 +38,8 @@ def test_postconfig_uses_repeatable_deployment_credentials() -> bool:
     require_contains(content, "client = create_deployment_cosmos_client()", "shared credential-aware Cosmos check")
     require_not_contains(content, 'os.getenv("var_cosmosDb_key")', "stale key precedence")
     require_not_contains(content, "DefaultAzureCredential()", "DefaultAzureCredential initialization")
+    require_contains(content, "close_cosmos_client(client)", "supported Cosmos client release")
+    require_not_contains(content, "client.close()", "CosmosClient.close() call unsupported by azure-cosmos 4.9.0")
 
     print("✅ postconfig imports AzureCliCredential")
     print("✅ postconfig uses explicit deployment authentication selection")

Validation of the patch

Run on 2026-09-30 in a scratch worktree from Development (be01d94) with the repo venv (Python 3.12, azure-cosmos 4.9.0):

  • Test changes only, original code: 4 tests fail. The tightened fakes catch the close() call.
  • Test changes plus fix: python -m pytest functional_tests/test_deployment_cosmos_access.py functional_tests/test_postconfig_azurecli_credential.py functional_tests/test_deployment_configuration.py gives 39 passed.

Acceptance Criteria

  • The patch above is applied to Development, and to paullizer-react-v2-ui unless it arrives through a merge.
  • deployers/version.txt has a patch bump. Note that Backport Key Vault Secrets Officer grants for app identities to Development #1587 moves Development to 1.0.32.
  • The test file headers show the current version, per repo convention.
  • There is a release note entry.
  • On a real environment, azd provision or azd hooks run postprovision -e <env> completes [4/4] Restarting web service and reports POST-PROVISION: Completed successfully.

Notes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions