Skip to content

Commit 1599ecc

Browse files
committed
set up shell execsvc client
1 parent a9296b2 commit 1599ecc

5 files changed

Lines changed: 499 additions & 254 deletions

File tree

src/azure-cli/azure/cli/command_modules/appservice/_help.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1973,40 +1973,50 @@
19731973

19741974
helps['webapp exec'] = """
19751975
type: command
1976-
short-summary: Interact with a Linux web app container via command execution or an interactive shell session.
1977-
long-summary: >
1978-
Interact with your Linux web app container using two modes.
1979-
This command is only supported for Linux App Service plans.
1980-
'execute' runs a command in the container and returns immediately without waiting for completion or output.
1981-
Redirect to a file to capture results (see examples).
1982-
'shell' starts an interactive shell session with the main webapp container.
1983-
Shell sessions are subject to an idle timeout and may be terminated if inactive for an extended period.
1984-
Requires SCM Basic Auth Publishing Credentials to be enabled.
1976+
short-summary: Open an interactive shell session or run a command in a Linux web app container.
1977+
long-summary: |
1978+
Interact with your Linux web app container in two modes:
1979+
- 'shell' (default): open an interactive shell session with your main app container.
1980+
- 'execute': run a fire-and-forget command in your main app container; it returns immediately without output.
1981+
1982+
Only supported for Linux App Service plans.
1983+
Shell sessions are intended for diagnostics, not long-running work: a session ends automatically after
1984+
3 hours of inactivity, and may also end if the underlying instance is reimaged or platform components are updated.
1985+
For 'execute' mode, redirect output to a file inside the command to capture results (see examples).
19851986
examples:
19861987
- name: Run a direct command in the container
19871988
text: >
1988-
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd
1989+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command mkdir --args "/home/site/newdir"
19891990
- name: Run a bash command and redirect output to a file
19901991
text: >
19911992
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command bash --args "-c" "pwd &> pwd.txt"
19921993
- name: Create a file in a specific working directory
19931994
text: >
19941995
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command touch --args "newfile.txt" --cwd /home/site
1996+
- name: Run a Python script in the container
1997+
text: >
1998+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command python --args "/home/site/wwwroot/script.py"
1999+
- name: Run a Node.js script in the container
2000+
text: >
2001+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command node --args "/home/site/wwwroot/app.js"
19952002
- name: Execute a command on a specific instance
19962003
text: >
1997-
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd --instance MyInstanceId
2004+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command touch --args "newfile.txt" --instance MyInstanceId
19982005
- name: Execute a command on all instances
19992006
text: >
2000-
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd --instance all
2007+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command touch --args "newfile.txt" --instance all
20012008
- name: Execute a command on a deployment slot
20022009
text: >
2003-
az webapp exec -g MyResourceGroup -n MyWebapp -s staging --mode execute --command pwd
2010+
az webapp exec -g MyResourceGroup -n MyWebapp -s staging --mode execute --command touch --args "newfile.txt"
20042011
- name: Start an interactive shell session with the web app container
20052012
text: >
20062013
az webapp exec -g MyResourceGroup -n MyWebapp --mode shell
20072014
- name: Start an interactive shell session on a specific instance
20082015
text: >
20092016
az webapp exec -g MyResourceGroup -n MyWebapp --mode shell --instance MyInstanceId
2017+
- name: Start an interactive shell session using a specific shell
2018+
text: >
2019+
az webapp exec -g MyResourceGroup -n MyWebapp --mode shell --shell /bin/sh
20102020
"""
20112021

