diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_create_util.py b/src/azure-cli/azure/cli/command_modules/appservice/_create_util.py index 7bc6868266c..5fe3583395a 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_create_util.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_create_util.py @@ -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 = "-" @@ -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): @@ -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'") @@ -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: @@ -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): diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index e0dad92e98c..71dd4cd7a68 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -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. @@ -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 diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 386ba088608..3c8b7bca1a4 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -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, @@ -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 diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 853eadc1edd..01cf0adabac 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -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('') + 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('') + 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('') + 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()