@@ -10949,37 +10949,168 @@ def _compute_checksum(input_bytes):
1094910949 return file_hash
1095010950
1095110951
10952- def _execute_command_on_instance(scm_url, headers, cookies, command, working_directory=None):
10952+ def _start_shell_session(scm_url, headers, cookies=None):
10953+ import sys
10954+ import ssl
10955+ import platform
10956+ import threading
10957+ import time
10958+ import websocket
10959+ from azure.cli.core.util import should_disable_connection_verify
10960+
10961+ ws_url = scm_url.replace('https://', 'wss://') + '/exec/shell'
10962+
10963+ cookie_str = '; '.join(f'{k}={v}' for k, v in cookies.items()) if cookies else None
10964+ # Respect user's SSL verification settings (e.g., self-signed certs on private stamps)
10965+ sslopt = {'cert_reqs': ssl.CERT_NONE} if should_disable_connection_verify() else {}
10966+
10967+ ws = websocket.create_connection(
10968+ ws_url,
10969+ header=headers,
10970+ cookie=cookie_str,
10971+ sslopt=sslopt,
10972+ timeout=30
10973+ )
10974+
10975+ logger.info("Connected to %s", ws_url)
10976+ print("Connected! Type commands. Ctrl+C twice to quit.\n")
10977+
10978+ closed = threading.Event()
10979+
10980+ # WebSocket → stdout
10981+ def recv_loop():
10982+ try:
10983+ while not closed.is_set():
10984+ opcode, data = ws.recv_data()
10985+ if not data:
10986+ break
10987+ text = data.decode('utf-8', errors='replace')
10988+ sys.stdout.write(text)
10989+ sys.stdout.flush()
10990+ except (websocket.WebSocketConnectionClosedException, OSError):
10991+ pass
10992+ finally:
10993+ closed.set()
10994+
10995+ recv_thread = threading.Thread(target=recv_loop, daemon=True)
10996+ recv_thread.start()
10997+
10998+ if platform.system() == 'Windows':
10999+ _shell_input_loop_windows(ws, closed)
11000+ else:
11001+ _shell_input_loop_unix(ws, closed)
11002+
11003+ closed.set()
11004+ try:
11005+ ws.close()
11006+ except Exception: # pylint: disable=broad-except
11007+ pass
11008+
11009+
11010+ def _shell_input_loop_windows(ws, closed):
11011+ import time
11012+ import msvcrt
11013+ import websocket as ws_module
11014+
11015+ # Windows special key codes → ANSI escape sequences
11016+ # Tab, Ctrl+D, Ctrl+L etc. work via the regular else branch (sent as-is)
11017+ # TODO: F1-F12, Insert, PgUp/PgDown not yet mapped — silently dropped
11018+ _WINDOWS_KEY_MAP = {
11019+ 72: b'\x1b[A', # Up
11020+ 80: b'\x1b[B', # Down
11021+ 77: b'\x1b[C', # Right
11022+ 75: b'\x1b[D', # Left
11023+ 71: b'\x1b[H', # Home
11024+ 79: b'\x1b[F', # End
11025+ 83: b'\x1b[3~', # Delete
11026+ }
11027+
11028+ last_ctrl_c = 0
11029+ try:
11030+ while not closed.is_set():
11031+ if not msvcrt.kbhit():
11032+ time.sleep(0.05)
11033+ continue
11034+
11035+ ch = msvcrt.getwch()
11036+ if ch == '\x03': # Ctrl+C
11037+ now = time.time()
11038+ if now - last_ctrl_c < 2:
11039+ break
11040+ last_ctrl_c = now
11041+ ws.send(b'\x03', opcode=ws_module.ABNF.OPCODE_BINARY)
11042+ elif ch == '\r': # Enter
11043+ ws.send(b'\n', opcode=ws_module.ABNF.OPCODE_BINARY)
11044+ elif ch == '\x08': # Backspace
11045+ ws.send(b'\x7f', opcode=ws_module.ABNF.OPCODE_BINARY)
11046+ elif ch in ('\x00', '\xe0'): # Special key prefix
11047+ code = ord(msvcrt.getwch())
11048+ escape = _WINDOWS_KEY_MAP.get(code)
11049+ if escape:
11050+ ws.send(escape, opcode=ws_module.ABNF.OPCODE_BINARY)
11051+ else:
11052+ ws.send(ch.encode('utf-8'), opcode=ws_module.ABNF.OPCODE_BINARY)
11053+ except (ws_module.WebSocketConnectionClosedException, OSError):
11054+ pass
11055+
11056+
11057+ def _shell_input_loop_unix(ws, closed):
11058+ import sys
11059+ import os
11060+ import tty
11061+ import termios
11062+ import time
11063+ import websocket as ws_module
11064+
11065+ fd = sys.stdin.fileno()
11066+ old_settings = termios.tcgetattr(fd)
11067+ last_ctrl_c = 0
11068+ try:
11069+ tty.setraw(fd)
11070+ while not closed.is_set():
11071+ ch = os.read(fd, 1)
11072+ if not ch:
11073+ break
11074+ if ch == b'\x03': # Ctrl+C
11075+ now = time.time()
11076+ if now - last_ctrl_c < 2:
11077+ break
11078+ last_ctrl_c = now
11079+ # Pass through everything (arrow keys already come as escape sequences)
11080+ ws.send(ch, opcode=ws_module.ABNF.OPCODE_BINARY)
11081+ except (ws_module.WebSocketConnectionClosedException, OSError):
11082+ pass
11083+ finally:
11084+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
11085+
11086+
11087+ def _execute_command_on_instance(scm_url, headers, cookies, command, args=None, working_directory=None):
1095311088 from azure.cli.core.util import should_disable_connection_verify
1095411089 import requests
10955-
10956- # Build the exec endpoint URL
11090+
1095711091 exec_url = f"{scm_url}/exec/execute"
10958-
10959- # Build request body
11092+
1096011093 body = {"Command": command}
11094+ if args:
11095+ body["Args"] = args
1096111096 if working_directory:
1096211097 body["WorkingDirectory"] = working_directory
10963-
10964- # Make the request
11098+
1096511099 response = requests.post(
1096611100 exec_url,
1096711101 json=body,
1096811102 headers=headers,
1096911103 cookies=cookies,
1097011104 verify=not should_disable_connection_verify()
1097111105 )
10972-
10973- # Handle response
11106+
1097411107 if response.status_code == 202:
10975- # Success - command accepted (no response body expected)
1097611108 return None
1097711109 else:
10978- # Error - response contains JSON with ErrorMessage
1097911110 raise CLIError(f"Command execution failed with status code {response.status_code}: {response.text}")
1098011111
1098111112
10982- def webapp_exec(cmd, resource_group_name, name, command, mode='execute', working_directory=None, instance=None, slot=None):
11113+ def webapp_exec(cmd, resource_group_name, name, command=None, args=None , mode='execute', working_directory=None, instance=None, slot=None):
1098311114 # Validate Linux App
1098411115 webapp = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get', slot)
1098511116 if not webapp:
@@ -10989,29 +11120,41 @@ def webapp_exec(cmd, resource_group_name, name, command, mode='execute', working
1098911120 if not is_linux:
1099011121 raise ValidationError("Only Linux App Service Plans supported.")
1099111122
10992- # Allow mode 'execute' only
10993- if mode.lower() != 'execute':
10994- raise ValidationError("Only 'execute' mode is supported currently.")
10995-
10996- # Validate command is provided if mode is 'execute'
10997- if mode.lower() == 'execute' and not command:
10998- raise ValidationError("Command is required.")
11123+ if mode.lower() == 'execute':
11124+ if not command:
11125+ raise ValidationError("Command is required for 'execute' mode.")
11126+ elif mode.lower() == 'shell':
11127+ if instance and instance.lower() == 'all':
11128+ raise ValidationError("Cannot open shell on all instances. Specify a single instance or omit for a random one.")
11129+ else:
11130+ raise ValidationError("Invalid mode '{}'. Supported modes: execute, shell.".format(mode))
1099911131
1100011132 # Get scm site and authorization
1100111133 scm_url = _get_scm_url(cmd, resource_group_name, name, slot)
1100211134 headers = get_scm_site_headers(cmd.cli_ctx, name, resource_group_name, slot)
1100311135
11004- # Get target instances if specified
11005- target_instances = []
11136+ # Shell mode — single interactive session
11137+ if mode.lower() == 'shell':
11138+ cookies = {}
11139+ if instance:
11140+ instances = list_instances(cmd, resource_group_name, name, slot=slot)
11141+ instance_names = set(i.name for i in instances)
11142+ if instance not in instance_names:
11143+ raise ValidationError("Instance '{}' is not valid. Valid instances: {}".format(
11144+ instance, ', '.join(sorted(instance_names))))
11145+ cookies['ARRAffinity'] = instance
11146+ _start_shell_session(scm_url, headers, cookies)
11147+ return None
11148+
11149+ # Resolve target instances
11150+ target_instances = [None] # default: no affinity cookie, random instance
1100611151 if instance is not None:
11007- # If command execution on all instances
1100811152 if instance.lower() == "all":
1100911153 instances = list_instances(cmd, resource_group_name, name, slot=slot)
1101011154 target_instances = [i.name for i in instances]
1101111155 if not target_instances:
1101211156 raise ValidationError("No instances found for this web app.")
1101311157 else:
11014- # Validate listed instances
1101511158 instances = list_instances(cmd, resource_group_name, name, slot=slot)
1101611159 requested_instances = [i.strip() for i in instance.split(',')]
1101711160 instance_names = set(i.name for i in instances)
@@ -11021,20 +11164,17 @@ def webapp_exec(cmd, resource_group_name, name, command, mode='execute', working
1102111164 ', '.join(invalid_instances), ', '.join(sorted(instance_names))))
1102211165 target_instances = requested_instances
1102311166
11024- # Execute command on target instances
11025- results = []
11026- for target in target_instances if target_instances else [None] :
11027- cookies = {}
11028- if target is not None:
11029- cookies['ARRAffinity'] = target
11167+ # Execute command on target instances
11168+ results = []
11169+ for target in target_instances:
11170+ cookies = {}
11171+ if target is not None:
11172+ cookies['ARRAffinity'] = target
1103011173
11031- try:
11032- result = _execute_command_on_instance(scm_url, headers, cookies, command, working_directory)
11033- results.append({'instance': target or 'default', 'status': 'success', 'result': result})
11034- except CLIError as e:
11035- results.append({'instance': target or 'default', 'status': 'failed', 'error': str(e)})
11036-
11037- return results if len(results) > 1 else results[0] if results else None
11038-
11039-
11174+ try:
11175+ result = _execute_command_on_instance(scm_url, headers, cookies, command, args, working_directory)
11176+ results.append({'instance': target or 'default', 'status': 'success', 'result': result})
11177+ except CLIError as e:
11178+ results.append({'instance': target or 'default', 'status': 'failed', 'error': str(e)})
1104011179
11180+ return results if len(results) > 1 else results[0] if results else None
0 commit comments