20122022
helps['webapp delete'] = """

src/azure-cli/azure/cli/command_modules/appservice/_params.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,18 +1608,25 @@ def load_arguments(self, _):
16081608
with self.argument_context('webapp exec') as c:
16091609
c.argument('name', arg_type=webapp_name_arg_type, id_part=None)
16101610
c.argument('command', options_list=['--command'],
1611-
help='The command or executable to run in the container (e.g., pwd, bash, touch).')
1611+
help="The command or executable to run in the container (e.g., touch, mkdir, bash, python)."
1612+
" Used only in 'execute' mode.")
16121613
c.argument('args', options_list=['--args'], nargs='+',
1613-
help='Arguments to pass to the command. For shell commands, use: --command bash --args "-c" "your command here".')
1614+
help='Arguments to pass to the command. For shell commands, use: --command bash --args "-c" "your command here".'
1615+
" Used only in 'execute' mode.")
16141616
c.argument('mode',
1615-
help="Execution mode. 'execute': Starts command execution and returns immediately without returning"
1616-
" command output. 'shell': Starts an interactive shell session with the main webapp container.",
1617-
arg_type=get_enum_type(['execute', 'shell']), default='execute')
1617+
help="Execution mode. 'shell' (default): Starts an interactive shell session with the main"
1618+
" web app container. 'execute': Starts command execution and returns immediately without"
1619+
" returning command output.",
1620+
arg_type=get_enum_type(['shell', 'execute']), default='shell')
16181621
c.argument('working_directory', options_list=['--working-directory', '--cwd'],
1619-
help="Working directory for command execution. Defaults to the container's working directory"
1620-
" (typically /home/site/wwwroot for App Service images).")
1622+
help="Working directory for command execution. Defaults to the container's working directory."
1623+
" Used only in 'execute' mode.")
16211624
c.argument('instance', options_list=['--instance', '-i'],
16221625
help='Webapp instance(s) to target. Specify a comma-separated list of instance IDs'
1623-
' (use "az webapp list-instances" to get IDs) or "all" for all instances. Defaults to a random instance.')
1626+
' (use "az webapp list-instances" to get IDs) or "all" for all instances. Defaults to a random instance.'
1627+
' "all" is supported only in \'execute\' mode.')
1628+
c.argument('shell', options_list=['--shell'],
1629+
help="Absolute path of the shell to launch (e.g. /bin/sh). "
1630+
"Defaults to /bin/bash. Used only in 'shell' mode.")
16241631
c.argument('slot', options_list=['--slot', '-s'],
16251632
help='Name of the web app slot. Default to the production slot if not specified.')

src/azure-cli/azure/cli/command_modules/appservice/commands.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,14 @@ def load_command_table(self, _):
129129

130130
logicapp_custom = CliCommandType(operations_tmpl='azure.cli.command_modules.appservice.logicapp.custom#{}')
131131

132+
webapp_exec_custom = CliCommandType(operations_tmpl='azure.cli.command_modules.appservice.webapp_exec#{}')
133+
132134
with self.command_group('webapp', webapp_sdk) as g:
133135
g.custom_command('create', 'create_webapp', exception_handler=ex_handler_factory(), validator=validate_vnet_integration)
134136
g.custom_command('up', 'webapp_up', exception_handler=ex_handler_factory(), validator=validate_webapp_up,
135137
deprecate_info=g.deprecate(redirect='webapp create and webapp deploy'))
136138
g.custom_command('ssh', 'ssh_webapp', exception_handler=ex_handler_factory(), is_preview=True)
137-
g.custom_command('exec', 'webapp_exec', exception_handler=ex_handler_factory(), is_preview=True)
139+
g.custom_command('exec', 'webapp_exec', custom_command_type=webapp_exec_custom, exception_handler=ex_handler_factory(), is_preview=True)
138140
g.custom_command('list', 'list_webapp', table_transformer=transform_web_list_output)
139141
g.custom_show_command('show', 'show_app', table_transformer=transform_web_output)
140142
g.custom_command('delete', 'delete_webapp')

src/azure-cli/azure/cli/command_modules/appservice/custom.py

Lines changed: 1 addition & 232 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
from azure.cli.core.azclierror import (InvalidArgumentValueError, MutuallyExclusiveArgumentError, ResourceNotFoundError,
5151
RequiredArgumentMissingError, ValidationError, CLIInternalError,
5252
UnclassifiedUserFault, AzureResponseError, AzureInternalError,
53-
ArgumentUsageError, FileOperationError)
53+
ArgumentUsageError, FileOperationError, AzureConnectionError)
5454

5555
from .tunnel import TunnelServer
5656

@@ -12883,234 +12883,3 @@ 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

Comments
 (0)