From 6545e1d6780adeec755d059f7eb3c4e829e2b74c Mon Sep 17 00:00:00 2001 From: a0x1ab <59631311+a0x1ab@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:17:17 +0000 Subject: [PATCH] [Container Apps] Fix #10375: `az containerapp show`: Return exit code 3 for missing apps Root cause: src/containerapp/azext_containerapp/_clients.py: ContainerAppPreviewClient inherited ContainerAppClient.show without classifying HTTP 404. custom.py: show_containerapp delegates to core BaseContainerAppDecorator.show and BaseResource.show, whose handle_raw_exception converts structured ARM ResourceNotFound JSON from core HTTPError into CLIInternalError, losing the not-found exit classification. Change: Convert only core HTTPError 404 from handwritten ContainerAppPreviewClient.show into CLI ResourceNotFoundError with a clean message identifying the app and resource group. Preserve the original response for existing 404-tolerant create/existence checks. Leave other errors and successful show/secrets behavior unchanged. Add six focused command/client tests, a release history note and patch version 1.3.1b1. Alternatives considered: - Changing shared core handle_raw_exception requires a separate Azure/azure-cli change and broadens scope. - Parsing ARM body error codes instead of HTTP status would miss empty or malformed 404 responses. Verified: - Trusted validation passed (1 command). - New or changed tests fail on the base revision and pass with the change: `src/containerapp/azext_containerapp/tests/latest/test_containerapp_show_unit.py::ContainerappShowClientTest::test_404_is_classified_without_parsing_the_body`, `src/containerapp/azext_containerapp/tests/latest/test_containerapp_show_unit.py::ContainerappShowClientTest::test_not_found_keeps_response_for_existing_resource_checks`, `src/containerapp/azext_containerapp/tests/latest/test_containerapp_show_unit.py::ContainerappShowCommandTest::test_missing_app_show_returns_exit_code_3`. - Unit tests: 6 executed, 0 skipped. - flake8 and pylint passed. - Pre-publication gates passed: release-artifact, generated-ownership, command-help, regression-test, independent-review. - Repository CI and live tests run on this PR. Fixes https://github.com/Azure/azure-cli-extensions/issues/10375 --- src/containerapp/HISTORY.rst | 4 + .../azext_containerapp/_clients.py | 15 +- .../latest/test_containerapp_show_unit.py | 166 ++++++++++++++++++ src/containerapp/setup.py | 2 +- 4 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 src/containerapp/azext_containerapp/tests/latest/test_containerapp_show_unit.py diff --git a/src/containerapp/HISTORY.rst b/src/containerapp/HISTORY.rst index 9616545fbd6..07307bb9ff1 100644 --- a/src/containerapp/HISTORY.rst +++ b/src/containerapp/HISTORY.rst @@ -5,6 +5,10 @@ Release History upcoming ++++++ +1.3.1b1 ++++++++ +* `az containerapp show`: Fix missing container apps returning exit code 1 instead of 3. + 1.3.0b5 ++++++ * 'az containerapp env storage set': Support Azure Files authentication with an Azure Key Vault secret or managed identity diff --git a/src/containerapp/azext_containerapp/_clients.py b/src/containerapp/azext_containerapp/_clients.py index a1455e71aca..ad55e0d3fe2 100644 --- a/src/containerapp/azext_containerapp/_clients.py +++ b/src/containerapp/azext_containerapp/_clients.py @@ -9,7 +9,7 @@ import os import requests -from azure.cli.core.azclierror import CLIError, AzureResponseError +from azure.cli.core.azclierror import CLIError, AzureResponseError, HTTPError, ResourceNotFoundError from azure.cli.core.util import send_raw_request from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.command_modules.containerapp._clients import ( @@ -72,6 +72,19 @@ class GitHubActionPreviewClient(GitHubActionClient): class ContainerAppPreviewClient(ContainerAppClient): api_version = PREVIEW_API_VERSION + @classmethod + def show(cls, cmd, resource_group_name, name): + try: + return super().show(cmd, resource_group_name, name) + except HTTPError as e: + if e.response.status_code != 404: + raise + error = ResourceNotFoundError( + f"(ResourceNotFound) The container app '{name}' in resource group '{resource_group_name}' was not found.") + # Existing resource checks use the response status to tolerate missing apps. + error.response = e.response + raise error from e + class LabelHistoryPreviewClient: api_version = PREVIEW_API_VERSION diff --git a/src/containerapp/azext_containerapp/tests/latest/test_containerapp_show_unit.py b/src/containerapp/azext_containerapp/tests/latest/test_containerapp_show_unit.py new file mode 100644 index 00000000000..6b2ab2e6660 --- /dev/null +++ b/src/containerapp/azext_containerapp/tests/latest/test_containerapp_show_unit.py @@ -0,0 +1,166 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +import os +import tempfile +import unittest +from io import StringIO +from unittest import mock + +from requests import Response + +from azure.cli.core import MainCommandsLoader +from azure.cli.core.azclierror import HTTPError, ResourceNotFoundError +from azure.cli.core.commands import _load_extension_command_loader +from azure.cli.core.mock import DummyCli + +from azext_containerapp._client_factory import handle_non_404_status_code_exception +from azext_containerapp._clients import ContainerAppPreviewClient + + +class _ContainerappTestCommandsLoader(MainCommandsLoader): + + def load_command_table(self, args): + command_table, _, loader = _load_extension_command_loader(self, args, 'azext_containerapp') + self.command_table.update(command_table) + self.cmd_to_loader_map.update({name: [loader] for name in command_table}) + return self.command_table + + +def _http_error(status_code, code, message): + response = Response() + response.status_code = status_code + body = json.dumps({"error": {"code": code, "message": message}}) + return HTTPError(body, response) + + +class ContainerappShowCommandTest(unittest.TestCase): + + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + environment = mock.patch.dict(os.environ, { + "AZURE_CONFIG_DIR": directory.name, + "AZURE_CORE_COLLECT_TELEMETRY": "no", + }) + environment.start() + self.addCleanup(environment.stop) + self.cli = DummyCli(commands_loader_cls=_ContainerappTestCommandsLoader) + for patcher in [ + mock.patch( + "azure.cli.core._profile.Profile.load_cached_subscriptions", + return_value=[{"id": "00000000-0000-0000-0000-000000000000", "name": "test-subscription"}]), + mock.patch( + "azure.cli.command_modules.containerapp.base_resource.BaseResource." + "validate_subscription_registered"), + mock.patch( + "azure.cli.command_modules.containerapp._clients.get_subscription_id", + return_value="00000000-0000-0000-0000-000000000000")]: + patcher.start() + self.addCleanup(patcher.stop) + raw_request = mock.patch("azure.cli.command_modules.containerapp._clients.send_raw_request") + self.raw_request = raw_request.start() + self.addCleanup(raw_request.stop) + secrets = mock.patch.object(ContainerAppPreviewClient, "list_secrets") + self.list_secrets = secrets.start() + self.addCleanup(secrets.stop) + + def _invoke(self, show_secrets=False, debug=False): + output = StringIO() + args = [ + "containerapp", "show", "--name", "nonexistent-app", "--resource-group", "test-group", + "--subscription", "00000000-0000-0000-0000-000000000000", + ] + if show_secrets: + args.append("--show-secrets") + if debug: + args.append("--debug") + exit_code = self.cli.invoke(args, out_file=output) + return exit_code, output.getvalue() + + def test_missing_app_show_returns_exit_code_3(self): + for show_secrets, debug in [(False, False), (True, False), (False, True)]: + with self.subTest(show_secrets=show_secrets, debug=debug): + self.raw_request.side_effect = _http_error( + 404, "ResourceNotFound", "The requested container app was not found.") + with self.assertLogs(level="ERROR") as errors, self.assertRaises(SystemExit) as exit_context: + self._invoke(show_secrets, debug) + self.assertEqual(3, exit_context.exception.code) + self.assertIn("ResourceNotFound", "\n".join(errors.output)) + self.assertIn("was not found", "\n".join(errors.output)) + self.assertIn("nonexistent-app", "\n".join(errors.output)) + self.assertIn("test-group", "\n".join(errors.output)) + self.list_secrets.assert_not_called() + + def test_non_404_show_keeps_exit_code_1_and_service_error(self): + self.raw_request.side_effect = _http_error( + 403, "AuthorizationFailed", "The client is not authorized to read the container app.") + with self.assertLogs(level="ERROR") as errors: + exit_code, _ = self._invoke() + self.assertEqual(1, exit_code) + self.assertIn("AuthorizationFailed", "\n".join(errors.output)) + self.assertIn("not authorized", "\n".join(errors.output)) + self.list_secrets.assert_not_called() + + def test_successful_show_with_and_without_secrets(self): + for show_secrets in [False, True]: + with self.subTest(show_secrets=show_secrets): + app = { + "name": "nonexistent-app", + "properties": {"configuration": {"secrets": [{"name": "example-secret"}]}}, + } + self.raw_request.return_value.json.return_value = app + self.list_secrets.reset_mock() + self.list_secrets.return_value = { + "value": [{"name": "example-secret", "value": "example-value"}], + } + exit_code, output = self._invoke(show_secrets) + self.assertEqual(0, exit_code) + self.assertEqual(app, json.loads(output)) + expected = self.list_secrets.return_value["value"] if show_secrets else [{"name": "example-secret"}] + self.assertEqual(expected, json.loads(output)["properties"]["configuration"]["secrets"]) + request_args = self.raw_request.call_args.args + self.assertEqual("GET", request_args[1]) + self.assertIn("/resourceGroups/test-group/providers/Microsoft.App/containerApps/nonexistent-app", + request_args[2]) + self.assertTrue(request_args[2].endswith("api-version=" + ContainerAppPreviewClient.api_version)) + if show_secrets: + self.list_secrets.assert_called_once() + else: + self.list_secrets.assert_not_called() + + +class ContainerappShowClientTest(unittest.TestCase): + + @mock.patch("azure.cli.command_modules.containerapp._clients.get_subscription_id") + @mock.patch("azure.cli.command_modules.containerapp._clients.send_raw_request") + def test_not_found_keeps_response_for_existing_resource_checks(self, raw_request, _): + error = _http_error(404, "ResourceNotFound", "The requested container app was not found.") + raw_request.side_effect = error + with self.assertRaises(ResourceNotFoundError) as raised: + ContainerAppPreviewClient.show(mock.MagicMock(), "test-group", "nonexistent-app") + self.assertIs(error.response, raised.exception.response) + self.assertIs(raised.exception, handle_non_404_status_code_exception(raised.exception)) + + @mock.patch("azure.cli.command_modules.containerapp._clients.get_subscription_id") + @mock.patch("azure.cli.command_modules.containerapp._clients.send_raw_request") + def test_404_is_classified_without_parsing_the_body(self, raw_request, _): + response = Response() + response.status_code = 404 + raw_request.side_effect = HTTPError("Not Found", response) + with self.assertRaisesRegex(ResourceNotFoundError, "nonexistent-app"): + ContainerAppPreviewClient.show(mock.MagicMock(), "test-group", "nonexistent-app") + + @mock.patch("azure.cli.command_modules.containerapp._clients.get_subscription_id") + @mock.patch("azure.cli.command_modules.containerapp._clients.send_raw_request") + def test_other_http_errors_are_reraised_unchanged(self, raw_request, _): + for status_code in [400, 401, 403, 409, 429, 500]: + with self.subTest(status_code=status_code): + error = _http_error(status_code, "OtherError", "The original service error.") + raw_request.side_effect = error + with self.assertRaises(HTTPError) as raised: + ContainerAppPreviewClient.show(mock.MagicMock(), "test-group", "nonexistent-app") + self.assertIs(error, raised.exception) diff --git a/src/containerapp/setup.py b/src/containerapp/setup.py index f1a5713542b..93e204fd6bc 100644 --- a/src/containerapp/setup.py +++ b/src/containerapp/setup.py @@ -28,7 +28,7 @@ # TODO: Confirm this is the right version number you want and it matches your # HISTORY.rst entry. -VERSION = '1.3.0b5' +VERSION = '1.3.1b1' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers