Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/containerapp/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ Release History
upcoming
++++++

1.3.0b5
++++++
* 'az containerapp debug': Add `--image` and `--entrypoint` parameters to customize the ephemeral debug container image and entrypoint command (used together with `--command`).

1.3.0b4
++++++
* 'az containerapp env --environment-mode': Add environment mode to create and update commands
Expand Down
6 changes: 6 additions & 0 deletions src/containerapp/azext_containerapp/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -2447,6 +2447,12 @@
- name: Debug by executing a command inside a container app and exit
text: |
az containerapp debug -n MyContainerapp -g MyResourceGroup --revision MyRevision --replica MyReplica --container MyContainer --command "echo Hello World"
- name: Debug with a custom container image
text: |
az containerapp debug -n MyContainerapp -g MyResourceGroup --container MyContainer --image mcr.microsoft.com/dotnet/sdk:8.0
- name: Debug with a custom container image and entrypoint
text: |
az containerapp debug -n MyContainerapp -g MyResourceGroup --container MyContainer --image mcr.microsoft.com/dotnet/sdk:8.0 --entrypoint /bin/bash
"""

helps['containerapp label-history'] = """
Expand Down
4 changes: 4 additions & 0 deletions src/containerapp/azext_containerapp/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,10 @@ def load_arguments(self, _):
help="The name of the container app revision. Default to the latest revision.")
c.argument('name', name_type, id_part=None, help="The name of the Containerapp.")
c.argument('resource_group_name', arg_type=resource_group_name_type, id_part=None)
c.argument('custom_debug_image_name', options_list=['--image'], is_preview=True,
help="Custom container image for the debug ephemeral container (e.g., 'mcr.microsoft.com/dotnet/sdk:8.0'). If not specified, the platform default debug image is used.")
c.argument('custom_debug_image_entrypoint_command', options_list=['--entrypoint'], is_preview=True,
help="Custom entrypoint command for the debug container (e.g., '/bin/bash'). Requires --image to also be specified.")

with self.argument_context('containerapp label-history') as c:
c.argument('resource_group_name', arg_type=resource_group_name_type, id_part=None)
Expand Down
6 changes: 6 additions & 0 deletions src/containerapp/azext_containerapp/_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,12 @@ def validate_session_timeout_in_seconds(cmd, namespace):

def validate_debug(cmd, namespace):
logger.warning("Validating...")
has_custom_image = getattr(namespace, 'custom_debug_image_name', None)
has_entrypoint = getattr(namespace, 'custom_debug_image_entrypoint_command', None)
if has_entrypoint and not has_custom_image:
raise ValidationError("--entrypoint requires --image to also be specified.")
if (has_custom_image or has_entrypoint) and not namespace.debug_command:
raise ValidationError("--image and --entrypoint are only supported with --command. Interactive mode with custom images is not yet supported.")
revision_already_set = bool(namespace.revision)
replica_already_set = bool(namespace.replica)
container_already_set = bool(namespace.container)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ def get_argument_container_name(self):
def get_argument_command(self):
return self.get_param("command")

def get_argument_custom_debug_image_name(self):
return self.get_param("custom_debug_image_name")

def get_argument_custom_debug_image_entrypoint_command(self):
return self.get_param("custom_debug_image_entrypoint_command")

