@@ -12883,3 +12883,234 @@ def _compute_checksum(input_bytes):
1288312883 logger.info("Computing the checksum of the file failed with exception:'%s'", ex)
1288412884
1288512885 return file_hash
12886+
12887+
12888+ def _start_shell_session(scm_url, headers, cookies=None):
12889+ import sys
12890+ import ssl
12891+ import platform
12892+ import threading
12893+ import time
12894+ import websocket
12895+ from azure.cli.core.util import should_disable_connection_verify
12896+
12897+ ws_url = scm_url.replace('https://', 'wss://') + '/exec/shell'
12898+
12899+ cookie_str = '; '.join(f'{k}={v}' for k, v in cookies.items()) if cookies else None
12900+ # Respect user's SSL verification settings (e.g., self-signed certs on private stamps)
12901+ sslopt = {'cert_reqs': ssl.CERT_NONE} if should_disable_connection_verify() else {}
12902+
12903+ ws = websocket.create_connection(
12904+ ws_url,
12905+ header=headers,
12906+ cookie=cookie_str,
12907+ sslopt=sslopt,
12908+ timeout=30
12909+ )
12910+
12911+ logger.info("Connected to %s", ws_url)
12912+ print("Connected! Type commands. Ctrl+C twice to quit.\n")
12913+
12914+ closed = threading.Event()
12915+
12916+ # WebSocket → stdout
12917+ def recv_loop():
12918+ try:
12919+ while not closed.is_set():
12920+ opcode, data = ws.recv_data()
12921+ if not data:
12922+ break
12923+ text = data.decode('utf-8', errors='replace')
12924+ sys.stdout.write(text)
12925+ sys.stdout.flush()
12926+ except (websocket.WebSocketConnectionClosedException, OSError):
12927+ pass
12928+ finally:
12929+ closed.set()
12930+
12931+ recv_thread = threading.Thread(target=recv_loop, daemon=True)
12932+ recv_thread.start()
12933+
12934+ if platform.system() == 'Windows':
12935+ _shell_input_loop_windows(ws, closed)
12936+ else:
12937+ _shell_input_loop_unix(ws, closed)
12938+
12939+ closed.set()
12940+ try:
12941+ ws.close()
12942+ except Exception: # pylint: disable=broad-except
12943+ pass
12944+
12945+
12946+ def _shell_input_loop_windows(ws, closed):
12947+ import time
12948+ import msvcrt
12949+ import websocket as ws_module
12950+
12951+ # Windows special key codes → ANSI escape sequences
12952+ # Tab, Ctrl+D, Ctrl+L etc. work via the regular else branch (sent as-is)
12953+ # TODO: F1-F12, Insert, PgUp/PgDown not yet mapped — silently dropped
12954+ _WINDOWS_KEY_MAP = {
12955+ 72: b'\x1b[A', # Up
12956+ 80: b'\x1b[B', # Down
12957+ 77: b'\x1b[C', # Right
12958+ 75: b'\x1b[D', # Left
12959+ 71: b'\x1b[H', # Home
12960+ 79: b'\x1b[F', # End
12961+ 83: b'\x1b[3~', # Delete
12962+ }
12963+
12964+ last_ctrl_c = 0
12965+ try:
12966+ while not closed.is_set():
12967+ if not msvcrt.kbhit():
12968+ time.sleep(0.05)
12969+ continue
12970+
12971+ ch = msvcrt.getwch()
12972+ if ch == '\x03': # Ctrl+C
12973+ now = time.time()
12974+ if now - last_ctrl_c < 2:
12975+ break
12976+ last_ctrl_c = now
12977+ ws.send(b'\x03', opcode=ws_module.ABNF.OPCODE_BINARY)
12978+ elif ch == '\r': # Enter
12979+ ws.send(b'\n', opcode=ws_module.ABNF.OPCODE_BINARY)
12980+ elif ch == '\x08': # Backspace
12981+ ws.send(b'\x7f', opcode=ws_module.ABNF.OPCODE_BINARY)
12982+ elif ch in ('\x00', '\xe0'): # Special key prefix
12983+ code = ord(msvcrt.getwch())
12984+ escape = _WINDOWS_KEY_MAP.get(code)
12985+ if escape:
12986+ ws.send(escape, opcode=ws_module.ABNF.OPCODE_BINARY)
12987+ else:
12988+ ws.send(ch.encode('utf-8'), opcode=ws_module.ABNF.OPCODE_BINARY)
12989+ except (ws_module.WebSocketConnectionClosedException, OSError):
12990+ pass
12991+
12992+
12993+ def _shell_input_loop_unix(ws, closed):
12994+ import sys
12995+ import os
12996+ import tty
12997+ import termios
12998+ import time
12999+ import websocket as ws_module
13000+
13001+ fd = sys.stdin.fileno()
13002+ old_settings = termios.tcgetattr(fd)
13003+ last_ctrl_c = 0
13004+ try:
13005+ tty.setraw(fd)
13006+ while not closed.is_set():
13007+ ch = os.read(fd, 1)
13008+ if not ch:
13009+ break
13010+ if ch == b'\x03': # Ctrl+C
13011+ now = time.time()
13012+ if now - last_ctrl_c < 2:
13013+ break
13014+ last_ctrl_c = now
13015+ # Pass through everything (arrow keys already come as escape sequences)
13016+ ws.send(ch, opcode=ws_module.ABNF.OPCODE_BINARY)
13017+ except (ws_module.WebSocketConnectionClosedException, OSError):
13018+ pass
13019+ finally:
13020+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
13021+
13022+
13023+ def _execute_command_on_instance(scm_url, headers, cookies, command, args=None, working_directory=None):
13024+ from azure.cli.core.util import should_disable_connection_verify
13025+ import requests
13026+
13027+ exec_url = f"{scm_url}/exec/execute"
13028+
13029+ body = {"Command": command}
13030+ if args:
13031+ body["Args"] = args
13032+ if working_directory:
13033+ body["WorkingDirectory"] = working_directory
13034+
13035+ response = requests.post(
13036+ exec_url,
13037+ json=body,
13038+ headers=headers,
13039+ cookies=cookies,
13040+ verify=not should_disable_connection_verify()
13041+ )
13042+
13043+ if response.status_code == 202:
13044+ return None
13045+ else:
13046+ raise CLIError(f"Command execution failed with status code {response.status_code}: {response.text}")
13047+
13048+
13049+ def webapp_exec(cmd, resource_group_name, name, command=None, args=None, mode='execute', working_directory=None, instance=None, slot=None):
13050+ # Validate Linux App
13051+ webapp = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get', slot)
13052+ if not webapp:
13053+ raise ResourceNotFoundError("Unable to find resource '{}' in ResourceGroup '{}'.".format(name, resource_group_name))
13054+
13055+ is_linux = webapp.reserved
13056+ if not is_linux:
13057+ raise ValidationError("Only Linux App Service Plans supported.")
13058+
13059+ if mode.lower() == 'execute':
13060+ if not command:
13061+ raise ValidationError("Command is required for 'execute' mode.")
13062+ elif mode.lower() == 'shell':
13063+ if instance and instance.lower() == 'all':
13064+ raise ValidationError("Cannot open shell on all instances. Specify a single instance or omit for a random one.")
13065+ else:
13066+ raise ValidationError("Invalid mode '{}'. Supported modes: execute, shell.".format(mode))
13067+
13068+ # Get scm site and authorization
13069+ scm_url = _get_scm_url(cmd, resource_group_name, name, slot)
13070+ headers = get_scm_site_headers(cmd.cli_ctx, name, resource_group_name, slot)
13071+
13072+ # Shell mode — single interactive session
13073+ if mode.lower() == 'shell':
13074+ cookies = {}
13075+ if instance:
13076+ instances = list_instances(cmd, resource_group_name, name, slot=slot)
13077+ instance_names = set(i.name for i in instances)
13078+ if instance not in instance_names:
13079+ raise ValidationError("Instance '{}' is not valid. Valid instances: {}".format(
13080+ instance, ', '.join(sorted(instance_names))))
13081+ cookies['ARRAffinity'] = instance
13082+ _start_shell_session(scm_url, headers, cookies)
13083+ return None
13084+
13085+ # Resolve target instances
13086+ target_instances = [None] # default: no affinity cookie, random instance
13087+ if instance is not None:
13088+ if instance.lower() == "all":
13089+ instances = list_instances(cmd, resource_group_name, name, slot=slot)
13090+ target_instances = [i.name for i in instances]
13091+ if not target_instances:
13092+ raise ValidationError("No instances found for this web app.")
13093+ else:
13094+ instances = list_instances(cmd, resource_group_name, name, slot=slot)
13095+ requested_instances = [i.strip() for i in instance.split(',')]
13096+ instance_names = set(i.name for i in instances)
13097+ invalid_instances = [inst for inst in requested_instances if inst not in instance_names]
13098+ if invalid_instances:
13099+ raise ValidationError("The following instances are not valid for this webapp: {}. Valid instances: {}".format(
13100+ ', '.join(invalid_instances), ', '.join(sorted(instance_names))))
13101+ target_instances = requested_instances
13102+
13103+ # Execute command on target instances
13104+ results = []
13105+ for target in target_instances:
13106+ cookies = {}
13107+ if target is not None:
13108+ cookies['ARRAffinity'] = target
13109+
13110+ try:
13111+ result = _execute_command_on_instance(scm_url, headers, cookies, command, args, working_directory)
13112+ results.append({'instance': target or 'default', 'status': 'success', 'result': result})
13113+ except CLIError as e:
13114+ results.append({'instance': target or 'default', 'status': 'failed', 'error': str(e)})
13115+
13116+ return results if len(results) > 1 else results[0] if results else None
0 commit comments