Skip to content

Commit a9296b2

Browse files
committed
Add az webapp exec command for Linux web apps (shell + execute)
1 parent 7af895a commit a9296b2

4 files changed

Lines changed: 288 additions & 0 deletions

File tree

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1971,6 +1971,44 @@
19711971
text: az webapp create-remote-connection --name MyWebApp --resource-group MyResourceGroup
19721972
"""
19731973

1974+
helps['webapp exec'] = """
1975+
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.
1985+
examples:
1986+
- name: Run a direct command in the container
1987+
text: >
1988+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd
1989+
- name: Run a bash command and redirect output to a file
1990+
text: >
1991+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command bash --args "-c" "pwd &> pwd.txt"
1992+
- name: Create a file in a specific working directory
1993+
text: >
1994+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command touch --args "newfile.txt" --cwd /home/site
1995+
- name: Execute a command on a specific instance
1996+
text: >
1997+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd --instance MyInstanceId
1998+
- name: Execute a command on all instances
1999+
text: >
2000+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd --instance all
2001+
- name: Execute a command on a deployment slot
2002+
text: >
2003+
az webapp exec -g MyResourceGroup -n MyWebapp -s staging --mode execute --command pwd
2004+
- name: Start an interactive shell session with the web app container
2005+
text: >
2006+
az webapp exec -g MyResourceGroup -n MyWebapp --mode shell
2007+
- name: Start an interactive shell session on a specific instance
2008+
text: >
2009+
az webapp exec -g MyResourceGroup -n MyWebapp --mode shell --instance MyInstanceId
2010+
"""
2011+
19742012
helps['webapp delete'] = """
19752013
type: command
19762014
short-summary: Delete a web app.

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1605,3 +1605,21 @@ def load_arguments(self, _):
16051605
c.argument('environment_name', help="Name of the environment of static site")
16061606
with self.argument_context('staticwebapp enterprise-edge') as c:
16071607
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)
1608+
with self.argument_context('webapp exec') as c:
1609+
c.argument('name', arg_type=webapp_name_arg_type, id_part=None)
1610+
c.argument('command', options_list=['--command'],
1611+
help='The command or executable to run in the container (e.g., pwd, bash, touch).')
1612+
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+
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')
1618+
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).")
1621+
c.argument('instance', options_list=['--instance', '-i'],
1622+
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.')
1624+
c.argument('slot', options_list=['--slot', '-s'],
1625+
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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ def load_command_table(self, _):
134134
g.custom_command('up', 'webapp_up', exception_handler=ex_handler_factory(), validator=validate_webapp_up,
135135
deprecate_info=g.deprecate(redirect='webapp create and webapp deploy'))
136136
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)
137138
g.custom_command('list', 'list_webapp', table_transformer=transform_web_list_output)
138139
g.custom_show_command('show', 'show_app', table_transformer=transform_web_output)
139140
g.custom_command('delete', 'delete_webapp')

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

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)