def validate_arguments(self):
validate_basic_arguments(
resource_group_name=self.get_argument_resource_group_name(),
Expand All @@ -59,7 +65,7 @@ def _get_logstream_endpoint(self, cmd, resource_group_name, container_app_name,
raise ValidationError(f"Error retrieving container in revision '{revision_name}' in the container app '{container_app_name}'.")
return container_info[0]["logStreamEndpoint"]

def _get_url(self, cmd, resource_group_name, container_app_name, revision_name, replica_name, container_name, command):
def _get_url(self, cmd, resource_group_name, container_app_name, revision_name, replica_name, container_name, command, custom_debug_image_name=None, custom_debug_image_entrypoint_command=None):
"""Get the debug url for the specified container in the replica"""
base_url = self._get_logstream_endpoint(cmd, resource_group_name, container_app_name, revision_name, replica_name, container_name)
proxy_api_url = base_url[:base_url.index("/subscriptions/")]
Expand All @@ -68,6 +74,10 @@ def _get_url(self, cmd, resource_group_name, container_app_name, revision_name,
debug_url = (f"{proxy_api_url}/subscriptions/{sub}/resourceGroups/{resource_group_name}/containerApps/{container_app_name}"
f"/revisions/{revision_name}/replicas/{replica_name}/debug"
f"?targetContainer={container_name}&command={encoded_cmd}")
if custom_debug_image_name:
debug_url += f"&customDebugImageName={urllib.parse.quote_plus(custom_debug_image_name)}"
if custom_debug_image_entrypoint_command:
debug_url += f"&customDebugImageEntrypointCommand={urllib.parse.quote_plus(custom_debug_image_entrypoint_command)}"
Comment on lines +77 to +80
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit b978fd8: added unit tests in tests/latest/test_containerapp_debug_unit.py covering URL building (4), command-decorator behavior (4), and the validate_debug validator (4) including the new test_entrypoint_without_image_raises failure case. All 12 unit tests pass locally. Happy to add a recorded test_containerapp_scenario.py case as well if preferred over unit-level coverage.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit b978fd8: added unit tests in tests/latest/test_containerapp_debug_unit.py covering URL building (4), command-decorator behavior (4), and the validate_debug validator (4) including the new test_entrypoint_without_image_raises failure case. All 12 unit tests pass locally. Happy to add a recorded test_containerapp_scenario.py case as well if preferred over unit-level coverage.

return debug_url

def _get_auth_token(self, cmd, resource_group_name, container_app_name):
Expand All @@ -82,7 +92,9 @@ def execute_Command(self, cmd):
replica_name = self.get_argument_replica_name()
container_name = self.get_argument_container_name()
command = self.get_argument_command()
url = self._get_url(cmd, resource_group_name, container_app_name, revision_name, replica_name, container_name, command)
custom_debug_image_name = self.get_argument_custom_debug_image_name()
custom_debug_image_entrypoint_command = self.get_argument_custom_debug_image_entrypoint_command()
url = self._get_url(cmd, resource_group_name, container_app_name, revision_name, replica_name, container_name, command, custom_debug_image_name, custom_debug_image_entrypoint_command)
token = self._get_auth_token(cmd, resource_group_name, container_app_name)
headers = [f"Authorization=Bearer {token}"]
r = send_raw_request(cmd.cli_ctx, "GET", url, headers=headers)
Expand Down
7 changes: 5 additions & 2 deletions src/containerapp/azext_containerapp/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -3624,7 +3624,8 @@ def list_maintenance_config(cmd, resource_group_name, env_name):
return r


def containerapp_debug(cmd, resource_group_name, name, container=None, revision=None, replica=None, debug_command=None):
def containerapp_debug(cmd, resource_group_name, name, container=None, revision=None, replica=None, debug_command=None,
custom_debug_image_name=None, custom_debug_image_entrypoint_command=None):
logger.warning("Connecting...")
if debug_command is not None:
raw_parameters = {
Expand All @@ -3633,7 +3634,9 @@ def containerapp_debug(cmd, resource_group_name, name, container=None, revision=
'revision_name': revision,
'replica_name': replica,
'container_name': container,
'command': debug_command
'command': debug_command,
'custom_debug_image_name': custom_debug_image_name,
'custom_debug_image_entrypoint_command': custom_debug_image_entrypoint_command,
}
debug_command_decorator = ContainerAppDebugCommandDecorator(
cmd=cmd,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# coding=utf-8
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

import unittest
from unittest import mock

from azure.cli.core.azclierror import ValidationError
from azext_containerapp.containerapp_debug_command_decorator import ContainerAppDebugCommandDecorator


class TestDebugCommandUrlBuilding(unittest.TestCase):
"""Unit tests for the debug command URL building with custom image parameters."""

def _create_decorator_with_params(self, params):
"""Helper to create a decorator instance with mocked params."""
with mock.patch.object(ContainerAppDebugCommandDecorator, '__init__', lambda self, *a, **kw: None):
decorator = ContainerAppDebugCommandDecorator()
decorator.raw_parameters = params
# Mock get_param to return from our dict
decorator.get_param = lambda key: params.get(key)
return decorator

def _mock_get_url(self, decorator, cmd_mock, **kwargs):
"""Helper to call _get_url with mocked logstream endpoint and subscription."""
base_endpoint = "https://proxy.example.com/subscriptions/test-sub/resourceGroups/test-rg/containerApps/test-app/revisions/test-rev/replicas/test-replica/logstream"
with mock.patch.object(decorator, '_get_logstream_endpoint', return_value=base_endpoint):
with mock.patch('azext_containerapp.containerapp_debug_command_decorator.get_subscription_id', return_value='test-sub'):
return decorator._get_url(
cmd_mock,
kwargs.get('resource_group_name', 'test-rg'),
kwargs.get('container_app_name', 'test-app'),
kwargs.get('revision_name', 'test-rev'),
kwargs.get('replica_name', 'test-replica'),
kwargs.get('container_name', 'test-container'),
kwargs.get('command', '/bin/bash'),
kwargs.get('custom_debug_image_name'),
kwargs.get('custom_debug_image_entrypoint_command'),
)

def test_url_without_custom_image(self):
"""URL should not contain custom image params when not specified."""
decorator = self._create_decorator_with_params({})
cmd_mock = mock.MagicMock()
url = self._mock_get_url(decorator, cmd_mock)

self.assertIn("targetContainer=test-container", url)
self.assertNotIn("customDebugImageName", url)
self.assertNotIn("customDebugImageEntrypointCommand", url)

def test_url_with_custom_image_only(self):
"""URL should contain customDebugImageName when --image is specified."""
decorator = self._create_decorator_with_params({})
cmd_mock = mock.MagicMock()
url = self._mock_get_url(decorator, cmd_mock, custom_debug_image_name="ubuntu:22.04")

self.assertIn("customDebugImageName=ubuntu%3A22.04", url)
self.assertNotIn("customDebugImageEntrypointCommand", url)

def test_url_with_custom_image_and_entrypoint(self):
"""URL should contain both params when --image and --entrypoint are specified."""
decorator = self._create_decorator_with_params({})
cmd_mock = mock.MagicMock()
url = self._mock_get_url(
decorator, cmd_mock,
custom_debug_image_name="mcr.microsoft.com/dotnet/sdk:8.0",
custom_debug_image_entrypoint_command="/bin/bash",
)

self.assertIn("customDebugImageName=mcr.microsoft.com%2Fdotnet%2Fsdk%3A8.0", url)
self.assertIn("customDebugImageEntrypointCommand=%2Fbin%2Fbash", url)

def test_url_encodes_special_characters(self):
"""Custom image params should be URL-encoded."""
decorator = self._create_decorator_with_params({})
cmd_mock = mock.MagicMock()
url = self._mock_get_url(
decorator, cmd_mock,
custom_debug_image_name="myregistry.azurecr.io/my-image:v1.0",
custom_debug_image_entrypoint_command="/bin/sh -c 'echo hello'",
)

self.assertIn("customDebugImageName=myregistry.azurecr.io%2Fmy-image%3Av1.0", url)
self.assertIn("customDebugImageEntrypointCommand=%2Fbin%2Fsh+-c+%27echo+hello%27", url)


class TestDebugCommandValidation(unittest.TestCase):
"""Unit tests for client-side validation of custom image parameters."""

def _create_decorator_with_params(self, params):
"""Helper to create a decorator instance with mocked params."""
with mock.patch.object(ContainerAppDebugCommandDecorator, '__init__', lambda self, *a, **kw: None):
decorator = ContainerAppDebugCommandDecorator()
decorator.get_param = lambda key: params.get(key)
return decorator

def test_image_without_entrypoint_succeeds(self):
"""--image without --entrypoint should not raise."""
decorator = self._create_decorator_with_params({
'custom_debug_image_name': 'ubuntu:22.04',
'custom_debug_image_entrypoint_command': None,
'resource_group_name': 'rg',
'container_app_name': 'app',
'revision_name': 'rev',
'replica_name': 'replica',
'container_name': 'container',
'command': '/bin/bash',
})

cmd_mock = mock.MagicMock()
mock_response = mock.MagicMock()
mock_response.json.return_value = {"status": "ok"}
with mock.patch.object(decorator, '_get_url', return_value='https://example.com/debug'), \
mock.patch.object(decorator, '_get_auth_token', return_value='token'), \
mock.patch('azext_containerapp.containerapp_debug_command_decorator.send_raw_request', return_value=mock_response), \
mock.patch('azext_containerapp.containerapp_debug_command_decorator.transform_debug_command_output', return_value={"status": "ok"}):
# Should not raise
decorator.execute_Command(cmd_mock)

def test_no_custom_params_succeeds(self):
"""No custom image params should not raise."""
decorator = self._create_decorator_with_params({
'custom_debug_image_name': None,
'custom_debug_image_entrypoint_command': None,
'resource_group_name': 'rg',
'container_app_name': 'app',
'revision_name': 'rev',
'replica_name': 'replica',
'container_name': 'container',
'command': '/bin/bash',
})

cmd_mock = mock.MagicMock()
mock_response = mock.MagicMock()
mock_response.json.return_value = {"status": "ok"}
with mock.patch.object(decorator, '_get_url', return_value='https://example.com/debug'), \
mock.patch.object(decorator, '_get_auth_token', return_value='token'), \
mock.patch('azext_containerapp.containerapp_debug_command_decorator.send_raw_request', return_value=mock_response), \
mock.patch('azext_containerapp.containerapp_debug_command_decorator.transform_debug_command_output', return_value={"status": "ok"}):
# Should not raise
decorator.execute_Command(cmd_mock)

def test_getter_methods(self):
"""Getter methods should return correct param values."""
decorator = self._create_decorator_with_params({
'custom_debug_image_name': 'ubuntu:22.04',
'custom_debug_image_entrypoint_command': '/bin/bash',
})

self.assertEqual(decorator.get_argument_custom_debug_image_name(), 'ubuntu:22.04')
self.assertEqual(decorator.get_argument_custom_debug_image_entrypoint_command(), '/bin/bash')

def test_getter_methods_return_none_when_not_set(self):
"""Getter methods should return None when params not provided."""
decorator = self._create_decorator_with_params({})

self.assertIsNone(decorator.get_argument_custom_debug_image_name())
self.assertIsNone(decorator.get_argument_custom_debug_image_entrypoint_command())


class TestValidateDebugCustomImageRequiresCommand(unittest.TestCase):
"""Validate that --image/--entrypoint require --command."""

def _make_namespace(self, **kwargs):
ns = mock.MagicMock()
ns.debug_command = kwargs.get('debug_command', None)
ns.custom_debug_image_name = kwargs.get('custom_debug_image_name', None)
ns.custom_debug_image_entrypoint_command = kwargs.get('custom_debug_image_entrypoint_command', None)
ns.revision = kwargs.get('revision', 'rev')
ns.replica = kwargs.get('replica', 'replica')
ns.container = kwargs.get('container', 'container')
ns.name = 'test-app'
ns.resource_group_name = 'test-rg'
return ns

@mock.patch('azext_containerapp._validators._set_debug_defaults')
def test_image_without_command_raises(self, mock_defaults):
from azext_containerapp._validators import validate_debug
ns = self._make_namespace(custom_debug_image_name='ubuntu:22.04')
with self.assertRaises(ValidationError) as ctx:
validate_debug(mock.MagicMock(), ns)
self.assertIn("--image", str(ctx.exception))

@mock.patch('azext_containerapp._validators._set_debug_defaults')
def test_entrypoint_without_command_raises(self, mock_defaults):
from azext_containerapp._validators import validate_debug
ns = self._make_namespace(custom_debug_image_entrypoint_command='/bin/bash')
with self.assertRaises(ValidationError) as ctx:
validate_debug(mock.MagicMock(), ns)
self.assertIn("--image", str(ctx.exception))

@mock.patch('azext_containerapp._validators._set_debug_defaults')
@mock.patch('azext_containerapp._validators._validate_revision_exists')
@mock.patch('azext_containerapp._validators._validate_replica_exists')
@mock.patch('azext_containerapp._validators._validate_container_exists')
def test_image_with_command_passes(self, mock_cont, mock_rep, mock_rev, mock_defaults):
from azext_containerapp._validators import validate_debug
ns = self._make_namespace(
debug_command='/bin/bash',
custom_debug_image_name='ubuntu:22.04',
)
validate_debug(mock.MagicMock(), ns) # should not raise

@mock.patch('azext_containerapp._validators._set_debug_defaults')
def test_entrypoint_without_image_raises(self, mock_defaults):
"""--entrypoint without --image should raise even when --command is set."""
from azext_containerapp._validators import validate_debug
ns = self._make_namespace(
debug_command='/bin/bash',
custom_debug_image_entrypoint_command='/bin/bash',
)
with self.assertRaises(ValidationError) as ctx:
validate_debug(mock.MagicMock(), ns)
self.assertIn("--entrypoint requires --image", str(ctx.exception))


if __name__ == '__main__':
unittest.main()
2 changes: 1 addition & 1 deletion src/containerapp/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
# TODO: Confirm this is the right version number you want and it matches your
# HISTORY.rst entry.

VERSION = '1.3.0b4'
VERSION = '1.3.0b5'

# The full list of classifiers is available at
# https://pypi.python.org/pypi?%3Aaction=list_classifiers
Expand Down
Loading