Skip to content

Commit ac0eaea

Browse files
vageorge00Copilot
andcommitted
implement shell mode
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0300fad commit ac0eaea

15 files changed

Lines changed: 220 additions & 284 deletions

File tree

.github/CODEOWNERS

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,3 @@
6767
/src/azure-cli/azure/cli/command_modules/util/ @jiasli @zhoxing-ms @evelyn-ys
6868
/src/azure-cli/azure/cli/command_modules/vm/ @zhoxing-ms @jsntcy @wangzelin007 @yanzhudd @Drewm3 @TravisCragg-MSFT @nikhilpatel909 @sandeepraichura @hilaryw29 @GabstaMSFT @ramankumarlive @ushnaarshadkhan
6969
/src/azure-cli/azure/cli/command_modules/containerapp/ @zhoxing-ms @yanzhudd @ruslany @sanchitmehta @ebencarek @JennyLawrance @howang-ms @vinisoto @chinadragon0515 @vturecek @torosent @pagariyaalok @Juliehzl @jijohn14 @Greedygre @ShichaoQiu
70-
/src/azure-cli/azure/cli/command_modules/webapp/ @vageorge00

doc/sphinx/azhelpgen/doc_source_map.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,5 @@
7777
"term": "src/azure-cli/azure/cli/command_modules/marketplaceordering/_help.py",
7878
"util": "src/azure-cli/azure/cli/command_modules/util/_help.py",
7979
"vm": "src/azure-cli/azure/cli/command_modules/vm/_help.py",
80-
"vmss": "src/azure-cli/azure/cli/command_modules/vm/_help.py",
81-
"webapp": "src/azure-cli/azure/cli/command_modules/webapp/_help.py"
80+
"vmss": "src/azure-cli/azure/cli/command_modules/vm/_help.py"
8281
}

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

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,24 +1919,40 @@
19191919

19201920
helps['webapp exec'] = """
19211921
type: command
1922-
short-summary: Execute a shell command in a web app container.
1922+
short-summary: Interact with a Linux web app container via command execution or an interactive shell session.
19231923
long-summary: >
1924-
Execute shell commands remotely in your web app container. The command returns immediately
1925-
after being accepted, without waiting for completion or output. Redirect to a file to capture results (see examples).
1924+
Interact with your Linux web app container using two modes.
1925+
This command is only supported for Linux App Service plans.
1926+
'execute' runs a command in the container and returns immediately without waiting for completion or output.
1927+
Redirect to a file to capture results (see examples).
1928+
'shell' starts an interactive shell session with the main webapp container.
1929+
Shell sessions are subject to an idle timeout and may be terminated if inactive for an extended period.
19261930
Requires SCM Basic Auth Publishing Credentials to be enabled.
19271931
examples:
1928-
- name: Execute a simple command on a web app and redirect output to a log file
1932+
- name: Run a direct command in the container
19291933
text: >
1930-
az webapp exec -g MyResourceGroup -n MyWebapp --command "(ls -l) > output.log 2>&1"
1931-
- name: Execute a command in a specific working directory on a specific instance
1934+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd
1935+
- name: Run a bash command and redirect output to a file
19321936
text: >
1933-
az webapp exec -g MyResourceGroup --name MyWebapp --command "npm install" --cwd /home/site/wwwroot --instance MyInstanceId
1937+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command bash --args "-c" "pwd &> pwd.txt"
1938+
- name: Create a file in a specific working directory
1939+
text: >
1940+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command touch --args "newfile.txt" --cwd /home/site
1941+
- name: Execute a command on a specific instance
1942+
text: >
1943+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd --instance MyInstanceId
19341944
- name: Execute a command on all instances
19351945
text: >
1936-
az webapp exec -g MyResourceGroup -n MyWebapp --command "systemctl restart nginx" --instance all
1946+
az webapp exec -g MyResourceGroup -n MyWebapp --mode execute --command pwd --instance all
19371947
- name: Execute a command on a deployment slot
19381948
text: >
1939-
az webapp exec -g MyResourceGroup -n MyWebapp -s staging --command "python manage.py migrate"
1949+
az webapp exec -g MyResourceGroup -n MyWebapp -s staging --mode execute --command pwd
1950+
- name: Start an interactive shell session with the web app container
1951+
text: >
1952+
az webapp exec -g MyResourceGroup -n MyWebapp --mode shell
1953+
- name: Start an interactive shell session on a specific instance
1954+
text: >
1955+
az webapp exec -g MyResourceGroup -n MyWebapp --mode shell --instance MyInstanceId
19401956
"""
19411957

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

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

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1504,13 +1504,19 @@ def load_arguments(self, _):
15041504
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)
15051505
with self.argument_context('webapp exec') as c:
15061506
c.argument('name', arg_type=webapp_name_arg_type, id_part=None)
1507-
c.argument('command', options_list=['--command'], help='The command to execute in the container.')
1508-
c.argument('mode',
1509-
help='Execution mode. \'execute\': Starts command execution and returns immediately without returning command output.',
1510-
arg_type=get_enum_type(['execute']), default='execute')
1511-
c.argument('working_directory', options_list=['--working-directory', '--cwd'],
1512-
help='Working directory for command execution. Defaults to the container\'s working directory (typically /home/site/wwwroot for App Service images).'')
1513-
c.argument('instance', options_list=['--instance', '-i'],
1514-
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.')
1515-
c.argument('slot', options_list=['--slot', '-s'],
1516-
help='Name of the web app slot. Default to the production slot if not specified.')
1507+
c.argument('command', options_list=['--command'],
1508+
help='The command or executable to run in the container (e.g., pwd, bash, touch).')
1509+
c.argument('args', options_list=['--args'], nargs='+',
1510+
help='Arguments to pass to the command. For shell commands, use: --command bash --args "-c" "your command here".')
1511+
c.argument('mode',
1512+
help="Execution mode. 'execute': Starts command execution and returns immediately without returning"
1513+
" command output. 'shell': Starts an interactive shell session with the main webapp container.",
1514+
arg_type=get_enum_type(['execute', 'shell']), default='execute')
1515+
c.argument('working_directory', options_list=['--working-directory', '--cwd'],
1516+
help="Working directory for command execution. Defaults to the container's working directory"
1517+
" (typically /home/site/wwwroot for App Service images).")
1518+
c.argument('instance', options_list=['--instance', '-i'],
1519+
help='Webapp instance(s) to target. Specify a comma-separated list of instance IDs'
1520+
' (use "az webapp list-instances" to get IDs) or "all" for all instances. Defaults to a random instance.')
1521+
c.argument('slot', options_list=['--slot', '-s'],
1522+
help='Name of the web app slot. Default to the production slot if not specified.')

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

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

src/azure-cli/azure/cli/command_modules/webapp/__init__.py

Lines changed: 0 additions & 32 deletions
This file was deleted.

0 commit comments

Comments
 (0)