From a9296b21fcbfd2b22e89eef679e7f18a3f834065 Mon Sep 17 00:00:00 2001 From: Vandana George Date: Mon, 22 Jun 2026 18:02:29 -0700 Subject: [PATCH 01/14] Add az webapp exec command for Linux web apps (shell + execute) --- .../cli/command_modules/appservice/_help.py | 38 +++ .../cli/command_modules/appservice/_params.py | 18 ++ .../command_modules/appservice/commands.py | 1 + .../cli/command_modules/appservice/custom.py | 231 ++++++++++++++++++ 4 files changed, 288 insertions(+) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index eea863ae35d..1f6bbcfb557 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -1971,6 +1971,44 @@ text: az webapp create-remote-connection --name MyWebApp --resource-group MyResourceGroup """ +helps['webapp exec'] = """ +type: command +short-summary: Interact with a Linux web app container via command execution or an interactive shell session. +long-summary: > + Interact with your Linux web app container using two modes. + This command is only supported for Linux App Service plans. + 'execute' runs a command in the container and returns immediately without waiting for completion or output. + Redirect to a file to capture results (see examples). + 'shell' starts an interactive shell session with the main webapp container. + Shell sessions are subject to an idle timeout and may be terminated if inactive for an extended period. + Requires SCM Basic Auth Publishing Credentials to be enabled. +examples: + - name: Run a direct command in the container + text: > + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd + - name: Run a bash command and redirect output to a file + text: > + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command bash --args "-c" "pwd &> pwd.txt" + - name: Create a file in a specific working directory + text: > + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command touch --args "newfile.txt" --cwd /home/site + - name: Execute a command on a specific instance + text: > + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd --instance MyInstanceId + - name: Execute a command on all instances + text: > + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd --instance all + - name: Execute a command on a deployment slot + text: > + az webapp exec -g MyResourceGroup -n MyWebapp -s staging --mode execute --command pwd + - name: Start an interactive shell session with the web app container + text: > + az webapp exec -g MyResourceGroup -n MyWebapp --mode shell + - name: Start an interactive shell session on a specific instance + text: > + az webapp exec -g MyResourceGroup -n MyWebapp --mode shell --instance MyInstanceId +""" + helps['webapp delete'] = """ type: command short-summary: Delete a web app. diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index 7958d119c36..8608c09bb3c 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -1605,3 +1605,21 @@ def load_arguments(self, _): c.argument('environment_name', help="Name of the environment of static site") with self.argument_context('staticwebapp enterprise-edge') as c: c.argument("no_register", help="Don't try to register the Microsoft.CDN provider. Registration can be done manually with: az provider register --wait --namespace Microsoft.CDN. For more details, please review the documentation available at https://go.microsoft.com/fwlink/?linkid=2184995 .", default=False) + with self.argument_context('webapp exec') as c: + c.argument('name', arg_type=webapp_name_arg_type, id_part=None) + c.argument('command', options_list=['--command'], + help='The command or executable to run in the container (e.g., pwd, bash, touch).') + c.argument('args', options_list=['--args'], nargs='+', + help='Arguments to pass to the command. For shell commands, use: --command bash --args "-c" "your command here".') + c.argument('mode', + help="Execution mode. 'execute': Starts command execution and returns immediately without returning" + " command output. 'shell': Starts an interactive shell session with the main webapp container.", + arg_type=get_enum_type(['execute', 'shell']), default='execute') + c.argument('working_directory', options_list=['--working-directory', '--cwd'], + help="Working directory for command execution. Defaults to the container's working directory" + " (typically /home/site/wwwroot for App Service images).") + c.argument('instance', options_list=['--instance', '-i'], + help='Webapp instance(s) to target. Specify a comma-separated list of instance IDs' + ' (use "az webapp list-instances" to get IDs) or "all" for all instances. Defaults to a random instance.') + c.argument('slot', options_list=['--slot', '-s'], + help='Name of the web app slot. Default to the production slot if not specified.') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py index 15462365f96..c208b91b8f5 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -134,6 +134,7 @@ def load_command_table(self, _): g.custom_command('up', 'webapp_up', exception_handler=ex_handler_factory(), validator=validate_webapp_up, deprecate_info=g.deprecate(redirect='webapp create and webapp deploy')) g.custom_command('ssh', 'ssh_webapp', exception_handler=ex_handler_factory(), is_preview=True) + g.custom_command('exec', 'webapp_exec', exception_handler=ex_handler_factory(), is_preview=True) g.custom_command('list', 'list_webapp', table_transformer=transform_web_list_output) g.custom_show_command('show', 'show_app', table_transformer=transform_web_output) g.custom_command('delete', 'delete_webapp') 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 31d333d7d28..129400de72a 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -12883,3 +12883,234 @@ def _compute_checksum(input_bytes): logger.info("Computing the checksum of the file failed with exception:'%s'", ex) return file_hash + + +def _start_shell_session(scm_url, headers, cookies=None): + import sys + import ssl + import platform + import threading + import time + import websocket + from azure.cli.core.util import should_disable_connection_verify + + ws_url = scm_url.replace('https://', 'wss://') + '/exec/shell' + + cookie_str = '; '.join(f'{k}={v}' for k, v in cookies.items()) if cookies else None + # Respect user's SSL verification settings (e.g., self-signed certs on private stamps) + sslopt = {'cert_reqs': ssl.CERT_NONE} if should_disable_connection_verify() else {} + + ws = websocket.create_connection( + ws_url, + header=headers, + cookie=cookie_str, + sslopt=sslopt, + timeout=30 + ) + + logger.info("Connected to %s", ws_url) + print("Connected! Type commands. Ctrl+C twice to quit.\n") + + closed = threading.Event() + + # WebSocket → stdout + def recv_loop(): + try: + while not closed.is_set(): + opcode, data = ws.recv_data() + if not data: + break + text = data.decode('utf-8', errors='replace') + sys.stdout.write(text) + sys.stdout.flush() + except (websocket.WebSocketConnectionClosedException, OSError): + pass + finally: + closed.set() + + recv_thread = threading.Thread(target=recv_loop, daemon=True) + recv_thread.start() + + if platform.system() == 'Windows': + _shell_input_loop_windows(ws, closed) + else: + _shell_input_loop_unix(ws, closed) + + closed.set() + try: + ws.close() + except Exception: # pylint: disable=broad-except + pass + + +def _shell_input_loop_windows(ws, closed): + import time + import msvcrt + import websocket as ws_module + + # Windows special key codes → ANSI escape sequences + # Tab, Ctrl+D, Ctrl+L etc. work via the regular else branch (sent as-is) + # TODO: F1-F12, Insert, PgUp/PgDown not yet mapped — silently dropped + _WINDOWS_KEY_MAP = { + 72: b'\x1b[A', # Up + 80: b'\x1b[B', # Down + 77: b'\x1b[C', # Right + 75: b'\x1b[D', # Left + 71: b'\x1b[H', # Home + 79: b'\x1b[F', # End + 83: b'\x1b[3~', # Delete + } + + last_ctrl_c = 0 + try: + while not closed.is_set(): + if not msvcrt.kbhit(): + time.sleep(0.05) + continue + + ch = msvcrt.getwch() + if ch == '\x03': # Ctrl+C + now = time.time() + if now - last_ctrl_c < 2: + break + last_ctrl_c = now + ws.send(b'\x03', opcode=ws_module.ABNF.OPCODE_BINARY) + elif ch == '\r': # Enter + ws.send(b'\n', opcode=ws_module.ABNF.OPCODE_BINARY) + elif ch == '\x08': # Backspace + ws.send(b'\x7f', opcode=ws_module.ABNF.OPCODE_BINARY) + elif ch in ('\x00', '\xe0'): # Special key prefix + code = ord(msvcrt.getwch()) + escape = _WINDOWS_KEY_MAP.get(code) + if escape: + ws.send(escape, opcode=ws_module.ABNF.OPCODE_BINARY) + else: + ws.send(ch.encode('utf-8'), opcode=ws_module.ABNF.OPCODE_BINARY) + except (ws_module.WebSocketConnectionClosedException, OSError): + pass + + +def _shell_input_loop_unix(ws, closed): + import sys + import os + import tty + import termios + import time + import websocket as ws_module + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + last_ctrl_c = 0 + try: + tty.setraw(fd) + while not closed.is_set(): + ch = os.read(fd, 1) + if not ch: + break + if ch == b'\x03': # Ctrl+C + now = time.time() + if now - last_ctrl_c < 2: + break + last_ctrl_c = now + # Pass through everything (arrow keys already come as escape sequences) + ws.send(ch, opcode=ws_module.ABNF.OPCODE_BINARY) + except (ws_module.WebSocketConnectionClosedException, OSError): + pass + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + +def _execute_command_on_instance(scm_url, headers, cookies, command, args=None, working_directory=None): + from azure.cli.core.util import should_disable_connection_verify + import requests + + exec_url = f"{scm_url}/exec/execute" + + body = {"Command": command} + if args: + body["Args"] = args + if working_directory: + body["WorkingDirectory"] = working_directory + + response = requests.post( + exec_url, + json=body, + headers=headers, + cookies=cookies, + verify=not should_disable_connection_verify() + ) + + if response.status_code == 202: + return None + else: + raise CLIError(f"Command execution failed with status code {response.status_code}: {response.text}") + + +def webapp_exec(cmd, resource_group_name, name, command=None, args=None, mode='execute', working_directory=None, instance=None, slot=None): + # Validate Linux App + webapp = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get', slot) + if not webapp: + raise ResourceNotFoundError("Unable to find resource '{}' in ResourceGroup '{}'.".format(name, resource_group_name)) + + is_linux = webapp.reserved + if not is_linux: + raise ValidationError("Only Linux App Service Plans supported.") + + if mode.lower() == 'execute': + if not command: + raise ValidationError("Command is required for 'execute' mode.") + elif mode.lower() == 'shell': + if instance and instance.lower() == 'all': + raise ValidationError("Cannot open shell on all instances. Specify a single instance or omit for a random one.") + else: + raise ValidationError("Invalid mode '{}'. Supported modes: execute, shell.".format(mode)) + + # Get scm site and authorization + scm_url = _get_scm_url(cmd, resource_group_name, name, slot) + headers = get_scm_site_headers(cmd.cli_ctx, name, resource_group_name, slot) + + # Shell mode — single interactive session + if mode.lower() == 'shell': + cookies = {} + if instance: + instances = list_instances(cmd, resource_group_name, name, slot=slot) + instance_names = set(i.name for i in instances) + if instance not in instance_names: + raise ValidationError("Instance '{}' is not valid. Valid instances: {}".format( + instance, ', '.join(sorted(instance_names)))) + cookies['ARRAffinity'] = instance + _start_shell_session(scm_url, headers, cookies) + return None + + # Resolve target instances + target_instances = [None] # default: no affinity cookie, random instance + if instance is not None: + if instance.lower() == "all": + instances = list_instances(cmd, resource_group_name, name, slot=slot) + target_instances = [i.name for i in instances] + if not target_instances: + raise ValidationError("No instances found for this web app.") + else: + instances = list_instances(cmd, resource_group_name, name, slot=slot) + requested_instances = [i.strip() for i in instance.split(',')] + instance_names = set(i.name for i in instances) + invalid_instances = [inst for inst in requested_instances if inst not in instance_names] + if invalid_instances: + raise ValidationError("The following instances are not valid for this webapp: {}. Valid instances: {}".format( + ', '.join(invalid_instances), ', '.join(sorted(instance_names)))) + target_instances = requested_instances + + # Execute command on target instances + results = [] + for target in target_instances: + cookies = {} + if target is not None: + cookies['ARRAffinity'] = target + + try: + result = _execute_command_on_instance(scm_url, headers, cookies, command, args, working_directory) + results.append({'instance': target or 'default', 'status': 'success', 'result': result}) + except CLIError as e: + results.append({'instance': target or 'default', 'status': 'failed', 'error': str(e)}) + + return results if len(results) > 1 else results[0] if results else None From 1599ecc8ffb8408f17880f7149fc84a743f18fb8 Mon Sep 17 00:00:00 2001 From: Vandana George Date: Wed, 24 Jun 2026 10:05:40 -0700 Subject: [PATCH 02/14] set up shell execsvc client --- .../cli/command_modules/appservice/_help.py | 36 +- .../cli/command_modules/appservice/_params.py | 23 +- .../command_modules/appservice/commands.py | 4 +- .../cli/command_modules/appservice/custom.py | 233 +-------- .../command_modules/appservice/webapp_exec.py | 457 ++++++++++++++++++ 5 files changed, 499 insertions(+), 254 deletions(-) create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index 1f6bbcfb557..01b00a25c10 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -1973,40 +1973,50 @@ helps['webapp exec'] = """ type: command -short-summary: Interact with a Linux web app container via command execution or an interactive shell session. -long-summary: > - Interact with your Linux web app container using two modes. - This command is only supported for Linux App Service plans. - 'execute' runs a command in the container and returns immediately without waiting for completion or output. - Redirect to a file to capture results (see examples). - 'shell' starts an interactive shell session with the main webapp container. - Shell sessions are subject to an idle timeout and may be terminated if inactive for an extended period. - Requires SCM Basic Auth Publishing Credentials to be enabled. +short-summary: Open an interactive shell session or run a command in a Linux web app container. +long-summary: | + Interact with your Linux web app container in two modes: + - 'shell' (default): open an interactive shell session with your main app container. + - 'execute': run a fire-and-forget command in your main app container; it returns immediately without output. + + Only supported for Linux App Service plans. + Shell sessions are intended for diagnostics, not long-running work: a session ends automatically after + 3 hours of inactivity, and may also end if the underlying instance is reimaged or platform components are updated. + For 'execute' mode, redirect output to a file inside the command to capture results (see examples). examples: - name: Run a direct command in the container text: > - az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command mkdir --args "/home/site/newdir" - name: Run a bash command and redirect output to a file text: > az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command bash --args "-c" "pwd &> pwd.txt" - name: Create a file in a specific working directory text: > az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command touch --args "newfile.txt" --cwd /home/site + - name: Run a Python script in the container + text: > + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command python --args "/home/site/wwwroot/script.py" + - name: Run a Node.js script in the container + text: > + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command node --args "/home/site/wwwroot/app.js" - name: Execute a command on a specific instance text: > - az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd --instance MyInstanceId + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command touch --args "newfile.txt" --instance MyInstanceId - name: Execute a command on all instances text: > - az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd --instance all + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command touch --args "newfile.txt" --instance all - name: Execute a command on a deployment slot text: > - az webapp exec -g MyResourceGroup -n MyWebapp -s staging --mode execute --command pwd + az webapp exec -g MyResourceGroup -n MyWebapp -s staging --mode execute --command touch --args "newfile.txt" - name: Start an interactive shell session with the web app container text: > az webapp exec -g MyResourceGroup -n MyWebapp --mode shell - name: Start an interactive shell session on a specific instance text: > az webapp exec -g MyResourceGroup -n MyWebapp --mode shell --instance MyInstanceId + - name: Start an interactive shell session using a specific shell + text: > + az webapp exec -g MyResourceGroup -n MyWebapp --mode shell --shell /bin/sh """ helps['webapp delete'] = """ diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index 8608c09bb3c..fcdbf3cacdc 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -1608,18 +1608,25 @@ def load_arguments(self, _): with self.argument_context('webapp exec') as c: c.argument('name', arg_type=webapp_name_arg_type, id_part=None) c.argument('command', options_list=['--command'], - help='The command or executable to run in the container (e.g., pwd, bash, touch).') + help="The command or executable to run in the container (e.g., touch, mkdir, bash, python)." + " Used only in 'execute' mode.") c.argument('args', options_list=['--args'], nargs='+', - help='Arguments to pass to the command. For shell commands, use: --command bash --args "-c" "your command here".') + help='Arguments to pass to the command. For shell commands, use: --command bash --args "-c" "your command here".' + " Used only in 'execute' mode.") c.argument('mode', - help="Execution mode. 'execute': Starts command execution and returns immediately without returning" - " command output. 'shell': Starts an interactive shell session with the main webapp container.", - arg_type=get_enum_type(['execute', 'shell']), default='execute') + help="Execution mode. 'shell' (default): Starts an interactive shell session with the main" + " web app container. 'execute': Starts command execution and returns immediately without" + " returning command output.", + arg_type=get_enum_type(['shell', 'execute']), default='shell') c.argument('working_directory', options_list=['--working-directory', '--cwd'], - help="Working directory for command execution. Defaults to the container's working directory" - " (typically /home/site/wwwroot for App Service images).") + help="Working directory for command execution. Defaults to the container's working directory." + " Used only in 'execute' mode.") c.argument('instance', options_list=['--instance', '-i'], help='Webapp instance(s) to target. Specify a comma-separated list of instance IDs' - ' (use "az webapp list-instances" to get IDs) or "all" for all instances. Defaults to a random instance.') + ' (use "az webapp list-instances" to get IDs) or "all" for all instances. Defaults to a random instance.' + ' "all" is supported only in \'execute\' mode.') + c.argument('shell', options_list=['--shell'], + help="Absolute path of the shell to launch (e.g. /bin/sh). " + "Defaults to /bin/bash. Used only in 'shell' mode.") c.argument('slot', options_list=['--slot', '-s'], help='Name of the web app slot. Default to the production slot if not specified.') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py index c208b91b8f5..d8e32dd6a31 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -129,12 +129,14 @@ def load_command_table(self, _): logicapp_custom = CliCommandType(operations_tmpl='azure.cli.command_modules.appservice.logicapp.custom#{}') + webapp_exec_custom = CliCommandType(operations_tmpl='azure.cli.command_modules.appservice.webapp_exec#{}') + with self.command_group('webapp', webapp_sdk) as g: g.custom_command('create', 'create_webapp', exception_handler=ex_handler_factory(), validator=validate_vnet_integration) g.custom_command('up', 'webapp_up', exception_handler=ex_handler_factory(), validator=validate_webapp_up, deprecate_info=g.deprecate(redirect='webapp create and webapp deploy')) g.custom_command('ssh', 'ssh_webapp', exception_handler=ex_handler_factory(), is_preview=True) - g.custom_command('exec', 'webapp_exec', exception_handler=ex_handler_factory(), is_preview=True) + g.custom_command('exec', 'webapp_exec', custom_command_type=webapp_exec_custom, exception_handler=ex_handler_factory(), is_preview=True) g.custom_command('list', 'list_webapp', table_transformer=transform_web_list_output) g.custom_show_command('show', 'show_app', table_transformer=transform_web_output) g.custom_command('delete', 'delete_webapp') 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 129400de72a..8284fef52ef 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -50,7 +50,7 @@ from azure.cli.core.azclierror import (InvalidArgumentValueError, MutuallyExclusiveArgumentError, ResourceNotFoundError, RequiredArgumentMissingError, ValidationError, CLIInternalError, UnclassifiedUserFault, AzureResponseError, AzureInternalError, - ArgumentUsageError, FileOperationError) + ArgumentUsageError, FileOperationError, AzureConnectionError) from .tunnel import TunnelServer @@ -12883,234 +12883,3 @@ def _compute_checksum(input_bytes): logger.info("Computing the checksum of the file failed with exception:'%s'", ex) return file_hash - - -def _start_shell_session(scm_url, headers, cookies=None): - import sys - import ssl - import platform - import threading - import time - import websocket - from azure.cli.core.util import should_disable_connection_verify - - ws_url = scm_url.replace('https://', 'wss://') + '/exec/shell' - - cookie_str = '; '.join(f'{k}={v}' for k, v in cookies.items()) if cookies else None - # Respect user's SSL verification settings (e.g., self-signed certs on private stamps) - sslopt = {'cert_reqs': ssl.CERT_NONE} if should_disable_connection_verify() else {} - - ws = websocket.create_connection( - ws_url, - header=headers, - cookie=cookie_str, - sslopt=sslopt, - timeout=30 - ) - - logger.info("Connected to %s", ws_url) - print("Connected! Type commands. Ctrl+C twice to quit.\n") - - closed = threading.Event() - - # WebSocket → stdout - def recv_loop(): - try: - while not closed.is_set(): - opcode, data = ws.recv_data() - if not data: - break - text = data.decode('utf-8', errors='replace') - sys.stdout.write(text) - sys.stdout.flush() - except (websocket.WebSocketConnectionClosedException, OSError): - pass - finally: - closed.set() - - recv_thread = threading.Thread(target=recv_loop, daemon=True) - recv_thread.start() - - if platform.system() == 'Windows': - _shell_input_loop_windows(ws, closed) - else: - _shell_input_loop_unix(ws, closed) - - closed.set() - try: - ws.close() - except Exception: # pylint: disable=broad-except - pass - - -def _shell_input_loop_windows(ws, closed): - import time - import msvcrt - import websocket as ws_module - - # Windows special key codes → ANSI escape sequences - # Tab, Ctrl+D, Ctrl+L etc. work via the regular else branch (sent as-is) - # TODO: F1-F12, Insert, PgUp/PgDown not yet mapped — silently dropped - _WINDOWS_KEY_MAP = { - 72: b'\x1b[A', # Up - 80: b'\x1b[B', # Down - 77: b'\x1b[C', # Right - 75: b'\x1b[D', # Left - 71: b'\x1b[H', # Home - 79: b'\x1b[F', # End - 83: b'\x1b[3~', # Delete - } - - last_ctrl_c = 0 - try: - while not closed.is_set(): - if not msvcrt.kbhit(): - time.sleep(0.05) - continue - - ch = msvcrt.getwch() - if ch == '\x03': # Ctrl+C - now = time.time() - if now - last_ctrl_c < 2: - break - last_ctrl_c = now - ws.send(b'\x03', opcode=ws_module.ABNF.OPCODE_BINARY) - elif ch == '\r': # Enter - ws.send(b'\n', opcode=ws_module.ABNF.OPCODE_BINARY) - elif ch == '\x08': # Backspace - ws.send(b'\x7f', opcode=ws_module.ABNF.OPCODE_BINARY) - elif ch in ('\x00', '\xe0'): # Special key prefix - code = ord(msvcrt.getwch()) - escape = _WINDOWS_KEY_MAP.get(code) - if escape: - ws.send(escape, opcode=ws_module.ABNF.OPCODE_BINARY) - else: - ws.send(ch.encode('utf-8'), opcode=ws_module.ABNF.OPCODE_BINARY) - except (ws_module.WebSocketConnectionClosedException, OSError): - pass - - -def _shell_input_loop_unix(ws, closed): - import sys - import os - import tty - import termios - import time - import websocket as ws_module - - fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) - last_ctrl_c = 0 - try: - tty.setraw(fd) - while not closed.is_set(): - ch = os.read(fd, 1) - if not ch: - break - if ch == b'\x03': # Ctrl+C - now = time.time() - if now - last_ctrl_c < 2: - break - last_ctrl_c = now - # Pass through everything (arrow keys already come as escape sequences) - ws.send(ch, opcode=ws_module.ABNF.OPCODE_BINARY) - except (ws_module.WebSocketConnectionClosedException, OSError): - pass - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) - - -def _execute_command_on_instance(scm_url, headers, cookies, command, args=None, working_directory=None): - from azure.cli.core.util import should_disable_connection_verify - import requests - - exec_url = f"{scm_url}/exec/execute" - - body = {"Command": command} - if args: - body["Args"] = args - if working_directory: - body["WorkingDirectory"] = working_directory - - response = requests.post( - exec_url, - json=body, - headers=headers, - cookies=cookies, - verify=not should_disable_connection_verify() - ) - - if response.status_code == 202: - return None - else: - raise CLIError(f"Command execution failed with status code {response.status_code}: {response.text}") - - -def webapp_exec(cmd, resource_group_name, name, command=None, args=None, mode='execute', working_directory=None, instance=None, slot=None): - # Validate Linux App - webapp = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get', slot) - if not webapp: - raise ResourceNotFoundError("Unable to find resource '{}' in ResourceGroup '{}'.".format(name, resource_group_name)) - - is_linux = webapp.reserved - if not is_linux: - raise ValidationError("Only Linux App Service Plans supported.") - - if mode.lower() == 'execute': - if not command: - raise ValidationError("Command is required for 'execute' mode.") - elif mode.lower() == 'shell': - if instance and instance.lower() == 'all': - raise ValidationError("Cannot open shell on all instances. Specify a single instance or omit for a random one.") - else: - raise ValidationError("Invalid mode '{}'. Supported modes: execute, shell.".format(mode)) - - # Get scm site and authorization - scm_url = _get_scm_url(cmd, resource_group_name, name, slot) - headers = get_scm_site_headers(cmd.cli_ctx, name, resource_group_name, slot) - - # Shell mode — single interactive session - if mode.lower() == 'shell': - cookies = {} - if instance: - instances = list_instances(cmd, resource_group_name, name, slot=slot) - instance_names = set(i.name for i in instances) - if instance not in instance_names: - raise ValidationError("Instance '{}' is not valid. Valid instances: {}".format( - instance, ', '.join(sorted(instance_names)))) - cookies['ARRAffinity'] = instance - _start_shell_session(scm_url, headers, cookies) - return None - - # Resolve target instances - target_instances = [None] # default: no affinity cookie, random instance - if instance is not None: - if instance.lower() == "all": - instances = list_instances(cmd, resource_group_name, name, slot=slot) - target_instances = [i.name for i in instances] - if not target_instances: - raise ValidationError("No instances found for this web app.") - else: - instances = list_instances(cmd, resource_group_name, name, slot=slot) - requested_instances = [i.strip() for i in instance.split(',')] - instance_names = set(i.name for i in instances) - invalid_instances = [inst for inst in requested_instances if inst not in instance_names] - if invalid_instances: - raise ValidationError("The following instances are not valid for this webapp: {}. Valid instances: {}".format( - ', '.join(invalid_instances), ', '.join(sorted(instance_names)))) - target_instances = requested_instances - - # Execute command on target instances - results = [] - for target in target_instances: - cookies = {} - if target is not None: - cookies['ARRAffinity'] = target - - try: - result = _execute_command_on_instance(scm_url, headers, cookies, command, args, working_directory) - results.append({'instance': target or 'default', 'status': 'success', 'result': result}) - except CLIError as e: - results.append({'instance': target or 'default', 'status': 'failed', 'error': str(e)}) - - return results if len(results) > 1 else results[0] if results else None diff --git a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py new file mode 100644 index 00000000000..08b4d8dff5a --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py @@ -0,0 +1,457 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.log import get_logger +from knack.util import CLIError + +from azure.cli.core.azclierror import ResourceNotFoundError, ValidationError, AzureConnectionError + +from ._appservice_utils import _generic_site_operation +from .custom import _get_scm_url, get_scm_site_headers, list_instances +from .utils import is_linux_webapp + +logger = get_logger(__name__) + +_MAX_SHELL_PATH_LENGTH = 256 + + +def webapp_exec(cmd, + resource_group_name, + name, + command=None, + args=None, + mode='shell', + working_directory=None, + instance=None, + shell=None, + slot=None): + # Validate Linux App + webapp = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get', slot) + if not webapp: + raise ResourceNotFoundError("Unable to find web app '{}' in resource group '{}'.".format(name, resource_group_name)) + + if not is_linux_webapp(webapp): + raise ValidationError("Site is not a Linux web app. 'az webapp exec' is only supported for Linux web apps.") + + # Validate parameters + if mode.lower() == 'execute': + if not command: + raise ValidationError("Command is required for 'execute' mode.") + if shell: + raise ValidationError("--shell is only supported in 'shell' mode.") + elif mode.lower() == 'shell': + if command: + raise ValidationError("--command is only supported in 'execute' mode.") + if args: + raise ValidationError("--args is only supported in 'execute' mode.") + if working_directory: + raise ValidationError("--working-directory is only supported in 'execute' mode.") + if instance and (instance.lower() == 'all' or ',' in instance): + raise ValidationError("Shell mode supports a single instance. Specify one instance, or omit to use a random one.") + if shell and not shell.startswith('/'): + raise ValidationError("--shell must be an absolute path (e.g. /bin/sh).") + if shell and len(shell) > _MAX_SHELL_PATH_LENGTH: + raise ValidationError( + "--shell path is too long (max {} characters).".format(_MAX_SHELL_PATH_LENGTH)) + else: + raise ValidationError("Invalid mode '{}'. Supported modes: execute, shell.".format(mode)) + + # Get scm site and authorization + scm_url = _get_scm_url(cmd, resource_group_name, name, slot) + headers = get_scm_site_headers(cmd.cli_ctx, name, resource_group_name, slot) + + # Resolve target instances (shared by both modes) + target_instances = _resolve_target_instances(cmd, resource_group_name, name, instance, slot) + + # Shell mode — single interactive session + if mode.lower() == 'shell': + target = target_instances[0] + cookies = {} + if target: + cookies['ARRAffinity'] = target + _start_shell_session(scm_url, headers, cookies, shell=shell) + return None + + # Execute mode - execute command on the resolved instance(s) + def _run_on_instance(target): + cookies = {} + if target is not None: + cookies['ARRAffinity'] = target + label = target or 'default' + try: + result = _execute_command_on_instance(scm_url, headers, cookies, command, args, working_directory) + logger.warning("Instance '%s' succeeded%s", label, ": {}".format(result) if result else ".") + return {'instance': label, 'status': 'success', 'result': result} + except CLIError as e: + logger.warning("Instance '%s' failed: %s", label, e) + return {'instance': label, 'status': 'failed', 'error': str(e)} + + # Execute the command on every resolved instance, in parallel. Each task + # returns a result dict (never raises), so one bad instance can't abort the + # others. The POST timeout (in _execute_command_on_instance) bounds how long + # any single thread can run, so threads are always released promptly. + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor(max_workers=min(10, len(target_instances))) as executor: + results = list(executor.map(_run_on_instance, target_instances)) + + return results if len(results) > 1 else results[0] if results else None + + +def _resolve_target_instances(cmd, resource_group_name, name, instance, slot): + # Resolve --instance into a validated list of instance names. + # - None means no instance was requested: returns [None] (the load balancer picks one). + # - "all" returns a sorted list of every instance name. + # - Comma-separated values (e.g. "i1,i2") returns those names, each validated to exist. + # Raises ValidationError if "all" finds no instances, or any requested name is invalid. + + if instance is None: + return [None] + + instance_names = set(i.name for i in list_instances(cmd, resource_group_name, name, slot=slot)) + + if instance.lower() == 'all': + if not instance_names: + raise ValidationError("No instances found for this web app.") + return sorted(instance_names) + + requested = [i.strip() for i in instance.split(',')] + invalid = [i for i in requested if i not in instance_names] + if invalid: + raise ValidationError( + "The following instances are not valid for this web app: {}. Valid instances: {}".format( + ', '.join(invalid), ', '.join(sorted(instance_names)))) + return requested + + +# --- shell mode --- + + +def _start_shell_session(scm_url, headers, cookies=None, shell=None): + import sys + import platform + import threading + import time + import websocket + + ws_url = scm_url.replace('https://', 'wss://') + '/exec/shell' + if shell: + import urllib.parse + ws_url += '?shell=' + urllib.parse.quote(shell, safe='') + + cookie_str = '; '.join(f'{k}={v}' for k, v in cookies.items()) if cookies else None + + # Request Websocket connection with 30s timeout + try: + ws = websocket.create_connection( + ws_url, + header=headers, + cookie=cookie_str, + timeout=30 + ) + except websocket.WebSocketBadStatusException as ex: + # The server rejected the upgrade handshake + raise CLIError(_friendly_exec_error_message(getattr(ex, 'resp_body', None))) + except (OSError, websocket.WebSocketException) as ex: + raise AzureConnectionError("Could not connect to the web app: {}".format(ex)) + # The 30s timeout only guards the initial connect; clear it so the recv loop blocks indefinitely + ws.settimeout(None) + + logger.info("Connected to %s", ws_url) + print("Connected to the web app container.") + print("This session ends after 3 hours of inactivity, and may also end if the " + "container restarts or the host undergoes maintenance.") + print("Press Ctrl+C twice to exit.\n") + + # Enable ANSI rendering on Windows consoles before the recv thread starts + # writing server output to stdout. No-op on Unix / redirected stdout. + vt_state = _enable_windows_vt_output() + + # Run two loops until one sets closed. + # 1. _read_from_server: server output -> stdout + # 2. _send_to_server : stdin -> server + closed = threading.Event() + + def _read_from_server(): + try: + while not closed.is_set(): + opcode, data = ws.recv_data() + # Stop on a close frame; only render real shell output (text/binary). + # Control frames (close/ping/pong) carry non-output payloads we must not print. + if opcode == websocket.ABNF.OPCODE_CLOSE: + break + if opcode not in (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY): + continue + text = data.decode('utf-8', errors='replace') + sys.stdout.write(text) + sys.stdout.flush() + except (websocket.WebSocketConnectionClosedException, OSError): + pass + finally: + closed.set() + + recv_thread = threading.Thread(target=_read_from_server, daemon=True) + recv_thread.start() + + # Tell the server our starting terminal size so the remote PTY matches. + _send_terminal_resize(ws) + + # stdin -> server + if platform.system() == 'Windows': + _send_to_server_windows(ws, closed) + else: + _send_to_server_non_windows(ws, closed) + + closed.set() + # Restore the original Windows console output mode, if we changed it. + if vt_state is not None: + import ctypes + ctypes.windll.kernel32.SetConsoleMode(vt_state[0], vt_state[1]) + try: + ws.close() + except Exception: # pylint: disable=broad-except + pass + + +def _enable_windows_vt_output(): + # Enable ANSI escape processing on the Windows stdout console. + # Server output contains ANSI escape sequences (colors, cursor movement from + # vim/top/htop). Modern Windows Terminal renders these by default, but classic + # conhost/cmd.exe shows them as raw codes unless ENABLE_VIRTUAL_TERMINAL_PROCESSING + # is set. Returns (handle, old_mode) so the caller can restore the original mode, + # or None when not applicable (non-Windows, or stdout is redirected/not a console). + import platform + if platform.system() != 'Windows': + return None + import ctypes + kernel32 = ctypes.windll.kernel32 + stdout_handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE + old_mode = ctypes.c_uint32() + # GetConsoleMode returns 0 when stdout isn't a real console (e.g. redirected to a file). + if not kernel32.GetConsoleMode(stdout_handle, ctypes.byref(old_mode)): + return None + kernel32.SetConsoleMode(stdout_handle, old_mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING + return stdout_handle, old_mode.value + + +def _send_terminal_resize(ws): + # Send the current terminal size to the server as a JSON text frame. + import os + import json + import websocket + try: + size = os.get_terminal_size() + ws.send(json.dumps({"width": size.columns, "height": size.lines})) + except (OSError, websocket.WebSocketConnectionClosedException): + # No console attached (stdout redirected) or the session is already gone. + pass + + +def _send_to_server_windows(ws, closed): + import os + import time + import ctypes + import msvcrt + import websocket as ws_module + + # Windows normally intercepts Ctrl+C as a local interrupt. Clear ENABLE_PROCESSED_INPUT + # so it arrives at getwch() as raw '\x03' to forward to the remote shell; restore on exit. + kernel32 = ctypes.windll.kernel32 + stdin_handle = kernel32.GetStdHandle(-10) # STD_INPUT_HANDLE + old_mode = ctypes.c_uint32() + kernel32.GetConsoleMode(stdin_handle, ctypes.byref(old_mode)) + kernel32.SetConsoleMode(stdin_handle, old_mode.value & ~0x0001) # clear ENABLE_PROCESSED_INPUT + + # Windows special key codes → ANSI escape sequences. + # Tab, Ctrl+D, Ctrl+L etc. work via the regular else branch (sent as-is). + # NOTE: these scan codes should be validated during live Windows testing. + _WINDOWS_KEY_MAP = { + 72: b'\x1b[A', # Up + 80: b'\x1b[B', # Down + 77: b'\x1b[C', # Right + 75: b'\x1b[D', # Left + 71: b'\x1b[H', # Home + 79: b'\x1b[F', # End + 82: b'\x1b[2~', # Insert + 83: b'\x1b[3~', # Delete + 73: b'\x1b[5~', # Page Up + 81: b'\x1b[6~', # Page Down + 59: b'\x1bOP', # F1 + 60: b'\x1bOQ', # F2 + 61: b'\x1bOR', # F3 + 62: b'\x1bOS', # F4 + 63: b'\x1b[15~', # F5 + 64: b'\x1b[17~', # F6 + 65: b'\x1b[18~', # F7 + 66: b'\x1b[19~', # F8 + 67: b'\x1b[20~', # F9 + 68: b'\x1b[21~', # F10 + 133: b'\x1b[23~', # F11 + 134: b'\x1b[24~', # F12 + 115: b'\x1b[1;5D', # Ctrl+Left + 116: b'\x1b[1;5C', # Ctrl+Right + 141: b'\x1b[1;5A', # Ctrl+Up + 145: b'\x1b[1;5B', # Ctrl+Down + } + + last_ctrl_c = 0 + # Windows has no SIGWINCH, so poll the console size ~once a second and notify + # the server when it changes (e.g. the user maximizes the window). + try: + last_size = os.get_terminal_size() + except OSError: + last_size = None + last_resize_check = time.time() + try: + while not closed.is_set(): + now = time.time() + if now - last_resize_check >= 1.0: + last_resize_check = now + try: + current_size = os.get_terminal_size() + except OSError: + current_size = None + if current_size is not None and current_size != last_size: + last_size = current_size + _send_terminal_resize(ws) + + # msvcrt is Python's Windows console API. kbhit() is a non-blocking peek + # (True if a key is waiting); getwch() below reads one char and blocks if + # the buffer is empty. So if nothing's queued, nap 50ms instead of blocking. + if not msvcrt.kbhit(): + time.sleep(0.05) + continue + + ch = msvcrt.getwch() + if ch == '\x03': # Ctrl+C: twice within 2s exits the session; a single press is forwarded to the shell + now = time.time() + if now - last_ctrl_c < 2: + break + last_ctrl_c = now + ws.send(b'\x03', opcode=ws_module.ABNF.OPCODE_BINARY) + elif ch == '\r': # Enter: Windows gives CR, Unix shells expect LF + ws.send(b'\n', opcode=ws_module.ABNF.OPCODE_BINARY) + elif ch == '\x08': # Backspace: Windows gives BS, Unix shells expect DEL (0x7f) + ws.send(b'\x7f', opcode=ws_module.ABNF.OPCODE_BINARY) + elif ch in ('\x00', '\xe0'): # Special key prefix + code = ord(msvcrt.getwch()) + escape = _WINDOWS_KEY_MAP.get(code) + if escape: + ws.send(escape, opcode=ws_module.ABNF.OPCODE_BINARY) + else: + ws.send(ch.encode('utf-8'), opcode=ws_module.ABNF.OPCODE_BINARY) + except (ws_module.WebSocketConnectionClosedException, OSError) as ex: + logger.info("Shell session closed: %s", ex) + finally: + kernel32.SetConsoleMode(stdin_handle, old_mode.value) + + +def _send_to_server_non_windows(ws, closed): + import sys + import os + import tty + import termios + import time + import signal + import select + import threading + import websocket as ws_module + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + last_ctrl_c = 0 + + # On Unix the terminal raises SIGWINCH whenever it's resized. The handler just + # flags an event; the actual send happens in the loop below so we never call + # ws.send() from inside an async signal handler. + resize_needed = threading.Event() + + def on_sigwinch(_signum, _frame): + resize_needed.set() + + signal.signal(signal.SIGWINCH, on_sigwinch) + try: + tty.setraw(fd) + while not closed.is_set(): + if resize_needed.is_set(): + resize_needed.clear() + _send_terminal_resize(ws) + + # Use select with a short timeout so the loop wakes up regularly to + # re-check closed.is_set(); a blocking os.read() would hang here when + # the server ends the session until the user happened to press a key. + ready, _, _ = select.select([fd], [], [], 0.1) + if not ready: + continue + data = os.read(fd, 4096) + if not data: + break + if b'\x03' in data: # Ctrl+C (anywhere in the chunk, e.g. a paste) + now = time.time() + if now - last_ctrl_c < 2: + break + last_ctrl_c = now + ws.send(data, opcode=ws_module.ABNF.OPCODE_BINARY) + except (ws_module.WebSocketConnectionClosedException, OSError) as ex: + logger.info("Shell session closed: %s", ex) + finally: + signal.signal(signal.SIGWINCH, signal.SIG_DFL) + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + +# --- execute mode --- + + +def _execute_command_on_instance(scm_url, headers, cookies, command, args=None, working_directory=None): + import requests + + exec_url = f"{scm_url}/exec/execute" + + body = {"Command": command} + if args: + body["Args"] = args + if working_directory: + body["WorkingDirectory"] = working_directory + + try: + response = requests.post( + exec_url, + json=body, + headers=headers, + cookies=cookies, + timeout=30 + ) + except requests.exceptions.RequestException as ex: + # No HTTP response: refused/timed-out connection, DNS/TLS/proxy error, etc. + raise AzureConnectionError("Could not connect to the web app: {}".format(ex)) + + if response.status_code == 202: + return _parse_server_message(response.text) + raise CLIError(_friendly_exec_error_message(response.text)) + + +# --- shared helpers --- + + +def _parse_server_message(body): + # Return the server-authored message from a response body, or None if empty. + import json + if not body: + return None + text = body.decode('utf-8', errors='ignore') if isinstance(body, (bytes, bytearray)) else str(body) + text = text.strip() + if not text: + return None + try: + parsed = json.loads(text) + if isinstance(parsed, dict): + return parsed.get('Message') or None + except (json.JSONDecodeError, TypeError, ValueError): + pass + return text + + +def _friendly_exec_error_message(body): + # Prefer the server's message; fall back to a generic line for a bodyless response. + return _parse_server_message(body) or "The request could not be completed. Please try again later." From 7c82a4005f35086483bb56f6f254312194dc4452 Mon Sep 17 00:00:00 2001 From: Vandana George Date: Thu, 2 Jul 2026 19:48:02 -0700 Subject: [PATCH 03/14] more changes, cleanup --- .../command_modules/appservice/webapp_exec.py | 303 ++++++++++-------- 1 file changed, 166 insertions(+), 137 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py index 08b4d8dff5a..4597361dcce 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py @@ -6,7 +6,7 @@ from knack.log import get_logger from knack.util import CLIError -from azure.cli.core.azclierror import ResourceNotFoundError, ValidationError, AzureConnectionError +from azure.cli.core.azclierror import ResourceNotFoundError, ValidationError, AzureConnectionError, CLIInternalError from ._appservice_utils import _generic_site_operation from .custom import _get_scm_url, get_scm_site_headers, list_instances @@ -16,6 +16,36 @@ _MAX_SHELL_PATH_LENGTH = 256 +# Windows special key codes and ANSI escape sequences. +_WINDOWS_KEY_MAP = { + 72: b'\x1b[A', # Up + 80: b'\x1b[B', # Down + 77: b'\x1b[C', # Right + 75: b'\x1b[D', # Left + 71: b'\x1b[H', # Home + 79: b'\x1b[F', # End + 82: b'\x1b[2~', # Insert + 83: b'\x1b[3~', # Delete + 73: b'\x1b[5~', # Page Up + 81: b'\x1b[6~', # Page Down + 59: b'\x1bOP', # F1 + 60: b'\x1bOQ', # F2 + 61: b'\x1bOR', # F3 + 62: b'\x1bOS', # F4 + 63: b'\x1b[15~', # F5 + 64: b'\x1b[17~', # F6 + 65: b'\x1b[18~', # F7 + 66: b'\x1b[19~', # F8 + 67: b'\x1b[20~', # F9 + 68: b'\x1b[21~', # F10 + 133: b'\x1b[23~', # F11 + 134: b'\x1b[24~', # F12 + 115: b'\x1b[1;5D', # Ctrl+Left + 116: b'\x1b[1;5C', # Ctrl+Right + 141: b'\x1b[1;5A', # Ctrl+Up + 145: b'\x1b[1;5B', # Ctrl+Down +} + def webapp_exec(cmd, resource_group_name, @@ -30,7 +60,8 @@ def webapp_exec(cmd, # Validate Linux App webapp = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get', slot) if not webapp: - raise ResourceNotFoundError("Unable to find web app '{}' in resource group '{}'.".format(name, resource_group_name)) + raise ResourceNotFoundError( + "Unable to find web app '{}' in resource group '{}'.".format(name, resource_group_name)) if not is_linux_webapp(webapp): raise ValidationError("Site is not a Linux web app. 'az webapp exec' is only supported for Linux web apps.") @@ -49,7 +80,8 @@ def webapp_exec(cmd, if working_directory: raise ValidationError("--working-directory is only supported in 'execute' mode.") if instance and (instance.lower() == 'all' or ',' in instance): - raise ValidationError("Shell mode supports a single instance. Specify one instance, or omit to use a random one.") + raise ValidationError( + "Shell mode supports a single instance. Specify one instance, or omit to use a random one.") if shell and not shell.startswith('/'): raise ValidationError("--shell must be an absolute path (e.g. /bin/sh).") if shell and len(shell) > _MAX_SHELL_PATH_LENGTH: @@ -74,38 +106,14 @@ def webapp_exec(cmd, _start_shell_session(scm_url, headers, cookies, shell=shell) return None - # Execute mode - execute command on the resolved instance(s) - def _run_on_instance(target): - cookies = {} - if target is not None: - cookies['ARRAffinity'] = target - label = target or 'default' - try: - result = _execute_command_on_instance(scm_url, headers, cookies, command, args, working_directory) - logger.warning("Instance '%s' succeeded%s", label, ": {}".format(result) if result else ".") - return {'instance': label, 'status': 'success', 'result': result} - except CLIError as e: - logger.warning("Instance '%s' failed: %s", label, e) - return {'instance': label, 'status': 'failed', 'error': str(e)} - - # Execute the command on every resolved instance, in parallel. Each task - # returns a result dict (never raises), so one bad instance can't abort the - # others. The POST timeout (in _execute_command_on_instance) bounds how long - # any single thread can run, so threads are always released promptly. - import concurrent.futures - with concurrent.futures.ThreadPoolExecutor(max_workers=min(10, len(target_instances))) as executor: - results = list(executor.map(_run_on_instance, target_instances)) + # Execute mode - run the command on each resolved instance in parallel. + args_list = [(target, scm_url, headers, command, args, working_directory) for target in target_instances] + results = _execute_in_parallel(_run_execute_on_instance, args_list) - return results if len(results) > 1 else results[0] if results else None + return results def _resolve_target_instances(cmd, resource_group_name, name, instance, slot): - # Resolve --instance into a validated list of instance names. - # - None means no instance was requested: returns [None] (the load balancer picks one). - # - "all" returns a sorted list of every instance name. - # - Comma-separated values (e.g. "i1,i2") returns those names, each validated to exist. - # Raises ValidationError if "all" finds no instances, or any requested name is invalid. - if instance is None: return [None] @@ -125,14 +133,38 @@ def _resolve_target_instances(cmd, resource_group_name, name, instance, slot): return requested +def _run_execute_on_instance(target, scm_url, headers, command, args, working_directory): + # Run the command on a single instance and return a result dict. Never raises: + # a CLIError from one instance is captured so it can't abort the others. + cookies = {} + if target is not None: + cookies['ARRAffinity'] = target + label = target or 'default' + try: + result = _execute_command_on_instance(scm_url, headers, cookies, command, args, working_directory) + logger.warning("Instance '%s' succeeded%s", label, ": {}".format(result) if result else ".") + return {'instance': label, 'status': 'success', 'result': result} + except CLIError as e: + logger.warning("Instance '%s' failed: %s", label, e) + return {'instance': label, 'status': 'failed', 'error': str(e)} + + +def _execute_in_parallel(fn, args_list, max_workers=10): + # Run fn(*args) for each arg tuple on a thread pool + import concurrent.futures + max_workers = min(max_workers, len(args_list)) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [executor.submit(fn, *args) for args in args_list] + return [f.result() for f in futures] + + # --- shell mode --- def _start_shell_session(scm_url, headers, cookies=None, shell=None): - import sys + import codecs import platform import threading - import time import websocket ws_url = scm_url.replace('https://', 'wss://') + '/exec/shell' @@ -152,10 +184,11 @@ def _start_shell_session(scm_url, headers, cookies=None, shell=None): ) except websocket.WebSocketBadStatusException as ex: # The server rejected the upgrade handshake - raise CLIError(_friendly_exec_error_message(getattr(ex, 'resp_body', None))) + raise CLIInternalError(_friendly_exec_error_message(getattr(ex, 'resp_body', None))) except (OSError, websocket.WebSocketException) as ex: raise AzureConnectionError("Could not connect to the web app: {}".format(ex)) - # The 30s timeout only guards the initial connect; clear it so the recv loop blocks indefinitely + + # Clear the 30s connect timeout so the read_from_server loop blocks indefinitely ws.settimeout(None) logger.info("Connected to %s", ws_url) @@ -164,47 +197,37 @@ def _start_shell_session(scm_url, headers, cookies=None, shell=None): "container restarts or the host undergoes maintenance.") print("Press Ctrl+C twice to exit.\n") - # Enable ANSI rendering on Windows consoles before the recv thread starts - # writing server output to stdout. No-op on Unix / redirected stdout. + # Enable ANSI rendering on Windows consoles. No-op on Unix / redirected stdout. vt_state = _enable_windows_vt_output() + # Incremental UTF-8 decoder: a multi-byte char can split across frames, so it buffers the + # partial bytes until the next frame completes them. Replace invalid char as a fallback. + decoder = codecs.getincrementaldecoder('utf-8')('replace') + # Run two loops until one sets closed. # 1. _read_from_server: server output -> stdout # 2. _send_to_server : stdin -> server closed = threading.Event() - def _read_from_server(): - try: - while not closed.is_set(): - opcode, data = ws.recv_data() - # Stop on a close frame; only render real shell output (text/binary). - # Control frames (close/ping/pong) carry non-output payloads we must not print. - if opcode == websocket.ABNF.OPCODE_CLOSE: - break - if opcode not in (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY): - continue - text = data.decode('utf-8', errors='replace') - sys.stdout.write(text) - sys.stdout.flush() - except (websocket.WebSocketConnectionClosedException, OSError): - pass - finally: - closed.set() - - recv_thread = threading.Thread(target=_read_from_server, daemon=True) - recv_thread.start() - - # Tell the server our starting terminal size so the remote PTY matches. + # 1. server -> stdout, on a background thread + threading.Thread( + target=_read_from_server, + args=(ws, closed, decoder), + daemon=True).start() + + # Send starting terminal size so the remote PTY matches. _send_terminal_resize(ws) - # stdin -> server + # 2. stdin -> server, blocks the main thread until the session ends if platform.system() == 'Windows': _send_to_server_windows(ws, closed) else: _send_to_server_non_windows(ws, closed) + # Send loop returned: signal the read thread to stop, then clean up. closed.set() - # Restore the original Windows console output mode, if we changed it. + + # Restore the original Windows console output mode, if changed. if vt_state is not None: import ctypes ctypes.windll.kernel32.SetConsoleMode(vt_state[0], vt_state[1]) @@ -219,22 +242,48 @@ def _enable_windows_vt_output(): # Server output contains ANSI escape sequences (colors, cursor movement from # vim/top/htop). Modern Windows Terminal renders these by default, but classic # conhost/cmd.exe shows them as raw codes unless ENABLE_VIRTUAL_TERMINAL_PROCESSING - # is set. Returns (handle, old_mode) so the caller can restore the original mode, - # or None when not applicable (non-Windows, or stdout is redirected/not a console). + # is set on stdout's console mode. Returns (handle, old_mode) so the caller can restore + # the original mode, or None when not applicable (non-Windows, or stdout is redirected/not a console). + import platform if platform.system() != 'Windows': return None import ctypes kernel32 = ctypes.windll.kernel32 + stdout_handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE - old_mode = ctypes.c_uint32() # GetConsoleMode returns 0 when stdout isn't a real console (e.g. redirected to a file). + old_mode = ctypes.c_uint32() if not kernel32.GetConsoleMode(stdout_handle, ctypes.byref(old_mode)): return None + + # OR in the VT bit, preserving the other mode flags. kernel32.SetConsoleMode(stdout_handle, old_mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING return stdout_handle, old_mode.value +def _read_from_server(ws, closed, decoder): + # Runs on a background thread: stream server output -> stdout until the socket closes. + import sys + import websocket + try: + while not closed.is_set(): + opcode, data = ws.recv_data() + # Stop on a close frame + if opcode == websocket.ABNF.OPCODE_CLOSE: + break + # Text and binary is considered shell output. Do not print non-shell output (e.g. ping/pong) to stdout. + if opcode not in (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY): + continue + text = decoder.decode(data) + sys.stdout.write(text) + sys.stdout.flush() + except (websocket.WebSocketConnectionClosedException, OSError): + pass + finally: + closed.set() + + def _send_terminal_resize(ws): # Send the current terminal size to the server as a JSON text frame. import os @@ -255,57 +304,28 @@ def _send_to_server_windows(ws, closed): import msvcrt import websocket as ws_module - # Windows normally intercepts Ctrl+C as a local interrupt. Clear ENABLE_PROCESSED_INPUT - # so it arrives at getwch() as raw '\x03' to forward to the remote shell; restore on exit. + # Clear the ENABLE_PROCESSED_INPUT flag from stdin's console mode so a Ctrl+C is processed as + # a raw byte and forwarded to the remote shell, instead of interrupting az locally; restored on exit. kernel32 = ctypes.windll.kernel32 stdin_handle = kernel32.GetStdHandle(-10) # STD_INPUT_HANDLE old_mode = ctypes.c_uint32() kernel32.GetConsoleMode(stdin_handle, ctypes.byref(old_mode)) - kernel32.SetConsoleMode(stdin_handle, old_mode.value & ~0x0001) # clear ENABLE_PROCESSED_INPUT - - # Windows special key codes → ANSI escape sequences. - # Tab, Ctrl+D, Ctrl+L etc. work via the regular else branch (sent as-is). - # NOTE: these scan codes should be validated during live Windows testing. - _WINDOWS_KEY_MAP = { - 72: b'\x1b[A', # Up - 80: b'\x1b[B', # Down - 77: b'\x1b[C', # Right - 75: b'\x1b[D', # Left - 71: b'\x1b[H', # Home - 79: b'\x1b[F', # End - 82: b'\x1b[2~', # Insert - 83: b'\x1b[3~', # Delete - 73: b'\x1b[5~', # Page Up - 81: b'\x1b[6~', # Page Down - 59: b'\x1bOP', # F1 - 60: b'\x1bOQ', # F2 - 61: b'\x1bOR', # F3 - 62: b'\x1bOS', # F4 - 63: b'\x1b[15~', # F5 - 64: b'\x1b[17~', # F6 - 65: b'\x1b[18~', # F7 - 66: b'\x1b[19~', # F8 - 67: b'\x1b[20~', # F9 - 68: b'\x1b[21~', # F10 - 133: b'\x1b[23~', # F11 - 134: b'\x1b[24~', # F12 - 115: b'\x1b[1;5D', # Ctrl+Left - 116: b'\x1b[1;5C', # Ctrl+Right - 141: b'\x1b[1;5A', # Ctrl+Up - 145: b'\x1b[1;5B', # Ctrl+Down - } + kernel32.SetConsoleMode(stdin_handle, old_mode.value & ~0x0001) last_ctrl_c = 0 - # Windows has no SIGWINCH, so poll the console size ~once a second and notify - # the server when it changes (e.g. the user maximizes the window). + + # Set up for terminal window resizing try: last_size = os.get_terminal_size() except OSError: last_size = None last_resize_check = time.time() + try: while not closed.is_set(): now = time.time() + + # Poll the console size every ~1s and notify the server when it changes if now - last_resize_check >= 1.0: last_resize_check = now try: @@ -316,31 +336,40 @@ def _send_to_server_windows(ws, closed): last_size = current_size _send_terminal_resize(ws) - # msvcrt is Python's Windows console API. kbhit() is a non-blocking peek - # (True if a key is waiting); getwch() below reads one char and blocks if - # the buffer is empty. So if nothing's queued, nap 50ms instead of blocking. + # kbhit() is a non-blocking peek: returns True when a key is waiting in the console input buffer. + # If no key is waiting, sleep 0.05 seconds if not msvcrt.kbhit(): time.sleep(0.05) continue - ch = msvcrt.getwch() - if ch == '\x03': # Ctrl+C: twice within 2s exits the session; a single press is forwarded to the shell - now = time.time() - if now - last_ctrl_c < 2: - break - last_ctrl_c = now - ws.send(b'\x03', opcode=ws_module.ABNF.OPCODE_BINARY) - elif ch == '\r': # Enter: Windows gives CR, Unix shells expect LF - ws.send(b'\n', opcode=ws_module.ABNF.OPCODE_BINARY) - elif ch == '\x08': # Backspace: Windows gives BS, Unix shells expect DEL (0x7f) - ws.send(b'\x7f', opcode=ws_module.ABNF.OPCODE_BINARY) - elif ch in ('\x00', '\xe0'): # Special key prefix - code = ord(msvcrt.getwch()) - escape = _WINDOWS_KEY_MAP.get(code) - if escape: - ws.send(escape, opcode=ws_module.ABNF.OPCODE_BINARY) - else: - ws.send(ch.encode('utf-8'), opcode=ws_module.ABNF.OPCODE_BINARY) + # If key is waiting: Drain every key queued right now into one buffer and send a single frame. + buf = bytearray() + exit_session = False + while msvcrt.kbhit(): + ch = msvcrt.getwch() + # If Ctrl+C twice within 2s, exit the session. Otherwise, send to server. + if ch == '\x03': + now = time.time() + if now - last_ctrl_c < 2: + exit_session = True + break + last_ctrl_c = now + buf += b'\x03' + elif ch == '\r': # Enter: Windows gives CR, Unix shells expect LF + buf += b'\n' + elif ch == '\x08': # Backspace: Windows gives BS, Unix shells expect DEL (0x7f) + buf += b'\x7f' + elif ch in ('\x00', '\xe0'): # Special key prefix: the next getwch() is the key code + escape = _WINDOWS_KEY_MAP.get(ord(msvcrt.getwch())) + if escape: + buf += escape + else: + buf += ch.encode('utf-8') + + if buf: + ws.send(bytes(buf), opcode=ws_module.ABNF.OPCODE_BINARY) + if exit_session: + break except (ws_module.WebSocketConnectionClosedException, OSError) as ex: logger.info("Shell session closed: %s", ex) finally: @@ -362,40 +391,41 @@ def _send_to_server_non_windows(ws, closed): old_settings = termios.tcgetattr(fd) last_ctrl_c = 0 - # On Unix the terminal raises SIGWINCH whenever it's resized. The handler just - # flags an event; the actual send happens in the loop below so we never call - # ws.send() from inside an async signal handler. + # Set up for terminal window resizing resize_needed = threading.Event() def on_sigwinch(_signum, _frame): resize_needed.set() + # On SIGWINCH (terminal resize), run on_sigwinch to signal resize_needed. + # This will eventually allow send_terminal_resize to be called and send resize request to server. signal.signal(signal.SIGWINCH, on_sigwinch) + try: + # Raw mode: pass keystrokes straight to the remote shell (no local echo/buffering). tty.setraw(fd) while not closed.is_set(): if resize_needed.is_set(): resize_needed.clear() _send_terminal_resize(ws) - # Use select with a short timeout so the loop wakes up regularly to - # re-check closed.is_set(); a blocking os.read() would hang here when - # the server ends the session until the user happened to press a key. + # Wait for input on fd (stdin). If nothing for 0.1s, loop back to re-check if the session closed. ready, _, _ = select.select([fd], [], [], 0.1) if not ready: continue data = os.read(fd, 4096) if not data: break - if b'\x03' in data: # Ctrl+C (anywhere in the chunk, e.g. a paste) + if b'\x03' in data: # Ctrl+C now = time.time() if now - last_ctrl_c < 2: - break + break # Ctrl+C twice in 2 seconds will end the session last_ctrl_c = now ws.send(data, opcode=ws_module.ABNF.OPCODE_BINARY) except (ws_module.WebSocketConnectionClosedException, OSError) as ex: logger.info("Shell session closed: %s", ex) finally: + # Reset defaults: stop listening for resize signals, restore terminal out of raw mode. signal.signal(signal.SIGWINCH, signal.SIG_DFL) termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) @@ -428,12 +458,16 @@ def _execute_command_on_instance(scm_url, headers, cookies, command, args=None, if response.status_code == 202: return _parse_server_message(response.text) - raise CLIError(_friendly_exec_error_message(response.text)) + raise CLIInternalError(_friendly_exec_error_message(response.text)) # --- shared helpers --- +def _friendly_exec_error_message(body): + return _parse_server_message(body) or "The request could not be completed. Please try again later." + + def _parse_server_message(body): # Return the server-authored message from a response body, or None if empty. import json @@ -447,11 +481,6 @@ def _parse_server_message(body): parsed = json.loads(text) if isinstance(parsed, dict): return parsed.get('Message') or None - except (json.JSONDecodeError, TypeError, ValueError): + except ValueError: # includes json.JSONDecodeError pass return text - - -def _friendly_exec_error_message(body): - # Prefer the server's message; fall back to a generic line for a bodyless response. - return _parse_server_message(body) or "The request could not be completed. Please try again later." From f6b45506bbe560443bce5c6f24adbaf7682b2cea Mon Sep 17 00:00:00 2001 From: Vandana George Date: Mon, 6 Jul 2026 15:11:50 -0700 Subject: [PATCH 04/14] Fixing params --- .../cli/command_modules/appservice/_help.py | 7 ++++-- .../cli/command_modules/appservice/_params.py | 22 +++++++++---------- .../command_modules/appservice/webapp_exec.py | 8 +++---- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index 01b00a25c10..3c40a0b52cf 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -1980,9 +1980,12 @@ - 'execute': run a fire-and-forget command in your main app container; it returns immediately without output. Only supported for Linux App Service plans. - Shell sessions are intended for diagnostics, not long-running work: a session ends automatically after + + 'shell' mode: Shell sessions are not intended for long-running work. A session ends automatically after 3 hours of inactivity, and may also end if the underlying instance is reimaged or platform components are updated. - For 'execute' mode, redirect output to a file inside the command to capture results (see examples). + + 'execute' mode: The command is fire-and-forget - the call returns immediately and does not wait for the + command to finish or return any logs or output. To capture output, redirect it to a file inside the command (see examples). examples: - name: Run a direct command in the container text: > diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index fcdbf3cacdc..86b76a577c6 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -1606,27 +1606,27 @@ def load_arguments(self, _): with self.argument_context('staticwebapp enterprise-edge') as c: c.argument("no_register", help="Don't try to register the Microsoft.CDN provider. Registration can be done manually with: az provider register --wait --namespace Microsoft.CDN. For more details, please review the documentation available at https://go.microsoft.com/fwlink/?linkid=2184995 .", default=False) with self.argument_context('webapp exec') as c: - c.argument('name', arg_type=webapp_name_arg_type, id_part=None) - c.argument('command', options_list=['--command'], - help="The command or executable to run in the container (e.g., touch, mkdir, bash, python)." - " Used only in 'execute' mode.") + c.argument('name', arg_type=webapp_name_arg_type, id_part=None, help='Name of the web app.') + c.argument('exec_command', options_list=['--command'], + help="[Execute mode] The command or executable to run in the container" + " (e.g., touch, mkdir, bash, python).") c.argument('args', options_list=['--args'], nargs='+', - help='Arguments to pass to the command. For shell commands, use: --command bash --args "-c" "your command here".' - " Used only in 'execute' mode.") + help='[Execute mode] Arguments to pass to the command.' + ' For a bash one-liner use: --command bash --args "-c" "your command here".') c.argument('mode', help="Execution mode. 'shell' (default): Starts an interactive shell session with the main" " web app container. 'execute': Starts command execution and returns immediately without" " returning command output.", arg_type=get_enum_type(['shell', 'execute']), default='shell') c.argument('working_directory', options_list=['--working-directory', '--cwd'], - help="Working directory for command execution. Defaults to the container's working directory." - " Used only in 'execute' mode.") + help="[Execute mode] Working directory for command execution." + " Defaults to the container's working directory.") c.argument('instance', options_list=['--instance', '-i'], help='Webapp instance(s) to target. Specify a comma-separated list of instance IDs' ' (use "az webapp list-instances" to get IDs) or "all" for all instances. Defaults to a random instance.' - ' "all" is supported only in \'execute\' mode.') + ' Specifying multiple instances (a comma-separated list or "all") is supported only in \'execute\' mode.') c.argument('shell', options_list=['--shell'], - help="Absolute path of the shell to launch (e.g. /bin/sh). " - "Defaults to /bin/bash. Used only in 'shell' mode.") + help="[Shell mode] Absolute path of the shell to launch (e.g. /bin/sh). " + "Defaults to /bin/bash.") c.argument('slot', options_list=['--slot', '-s'], help='Name of the web app slot. Default to the production slot if not specified.') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py index 4597361dcce..f1e39878d0c 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py @@ -50,7 +50,7 @@ def webapp_exec(cmd, resource_group_name, name, - command=None, + exec_command=None, args=None, mode='shell', working_directory=None, @@ -68,12 +68,12 @@ def webapp_exec(cmd, # Validate parameters if mode.lower() == 'execute': - if not command: + if not exec_command: raise ValidationError("Command is required for 'execute' mode.") if shell: raise ValidationError("--shell is only supported in 'shell' mode.") elif mode.lower() == 'shell': - if command: + if exec_command: raise ValidationError("--command is only supported in 'execute' mode.") if args: raise ValidationError("--args is only supported in 'execute' mode.") @@ -107,7 +107,7 @@ def webapp_exec(cmd, return None # Execute mode - run the command on each resolved instance in parallel. - args_list = [(target, scm_url, headers, command, args, working_directory) for target in target_instances] + args_list = [(target, scm_url, headers, exec_command, args, working_directory) for target in target_instances] results = _execute_in_parallel(_run_execute_on_instance, args_list) return results From 1a0e0c3d83bf73537bca14e9606694f101ec0d93 Mon Sep 17 00:00:00 2001 From: Vandana George Date: Mon, 6 Jul 2026 20:37:34 -0700 Subject: [PATCH 05/14] unit tests --- .../latest/test_webapp_exec_thru_mock.py | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py new file mode 100644 index 00000000000..5f798694222 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py @@ -0,0 +1,360 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import codecs +import io +import unittest +from unittest import mock + +import requests +import websocket + +from azure.cli.core.azclierror import ( + ResourceNotFoundError, + ValidationError, + AzureConnectionError, + CLIInternalError, +) +from azure.cli.core.profiles import ResourceType + +from azure.cli.command_modules.appservice.webapp_exec import ( + webapp_exec, + _resolve_target_instances, + _execute_command_on_instance, + _parse_server_message, + _friendly_exec_error_message, + _read_from_server, + _start_shell_session, +) + +_MODULE = 'azure.cli.command_modules.appservice.webapp_exec' + + +def _get_test_cmd(): + """Return a mock CLI context (`cmd`) to pass as the first argument of `webapp_exec`.""" + from azure.cli.core.mock import DummyCli + from azure.cli.core import AzCommandsLoader + from azure.cli.core.commands import AzCliCommand + cli_ctx = DummyCli() + loader = AzCommandsLoader(cli_ctx, resource_type=ResourceType.MGMT_APPSERVICE) + cmd = AzCliCommand(loader, 'test', None) + cmd.command_kwargs = {'resource_type': ResourceType.MGMT_APPSERVICE} + cmd.cli_ctx = cli_ctx + return cmd + + +def _instance(name): + """Return a mock instance object as `list_instances` yields.""" + inst = mock.Mock() + inst.name = name + return inst + + +class WebappExecValidationTest(unittest.TestCase): + """Validate the parameter-checking branches of webapp_exec.""" + + def setUp(self): + # Set up default mocks so webapp_exec sees an existing Linux site + site_op_patcher = mock.patch(_MODULE + '._generic_site_operation', return_value=mock.Mock()) + is_linux_patcher = mock.patch(_MODULE + '.is_linux_webapp', return_value=True) + self.site_op = site_op_patcher.start() + self.is_linux = is_linux_patcher.start() + self.addCleanup(site_op_patcher.stop) + self.addCleanup(is_linux_patcher.stop) + self.cmd = _get_test_cmd() + + def test_site_not_found_raises(self): + self.site_op.return_value = None + with self.assertRaisesRegex(ResourceNotFoundError, "Unable to find web app"): + webapp_exec(self.cmd, 'rg', 'app') + + def test_non_linux_site_raises(self): + self.is_linux.return_value = False + with self.assertRaisesRegex(ValidationError, "not a Linux web app"): + webapp_exec(self.cmd, 'rg', 'app') + + def test_execute_mode_requires_command(self): + with self.assertRaisesRegex(ValidationError, "Command is required"): + webapp_exec(self.cmd, 'rg', 'app', mode='execute') + + def test_execute_mode_rejects_shell(self): + with self.assertRaisesRegex(ValidationError, r"--shell is only supported in 'shell' mode"): + webapp_exec(self.cmd, 'rg', 'app', mode='execute', exec_command='ls', shell='/bin/sh') + + def test_shell_mode_rejects_command(self): + with self.assertRaisesRegex(ValidationError, r"--command is only supported in 'execute' mode"): + webapp_exec(self.cmd, 'rg', 'app', mode='shell', exec_command='ls') + + def test_shell_mode_rejects_args(self): + with self.assertRaisesRegex(ValidationError, r"--args is only supported in 'execute' mode"): + webapp_exec(self.cmd, 'rg', 'app', mode='shell', args=['-l']) + + def test_shell_mode_rejects_working_directory(self): + with self.assertRaisesRegex(ValidationError, r"--working-directory is only supported in 'execute' mode"): + webapp_exec(self.cmd, 'rg', 'app', mode='shell', working_directory='/home') + + def test_shell_mode_rejects_all_instances(self): + with self.assertRaisesRegex(ValidationError, "single instance"): + webapp_exec(self.cmd, 'rg', 'app', mode='shell', instance='all') + + def test_shell_mode_rejects_instance_list(self): + with self.assertRaisesRegex(ValidationError, "single instance"): + webapp_exec(self.cmd, 'rg', 'app', mode='shell', instance='i1,i2') + + def test_shell_mode_rejects_relative_shell_path(self): + with self.assertRaisesRegex(ValidationError, "absolute path"): + webapp_exec(self.cmd, 'rg', 'app', mode='shell', shell='bash') + + def test_shell_mode_rejects_overlong_shell_path(self): + with self.assertRaisesRegex(ValidationError, "too long"): + webapp_exec(self.cmd, 'rg', 'app', mode='shell', shell='/' + 'a' * 300) + + def test_invalid_mode_raises(self): + with self.assertRaisesRegex(ValidationError, "Invalid mode"): + webapp_exec(self.cmd, 'rg', 'app', mode='bogus') + + @mock.patch(_MODULE + '._start_shell_session') + @mock.patch(_MODULE + '._resolve_target_instances', return_value=[None]) + @mock.patch(_MODULE + '.get_scm_site_headers', return_value={}) + @mock.patch(_MODULE + '._get_scm_url', return_value='https://app.scm.azurewebsites.net') + def test_shell_mode_happy_path_starts_session(self, _scm, _headers, _resolve, start_session): + result = webapp_exec(self.cmd, 'rg', 'app', mode='shell') + self.assertIsNone(result) + start_session.assert_called_once() + + @mock.patch(_MODULE + '._execute_in_parallel', return_value=[{'status': 'success'}]) + @mock.patch(_MODULE + '._resolve_target_instances', return_value=[None]) + @mock.patch(_MODULE + '.get_scm_site_headers', return_value={}) + @mock.patch(_MODULE + '._get_scm_url', return_value='https://app.scm.azurewebsites.net') + def test_execute_mode_happy_path_runs_in_parallel(self, _scm, _headers, _resolve, run_parallel): + result = webapp_exec(self.cmd, 'rg', 'app', mode='execute', exec_command='ls') + self.assertEqual(result, [{'status': 'success'}]) + run_parallel.assert_called_once() + + +class ResolveTargetInstancesTest(unittest.TestCase): + """Validate _resolve_target_instances instance-selection logic.""" + + def setUp(self): + self.cmd = _get_test_cmd() + + @mock.patch(_MODULE + '.list_instances') + def test_none_returns_single_default(self, list_mock): + # No instance requested -> a single unpinned target; no list_instances call needed. + self.assertEqual(_resolve_target_instances(self.cmd, 'rg', 'app', None, None), [None]) + list_mock.assert_not_called() + + @mock.patch(_MODULE + '.list_instances', return_value=[_instance('b'), _instance('a')]) + def test_all_returns_sorted_names(self, _list_mock): + self.assertEqual(_resolve_target_instances(self.cmd, 'rg', 'app', 'all', None), ['a', 'b']) + + @mock.patch(_MODULE + '.list_instances', return_value=[]) + def test_all_with_no_instances_raises(self, _list_mock): + with self.assertRaisesRegex(ValidationError, "No instances found"): + _resolve_target_instances(self.cmd, 'rg', 'app', 'all', None) + + @mock.patch(_MODULE + '.list_instances', return_value=[_instance('i1'), _instance('i2')]) + def test_valid_comma_list_returns_requested(self, _list_mock): + self.assertEqual(_resolve_target_instances(self.cmd, 'rg', 'app', 'i1, i2', None), ['i1', 'i2']) + + @mock.patch(_MODULE + '.list_instances', return_value=[_instance('i1')]) + def test_invalid_instance_raises(self, _list_mock): + with self.assertRaisesRegex(ValidationError, "not valid for this web app"): + _resolve_target_instances(self.cmd, 'rg', 'app', 'i1,nope', None) + + +class ParseServerMessageTest(unittest.TestCase): + """Validate _parse_server_message body handling.""" + + def test_none_body_returns_none(self): + self.assertIsNone(_parse_server_message(None)) + + def test_empty_and_whitespace_body_returns_none(self): + self.assertIsNone(_parse_server_message('')) + self.assertIsNone(_parse_server_message(' \n')) + + def test_json_message_field_is_extracted(self): + self.assertEqual(_parse_server_message('{"Message": "hello"}'), 'hello') + + def test_json_dict_without_message_returns_none(self): + self.assertIsNone(_parse_server_message('{"Other": "x"}')) + + def test_non_json_text_returned_verbatim(self): + self.assertEqual(_parse_server_message('non json text'), 'non json text') + + def test_json_non_dict_returned_as_text(self): + self.assertEqual(_parse_server_message('[1, 2]'), '[1, 2]') + + def test_bytes_body_is_decoded(self): + self.assertEqual(_parse_server_message(b'{"Message": "hi"}'), 'hi') + + +class FriendlyExecErrorMessageTest(unittest.TestCase): + """Validate _friendly_exec_error_message fallback behavior.""" + + def test_empty_body_uses_fallback(self): + self.assertEqual( + _friendly_exec_error_message(''), + "The request could not be completed. Please try again later.") + + def test_message_passthrough(self): + self.assertEqual(_friendly_exec_error_message('{"Message": "denied"}'), 'denied') + + +class ExecuteCommandOnInstanceTest(unittest.TestCase): + """Validate _execute_command_on_instance request building and response handling.""" + + @mock.patch('requests.post', autospec=True) + def test_success_returns_parsed_message(self, post_mock): + post_mock.return_value = mock.Mock(status_code=202, text='{"Message": "done"}') + result = _execute_command_on_instance('https://scm', {}, {}, 'ls') + self.assertEqual(result, 'done') + + @mock.patch('requests.post', autospec=True) + def test_body_omits_optional_fields_when_absent(self, post_mock): + post_mock.return_value = mock.Mock(status_code=202, text='') + _execute_command_on_instance('https://scm', {}, {}, 'ls') + self.assertEqual(post_mock.call_args.kwargs['json'], {'Command': 'ls'}) + + @mock.patch('requests.post', autospec=True) + def test_body_includes_args_and_working_directory(self, post_mock): + post_mock.return_value = mock.Mock(status_code=202, text='') + _execute_command_on_instance('https://scm', {}, {}, 'bash', args=['-c', 'pwd'], working_directory='/home') + self.assertEqual( + post_mock.call_args.kwargs['json'], + {'Command': 'bash', 'Args': ['-c', 'pwd'], 'WorkingDirectory': '/home'}) + + @mock.patch('requests.post', autospec=True) + def test_non_202_raises_internal_error_with_message(self, post_mock): + post_mock.return_value = mock.Mock(status_code=500, text='{"Message": "Internal Error"}') + with self.assertRaisesRegex(CLIInternalError, "Internal Error"): + _execute_command_on_instance('https://scm', {}, {}, 'ls') + + @mock.patch('requests.post', autospec=True) + def test_non_202_empty_body_uses_fallback_message(self, post_mock): + post_mock.return_value = mock.Mock(status_code=500, text='') + with self.assertRaisesRegex(CLIInternalError, "could not be completed"): + _execute_command_on_instance('https://scm', {}, {}, 'ls') + + @mock.patch('requests.post', autospec=True) + def test_request_exception_raises_connection_error(self, post_mock): + post_mock.side_effect = requests.exceptions.ConnectTimeout('timed out') + with self.assertRaisesRegex(AzureConnectionError, "Could not connect"): + _execute_command_on_instance('https://scm', {}, {}, 'ls') + + +class ReadFromServerTest(unittest.TestCase): + """Validate _read_from_server opcode handling and UTF-8 decoding.""" + + @staticmethod + def _run(frames): + # Mock WebSocket frames containing shell output from the server. Pass frames through + # _read_from_server and return the parsed output for testing _read_from_server. + import threading + ws = mock.Mock() + ws.recv_data.side_effect = list(frames) + closed = threading.Event() + decoder = codecs.getincrementaldecoder('utf-8')('replace') + # Redirect stdout to an in-memory buffer to capture what _read_from_server prints. + out = io.StringIO() + with mock.patch('sys.stdout', out): + _read_from_server(ws, closed, decoder) + return out.getvalue(), closed + + def test_text_frame_written_to_stdout(self): + out, closed = self._run([ + (websocket.ABNF.OPCODE_TEXT, b'hello'), + (websocket.ABNF.OPCODE_CLOSE, b''), + ]) + self.assertEqual(out, 'hello') + self.assertTrue(closed.is_set()) + + def test_binary_frame_written_to_stdout(self): + out, _closed = self._run([ + (websocket.ABNF.OPCODE_BINARY, b'hi'), + (websocket.ABNF.OPCODE_CLOSE, b''), + ]) + self.assertEqual(out, 'hi') + + def test_ping_and_pong_frames_are_skipped(self): + out, _closed = self._run([ + (websocket.ABNF.OPCODE_PING, b'x'), + (websocket.ABNF.OPCODE_PONG, b'y'), + (websocket.ABNF.OPCODE_TEXT, b'ok'), + (websocket.ABNF.OPCODE_CLOSE, b''), + ]) + self.assertEqual(out, 'ok') + + def test_close_frame_stops_before_writing(self): + out, closed = self._run([ + (websocket.ABNF.OPCODE_CLOSE, b''), + ]) + self.assertEqual(out, '') + self.assertTrue(closed.is_set()) + + def test_multibyte_utf8_split_across_frames_is_decoded(self): + # 'e-acute' is bytes 0xC3 0xA9; deliver the two bytes in separate frames. + out, _closed = self._run([ + (websocket.ABNF.OPCODE_BINARY, b'\xc3'), + (websocket.ABNF.OPCODE_BINARY, b'\xa9'), + (websocket.ABNF.OPCODE_CLOSE, b''), + ]) + self.assertEqual(out, '\u00e9') + + +class ShellSessionConnectTest(unittest.TestCase): + """Validate _start_shell_session URL/cookie building and handshake error mapping.""" + + def setUp(self): + # Stub every post-connect step so only the connection setup runs. + patchers = [ + mock.patch(_MODULE + '._read_from_server'), + mock.patch(_MODULE + '._send_terminal_resize'), + mock.patch(_MODULE + '._send_to_server_windows'), + mock.patch(_MODULE + '._send_to_server_non_windows'), + mock.patch(_MODULE + '._enable_windows_vt_output', return_value=None), + mock.patch('websocket.create_connection'), + ] + started = [p.start() for p in patchers] + for patcher in patchers: + self.addCleanup(patcher.stop) + # Save a reference to the create_connection mock so each test can + # check the parameters used when called (URL, headers, and cookie) + self.create_conn = started[-1] + + def test_builds_wss_url_and_forwards_headers(self): + headers = {'Authorization': 'Bearer token'} + _start_shell_session('https://scm.example', headers) + self.assertEqual(self.create_conn.call_args.args[0], 'wss://scm.example/exec/shell') + self.assertEqual(self.create_conn.call_args.kwargs['header'], headers) + + def test_shell_param_is_appended_and_url_encoded(self): + _start_shell_session('https://scm.example', {}, shell='/bin/bash') + self.assertEqual( + self.create_conn.call_args.args[0], + 'wss://scm.example/exec/shell?shell=%2Fbin%2Fbash') + + def test_cookies_are_formatted_into_cookie_string(self): + _start_shell_session('https://scm.example', {}, cookies={'ARRAffinity': 'abc'}) + self.assertEqual(self.create_conn.call_args.kwargs['cookie'], 'ARRAffinity=abc') + + def test_no_cookies_sends_none(self): + _start_shell_session('https://scm.example', {}) + self.assertIsNone(self.create_conn.call_args.kwargs['cookie']) + + def test_bad_handshake_raises_internal_error(self): + self.create_conn.side_effect = websocket.WebSocketBadStatusException( + 'Handshake status 403', 403, resp_body='{"Message": "Access denied"}') + with self.assertRaisesRegex(CLIInternalError, 'Access denied'): + _start_shell_session('https://scm.example', {}) + + def test_connection_failure_raises_connection_error(self): + self.create_conn.side_effect = OSError('refused') + with self.assertRaisesRegex(AzureConnectionError, 'Could not connect'): + _start_shell_session('https://scm.example', {}) + + +if __name__ == '__main__': + unittest.main() From da52af7a2a601031e5c0b54a57355c46f52010df Mon Sep 17 00:00:00 2001 From: Vandana George Date: Thu, 9 Jul 2026 10:43:07 -0700 Subject: [PATCH 06/14] update help text and shell-command params --- .../cli/command_modules/appservice/_help.py | 41 +++++---- .../cli/command_modules/appservice/_params.py | 35 ++++---- .../latest/test_webapp_exec_thru_mock.py | 88 +++++++++++++++++-- .../command_modules/appservice/webapp_exec.py | 67 +++++++++++--- 4 files changed, 179 insertions(+), 52 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index 3c40a0b52cf..1e540e56c4e 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -1976,41 +1976,48 @@ short-summary: Open an interactive shell session or run a command in a Linux web app container. long-summary: | Interact with your Linux web app container in two modes: - - 'shell' (default): open an interactive shell session with your main app container. - - 'execute': run a fire-and-forget command in your main app container; it returns immediately without output. + - 'shell' (default): open an interactive shell session in your main app container. + - 'execute': fire-and-forget a command in your main app container; returns immediately, no output. Only supported for Linux App Service plans. - 'shell' mode: Shell sessions are not intended for long-running work. A session ends automatically after - 3 hours of inactivity, and may also end if the underlying instance is reimaged or platform components are updated. + 'shell' mode: Open an interactive shell in your app's main container. + A session ends automatically after 3 hours of inactivity, + and may also end if the underlying instance is reimaged or platform components are updated. - 'execute' mode: The command is fire-and-forget - the call returns immediately and does not wait for the - command to finish or return any logs or output. To capture output, redirect it to a file inside the command (see examples). + 'execute' mode: Fire-and-forget a command in the main app container. A 'succeeded' result means the command was accepted. + It does not confirm the command ran or completed, and no logs, output, or exit code are returned. For immediate output, use 'shell' mode. + The CLI reports failure only if the command (or the shell) could not be started. + This makes execute mode well-suited to background or long-running work. + The process runs detached and lives for the lifetime of the container (or until it finishes on its own). + Use --command to run a single program, e.g. "npm start". + Use --shell-command to run a shell command line where shell operators (|, &&, >, etc.) work, e.g. "cat log.txt | grep error > out.txt". + Check the parameters and examples below, including how to capture output to a file. examples: - name: Run a direct command in the container text: > - az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command mkdir --args "/home/site/newdir" - - name: Run a bash command and redirect output to a file + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command "mkdir /home/site/newdir" + - name: Run a shell command and redirect output to a file text: > - az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command bash --args "-c" "pwd &> pwd.txt" - - name: Create a file in a specific working directory + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --shell-command "echo hello > /home/LogFiles/out.txt 2>&1" + - name: Run a command in a specific working directory text: > - az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command touch --args "newfile.txt" --cwd /home/site + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --cwd /home/site --command "touch newfile.txt" - name: Run a Python script in the container text: > - az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command python --args "/home/site/wwwroot/script.py" - - name: Run a Node.js script in the container + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command "python3 /home/site/wwwroot/script.py" + - name: Run a shell command with a non-default shell text: > - az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command node --args "/home/site/wwwroot/app.js" + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --shell /bin/sh --shell-command "echo hi | grep h" - name: Execute a command on a specific instance text: > - az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command touch --args "newfile.txt" --instance MyInstanceId + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command "touch newfile.txt" --instance MyInstanceId - name: Execute a command on all instances text: > - az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command touch --args "newfile.txt" --instance all + az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command "touch newfile.txt" --instance all - name: Execute a command on a deployment slot text: > - az webapp exec -g MyResourceGroup -n MyWebapp -s staging --mode execute --command touch --args "newfile.txt" + az webapp exec -g MyResourceGroup -n MyWebapp -s staging --mode execute --command "touch newfile.txt" - name: Start an interactive shell session with the web app container text: > az webapp exec -g MyResourceGroup -n MyWebapp --mode shell diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index 4b223249764..3639f47467b 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -1611,25 +1611,30 @@ def load_arguments(self, _): with self.argument_context('webapp exec') as c: c.argument('name', arg_type=webapp_name_arg_type, id_part=None, help='Name of the web app.') c.argument('exec_command', options_list=['--command'], - help="[Execute mode] The command or executable to run in the container" - " (e.g., touch, mkdir, bash, python).") - c.argument('args', options_list=['--args'], nargs='+', - help='[Execute mode] Arguments to pass to the command.' - ' For a bash one-liner use: --command bash --args "-c" "your command here".') + help='[Execute mode] A command to run directly in the container, without a shell. ' + 'Quote the whole command (e.g. --command "python /home/site/app.py --port 8080"). ' + 'Shell operators (>, |, &&, etc.) are not interpreted - use --shell-command for those. ' + 'Mutually exclusive with --shell-command.') + c.argument('shell_command', options_list=['--shell-command'], + help='[Execute mode] A command line to run through a shell, so shell operators (|, &&, >, etc.) work ' + '(e.g. --shell-command "echo hi > /home/LogFiles/out.txt"). ' + 'Runs as " -c "; the shell defaults to ' + '/bin/bash and can be overridden with --shell. Mutually exclusive with --command.') c.argument('mode', - help="Execution mode. 'shell' (default): Starts an interactive shell session with the main" - " web app container. 'execute': Starts command execution and returns immediately without" - " returning command output.", + help="Execution mode. 'shell' (default): Starts an interactive shell session with the main " + "web app container. 'execute': Starts command execution and returns immediately without " + "returning command output.", arg_type=get_enum_type(['shell', 'execute']), default='shell') c.argument('working_directory', options_list=['--working-directory', '--cwd'], - help="[Execute mode] Working directory for command execution." - " Defaults to the container's working directory.") + help="[Execute mode] Working directory for command execution. " + "Defaults to the container's working directory.") c.argument('instance', options_list=['--instance', '-i'], - help='Webapp instance(s) to target. Specify a comma-separated list of instance IDs' - ' (use "az webapp list-instances" to get IDs) or "all" for all instances. Defaults to a random instance.' - ' Specifying multiple instances (a comma-separated list or "all") is supported only in \'execute\' mode.') + help='Webapp instance(s) to target. Specify a comma-separated list of instance IDs ' + '(use "az webapp list-instances" to get IDs) or "all" for all instances. Defaults to a random instance. ' + 'Specifying multiple instances (a comma-separated list or "all") is supported only in \'execute\' mode.') c.argument('shell', options_list=['--shell'], - help="[Shell mode] Absolute path of the shell to launch (e.g. /bin/sh). " - "Defaults to /bin/bash.") + help="Absolute path of the shell to use (e.g. /bin/sh); defaults to /bin/bash. " + "In 'shell' mode it is the interactive shell to launch; in 'execute' mode it is " + "the shell used to run --shell-command.") c.argument('slot', options_list=['--slot', '-s'], help='Name of the web app slot. Default to the production slot if not specified.') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py index 5f798694222..73f309d446f 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py @@ -15,6 +15,7 @@ ResourceNotFoundError, ValidationError, AzureConnectionError, + AzureResponseError, CLIInternalError, ) from azure.cli.core.profiles import ResourceType @@ -22,6 +23,7 @@ from azure.cli.command_modules.appservice.webapp_exec import ( webapp_exec, _resolve_target_instances, + _build_execute_invocation, _execute_command_on_instance, _parse_server_message, _friendly_exec_error_message, @@ -76,20 +78,28 @@ def test_non_linux_site_raises(self): webapp_exec(self.cmd, 'rg', 'app') def test_execute_mode_requires_command(self): - with self.assertRaisesRegex(ValidationError, "Command is required"): + with self.assertRaisesRegex(ValidationError, "Either --command or --shell-command is required"): webapp_exec(self.cmd, 'rg', 'app', mode='execute') - def test_execute_mode_rejects_shell(self): - with self.assertRaisesRegex(ValidationError, r"--shell is only supported in 'shell' mode"): + def test_execute_mode_rejects_blank_shell_command(self): + with self.assertRaisesRegex(ValidationError, "--shell-command must not be empty"): + webapp_exec(self.cmd, 'rg', 'app', mode='execute', shell_command=' ') + + def test_execute_mode_rejects_command_and_shell_command_together(self): + with self.assertRaisesRegex(ValidationError, "either --command or --shell-command, not both"): + webapp_exec(self.cmd, 'rg', 'app', mode='execute', exec_command='ls', shell_command='ls') + + def test_execute_mode_rejects_shell_without_shell_command(self): + with self.assertRaisesRegex(ValidationError, "--shell is only valid together with --shell-command"): webapp_exec(self.cmd, 'rg', 'app', mode='execute', exec_command='ls', shell='/bin/sh') def test_shell_mode_rejects_command(self): with self.assertRaisesRegex(ValidationError, r"--command is only supported in 'execute' mode"): webapp_exec(self.cmd, 'rg', 'app', mode='shell', exec_command='ls') - def test_shell_mode_rejects_args(self): - with self.assertRaisesRegex(ValidationError, r"--args is only supported in 'execute' mode"): - webapp_exec(self.cmd, 'rg', 'app', mode='shell', args=['-l']) + def test_shell_mode_rejects_shell_command(self): + with self.assertRaisesRegex(ValidationError, r"--shell-command is only supported in 'execute' mode"): + webapp_exec(self.cmd, 'rg', 'app', mode='shell', shell_command='ls') def test_shell_mode_rejects_working_directory(self): with self.assertRaisesRegex(ValidationError, r"--working-directory is only supported in 'execute' mode"): @@ -133,6 +143,72 @@ def test_execute_mode_happy_path_runs_in_parallel(self, _scm, _headers, _resolve self.assertEqual(result, [{'status': 'success'}]) run_parallel.assert_called_once() + @mock.patch(_MODULE + '._execute_in_parallel', return_value=[{'status': 'success'}]) + @mock.patch(_MODULE + '._resolve_target_instances', return_value=[None]) + @mock.patch(_MODULE + '.get_scm_site_headers', return_value={}) + @mock.patch(_MODULE + '._get_scm_url', return_value='https://app.scm.azurewebsites.net') + def test_execute_mode_shell_command_happy_path(self, _scm, _headers, _resolve, run_parallel): + result = webapp_exec(self.cmd, 'rg', 'app', mode='execute', + shell_command='echo hi > /home/LogFiles/out.txt') + self.assertEqual(result, [{'status': 'success'}]) + run_parallel.assert_called_once() + + @mock.patch(_MODULE + '._execute_in_parallel', + return_value=[{'instance': 'default', 'status': 'failed', 'error': 'boom'}]) + @mock.patch(_MODULE + '._resolve_target_instances', return_value=[None]) + @mock.patch(_MODULE + '.get_scm_site_headers', return_value={}) + @mock.patch(_MODULE + '._get_scm_url', return_value='https://app.scm.azurewebsites.net') + def test_execute_mode_failed_instance_raises(self, _scm, _headers, _resolve, _run_parallel): + with self.assertRaisesRegex(AzureResponseError, "failed on 1 of 1 instance"): + webapp_exec(self.cmd, 'rg', 'app', mode='execute', exec_command='ls') + + +class BuildExecuteInvocationTest(unittest.TestCase): + """Validate how --command / --shell-command map to the backend (command, args) argv pair.""" + + def test_command_is_split_into_executable_and_args(self): + command, args = _build_execute_invocation('python /home/app.py --port 8080', None, None) + self.assertEqual(command, 'python') + self.assertEqual(args, ['/home/app.py', '--port', '8080']) + + def test_command_respects_quoting(self): + command, args = _build_execute_invocation('touch "my file.txt"', None, None) + self.assertEqual(command, 'touch') + self.assertEqual(args, ['my file.txt']) + + def test_shell_command_wraps_in_default_shell(self): + command, args = _build_execute_invocation(None, 'echo hi | grep h', None) + self.assertEqual(command, '/bin/bash') + self.assertEqual(args, ['-c', 'echo hi | grep h']) + + def test_shell_command_honors_custom_shell(self): + command, args = _build_execute_invocation(None, 'echo hi', '/bin/sh') + self.assertEqual(command, '/bin/sh') + self.assertEqual(args, ['-c', 'echo hi']) + + def test_empty_command_raises(self): + with self.assertRaisesRegex(ValidationError, "must not be empty"): + _build_execute_invocation(' ', None, None) + + def test_unbalanced_quotes_raises(self): + with self.assertRaisesRegex(ValidationError, "Could not parse --command"): + _build_execute_invocation('echo "unterminated', None, None) + + def test_single_token_command_has_no_args(self): + command, args = _build_execute_invocation('nginx', None, None) + self.assertEqual(command, 'nginx') + self.assertEqual(args, []) + + def test_shell_operators_pass_through_as_literal_args(self): + command, args = _build_execute_invocation('myprog >&>>&&&&>', None, None) + self.assertEqual(command, 'myprog') + self.assertEqual(args, ['>&>>&&&&>']) + + def test_shell_command_empty_shell_falls_back_to_default(self): + command, args = _build_execute_invocation(None, 'echo hi', '') + self.assertEqual(command, '/bin/bash') + self.assertEqual(args, ['-c', 'echo hi']) + class ResolveTargetInstancesTest(unittest.TestCase): """Validate _resolve_target_instances instance-selection logic.""" diff --git a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py index f1e39878d0c..1a44efb0aa4 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py @@ -6,7 +6,8 @@ from knack.log import get_logger from knack.util import CLIError -from azure.cli.core.azclierror import ResourceNotFoundError, ValidationError, AzureConnectionError, CLIInternalError +from azure.cli.core.azclierror import (ResourceNotFoundError, ValidationError, AzureConnectionError, + AzureResponseError, CLIInternalError) from ._appservice_utils import _generic_site_operation from .custom import _get_scm_url, get_scm_site_headers, list_instances @@ -51,7 +52,7 @@ def webapp_exec(cmd, resource_group_name, name, exec_command=None, - args=None, + shell_command=None, mode='shell', working_directory=None, instance=None, @@ -67,26 +68,32 @@ def webapp_exec(cmd, raise ValidationError("Site is not a Linux web app. 'az webapp exec' is only supported for Linux web apps.") # Validate parameters + if shell: + if not shell.startswith('/'): + raise ValidationError("--shell must be an absolute path (e.g. /bin/sh).") + if len(shell) > _MAX_SHELL_PATH_LENGTH: + raise ValidationError( + "--shell path is too long (max {} characters).".format(_MAX_SHELL_PATH_LENGTH)) + if mode.lower() == 'execute': - if not exec_command: - raise ValidationError("Command is required for 'execute' mode.") - if shell: - raise ValidationError("--shell is only supported in 'shell' mode.") + if exec_command and shell_command: + raise ValidationError("Specify either --command or --shell-command, not both.") + if not exec_command and not shell_command: + raise ValidationError("Either --command or --shell-command is required for 'execute' mode.") + if shell_command is not None and not shell_command.strip(): + raise ValidationError("--shell-command must not be empty.") + if shell and not shell_command: + raise ValidationError("--shell is only valid together with --shell-command in 'execute' mode.") elif mode.lower() == 'shell': if exec_command: raise ValidationError("--command is only supported in 'execute' mode.") - if args: - raise ValidationError("--args is only supported in 'execute' mode.") + if shell_command: + raise ValidationError("--shell-command is only supported in 'execute' mode.") if working_directory: raise ValidationError("--working-directory is only supported in 'execute' mode.") if instance and (instance.lower() == 'all' or ',' in instance): raise ValidationError( "Shell mode supports a single instance. Specify one instance, or omit to use a random one.") - if shell and not shell.startswith('/'): - raise ValidationError("--shell must be an absolute path (e.g. /bin/sh).") - if shell and len(shell) > _MAX_SHELL_PATH_LENGTH: - raise ValidationError( - "--shell path is too long (max {} characters).".format(_MAX_SHELL_PATH_LENGTH)) else: raise ValidationError("Invalid mode '{}'. Supported modes: execute, shell.".format(mode)) @@ -107,9 +114,22 @@ def webapp_exec(cmd, return None # Execute mode - run the command on each resolved instance in parallel. - args_list = [(target, scm_url, headers, exec_command, args, working_directory) for target in target_instances] + command, command_args = _build_execute_invocation(exec_command, shell_command, shell) + logger.warning( + "Execute mode is fire-and-forget: a 'succeeded' result means the command was accepted, " + "not that it ran or finished. No output is returned - it is best for background or " + "long-running work; for immediate output, use '--mode shell'. " + "See 'az webapp exec --help' for how to capture output.") + args_list = [(target, scm_url, headers, command, command_args, working_directory) + for target in target_instances] results = _execute_in_parallel(_run_execute_on_instance, args_list) + failed = [r for r in results if r.get('status') == 'failed'] + if failed: + raise AzureResponseError( + "Command execution failed on {} of {} instance(s). See the messages above for details." + .format(len(failed), len(results))) + return results @@ -133,6 +153,25 @@ def _resolve_target_instances(cmd, resource_group_name, name, instance, slot): return requested +def _build_execute_invocation(exec_command, shell_command, shell): + # Parse the user's input into the (command, args) argv pair. + if shell_command: + shell_path = shell or '/bin/bash' + logger.warning("Running shell command with %s: %s", shell_path, shell_command) + return shell_path, ['-c', shell_command] + + import shlex + try: + tokens = shlex.split(exec_command) + except ValueError as ex: + raise ValidationError( + "Could not parse --command: {}. Check for unbalanced quotes.".format(ex)) + if not tokens: + raise ValidationError("--command must not be empty.") + logger.warning("Running command: %s | arguments: %s", tokens[0], tokens[1:]) + return tokens[0], tokens[1:] + + def _run_execute_on_instance(target, scm_url, headers, command, args, working_directory): # Run the command on a single instance and return a result dict. Never raises: # a CLIError from one instance is captured so it can't abort the others. From 3e56150e10f7ff3969e85f18c072c016772058d9 Mon Sep 17 00:00:00 2001 From: Vandana George Date: Mon, 13 Jul 2026 18:56:28 -0700 Subject: [PATCH 07/14] utf-16 cases --- .../command_modules/appservice/webapp_exec.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py index 1a44efb0aa4..188d64be849 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py @@ -311,11 +311,15 @@ def _read_from_server(ws, closed, decoder): # Stop on a close frame if opcode == websocket.ABNF.OPCODE_CLOSE: break - # Text and binary is considered shell output. Do not print non-shell output (e.g. ping/pong) to stdout. + # Print shell output (text, binary). if opcode not in (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY): continue text = decoder.decode(data) - sys.stdout.write(text) + try: + sys.stdout.write(text) + except UnicodeEncodeError: + enc = getattr(sys.stdout, 'encoding', None) or 'utf-8' + sys.stdout.write(text.encode(enc, 'replace').decode(enc, 'replace')) sys.stdout.flush() except (websocket.WebSocketConnectionClosedException, OSError): pass @@ -381,7 +385,7 @@ def _send_to_server_windows(ws, closed): time.sleep(0.05) continue - # If key is waiting: Drain every key queued right now into one buffer and send a single frame. + # If key is waiting: Drain every key queued into one buffer and send a single frame. buf = bytearray() exit_session = False while msvcrt.kbhit(): @@ -403,7 +407,12 @@ def _send_to_server_windows(ws, closed): if escape: buf += escape else: - buf += ch.encode('utf-8') + # If key is first half of a UTF-16 non-BMP char (e.g. emoji), read the + # second half and fuse pair into one real character. + if 0xD800 <= ord(ch) <= 0xDBFF and msvcrt.kbhit(): + ch += msvcrt.getwch() + ch = ch.encode('utf-16-le', 'surrogatepass').decode('utf-16-le', 'replace') + buf += ch.encode('utf-8', 'replace') if buf: ws.send(bytes(buf), opcode=ws_module.ABNF.OPCODE_BINARY) From d3ee1957e33dad18c0ab1d02eccf7f43dd6a253e Mon Sep 17 00:00:00 2001 From: Vandana George Date: Mon, 20 Jul 2026 18:48:08 -0700 Subject: [PATCH 08/14] remove confusing logs --- .../command_modules/appservice/webapp_exec.py | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py index 188d64be849..165843258d1 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py @@ -15,8 +15,6 @@ logger = get_logger(__name__) -_MAX_SHELL_PATH_LENGTH = 256 - # Windows special key codes and ANSI escape sequences. _WINDOWS_KEY_MAP = { 72: b'\x1b[A', # Up @@ -71,9 +69,6 @@ def webapp_exec(cmd, if shell: if not shell.startswith('/'): raise ValidationError("--shell must be an absolute path (e.g. /bin/sh).") - if len(shell) > _MAX_SHELL_PATH_LENGTH: - raise ValidationError( - "--shell path is too long (max {} characters).".format(_MAX_SHELL_PATH_LENGTH)) if mode.lower() == 'execute': if exec_command and shell_command: @@ -116,10 +111,8 @@ def webapp_exec(cmd, # Execute mode - run the command on each resolved instance in parallel. command, command_args = _build_execute_invocation(exec_command, shell_command, shell) logger.warning( - "Execute mode is fire-and-forget: a 'succeeded' result means the command was accepted, " - "not that it ran or finished. No output is returned - it is best for background or " - "long-running work; for immediate output, use '--mode shell'. " - "See 'az webapp exec --help' for how to capture output.") + "Execute mode is fire-and-forget: 'succeeded' means the command was accepted, " + "not that it ran or finished. No output is returned.") args_list = [(target, scm_url, headers, command, command_args, working_directory) for target in target_instances] results = _execute_in_parallel(_run_execute_on_instance, args_list) @@ -210,10 +203,14 @@ def _start_shell_session(scm_url, headers, cookies=None, shell=None): if shell: import urllib.parse ws_url += '?shell=' + urllib.parse.quote(shell, safe='') + else: + print("No --shell provided; defaulting to /bin/bash. If your container image does not " + "include bash, run with --shell (e.g. --shell /bin/sh).") cookie_str = '; '.join(f'{k}={v}' for k, v in cookies.items()) if cookies else None # Request Websocket connection with 30s timeout + print("Connecting to the web app container...") try: ws = websocket.create_connection( ws_url, @@ -230,10 +227,6 @@ def _start_shell_session(scm_url, headers, cookies=None, shell=None): # Clear the 30s connect timeout so the read_from_server loop blocks indefinitely ws.settimeout(None) - logger.info("Connected to %s", ws_url) - print("Connected to the web app container.") - print("This session ends after 3 hours of inactivity, and may also end if the " - "container restarts or the host undergoes maintenance.") print("Press Ctrl+C twice to exit.\n") # Enable ANSI rendering on Windows consoles. No-op on Unix / redirected stdout. @@ -419,7 +412,7 @@ def _send_to_server_windows(ws, closed): if exit_session: break except (ws_module.WebSocketConnectionClosedException, OSError) as ex: - logger.info("Shell session closed: %s", ex) + logger.warning("Shell session closed: %s", ex) finally: kernel32.SetConsoleMode(stdin_handle, old_mode.value) @@ -471,7 +464,7 @@ def on_sigwinch(_signum, _frame): last_ctrl_c = now ws.send(data, opcode=ws_module.ABNF.OPCODE_BINARY) except (ws_module.WebSocketConnectionClosedException, OSError) as ex: - logger.info("Shell session closed: %s", ex) + logger.warning("Shell session closed: %s", ex) finally: # Reset defaults: stop listening for resize signals, restore terminal out of raw mode. signal.signal(signal.SIGWINCH, signal.SIG_DFL) From 2ae213ada1b7730a96d6bd306388ec9b959fa303 Mon Sep 17 00:00:00 2001 From: Vandana George Date: Tue, 21 Jul 2026 19:55:13 -0700 Subject: [PATCH 09/14] nit --- .../cli/command_modules/appservice/_help.py | 18 +++++----- .../cli/command_modules/appservice/custom.py | 2 +- .../latest/test_webapp_exec_thru_mock.py | 4 --- .../command_modules/appservice/webapp_exec.py | 35 ++++++++++--------- 4 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index 1e540e56c4e..61c593db7f6 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -1994,6 +1994,15 @@ Use --shell-command to run a shell command line where shell operators (|, &&, >, etc.) work, e.g. "cat log.txt | grep error > out.txt". Check the parameters and examples below, including how to capture output to a file. examples: + - name: Start an interactive shell session with the web app container + text: > + az webapp exec -g MyResourceGroup -n MyWebapp --mode shell + - name: Start an interactive shell session on a specific instance + text: > + az webapp exec -g MyResourceGroup -n MyWebapp --mode shell --instance MyInstanceId + - name: Start an interactive shell session using a specific shell + text: > + az webapp exec -g MyResourceGroup -n MyWebapp --mode shell --shell /bin/sh - name: Run a direct command in the container text: > az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command "mkdir /home/site/newdir" @@ -2018,15 +2027,6 @@ - name: Execute a command on a deployment slot text: > az webapp exec -g MyResourceGroup -n MyWebapp -s staging --mode execute --command "touch newfile.txt" - - name: Start an interactive shell session with the web app container - text: > - az webapp exec -g MyResourceGroup -n MyWebapp --mode shell - - name: Start an interactive shell session on a specific instance - text: > - az webapp exec -g MyResourceGroup -n MyWebapp --mode shell --instance MyInstanceId - - name: Start an interactive shell session using a specific shell - text: > - az webapp exec -g MyResourceGroup -n MyWebapp --mode shell --shell /bin/sh """ helps['webapp delete'] = """ 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 7d47ed3a796..fe3ec65ac8b 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -50,7 +50,7 @@ from azure.cli.core.azclierror import (InvalidArgumentValueError, MutuallyExclusiveArgumentError, ResourceNotFoundError, RequiredArgumentMissingError, ValidationError, CLIInternalError, UnclassifiedUserFault, AzureResponseError, AzureInternalError, - ArgumentUsageError, FileOperationError, AzureConnectionError) + ArgumentUsageError, FileOperationError) from .tunnel import TunnelServer diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py index 73f309d446f..99d6cc86bfc 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py @@ -117,10 +117,6 @@ def test_shell_mode_rejects_relative_shell_path(self): with self.assertRaisesRegex(ValidationError, "absolute path"): webapp_exec(self.cmd, 'rg', 'app', mode='shell', shell='bash') - def test_shell_mode_rejects_overlong_shell_path(self): - with self.assertRaisesRegex(ValidationError, "too long"): - webapp_exec(self.cmd, 'rg', 'app', mode='shell', shell='/' + 'a' * 300) - def test_invalid_mode_raises(self): with self.assertRaisesRegex(ValidationError, "Invalid mode"): webapp_exec(self.cmd, 'rg', 'app', mode='bogus') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py index 165843258d1..8d4807a42b3 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py @@ -137,7 +137,9 @@ def _resolve_target_instances(cmd, resource_group_name, name, instance, slot): raise ValidationError("No instances found for this web app.") return sorted(instance_names) - requested = [i.strip() for i in instance.split(',')] + requested = [i.strip() for i in instance.split(',') if i.strip()] + if not requested: + raise ValidationError("--instance must contain at least one instance ID, or 'all'.") invalid = [i for i in requested if i not in instance_names] if invalid: raise ValidationError( @@ -251,22 +253,23 @@ def _start_shell_session(scm_url, headers, cookies=None, shell=None): _send_terminal_resize(ws) # 2. stdin -> server, blocks the main thread until the session ends - if platform.system() == 'Windows': - _send_to_server_windows(ws, closed) - else: - _send_to_server_non_windows(ws, closed) - - # Send loop returned: signal the read thread to stop, then clean up. - closed.set() - - # Restore the original Windows console output mode, if changed. - if vt_state is not None: - import ctypes - ctypes.windll.kernel32.SetConsoleMode(vt_state[0], vt_state[1]) try: - ws.close() - except Exception: # pylint: disable=broad-except - pass + if platform.system() == 'Windows': + _send_to_server_windows(ws, closed) + else: + _send_to_server_non_windows(ws, closed) + finally: + # Send loop returned (or raised): signal the read thread to stop, then clean up. + closed.set() + + # Restore the original Windows console output mode, if changed. + if vt_state is not None: + import ctypes + ctypes.windll.kernel32.SetConsoleMode(vt_state[0], vt_state[1]) + try: + ws.close() + except Exception: # pylint: disable=broad-except + pass def _enable_windows_vt_output(): From 150c3e51b87a8bd53eae7e17de921b6671bf4114 Mon Sep 17 00:00:00 2001 From: Vandana George Date: Sat, 25 Jul 2026 19:50:53 -0700 Subject: [PATCH 10/14] comment fixes --- .../latest/test_webapp_exec_thru_mock.py | 97 ++++++++++++++++++- .../command_modules/appservice/webapp_exec.py | 61 ++++++++---- 2 files changed, 136 insertions(+), 22 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py index 99d6cc86bfc..ff17b5aace8 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py @@ -5,6 +5,7 @@ import codecs import io +import ssl import unittest from unittest import mock @@ -24,6 +25,7 @@ webapp_exec, _resolve_target_instances, _build_execute_invocation, + _run_execute_on_instance, _execute_command_on_instance, _parse_server_message, _friendly_exec_error_message, @@ -109,6 +111,10 @@ def test_shell_mode_rejects_all_instances(self): with self.assertRaisesRegex(ValidationError, "single instance"): webapp_exec(self.cmd, 'rg', 'app', mode='shell', instance='all') + def test_shell_mode_rejects_all_instances_with_whitespace(self): + with self.assertRaisesRegex(ValidationError, "single instance"): + webapp_exec(self.cmd, 'rg', 'app', mode='shell', instance=' all ') + def test_shell_mode_rejects_instance_list(self): with self.assertRaisesRegex(ValidationError, "single instance"): webapp_exec(self.cmd, 'rg', 'app', mode='shell', instance='i1,i2') @@ -130,23 +136,23 @@ def test_shell_mode_happy_path_starts_session(self, _scm, _headers, _resolve, st self.assertIsNone(result) start_session.assert_called_once() - @mock.patch(_MODULE + '._execute_in_parallel', return_value=[{'status': 'success'}]) + @mock.patch(_MODULE + '._execute_in_parallel', return_value=[{'status': 'accepted'}]) @mock.patch(_MODULE + '._resolve_target_instances', return_value=[None]) @mock.patch(_MODULE + '.get_scm_site_headers', return_value={}) @mock.patch(_MODULE + '._get_scm_url', return_value='https://app.scm.azurewebsites.net') def test_execute_mode_happy_path_runs_in_parallel(self, _scm, _headers, _resolve, run_parallel): result = webapp_exec(self.cmd, 'rg', 'app', mode='execute', exec_command='ls') - self.assertEqual(result, [{'status': 'success'}]) + self.assertEqual(result, [{'status': 'accepted'}]) run_parallel.assert_called_once() - @mock.patch(_MODULE + '._execute_in_parallel', return_value=[{'status': 'success'}]) + @mock.patch(_MODULE + '._execute_in_parallel', return_value=[{'status': 'accepted'}]) @mock.patch(_MODULE + '._resolve_target_instances', return_value=[None]) @mock.patch(_MODULE + '.get_scm_site_headers', return_value={}) @mock.patch(_MODULE + '._get_scm_url', return_value='https://app.scm.azurewebsites.net') def test_execute_mode_shell_command_happy_path(self, _scm, _headers, _resolve, run_parallel): result = webapp_exec(self.cmd, 'rg', 'app', mode='execute', shell_command='echo hi > /home/LogFiles/out.txt') - self.assertEqual(result, [{'status': 'success'}]) + self.assertEqual(result, [{'status': 'accepted'}]) run_parallel.assert_called_once() @mock.patch(_MODULE + '._execute_in_parallel', @@ -155,7 +161,7 @@ def test_execute_mode_shell_command_happy_path(self, _scm, _headers, _resolve, r @mock.patch(_MODULE + '.get_scm_site_headers', return_value={}) @mock.patch(_MODULE + '._get_scm_url', return_value='https://app.scm.azurewebsites.net') def test_execute_mode_failed_instance_raises(self, _scm, _headers, _resolve, _run_parallel): - with self.assertRaisesRegex(AzureResponseError, "failed on 1 of 1 instance"): + with self.assertRaisesRegex(AzureResponseError, "not accepted on 1 of 1 instance"): webapp_exec(self.cmd, 'rg', 'app', mode='execute', exec_command='ls') @@ -182,6 +188,16 @@ def test_shell_command_honors_custom_shell(self): self.assertEqual(command, '/bin/sh') self.assertEqual(args, ['-c', 'echo hi']) + @mock.patch(_MODULE + '.logger.warning') + def test_shell_command_contents_are_not_logged(self, warning_mock): + _build_execute_invocation(None, 'echo super-secret-value', None) + warning_mock.assert_called_once_with("Running shell command with %s.", '/bin/bash') + + @mock.patch(_MODULE + '.logger.warning') + def test_direct_command_contents_are_not_logged(self, warning_mock): + _build_execute_invocation('curl --header super-secret-value', None, None) + warning_mock.assert_not_called() + def test_empty_command_raises(self): with self.assertRaisesRegex(ValidationError, "must not be empty"): _build_execute_invocation(' ', None, None) @@ -206,6 +222,41 @@ def test_shell_command_empty_shell_falls_back_to_default(self): self.assertEqual(args, ['-c', 'echo hi']) +class RunExecuteOnInstanceTest(unittest.TestCase): + """Validate per-instance result messages.""" + + @mock.patch(_MODULE + '.logger.warning') + @mock.patch(_MODULE + '._execute_command_on_instance', return_value='Command accepted.') + def test_accepted_without_explicit_instance_omits_instance_label(self, _execute_mock, warning_mock): + result = _run_execute_on_instance(None, 'https://scm', {}, 'ls', [], None) + warning_mock.assert_called_once_with("%s", "Command accepted.") + self.assertEqual(result['instance'], 'default') + + @mock.patch(_MODULE + '.logger.warning') + @mock.patch(_MODULE + '._execute_command_on_instance', side_effect=CLIInternalError('rejected')) + def test_failed_without_explicit_instance_omits_instance_label(self, _execute_mock, warning_mock): + result = _run_execute_on_instance(None, 'https://scm', {}, 'ls', [], None) + warning_mock.assert_called_once_with("%s", mock.ANY) + self.assertEqual(result['instance'], 'default') + + @mock.patch(_MODULE + '.logger.debug') + @mock.patch(_MODULE + '.logger.warning') + @mock.patch(_MODULE + '._execute_command_on_instance', side_effect=ValueError('unexpected')) + def test_unexpected_exception_returns_failed_result(self, _execute_mock, warning_mock, debug_mock): + result = _run_execute_on_instance(None, 'https://scm', {}, 'ls', [], None) + debug_mock.assert_called_once_with( + "An unexpected error occurred while submitting the command.", exc_info=True) + warning_mock.assert_called_once_with( + "%s", "An unexpected error occurred while submitting the command.") + self.assertEqual( + result, + { + 'instance': 'default', + 'status': 'failed', + 'error': 'An unexpected error occurred while submitting the command.', + }) + + class ResolveTargetInstancesTest(unittest.TestCase): """Validate _resolve_target_instances instance-selection logic.""" @@ -298,6 +349,20 @@ def test_body_includes_args_and_working_directory(self, post_mock): post_mock.call_args.kwargs['json'], {'Command': 'bash', 'Args': ['-c', 'pwd'], 'WorkingDirectory': '/home'}) + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) + @mock.patch('requests.post', autospec=True) + def test_request_verifies_certificates_by_default(self, post_mock, _disable_verify): + post_mock.return_value = mock.Mock(status_code=202, text='') + _execute_command_on_instance('https://scm', {}, {}, 'ls') + self.assertTrue(post_mock.call_args.kwargs['verify']) + + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=True) + @mock.patch('requests.post', autospec=True) + def test_request_honors_disabled_certificate_verification(self, post_mock, _disable_verify): + post_mock.return_value = mock.Mock(status_code=202, text='') + _execute_command_on_instance('https://scm', {}, {}, 'ls') + self.assertFalse(post_mock.call_args.kwargs['verify']) + @mock.patch('requests.post', autospec=True) def test_non_202_raises_internal_error_with_message(self, post_mock): post_mock.return_value = mock.Mock(status_code=500, text='{"Message": "Internal Error"}') @@ -387,6 +452,7 @@ def setUp(self): mock.patch(_MODULE + '._send_to_server_windows'), mock.patch(_MODULE + '._send_to_server_non_windows'), mock.patch(_MODULE + '._enable_windows_vt_output', return_value=None), + mock.patch('sys.stdin.isatty', return_value=True), mock.patch('websocket.create_connection'), ] started = [p.start() for p in patchers] @@ -416,6 +482,27 @@ def test_no_cookies_sends_none(self): _start_shell_session('https://scm.example', {}) self.assertIsNone(self.create_conn.call_args.kwargs['cookie']) + def test_redirected_stdin_raises_validation_error_before_connecting(self): + with mock.patch('sys.stdin.isatty', return_value=False): + with self.assertRaisesRegex(ValidationError, "requires an interactive terminal"): + _start_shell_session('https://scm.example', {}) + self.create_conn.assert_not_called() + + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) + def test_connection_is_thread_safe_and_verifies_certificates_by_default(self, _disable_verify): + _start_shell_session('https://scm.example', {}) + self.assertTrue(self.create_conn.call_args.kwargs['enable_multithread']) + self.assertEqual( + self.create_conn.call_args.kwargs['sslopt'], + {'cert_reqs': ssl.CERT_REQUIRED}) + + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=True) + def test_connection_honors_disabled_certificate_verification(self, _disable_verify): + _start_shell_session('https://scm.example', {}) + self.assertEqual( + self.create_conn.call_args.kwargs['sslopt'], + {'cert_reqs': ssl.CERT_NONE}) + def test_bad_handshake_raises_internal_error(self): self.create_conn.side_effect = websocket.WebSocketBadStatusException( 'Handshake status 403', 403, resp_body='{"Message": "Access denied"}') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py index 8d4807a42b3..e992dff11eb 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py @@ -66,6 +66,9 @@ def webapp_exec(cmd, raise ValidationError("Site is not a Linux web app. 'az webapp exec' is only supported for Linux web apps.") # Validate parameters + if instance is not None: + instance = instance.strip() + if shell: if not shell.startswith('/'): raise ValidationError("--shell must be an absolute path (e.g. /bin/sh).") @@ -111,8 +114,8 @@ def webapp_exec(cmd, # Execute mode - run the command on each resolved instance in parallel. command, command_args = _build_execute_invocation(exec_command, shell_command, shell) logger.warning( - "Execute mode is fire-and-forget: 'succeeded' means the command was accepted, " - "not that it ran or finished. No output is returned.") + "Execute mode is fire-and-forget: 'accepted' means the command request was accepted, " + "not that it ran or finished. No output is returned.\n") args_list = [(target, scm_url, headers, command, command_args, working_directory) for target in target_instances] results = _execute_in_parallel(_run_execute_on_instance, args_list) @@ -120,7 +123,7 @@ def webapp_exec(cmd, failed = [r for r in results if r.get('status') == 'failed'] if failed: raise AzureResponseError( - "Command execution failed on {} of {} instance(s). See the messages above for details." + "Command request was not accepted on {} of {} instance(s). See the messages above for details." .format(len(failed), len(results))) return results @@ -152,7 +155,7 @@ def _build_execute_invocation(exec_command, shell_command, shell): # Parse the user's input into the (command, args) argv pair. if shell_command: shell_path = shell or '/bin/bash' - logger.warning("Running shell command with %s: %s", shell_path, shell_command) + logger.warning("Running shell command with %s.", shell_path) return shell_path, ['-c', shell_command] import shlex @@ -163,24 +166,37 @@ def _build_execute_invocation(exec_command, shell_command, shell): "Could not parse --command: {}. Check for unbalanced quotes.".format(ex)) if not tokens: raise ValidationError("--command must not be empty.") - logger.warning("Running command: %s | arguments: %s", tokens[0], tokens[1:]) return tokens[0], tokens[1:] def _run_execute_on_instance(target, scm_url, headers, command, args, working_directory): # Run the command on a single instance and return a result dict. Never raises: - # a CLIError from one instance is captured so it can't abort the others. + # failures from one instance are captured so they can't abort the others. cookies = {} if target is not None: cookies['ARRAffinity'] = target label = target or 'default' try: result = _execute_command_on_instance(scm_url, headers, cookies, command, args, working_directory) - logger.warning("Instance '%s' succeeded%s", label, ": {}".format(result) if result else ".") - return {'instance': label, 'status': 'success', 'result': result} + if target is not None: + logger.warning("Instance '%s': %s", target, result) + else: + logger.warning("%s", result) + return {'instance': label, 'status': 'accepted', 'result': result} except CLIError as e: - logger.warning("Instance '%s' failed: %s", label, e) + if target is not None: + logger.warning("Instance '%s': %s", target, e) + else: + logger.warning("%s", e) return {'instance': label, 'status': 'failed', 'error': str(e)} + except Exception: # pylint: disable=broad-except + message = "An unexpected error occurred while submitting the command." + logger.debug(message, exc_info=True) + if target is not None: + logger.warning("Instance '%s': %s", target, message) + else: + logger.warning("%s", message) + return {'instance': label, 'status': 'failed', 'error': message} def _execute_in_parallel(fn, args_list, max_workers=10): @@ -198,27 +214,38 @@ def _execute_in_parallel(fn, args_list, max_workers=10): def _start_shell_session(scm_url, headers, cookies=None, shell=None): import codecs import platform + import ssl + import sys import threading import websocket + from azure.cli.core.util import should_disable_connection_verify + + if not sys.stdin.isatty(): + raise ValidationError( + "Interactive shell mode requires an interactive terminal; stdin cannot be redirected.") ws_url = scm_url.replace('https://', 'wss://') + '/exec/shell' if shell: import urllib.parse ws_url += '?shell=' + urllib.parse.quote(shell, safe='') else: - print("No --shell provided; defaulting to /bin/bash. If your container image does not " - "include bash, run with --shell (e.g. --shell /bin/sh).") + logger.warning("No --shell provided; defaulting to /bin/bash. If your container image does not " + "include bash, run with --shell (e.g. --shell /bin/sh).") cookie_str = '; '.join(f'{k}={v}' for k, v in cookies.items()) if cookies else None # Request Websocket connection with 30s timeout - print("Connecting to the web app container...") + logger.warning("Connecting to the web app container...") + # Honor the CLI-wide certificate verification setting, consistent with other SCM connections. + verify_mode = ssl.CERT_NONE if should_disable_connection_verify() else ssl.CERT_REQUIRED try: ws = websocket.create_connection( ws_url, header=headers, cookie=cookie_str, - timeout=30 + sslopt={'cert_reqs': verify_mode}, + timeout=30, + enable_multithread=True ) except websocket.WebSocketBadStatusException as ex: # The server rejected the upgrade handshake @@ -229,7 +256,7 @@ def _start_shell_session(scm_url, headers, cookies=None, shell=None): # Clear the 30s connect timeout so the read_from_server loop blocks indefinitely ws.settimeout(None) - print("Press Ctrl+C twice to exit.\n") + logger.warning("Press Ctrl+C twice to exit.\n") # Enable ANSI rendering on Windows consoles. No-op on Unix / redirected stdout. vt_state = _enable_windows_vt_output() @@ -394,8 +421,6 @@ def _send_to_server_windows(ws, closed): break last_ctrl_c = now buf += b'\x03' - elif ch == '\r': # Enter: Windows gives CR, Unix shells expect LF - buf += b'\n' elif ch == '\x08': # Backspace: Windows gives BS, Unix shells expect DEL (0x7f) buf += b'\x7f' elif ch in ('\x00', '\xe0'): # Special key prefix: the next getwch() is the key code @@ -479,6 +504,7 @@ def on_sigwinch(_signum, _frame): def _execute_command_on_instance(scm_url, headers, cookies, command, args=None, working_directory=None): import requests + from azure.cli.core.util import should_disable_connection_verify exec_url = f"{scm_url}/exec/execute" @@ -494,7 +520,8 @@ def _execute_command_on_instance(scm_url, headers, cookies, command, args=None, json=body, headers=headers, cookies=cookies, - timeout=30 + timeout=30, + verify=not should_disable_connection_verify() ) except requests.exceptions.RequestException as ex: # No HTTP response: refused/timed-out connection, DNS/TLS/proxy error, etc. From 110faaf176697bd8a68441e40fca978c5520f007 Mon Sep 17 00:00:00 2001 From: Vandana George Date: Sun, 26 Jul 2026 08:24:37 -0700 Subject: [PATCH 11/14] nits --- src/azure-cli/azure/cli/command_modules/appservice/_params.py | 2 +- .../azure/cli/command_modules/appservice/webapp_exec.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index 3639f47467b..53ccf159fb6 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -1618,7 +1618,7 @@ def load_arguments(self, _): c.argument('shell_command', options_list=['--shell-command'], help='[Execute mode] A command line to run through a shell, so shell operators (|, &&, >, etc.) work ' '(e.g. --shell-command "echo hi > /home/LogFiles/out.txt"). ' - 'Runs as " -c "; the shell defaults to ' + 'Runs as ` -c `; the shell defaults to ' '/bin/bash and can be overridden with --shell. Mutually exclusive with --command.') c.argument('mode', help="Execution mode. 'shell' (default): Starts an interactive shell session with the main " diff --git a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py index e992dff11eb..4feb8e2e0bf 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py @@ -449,7 +449,7 @@ def _send_to_server_non_windows(ws, closed): import sys import os import tty - import termios + import termios # pylint: disable=import-error import time import signal import select From c66d4c91bd44de6a1f04c9867b6dc68d93642baa Mon Sep 17 00:00:00 2001 From: Vandana George Date: Sun, 26 Jul 2026 09:59:46 -0700 Subject: [PATCH 12/14] extra error catching, pylint fixes --- .../cli/command_modules/appservice/_params.py | 2 +- .../latest/test_webapp_exec_thru_mock.py | 198 +++++++++++++++++- .../command_modules/appservice/webapp_exec.py | 72 +++++-- 3 files changed, 248 insertions(+), 24 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index 53ccf159fb6..eb39b433528 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -1626,7 +1626,7 @@ def load_arguments(self, _): "returning command output.", arg_type=get_enum_type(['shell', 'execute']), default='shell') c.argument('working_directory', options_list=['--working-directory', '--cwd'], - help="[Execute mode] Working directory for command execution. " + help="[Execute mode] Absolute working directory for command execution. " "Defaults to the container's working directory.") c.argument('instance', options_list=['--instance', '-i'], help='Webapp instance(s) to target. Specify a comma-separated list of instance IDs ' diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py index ff17b5aace8..b9967ea585a 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py @@ -30,6 +30,8 @@ _parse_server_message, _friendly_exec_error_message, _read_from_server, + _send_to_server_windows, + _send_to_server_non_windows, _start_shell_session, ) @@ -95,6 +97,14 @@ def test_execute_mode_rejects_shell_without_shell_command(self): with self.assertRaisesRegex(ValidationError, "--shell is only valid together with --shell-command"): webapp_exec(self.cmd, 'rg', 'app', mode='execute', exec_command='ls', shell='/bin/sh') + def test_execute_mode_rejects_invalid_working_directory(self): + for working_directory in (' ', 'home/site'): + with self.subTest(working_directory=working_directory): + with self.assertRaisesRegex(ValidationError, "--working-directory must be a non-empty absolute path"): + webapp_exec( + self.cmd, 'rg', 'app', mode='execute', exec_command='ls', + working_directory=working_directory) + def test_shell_mode_rejects_command(self): with self.assertRaisesRegex(ValidationError, r"--command is only supported in 'execute' mode"): webapp_exec(self.cmd, 'rg', 'app', mode='shell', exec_command='ls') @@ -282,6 +292,12 @@ def test_all_with_no_instances_raises(self, _list_mock): def test_valid_comma_list_returns_requested(self, _list_mock): self.assertEqual(_resolve_target_instances(self.cmd, 'rg', 'app', 'i1, i2', None), ['i1', 'i2']) + @mock.patch(_MODULE + '.list_instances', return_value=[_instance('i1'), _instance('i2')]) + def test_duplicate_instances_are_removed_preserving_order(self, _list_mock): + self.assertEqual( + _resolve_target_instances(self.cmd, 'rg', 'app', 'i2,i1,i2,i1', None), + ['i2', 'i1']) + @mock.patch(_MODULE + '.list_instances', return_value=[_instance('i1')]) def test_invalid_instance_raises(self, _list_mock): with self.assertRaisesRegex(ValidationError, "not valid for this web app"): @@ -364,17 +380,27 @@ def test_request_honors_disabled_certificate_verification(self, post_mock, _disa self.assertFalse(post_mock.call_args.kwargs['verify']) @mock.patch('requests.post', autospec=True) - def test_non_202_raises_internal_error_with_message(self, post_mock): + def test_server_error_raises_response_error_with_message(self, post_mock): post_mock.return_value = mock.Mock(status_code=500, text='{"Message": "Internal Error"}') - with self.assertRaisesRegex(CLIInternalError, "Internal Error"): + with self.assertRaisesRegex(AzureResponseError, "Internal Error"): _execute_command_on_instance('https://scm', {}, {}, 'ls') @mock.patch('requests.post', autospec=True) - def test_non_202_empty_body_uses_fallback_message(self, post_mock): + def test_server_error_with_empty_body_uses_fallback_message(self, post_mock): post_mock.return_value = mock.Mock(status_code=500, text='') - with self.assertRaisesRegex(CLIInternalError, "could not be completed"): + with self.assertRaisesRegex(AzureResponseError, "could not be completed"): _execute_command_on_instance('https://scm', {}, {}, 'ls') + @mock.patch('requests.post', autospec=True) + def test_non_202_responses_preserve_server_message(self, post_mock): + for status_code in (400, 403, 404, 500, 503): + with self.subTest(status_code=status_code): + post_mock.return_value = mock.Mock( + status_code=status_code, + text='{"Message": "request rejected"}') + with self.assertRaisesRegex(AzureResponseError, "request rejected"): + _execute_command_on_instance('https://scm', {}, {}, 'ls') + @mock.patch('requests.post', autospec=True) def test_request_exception_raises_connection_error(self, post_mock): post_mock.side_effect = requests.exceptions.ConnectTimeout('timed out') @@ -440,6 +466,170 @@ def test_multibyte_utf8_split_across_frames_is_decoded(self): ]) self.assertEqual(out, '\u00e9') + @mock.patch(_MODULE + '.logger.debug') + @mock.patch(_MODULE + '.logger.warning') + def test_websocket_error_is_sanitized_and_closes_session(self, warning_mock, debug_mock): + import threading + ws = mock.Mock() + ws.recv_data.side_effect = websocket.WebSocketProtocolException('invalid frame details') + closed = threading.Event() + decoder = codecs.getincrementaldecoder('utf-8')('replace') + with mock.patch('sys.stdout', io.StringIO()): + _read_from_server(ws, closed, decoder) + warning_mock.assert_called_once_with("Shell session closed unexpectedly.") + debug_mock.assert_called_once_with( + "Shell session receive error: %s", mock.ANY, exc_info=True) + self.assertTrue(closed.is_set()) + + @mock.patch(_MODULE + '.logger.debug') + @mock.patch(_MODULE + '.logger.warning') + def test_unexpected_reader_error_does_not_escape_background_thread(self, warning_mock, debug_mock): + import threading + ws = mock.Mock() + ws.recv_data.side_effect = RuntimeError('internal details') + closed = threading.Event() + decoder = codecs.getincrementaldecoder('utf-8')('replace') + with mock.patch('sys.stdout', io.StringIO()): + _read_from_server(ws, closed, decoder) + warning_mock.assert_called_once_with("Shell session closed unexpectedly.") + debug_mock.assert_called_once_with( + "Unexpected shell session receive error: %s", mock.ANY, exc_info=True) + self.assertTrue(closed.is_set()) + + @mock.patch(_MODULE + '.logger.debug') + @mock.patch(_MODULE + '.logger.warning') + def test_connection_closed_is_reported_when_cleanup_has_not_started(self, warning_mock, debug_mock): + import threading + ws = mock.Mock() + ws.recv_data.side_effect = websocket.WebSocketConnectionClosedException('closed') + closed = threading.Event() + decoder = codecs.getincrementaldecoder('utf-8')('replace') + _read_from_server(ws, closed, decoder) + warning_mock.assert_called_once_with("Shell session connection closed.") + debug_mock.assert_called_once_with( + "Shell session receive connection closed: %s", mock.ANY, exc_info=True) + self.assertTrue(closed.is_set()) + + @mock.patch(_MODULE + '.logger.debug') + @mock.patch(_MODULE + '.logger.warning') + def test_connection_closed_is_silent_when_cleanup_is_in_progress(self, warning_mock, debug_mock): + ws = mock.Mock() + ws.recv_data.side_effect = websocket.WebSocketConnectionClosedException('closed') + closed = mock.Mock() + closed.is_set.side_effect = [False, True] + decoder = codecs.getincrementaldecoder('utf-8')('replace') + _read_from_server(ws, closed, decoder) + warning_mock.assert_not_called() + debug_mock.assert_not_called() + closed.set.assert_called_once_with() + + +class NonWindowsTerminalTest(unittest.TestCase): + """Validate Unix terminal state cleanup without requiring a Unix test host.""" + + def test_terminal_setup_errors_are_sanitized(self): + for failure_stage in ('attributes', 'signal', 'raw-mode'): + with self.subTest(failure_stage=failure_stage): + termios_mock = mock.Mock(TCSADRAIN=1) + tty_mock = mock.Mock() + signal_mock = mock.Mock(SIGWINCH=object()) + signal_mock.getsignal.return_value = object() + select_mock = mock.Mock() + if failure_stage == 'attributes': + termios_mock.tcgetattr.side_effect = OSError('terminal details') + elif failure_stage == 'signal': + signal_mock.signal.side_effect = ValueError('signal details') + else: + termios_mock.tcgetattr.return_value = object() + tty_mock.setraw.side_effect = OSError('raw mode details') + + with mock.patch.dict('sys.modules', { + 'termios': termios_mock, + 'tty': tty_mock, + 'signal': signal_mock, + 'select': select_mock, + }), mock.patch('sys.stdin.fileno', return_value=7): + with self.assertRaisesRegex( + CLIInternalError, + "Could not configure the local terminal for interactive shell mode"): + _send_to_server_non_windows(mock.Mock(), mock.Mock()) + + def test_terminal_settings_and_resize_handler_are_restored(self): + fd = 7 + old_settings = object() + old_winch_handler = object() + sigwinch = object() + termios_mock = mock.Mock(TCSADRAIN=1) + termios_mock.tcgetattr.return_value = old_settings + tty_mock = mock.Mock() + signal_mock = mock.Mock(SIGWINCH=sigwinch) + signal_mock.getsignal.return_value = old_winch_handler + select_mock = mock.Mock() + closed = mock.Mock() + closed.is_set.return_value = True + + with mock.patch.dict('sys.modules', { + 'termios': termios_mock, + 'tty': tty_mock, + 'signal': signal_mock, + 'select': select_mock, + }), mock.patch('sys.stdin.fileno', return_value=fd): + _send_to_server_non_windows(mock.Mock(), closed) + + tty_mock.setraw.assert_called_once_with(fd) + self.assertEqual(signal_mock.signal.call_count, 2) + self.assertEqual(signal_mock.signal.call_args_list[0].args[0], sigwinch) + self.assertTrue(callable(signal_mock.signal.call_args_list[0].args[1])) + self.assertEqual( + signal_mock.signal.call_args_list[1], + mock.call(sigwinch, old_winch_handler)) + termios_mock.tcsetattr.assert_called_once_with(fd, termios_mock.TCSADRAIN, old_settings) + + +class WindowsTerminalTest(unittest.TestCase): + """Validate Windows console setup and cleanup without requiring a Windows console.""" + + @staticmethod + def _console_modules(get_mode_result=True, set_mode_result=True): + kernel32 = mock.Mock() + kernel32.GetStdHandle.return_value = 12 + kernel32.GetConsoleMode.return_value = get_mode_result + kernel32.SetConsoleMode.return_value = set_mode_result + old_mode = mock.Mock(value=7) + ctypes_mock = mock.Mock() + ctypes_mock.windll.kernel32 = kernel32 + ctypes_mock.c_uint32.return_value = old_mode + ctypes_mock.byref.side_effect = lambda value: value + return ctypes_mock, mock.Mock(), kernel32 + + def test_console_setup_errors_are_sanitized(self): + for get_mode_result, set_mode_result in ((False, True), (True, False)): + with self.subTest(get_mode_result=get_mode_result, set_mode_result=set_mode_result): + ctypes_mock, msvcrt_mock, _kernel32 = self._console_modules( + get_mode_result, set_mode_result) + with mock.patch.dict('sys.modules', { + 'ctypes': ctypes_mock, + 'msvcrt': msvcrt_mock, + }): + with self.assertRaisesRegex( + CLIInternalError, + "Could not configure the local terminal for interactive shell mode"): + _send_to_server_windows(mock.Mock(), mock.Mock()) + + def test_console_mode_is_restored(self): + ctypes_mock, msvcrt_mock, kernel32 = self._console_modules() + closed = mock.Mock() + closed.is_set.return_value = True + with mock.patch.dict('sys.modules', { + 'ctypes': ctypes_mock, + 'msvcrt': msvcrt_mock, + }): + _send_to_server_windows(mock.Mock(), closed) + + self.assertEqual( + kernel32.SetConsoleMode.call_args_list, + [mock.call(12, 6), mock.call(12, 7)]) + class ShellSessionConnectTest(unittest.TestCase): """Validate _start_shell_session URL/cookie building and handshake error mapping.""" diff --git a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py index 4feb8e2e0bf..60cc17539fe 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py @@ -69,9 +69,8 @@ def webapp_exec(cmd, if instance is not None: instance = instance.strip() - if shell: - if not shell.startswith('/'): - raise ValidationError("--shell must be an absolute path (e.g. /bin/sh).") + if shell and not shell.startswith('/'): + raise ValidationError("--shell must be an absolute path (e.g. /bin/sh).") if mode.lower() == 'execute': if exec_command and shell_command: @@ -82,6 +81,9 @@ def webapp_exec(cmd, raise ValidationError("--shell-command must not be empty.") if shell and not shell_command: raise ValidationError("--shell is only valid together with --shell-command in 'execute' mode.") + if working_directory is not None and ( + not working_directory.strip() or not working_directory.startswith('/')): + raise ValidationError("--working-directory must be a non-empty absolute path.") elif mode.lower() == 'shell': if exec_command: raise ValidationError("--command is only supported in 'execute' mode.") @@ -140,7 +142,7 @@ def _resolve_target_instances(cmd, resource_group_name, name, instance, slot): raise ValidationError("No instances found for this web app.") return sorted(instance_names) - requested = [i.strip() for i in instance.split(',') if i.strip()] + requested = list(dict.fromkeys(i.strip() for i in instance.split(',') if i.strip())) if not requested: raise ValidationError("--instance must contain at least one instance ID, or 'all'.") invalid = [i for i in requested if i not in instance_names] @@ -344,8 +346,17 @@ def _read_from_server(ws, closed, decoder): enc = getattr(sys.stdout, 'encoding', None) or 'utf-8' sys.stdout.write(text.encode(enc, 'replace').decode(enc, 'replace')) sys.stdout.flush() - except (websocket.WebSocketConnectionClosedException, OSError): - pass + except websocket.WebSocketConnectionClosedException as ex: + if not closed.is_set(): + logger.warning("Shell session connection closed.") + logger.debug("Shell session receive connection closed: %s", ex, exc_info=True) + except (websocket.WebSocketException, OSError) as ex: + logger.warning("Shell session closed unexpectedly.") + logger.debug("Shell session receive error: %s", ex, exc_info=True) + except Exception as ex: # pylint: disable=broad-except + # This runs on a background thread, so prevent unexpected failures from escaping as raw tracebacks. + logger.warning("Shell session closed unexpectedly.") + logger.debug("Unexpected shell session receive error: %s", ex, exc_info=True) finally: closed.set() @@ -358,12 +369,12 @@ def _send_terminal_resize(ws): try: size = os.get_terminal_size() ws.send(json.dumps({"width": size.columns, "height": size.lines})) - except (OSError, websocket.WebSocketConnectionClosedException): + except (OSError, websocket.WebSocketException): # No console attached (stdout redirected) or the session is already gone. pass -def _send_to_server_windows(ws, closed): +def _send_to_server_windows(ws, closed): # pylint: disable=too-many-branches import os import time import ctypes @@ -375,8 +386,9 @@ def _send_to_server_windows(ws, closed): kernel32 = ctypes.windll.kernel32 stdin_handle = kernel32.GetStdHandle(-10) # STD_INPUT_HANDLE old_mode = ctypes.c_uint32() - kernel32.GetConsoleMode(stdin_handle, ctypes.byref(old_mode)) - kernel32.SetConsoleMode(stdin_handle, old_mode.value & ~0x0001) + if not (kernel32.GetConsoleMode(stdin_handle, ctypes.byref(old_mode)) and + kernel32.SetConsoleMode(stdin_handle, old_mode.value & ~0x0001)): + raise CLIInternalError("Could not configure the local terminal for interactive shell mode.") last_ctrl_c = 0 @@ -439,8 +451,13 @@ def _send_to_server_windows(ws, closed): ws.send(bytes(buf), opcode=ws_module.ABNF.OPCODE_BINARY) if exit_session: break - except (ws_module.WebSocketConnectionClosedException, OSError) as ex: - logger.warning("Shell session closed: %s", ex) + except ws_module.WebSocketConnectionClosedException as ex: + if not closed.is_set(): + logger.warning("Shell session connection closed.") + logger.debug("Shell session send connection closed: %s", ex, exc_info=True) + except (ws_module.WebSocketException, OSError) as ex: + logger.warning("Shell session closed unexpectedly.") + logger.debug("Shell session send error: %s", ex, exc_info=True) finally: kernel32.SetConsoleMode(stdin_handle, old_mode.value) @@ -457,7 +474,10 @@ def _send_to_server_non_windows(ws, closed): import websocket as ws_module fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) + try: + old_settings = termios.tcgetattr(fd) + except OSError as ex: + raise CLIInternalError("Could not configure the local terminal for interactive shell mode.") from ex last_ctrl_c = 0 # Set up for terminal window resizing @@ -468,11 +488,17 @@ def on_sigwinch(_signum, _frame): # On SIGWINCH (terminal resize), run on_sigwinch to signal resize_needed. # This will eventually allow send_terminal_resize to be called and send resize request to server. - signal.signal(signal.SIGWINCH, on_sigwinch) + try: + old_winch_handler = signal.getsignal(signal.SIGWINCH) + signal.signal(signal.SIGWINCH, on_sigwinch) + except (OSError, ValueError) as ex: + raise CLIInternalError("Could not configure the local terminal for interactive shell mode.") from ex + terminal_ready = False try: # Raw mode: pass keystrokes straight to the remote shell (no local echo/buffering). tty.setraw(fd) + terminal_ready = True while not closed.is_set(): if resize_needed.is_set(): resize_needed.clear() @@ -491,11 +517,18 @@ def on_sigwinch(_signum, _frame): break # Ctrl+C twice in 2 seconds will end the session last_ctrl_c = now ws.send(data, opcode=ws_module.ABNF.OPCODE_BINARY) - except (ws_module.WebSocketConnectionClosedException, OSError) as ex: - logger.warning("Shell session closed: %s", ex) + except ws_module.WebSocketConnectionClosedException as ex: + if not closed.is_set(): + logger.warning("Shell session connection closed.") + logger.debug("Shell session send connection closed: %s", ex, exc_info=True) + except (ws_module.WebSocketException, OSError) as ex: + if not terminal_ready: + raise CLIInternalError("Could not configure the local terminal for interactive shell mode.") from ex + logger.warning("Shell session closed unexpectedly.") + logger.debug("Shell session send error: %s", ex, exc_info=True) finally: - # Reset defaults: stop listening for resize signals, restore terminal out of raw mode. - signal.signal(signal.SIGWINCH, signal.SIG_DFL) + # Restore the previous resize handler and terminal mode. + signal.signal(signal.SIGWINCH, old_winch_handler) termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) @@ -529,7 +562,8 @@ def _execute_command_on_instance(scm_url, headers, cookies, command, args=None, if response.status_code == 202: return _parse_server_message(response.text) - raise CLIInternalError(_friendly_exec_error_message(response.text)) + + raise AzureResponseError(_friendly_exec_error_message(response.text)) # --- shared helpers --- From 3b1c198a22531f6a0fd323fe959b8e9785a2372f Mon Sep 17 00:00:00 2001 From: Vandana George Date: Sun, 26 Jul 2026 10:29:13 -0700 Subject: [PATCH 13/14] nit, rmeove unnecessary warning --- .../latest/test_webapp_exec_thru_mock.py | 7 +++---- .../command_modules/appservice/webapp_exec.py | 20 +++++++------------ 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py index b9967ea585a..2d1a5949ff1 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_exec_thru_mock.py @@ -498,16 +498,15 @@ def test_unexpected_reader_error_does_not_escape_background_thread(self, warning @mock.patch(_MODULE + '.logger.debug') @mock.patch(_MODULE + '.logger.warning') - def test_connection_closed_is_reported_when_cleanup_has_not_started(self, warning_mock, debug_mock): + def test_connection_closed_is_silent_when_cleanup_has_not_started(self, warning_mock, debug_mock): import threading ws = mock.Mock() ws.recv_data.side_effect = websocket.WebSocketConnectionClosedException('closed') closed = threading.Event() decoder = codecs.getincrementaldecoder('utf-8')('replace') _read_from_server(ws, closed, decoder) - warning_mock.assert_called_once_with("Shell session connection closed.") - debug_mock.assert_called_once_with( - "Shell session receive connection closed: %s", mock.ANY, exc_info=True) + warning_mock.assert_not_called() + debug_mock.assert_not_called() self.assertTrue(closed.is_set()) @mock.patch(_MODULE + '.logger.debug') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py index 60cc17539fe..df0a0b228e2 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py @@ -346,10 +346,8 @@ def _read_from_server(ws, closed, decoder): enc = getattr(sys.stdout, 'encoding', None) or 'utf-8' sys.stdout.write(text.encode(enc, 'replace').decode(enc, 'replace')) sys.stdout.flush() - except websocket.WebSocketConnectionClosedException as ex: - if not closed.is_set(): - logger.warning("Shell session connection closed.") - logger.debug("Shell session receive connection closed: %s", ex, exc_info=True) + except websocket.WebSocketConnectionClosedException: + pass except (websocket.WebSocketException, OSError) as ex: logger.warning("Shell session closed unexpectedly.") logger.debug("Shell session receive error: %s", ex, exc_info=True) @@ -374,7 +372,7 @@ def _send_terminal_resize(ws): pass -def _send_to_server_windows(ws, closed): # pylint: disable=too-many-branches +def _send_to_server_windows(ws, closed): import os import time import ctypes @@ -451,10 +449,8 @@ def _send_to_server_windows(ws, closed): # pylint: disable=too-many-branches ws.send(bytes(buf), opcode=ws_module.ABNF.OPCODE_BINARY) if exit_session: break - except ws_module.WebSocketConnectionClosedException as ex: - if not closed.is_set(): - logger.warning("Shell session connection closed.") - logger.debug("Shell session send connection closed: %s", ex, exc_info=True) + except ws_module.WebSocketConnectionClosedException: + pass except (ws_module.WebSocketException, OSError) as ex: logger.warning("Shell session closed unexpectedly.") logger.debug("Shell session send error: %s", ex, exc_info=True) @@ -517,10 +513,8 @@ def on_sigwinch(_signum, _frame): break # Ctrl+C twice in 2 seconds will end the session last_ctrl_c = now ws.send(data, opcode=ws_module.ABNF.OPCODE_BINARY) - except ws_module.WebSocketConnectionClosedException as ex: - if not closed.is_set(): - logger.warning("Shell session connection closed.") - logger.debug("Shell session send connection closed: %s", ex, exc_info=True) + except ws_module.WebSocketConnectionClosedException: + pass except (ws_module.WebSocketException, OSError) as ex: if not terminal_ready: raise CLIInternalError("Could not configure the local terminal for interactive shell mode.") from ex From 8b32e14b1990ac31cede53d74cae012be1920989 Mon Sep 17 00:00:00 2001 From: Vandana George Date: Tue, 28 Jul 2026 20:48:39 -0700 Subject: [PATCH 14/14] fix import issue --- .../azure/cli/command_modules/appservice/webapp_exec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py index df0a0b228e2..286d44b1f68 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/webapp_exec.py @@ -376,7 +376,7 @@ def _send_to_server_windows(ws, closed): import os import time import ctypes - import msvcrt + import msvcrt # pylint: disable=import-error import websocket as ws_module # Clear the ENABLE_PROCESSED_INPUT flag from stdin's console mode so a Ctrl+C is processed as