Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,11 @@ def get_runtime_version_details(file_path, lang_name, stack_helper, is_linux=Fal
version_detected = parse_node_version(file_path)[0]
version_to_create = detect_node_version_tocreate(version_detected, versions, default_version)
elif lang_name.lower() == PYTHON_RUNTIME_NAME:
version_detected = "-"
version_to_create = default_version
version_detected = _detect_python_version(file_path)
if version_detected != "-" and version_detected in versions:
version_to_create = version_detected
else:
version_to_create = default_version
elif lang_name.lower() == STATIC_RUNTIME_NAME:
version_detected = "-"
version_to_create = "-"
Expand Down Expand Up @@ -165,10 +168,10 @@ def get_lang_from_content(src_path, html=False, is_linux=False):
import fnmatch
for _dirpath, _dirnames, files in os.walk(src_path):
for file in files:
if html and (fnmatch.fnmatch(file, "*.html") or fnmatch.fnmatch(file, "*.htm") or
fnmatch.fnmatch(file, "*shtml.")):
static_html_file = os.path.join(src_path, file)
break
if fnmatch.fnmatch(file, "*.html") or fnmatch.fnmatch(file, "*.htm") or \
fnmatch.fnmatch(file, "*.shtml"):
if not static_html_file:
static_html_file = os.path.join(_dirpath, file)
if fnmatch.fnmatch(file, "*.csproj"):
package_netcore_file = os.path.join(src_path, file)
if not os.path.isfile(package_netcore_file):
Expand Down Expand Up @@ -196,7 +199,12 @@ def get_lang_from_content(src_path, html=False, is_linux=False):
runtime_details_dict['language'] = runtime_lang
runtime_details_dict['file_loc'] = package_netcore_file
runtime_details_dict['default_sku'] = 'F1'
else: # TODO: Update the doc when the detection logic gets updated
elif static_html_file:
# Auto-detect static HTML even without --html flag
runtime_details_dict['language'] = STATIC_RUNTIME_NAME
runtime_details_dict['file_loc'] = static_html_file
runtime_details_dict['default_sku'] = 'F1'
else:
raise CLIError("Could not auto-detect the runtime stack of your app.\n"
"HINT: Are you in the right folder?\n"
"For more information, see 'https://go.microsoft.com/fwlink/?linkid=2109470'")
Expand Down Expand Up @@ -284,6 +292,40 @@ def parse_node_version(file_path):
return version_detected or ['0.0']


def _detect_python_version(file_path):
"""Detect Python version from runtime.txt or .python-version in the project directory."""
import re
src_dir = os.path.dirname(file_path) if file_path else ''
if not src_dir:
return "-"

# Check runtime.txt (used by Azure/Heroku: "python-3.11.4")
runtime_txt = os.path.join(src_dir, 'runtime.txt')
if os.path.isfile(runtime_txt):
try:
with open(runtime_txt) as f:
content = f.read().strip().lower()
match = re.search(r'python-(\d+\.\d+)', content)
if match:
return match.group(1)
except Exception: # pylint: disable=broad-except
pass

# Check .python-version (used by pyenv: "3.11.4" or "3.11")
python_version_file = os.path.join(src_dir, '.python-version')
if os.path.isfile(python_version_file):
try:
with open(python_version_file) as f:
content = f.read().strip()
match = re.match(r'^(\d+\.\d+)', content)
if match:
return match.group(1)
except Exception: # pylint: disable=broad-except
pass

return "-"


def detect_dotnet_version_tocreate(detected_ver, default_version, versions_list):
min_ver = versions_list[0]
if detected_ver in versions_list:
Expand Down Expand Up @@ -402,8 +444,33 @@ def detect_os_from_src(src_dir, html=False, runtime=None):
language = runtime.split(_StackRuntimeHelper.DEFAULT_DELIMETER)[0]
else:
language = get_lang_from_content(src_dir, html).get('language')
return "Linux" if language is not None and language.lower() == NODE_RUNTIME_NAME \
or language.lower() == PYTHON_RUNTIME_NAME else OS_DEFAULT
if language is None:
return OS_DEFAULT
lang_lower = language.lower()
# Python and Node are Linux-first; .NET Core / modern dotnet also default to Linux
if lang_lower in (NODE_RUNTIME_NAME, PYTHON_RUNTIME_NAME, NETCORE_RUNTIME_NAME, DOTNET_RUNTIME_NAME):
return "Linux"
# Static HTML sites can run on Linux via Node
if lang_lower == STATIC_RUNTIME_NAME:
return "Linux"
return OS_DEFAULT


def validate_runtime_os_combo(language, version_used_create, os_name, stack_helper, is_linux):
"""Validate that the runtime+OS combination is supported before creating resources.
Raises ValidationError with a helpful message if the combination is invalid."""
from azure.cli.core.azclierror import ValidationError
if not language or language.lower() == STATIC_RUNTIME_NAME:
return # static doesn't need runtime validation
runtime_version = "{}|{}".format(language, version_used_create) if version_used_create != "-" else None
if runtime_version:
match = stack_helper.resolve(runtime_version, is_linux)
if not match:
raise ValidationError(
"The runtime '{}' is not supported on {os_name}. "
"Please check supported runtimes with: 'az webapp list-runtimes --os {os_name}'.\n"
"HINT: Try a different --os-type or --runtime value.".format(
runtime_version, os_name=os_name))


def get_plan_to_use(cmd, user, loc, sku, create_rg, resource_group_name, client, is_linux=False, plan=None):
Expand Down
19 changes: 15 additions & 4 deletions src/azure-cli/azure/cli/command_modules/appservice/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -2586,9 +2586,11 @@
type: command
short-summary: >
Create a webapp and deploy code from a local workspace to the app. The command is required to run from the folder
where the code is present. Current support includes Node, Python, .NET Core and ASP.NET. Node,
Python apps are created as Linux apps. .Net Core, ASP.NET, and static HTML apps are created as Windows apps.
Append the html flag to deploy as a static HTML app.
where the code is present. Current support includes Node, Python, .NET Core and ASP.NET. Node, Python, and .NET Core
apps are created as Linux apps. ASP.NET apps are created as Windows apps.
Static HTML sites are auto-detected and deployed as Linux apps; use the --html flag to force HTML detection.
The runtime and OS are auto-detected from source files but can be overridden with --runtime and --os-type.
The runtime version is read from project files (runtime.txt, .python-version, package.json engines, *.csproj).
Each time the command is successfully run, default argument values for resource group, sku, location, plan, and name are saved for the current directory.
These defaults are then used for any arguments not provided on subsequent runs of the command in the same directory. Use 'az configure' to manage defaults.
Run this command with the --debug parameter to see the API calls and parameters values being used.
Expand All @@ -2606,15 +2608,24 @@
- name: Create a web app with a specified name and a Java 11 runtime
text: >
az webapp up -n MyUniqueAppName --runtime "java:11:Java SE:11"
- name: Deploy a Python app (auto-detected from requirements.txt) to a Linux app
text: >
az webapp up -n MyPythonApp
- name: Deploy a Node.js app with a specific runtime version
text: >
az webapp up -n MyNodeApp --runtime "node|18-lts"
- name: Create a web app in a specific region, by running the command from the folder where the code to be deployed exists.
text: >
az webapp up -l locationName
- name: Create a web app and enable log streaming after the deployment operation is complete. This will enable the default configuration required to enable log streaming.
text: >
az webapp up --logs
- name: Create a web app and deploy as a static HTML app.
- name: Deploy a static HTML site (auto-detected or forced with --html)
text: >
az webapp up --html
- name: Deploy a .NET app and explicitly set the OS type
text: >
az webapp up -n MyDotnetApp --os-type Linux --runtime "dotnetcore|8.0"
- name: Create a web app with a specified domain name scope for unique hostname generation
text: >
az webapp up -n MyUniqueAppName --domain-name-scope TenantReuse
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
get_plan_to_use, get_lang_from_content, get_rg_to_use, get_sku_to_use,
detect_os_from_src, get_current_stack_from_runtime, generate_default_app_name,
get_or_create_default_workspace, get_or_create_default_resource_group,
get_workspace)
get_workspace, validate_runtime_os_combo)
from ._constants import (FUNCTIONS_STACKS_API_KEYS, FUNCTIONS_LINUX_RUNTIME_VERSION_REGEX,
FUNCTIONS_WINDOWS_RUNTIME_VERSION_REGEX, PUBLIC_CLOUD,
LINUX_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH, WINDOWS_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH,
Expand Down Expand Up @@ -9119,6 +9119,10 @@ def webapp_up(cmd, name=None, resource_group_name=None, plan=None, location=None
version_used_create = _data.get('to_create')
detected_version = _data.get('detected')

# Pre-validate runtime+OS combo before creating any resources (#25597)
if _create_new_app:
validate_runtime_os_combo(language, version_used_create, os_name, helper, _is_linux)

runtime_version = "{}|{}".format(language, version_used_create) if \
version_used_create != "-" else version_used_create
site_config = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -644,5 +644,154 @@ def __init__(self, status_code):
self.status_code = status_code


class TestDetectOsFromSrc(unittest.TestCase):
"""Tests for detect_os_from_src improvements (#25743)"""

def test_python_detected_as_linux(self):
from azure.cli.command_modules.appservice._create_util import detect_os_from_src
import tempfile
with tempfile.TemporaryDirectory() as tmp:
with open(os.path.join(tmp, 'requirements.txt'), 'w') as f:
f.write('flask\n')
result = detect_os_from_src(tmp)
self.assertEqual(result, "Linux")

def test_node_detected_as_linux(self):
from azure.cli.command_modules.appservice._create_util import detect_os_from_src
import tempfile
import json
with tempfile.TemporaryDirectory() as tmp:
with open(os.path.join(tmp, 'package.json'), 'w') as f:
json.dump({"name": "test", "version": "1.0.0"}, f)
result = detect_os_from_src(tmp)
self.assertEqual(result, "Linux")

def test_html_detected_as_linux(self):
from azure.cli.command_modules.appservice._create_util import detect_os_from_src
import tempfile
with tempfile.TemporaryDirectory() as tmp:
with open(os.path.join(tmp, 'index.html'), 'w') as f:
f.write('<html></html>')
result = detect_os_from_src(tmp, html=True)
self.assertEqual(result, "Linux")

def test_runtime_override_python(self):
from azure.cli.command_modules.appservice._create_util import detect_os_from_src
import tempfile
with tempfile.TemporaryDirectory() as tmp:
result = detect_os_from_src(tmp, runtime="python|3.11")
self.assertEqual(result, "Linux")

def test_runtime_override_aspnet(self):
from azure.cli.command_modules.appservice._create_util import detect_os_from_src
import tempfile
with tempfile.TemporaryDirectory() as tmp:
result = detect_os_from_src(tmp, runtime="aspnet|4.8")
self.assertEqual(result, "Windows")


class TestGetLangFromContent(unittest.TestCase):
"""Tests for static HTML auto-detection (#25129)"""

def test_html_autodetected_without_flag(self):
from azure.cli.command_modules.appservice._create_util import get_lang_from_content
import tempfile
with tempfile.TemporaryDirectory() as tmp:
with open(os.path.join(tmp, 'index.html'), 'w') as f:
f.write('<html></html>')
result = get_lang_from_content(tmp, html=False)
self.assertEqual(result['language'], 'static')

def test_python_takes_precedence_over_html(self):
from azure.cli.command_modules.appservice._create_util import get_lang_from_content
import tempfile
with tempfile.TemporaryDirectory() as tmp:
with open(os.path.join(tmp, 'requirements.txt'), 'w') as f:
f.write('flask\n')
with open(os.path.join(tmp, 'index.html'), 'w') as f:
f.write('<html></html>')
result = get_lang_from_content(tmp, html=False)
self.assertEqual(result['language'], 'python')


class TestDetectPythonVersion(unittest.TestCase):
"""Tests for Python version detection from project files (#30756)"""

def test_detect_from_runtime_txt(self):
from azure.cli.command_modules.appservice._create_util import _detect_python_version
import tempfile
with tempfile.TemporaryDirectory() as tmp:
with open(os.path.join(tmp, 'requirements.txt'), 'w') as f:
f.write('flask\n')
with open(os.path.join(tmp, 'runtime.txt'), 'w') as f:
f.write('python-3.11.4\n')
result = _detect_python_version(os.path.join(tmp, 'requirements.txt'))
self.assertEqual(result, '3.11')

def test_detect_from_python_version_file(self):
from azure.cli.command_modules.appservice._create_util import _detect_python_version
import tempfile
with tempfile.TemporaryDirectory() as tmp:
with open(os.path.join(tmp, 'requirements.txt'), 'w') as f:
f.write('flask\n')
with open(os.path.join(tmp, '.python-version'), 'w') as f:
f.write('3.10.2\n')
result = _detect_python_version(os.path.join(tmp, 'requirements.txt'))
self.assertEqual(result, '3.10')

def test_no_version_file_returns_dash(self):
from azure.cli.command_modules.appservice._create_util import _detect_python_version
import tempfile
with tempfile.TemporaryDirectory() as tmp:
with open(os.path.join(tmp, 'requirements.txt'), 'w') as f:
f.write('flask\n')
result = _detect_python_version(os.path.join(tmp, 'requirements.txt'))
self.assertEqual(result, '-')

def test_runtime_txt_takes_precedence(self):
from azure.cli.command_modules.appservice._create_util import _detect_python_version
import tempfile
with tempfile.TemporaryDirectory() as tmp:
with open(os.path.join(tmp, 'requirements.txt'), 'w') as f:
f.write('flask\n')
with open(os.path.join(tmp, 'runtime.txt'), 'w') as f:
f.write('python-3.12.0\n')
with open(os.path.join(tmp, '.python-version'), 'w') as f:
f.write('3.10\n')
result = _detect_python_version(os.path.join(tmp, 'requirements.txt'))
self.assertEqual(result, '3.12')


class TestValidateRuntimeOsCombo(unittest.TestCase):
"""Tests for runtime+OS pre-validation (#25597)"""

def test_static_runtime_skips_validation(self):
from azure.cli.command_modules.appservice._create_util import validate_runtime_os_combo
# Should not raise for static runtime
validate_runtime_os_combo('static', '-', 'Linux', None, True)

def test_no_version_skips_validation(self):
from azure.cli.command_modules.appservice._create_util import validate_runtime_os_combo
# Should not raise when version is "-"
validate_runtime_os_combo('python', '-', 'Linux', None, True)

def test_invalid_combo_raises_error(self):
from azure.cli.command_modules.appservice._create_util import validate_runtime_os_combo
from azure.cli.core.azclierror import ValidationError

mock_helper = mock.MagicMock()
mock_helper.resolve.return_value = None # no match => invalid combo
with self.assertRaises(ValidationError):
validate_runtime_os_combo('python', '3.11', 'Windows', mock_helper, False)

def test_valid_combo_passes(self):
from azure.cli.command_modules.appservice._create_util import validate_runtime_os_combo

mock_helper = mock.MagicMock()
mock_helper.resolve.return_value = mock.MagicMock() # match found => valid
# Should not raise
validate_runtime_os_combo('python', '3.11', 'Linux', mock_helper, True)


if __name__ == '__main__':
unittest.main()
Loading