From 59a86b7e03e0d816e99fb773d327b081412620f4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:48:04 +0930 Subject: [PATCH 1/7] [AppService] Fix #34005: `az webapp ssh`: Report SSH session failures instead of exiting 0 * Initial plan * [AppService] `az webapp ssh`: Report SSH session failures instead of exiting 0 Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- .../cli/command_modules/appservice/custom.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 61786d5c7ec..774b87a4c7e 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -11401,8 +11401,15 @@ def create_tunnel_and_session(cmd, resource_group_name, name, port=None, slot=No ssh_user_name = 'root' ssh_user_password = 'Docker!' - s = threading.Thread(target=_start_ssh_session, - args=('localhost', tunnel_server.get_port(), ssh_user_name, ssh_user_password)) + ssh_exception_holder = [None] + + def _ssh_session_with_error_capture(): + try: + _start_ssh_session('localhost', tunnel_server.get_port(), ssh_user_name, ssh_user_password) + except Exception as ex: # pylint: disable=broad-except + ssh_exception_holder[0] = ex + + s = threading.Thread(target=_ssh_session_with_error_capture) s.daemon = True s.start() @@ -11412,6 +11419,9 @@ def create_tunnel_and_session(cmd, resource_group_name, name, port=None, slot=No while s.is_alive() and t.is_alive(): time.sleep(5) + if ssh_exception_holder[0] is not None: + raise ssh_exception_holder[0] + def perform_onedeploy_functionapp(cmd, resource_group_name, @@ -12125,7 +12135,7 @@ def _start_ssh_session(hostname, port, username, password): pass c.run('source /etc/profile; exec $SHELL -l', pty=True) except Exception as ex: # pylint: disable=broad-except - logger.info(ex) + raise CLIError("SSH session failed: {}".format(ex)) from ex finally: c.close() From cfc16411cd522dbfbc15515a6a18f0ce5b3ab5f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:45:05 +0000 Subject: [PATCH 2/7] Add unit tests for SSH session failure reporting in appservice Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- .../latest/test_webapp_commands_thru_mock.py | 90 ++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 7cb3d748f72..e1c9a47ba85 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -2560,5 +2560,93 @@ def test_get_java_runtimes_from_container_settings_reads_mapping(self): self.assertTrue(all(is_auto for _, _, is_auto in runtimes)) +class TestSshSessionFailureReporting(unittest.TestCase): + """Tests that SSH session failures are reported instead of silently exiting 0.""" + + @mock.patch('azure.cli.command_modules.appservice.custom.Connection') + def test_start_ssh_session_raises_clierror_on_run_failure(self, mock_connection_cls): + """_start_ssh_session raises CLIError when c.run() raises an exception.""" + from knack.util import CLIError + from azure.cli.command_modules.appservice.custom import _start_ssh_session + + mock_conn = mock.MagicMock() + mock_conn.run.side_effect = Exception("buffer overflow") + mock_connection_cls.return_value = mock_conn + + with self.assertRaises(CLIError) as ctx: + _start_ssh_session('localhost', 2222, 'root', 'Docker!') + + self.assertIn("SSH session failed", str(ctx.exception)) + mock_conn.close.assert_called_once() + + @mock.patch('azure.cli.command_modules.appservice.custom.Connection') + def test_start_ssh_session_closes_connection_on_failure(self, mock_connection_cls): + """_start_ssh_session always closes the connection even when an error occurs.""" + from knack.util import CLIError + from azure.cli.command_modules.appservice.custom import _start_ssh_session + + mock_conn = mock.MagicMock() + mock_conn.run.side_effect = RuntimeError("ioctl error") + mock_connection_cls.return_value = mock_conn + + with self.assertRaises(CLIError): + _start_ssh_session('localhost', 2222, 'root', 'Docker!') + + mock_conn.close.assert_called_once() + + @mock.patch('azure.cli.command_modules.appservice.custom._start_tunnel') + @mock.patch('azure.cli.command_modules.appservice.custom.get_tunnel') + def test_create_tunnel_and_session_propagates_ssh_error(self, mock_get_tunnel, mock_start_tunnel): + """create_tunnel_and_session raises when the SSH session thread encounters an error.""" + import threading + from knack.util import CLIError + from azure.cli.command_modules.appservice.custom import create_tunnel_and_session + + mock_tunnel_server = mock.MagicMock() + mock_tunnel_server.get_port.return_value = 2222 + mock_get_tunnel.return_value = mock_tunnel_server + + ssh_error = CLIError("SSH session failed: buffer overflow") + + def fake_ssh_session(hostname, port, username, password): + raise ssh_error + + with mock.patch('azure.cli.command_modules.appservice.custom._start_ssh_session', + side_effect=fake_ssh_session): + with mock.patch('azure.cli.command_modules.appservice.custom.threading.Thread') as mock_thread_cls: + # Set up tunnel thread (t) to die immediately so the loop exits + tunnel_thread = mock.MagicMock() + tunnel_thread.is_alive.return_value = False + + # Capture the SSH thread target and run it synchronously so the + # exception is placed into ssh_exception_holder before the check. + ssh_thread = mock.MagicMock() + ssh_thread_target = None + + def make_thread(*args, **kwargs): + nonlocal ssh_thread_target + target = kwargs.get('target') + if target is not None: + ssh_thread_target = target + return ssh_thread + return tunnel_thread + + mock_thread_cls.side_effect = make_thread + ssh_thread.is_alive.return_value = False + + cmd = mock.MagicMock() + + def run_ssh_thread_synchronously(): + if ssh_thread_target: + ssh_thread_target() + + ssh_thread.start.side_effect = run_ssh_thread_synchronously + + with self.assertRaises(CLIError) as ctx: + create_tunnel_and_session(cmd, 'rg', 'myapp', timeout=None) + + self.assertIn("SSH session failed", str(ctx.exception)) + + if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() From 2925d5d7eb5cbf3d119838776eb5fedb2d049a16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:46:33 +0000 Subject: [PATCH 3/7] Fix test: use call counter to distinguish tunnel vs SSH thread mocks Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- .../latest/test_webapp_commands_thru_mock.py | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index e1c9a47ba85..5d997dffad9 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -2614,34 +2614,38 @@ def fake_ssh_session(hostname, port, username, password): with mock.patch('azure.cli.command_modules.appservice.custom._start_ssh_session', side_effect=fake_ssh_session): with mock.patch('azure.cli.command_modules.appservice.custom.threading.Thread') as mock_thread_cls: - # Set up tunnel thread (t) to die immediately so the loop exits + # The tunnel thread (t) is created first; the SSH thread (s) is created second. tunnel_thread = mock.MagicMock() tunnel_thread.is_alive.return_value = False - # Capture the SSH thread target and run it synchronously so the - # exception is placed into ssh_exception_holder before the check. ssh_thread = mock.MagicMock() - ssh_thread_target = None + ssh_thread.is_alive.return_value = False + + # Use a call counter to distinguish the two Thread() calls. + call_order = [0] + ssh_thread_target_holder = [] - def make_thread(*args, **kwargs): - nonlocal ssh_thread_target + def capturing_thread_factory(*args, **kwargs): + call_index = call_order[0] + call_order[0] += 1 + # First call is the tunnel thread, second is the SSH thread. + if call_index == 0: + return tunnel_thread target = kwargs.get('target') if target is not None: - ssh_thread_target = target - return ssh_thread - return tunnel_thread + ssh_thread_target_holder.append(target) + return ssh_thread - mock_thread_cls.side_effect = make_thread - ssh_thread.is_alive.return_value = False - - cmd = mock.MagicMock() + mock_thread_cls.side_effect = capturing_thread_factory def run_ssh_thread_synchronously(): - if ssh_thread_target: - ssh_thread_target() + if ssh_thread_target_holder: + ssh_thread_target_holder[0]() ssh_thread.start.side_effect = run_ssh_thread_synchronously + cmd = mock.MagicMock() + with self.assertRaises(CLIError) as ctx: create_tunnel_and_session(cmd, 'rg', 'myapp', timeout=None) From 08ff8448a8844856c1b406ab647f1bc58f8255d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:00:28 +0000 Subject: [PATCH 4/7] fix: resolve pylint raising-bad-type in appservice/custom.py:11423 Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- src/azure-cli/azure/cli/command_modules/appservice/custom.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 774b87a4c7e..f6a5bcb1824 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -11419,8 +11419,9 @@ def _ssh_session_with_error_capture(): while s.is_alive() and t.is_alive(): time.sleep(5) - if ssh_exception_holder[0] is not None: - raise ssh_exception_holder[0] + ssh_ex = ssh_exception_holder[0] + if ssh_ex is not None: + raise ssh_ex def perform_onedeploy_functionapp(cmd, From aab5a7582812eb5b332d9ec007ca748135086a65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:45:36 +0000 Subject: [PATCH 5/7] chore: upgrade invoke from 2.2.0 to 3.0.3 Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- src/azure-cli/requirements.py3.Darwin.txt | 2 +- src/azure-cli/requirements.py3.Linux.txt | 2 +- src/azure-cli/requirements.py3.windows.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index ecae15c69b3..80e6b4ec9bf 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -104,7 +104,7 @@ cryptography==48.0.1 fabric==3.2.2 humanfriendly==10.0 idna==3.15 -invoke==2.2.0 +invoke==3.0.3 isodate==0.6.1 javaproperties==0.5.1 jmespath==0.9.5 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index 2238e5fb6cb..09f0efa57ed 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -105,7 +105,7 @@ distro==1.6.0 fabric==3.2.2 humanfriendly==10.0 idna==3.15 -invoke==2.2.0 +invoke==3.0.3 isodate==0.6.1 javaproperties==0.5.1 jmespath==0.9.5 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 8f1c7b8ff2d..5b0a03bc107 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -104,7 +104,7 @@ cryptography==48.0.1 fabric==3.2.2 humanfriendly==10.0 idna==3.15 -invoke==2.2.0 +invoke==3.0.3 isodate==0.6.1 javaproperties==0.5.1 jmespath==0.9.5 From 2652841892ef417005e2d4322a4b651ede53f47a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:34:51 +0000 Subject: [PATCH 6/7] doc: add section on closing parenthesis ) being stripped in PowerShell Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- doc/quoting-issues-with-powershell.md | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/doc/quoting-issues-with-powershell.md b/doc/quoting-issues-with-powershell.md index a70e1553663..cc2f05ebd5e 100644 --- a/doc/quoting-issues-with-powershell.md +++ b/doc/quoting-issues-with-powershell.md @@ -169,6 +169,38 @@ Command arguments: ['{"key": "value"}', '--debug'] Command arguments: ['{"key": "value"}', '--debug'] ``` +### Closing parenthesis `)` is stripped + +This is a common issue that affects many `az` commands — not just a single command. It occurs when an argument ends with `)`, such as a password, JMESPath expression, blob name, or Key Vault secret value. + +When PowerShell calls a `.cmd` script, it passes arguments through Command Prompt, which treats `()` as grouping operators for command blocks. A trailing `)` that lacks a matching `(` is therefore consumed by the Command Prompt parser, and Azure CLI never receives it. + +```powershell +# Wrong! The trailing ) is stripped by cmd.exe +> az aks update -g MyGroup -n MyCluster --windows-admin-password "Pass@word1)" +# Azure CLI receives: Pass@word1 + +# Wrong! The trailing ) is stripped by cmd.exe +> az keyvault secret set --name secret --value "myvalue)" +# Azure CLI receives: myvalue +``` + +To solve it: + +```powershell +# Wrap the argument in double quotes passed through single-quoted outer string so that +# Command Prompt treats ) as a literal character +> az aks update -g MyGroup -n MyCluster --windows-admin-password '"Pass@word1)"' +# Azure CLI receives: Pass@word1) + +# Use --% to stop PowerShell from parsing the argument; +# wrap the argument in double quotes as required by Command Prompt +> az --% aks update -g MyGroup -n MyCluster --windows-admin-password "Pass@word1)" +# Azure CLI receives: Pass@word1) +``` + +This issue is tracked at [#15529](https://github.com/Azure/azure-cli/issues/15529) and has been reported across many commands, including passwords ([#10370](https://github.com/Azure/azure-cli/issues/10370), [#23018](https://github.com/Azure/azure-cli/issues/23018), [#25173](https://github.com/Azure/azure-cli/issues/25173)), blob names ([#15418](https://github.com/Azure/azure-cli/issues/15418)), and JMESPath expressions ([#32320](https://github.com/Azure/azure-cli/issues/32320)). + ## Best practice: use file input for JSON For complex arguments like JSON string, the best practice is to use Azure CLI's `@` convention to load from a file to bypass the shell's interpretation. From 0ec38c18117236f6822f8147d5d10a11ea1d4e69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:53:11 +0000 Subject: [PATCH 7/7] Remove unrelated changes: revert invoke upgrade and PowerShell doc section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts: - invoke 3.0.3 → 2.2.0 in requirements.py3.*.txt (unrelated to SSH fix) - doc section on PowerShell closing parenthesis (unrelated to SSH fix) Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- doc/quoting-issues-with-powershell.md | 32 ---------------------- src/azure-cli/requirements.py3.Darwin.txt | 2 +- src/azure-cli/requirements.py3.Linux.txt | 2 +- src/azure-cli/requirements.py3.windows.txt | 2 +- 4 files changed, 3 insertions(+), 35 deletions(-) diff --git a/doc/quoting-issues-with-powershell.md b/doc/quoting-issues-with-powershell.md index cc2f05ebd5e..a70e1553663 100644 --- a/doc/quoting-issues-with-powershell.md +++ b/doc/quoting-issues-with-powershell.md @@ -169,38 +169,6 @@ Command arguments: ['{"key": "value"}', '--debug'] Command arguments: ['{"key": "value"}', '--debug'] ``` -### Closing parenthesis `)` is stripped - -This is a common issue that affects many `az` commands — not just a single command. It occurs when an argument ends with `)`, such as a password, JMESPath expression, blob name, or Key Vault secret value. - -When PowerShell calls a `.cmd` script, it passes arguments through Command Prompt, which treats `()` as grouping operators for command blocks. A trailing `)` that lacks a matching `(` is therefore consumed by the Command Prompt parser, and Azure CLI never receives it. - -```powershell -# Wrong! The trailing ) is stripped by cmd.exe -> az aks update -g MyGroup -n MyCluster --windows-admin-password "Pass@word1)" -# Azure CLI receives: Pass@word1 - -# Wrong! The trailing ) is stripped by cmd.exe -> az keyvault secret set --name secret --value "myvalue)" -# Azure CLI receives: myvalue -``` - -To solve it: - -```powershell -# Wrap the argument in double quotes passed through single-quoted outer string so that -# Command Prompt treats ) as a literal character -> az aks update -g MyGroup -n MyCluster --windows-admin-password '"Pass@word1)"' -# Azure CLI receives: Pass@word1) - -# Use --% to stop PowerShell from parsing the argument; -# wrap the argument in double quotes as required by Command Prompt -> az --% aks update -g MyGroup -n MyCluster --windows-admin-password "Pass@word1)" -# Azure CLI receives: Pass@word1) -``` - -This issue is tracked at [#15529](https://github.com/Azure/azure-cli/issues/15529) and has been reported across many commands, including passwords ([#10370](https://github.com/Azure/azure-cli/issues/10370), [#23018](https://github.com/Azure/azure-cli/issues/23018), [#25173](https://github.com/Azure/azure-cli/issues/25173)), blob names ([#15418](https://github.com/Azure/azure-cli/issues/15418)), and JMESPath expressions ([#32320](https://github.com/Azure/azure-cli/issues/32320)). - ## Best practice: use file input for JSON For complex arguments like JSON string, the best practice is to use Azure CLI's `@` convention to load from a file to bypass the shell's interpretation. diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 80e6b4ec9bf..ecae15c69b3 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -104,7 +104,7 @@ cryptography==48.0.1 fabric==3.2.2 humanfriendly==10.0 idna==3.15 -invoke==3.0.3 +invoke==2.2.0 isodate==0.6.1 javaproperties==0.5.1 jmespath==0.9.5 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index 09f0efa57ed..2238e5fb6cb 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -105,7 +105,7 @@ distro==1.6.0 fabric==3.2.2 humanfriendly==10.0 idna==3.15 -invoke==3.0.3 +invoke==2.2.0 isodate==0.6.1 javaproperties==0.5.1 jmespath==0.9.5 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 5b0a03bc107..8f1c7b8ff2d 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -104,7 +104,7 @@ cryptography==48.0.1 fabric==3.2.2 humanfriendly==10.0 idna==3.15 -invoke==3.0.3 +invoke==2.2.0 isodate==0.6.1 javaproperties==0.5.1 jmespath==0.9.5