Repository navigation
Post-provision hook always fails: postconfig calls CosmosClient.close(), which azure-cosmos 4.9.0 does not have #1589
Description
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
- Check out
Developmentorpaullizer-react-v2-ui. - Run
azd uporazd provisionwith post-configuration enabled (var_configureApplication=true, the default). Alternatively, runazd hooks run postprovision -e <env>against an existing environment. - 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 provisionwith 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 thelist_databases()probe fails, theclose()call raisesAttributeErrorinside theexceptblock. That traceback replaces the network or RBAC guidance in the intendedRuntimeError.
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)todeployment_cosmos.py. It releases the transport withclient.__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 asclose(), 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.pygives 39 passed.
Acceptance Criteria
- The patch above is applied to
Development, and topaullizer-react-v2-uiunless it arrives through a merge. -
deployers/version.txthas a patch bump. Note that Backport Key Vault Secrets Officer grants for app identities to Development #1587 movesDevelopmentto1.0.32. - The test file headers show the current version, per repo convention.
- There is a release note entry.
- On a real environment,
azd provisionorazd hooks run postprovision -e <env>completes[4/4] Restarting web serviceand reportsPOST-PROVISION: Completed successfully.
Notes
- Found while moving a SimpleChat POC environment from Azure Cache for Redis to Azure Managed Redis. Postconfig saved the managed Redis settings correctly, and the app was restarted by hand.
- Introduced by Fix deployment authentication, settings publication, and Search initialization #1488 (b9a063a).
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsPending Evaluation
Activity