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 31d333d7d28..ea3f8417c28 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -14,6 +14,7 @@ from urllib.request import urlopen from binascii import hexlify +from collections.abc import Mapping from os import urandom import datetime import json @@ -7732,64 +7733,14 @@ def _java_version_sort_key(version): except ValueError: return 0 - @staticmethod - def _get_java_versions_from_minor_versions(minor_versions): - """Dynamically extract unique Java versions from minor version values. - Used for Linux Java SE containers where minor.value is like "25.0.0", "21.0.0". - Returns versions sorted in descending order (newest first).""" - java_versions = set() - for minor in minor_versions: - # minor.value is like "25.0.0", "21.0.0", "17.0.0", "11.0.0", "8.0.0" or "1.8.0" - value = minor.value - if value: - # Handle both "1.8" format and newer "25", "21" formats - if value.startswith("1.8"): - java_versions.add("1.8") - else: - # Extract major version number (e.g., "25" from "25.0.0") - major_ver = value.split('.')[0] - if major_ver.isdigit(): - java_versions.add(major_ver) - # Sort descending (newest versions first) - return sorted(java_versions, key=_StackRuntimeHelper._java_version_sort_key) - @staticmethod def _get_java_versions_from_windows_container(container_settings): """Dynamically extract Java versions from Windows container settings. - Looks at the 'runtimes' array in additional_properties or directly on the object. + Looks at the 'runtimes' array exposed by the container settings. Returns versions sorted in descending order (newest first).""" java_versions = set() - runtimes_array = [] - - # Handle both dict and object representations of container_settings - if isinstance(container_settings, dict): - runtimes_array = container_settings.get('runtimes', []) - else: - # Try multiple ways to access the runtimes array - # 1. Check additional_properties (where SDK puts unknown fields) - additional_props = getattr(container_settings, 'additional_properties', None) - if additional_props and isinstance(additional_props, dict): - runtimes_array = additional_props.get('runtimes', []) - - # 2. Try direct attribute access (in case SDK exposes it directly) - if not runtimes_array: - runtimes_array = getattr(container_settings, 'runtimes', None) or [] - - # 3. Try as_dict() if available (converts SDK model to dict) - if not runtimes_array and hasattr(container_settings, 'as_dict'): - try: - settings_dict = container_settings.as_dict() - runtimes_array = settings_dict.get('runtimes', []) - except (AttributeError, TypeError, KeyError): - pass - - # 4. Try serialize() if available - if not runtimes_array and hasattr(container_settings, 'serialize'): - try: - settings_dict = container_settings.serialize() - runtimes_array = settings_dict.get('runtimes', []) - except (AttributeError, TypeError, KeyError): - pass + data = _StackRuntimeHelper._get_container_settings_data(container_settings) + runtimes_array = data.get('runtimes') or [] for runtime_info in runtimes_array: if isinstance(runtime_info, dict): @@ -7804,19 +7755,48 @@ def _get_java_versions_from_windows_container(container_settings): # Sort descending (newest versions first) return sorted(java_versions, key=_StackRuntimeHelper._java_version_sort_key) + @staticmethod + def _get_container_settings_data(container_settings): + """Return a dict of a container settings object's raw (camelCase) API fields. + + The azure-mgmt-web SDK returns typespec models that behave like a read-only + ``Mapping`` and preserve API fields the SDK does not explicitly model -- e.g. + ``java17Runtime``/``java21Runtime``/``java25Runtime`` and the ``runtimes`` array. + Older msrest-based models instead expose such unknown fields via + ``additional_properties``. Reading only the SDK-typed attributes (which cover just + ``java8Runtime``/``java11Runtime``) or only ``additional_properties`` silently drops + newer Java versions, so consult every representation. + """ + if container_settings is None: + return {} + if isinstance(container_settings, dict): + return container_settings + data = {} + # Legacy msrest models: unknown fields land in additional_properties. + additional = getattr(container_settings, 'additional_properties', None) + if isinstance(additional, dict): + data.update(additional) + # Typespec _Model instances are Mappings keyed by the raw camelCase field names. + if isinstance(container_settings, Mapping): + data.update(dict(container_settings)) + return data + @staticmethod def _get_java_runtimes_from_container_settings(container_settings): """Dynamically extract Java runtimes from container settings. Prefers the 'runtimes' array from the API when available (most future-proof), - falls back to individual java*Runtime properties in additional_properties, - and finally SDK-defined properties (java8_runtime, java11_runtime). + falls back to individual java*Runtime fields, and finally to the SDK-typed + java8_runtime/java11_runtime attributes. Returns list of tuples: (runtime_name, version, is_auto_update)""" runtimes = [] - is_auto_update = getattr(container_settings, 'is_auto_update', False) - additional_props = getattr(container_settings, 'additional_properties', {}) or {} + data = _StackRuntimeHelper._get_container_settings_data(container_settings) + is_auto_update = data.get('isAutoUpdate') + if is_auto_update is None: + is_auto_update = getattr(container_settings, 'is_auto_update', False) + is_auto_update = bool(is_auto_update) # Prefer the 'runtimes' array if available (cleanest, most future-proof) - runtimes_array = additional_props.get('runtimes', []) + runtimes_array = data.get('runtimes') or [] if runtimes_array: for runtime_info in runtimes_array: runtime_name = runtime_info.get('runtime') @@ -7824,17 +7804,17 @@ def _get_java_runtimes_from_container_settings(container_settings): if runtime_name and version: runtimes.append((runtime_name, version, is_auto_update)) else: - # Fallback: Get runtimes from additional_properties (java*Runtime keys) - for key, value in additional_props.items(): + # Fallback: Get runtimes from the raw java*Runtime fields + for key, value in data.items(): # Match pattern like "java25Runtime", "java21Runtime", etc. match = re.match(r'^java(\d+)Runtime$', key) if match and value: version = match.group(1) runtimes.append((value, version, is_auto_update)) - # Also get runtimes from SDK-defined properties (java8_runtime, java11_runtime) + # Also get runtimes from SDK-typed properties (java8_runtime, java11_runtime) if getattr(container_settings, 'java11_runtime', None): - # Avoid duplicates if already found in additional_properties + # Avoid duplicates if already found above if not any(v == "11" for _, v, _ in runtimes): runtimes.append((container_settings.java11_runtime, "11", is_auto_update)) if getattr(container_settings, 'java8_runtime', None): @@ -7946,13 +7926,23 @@ def _parse_major_version_linux(self, major_version, parsed_results, seen_runtime minor_java_container_versions = self._get_valid_minor_versions( major_version, linux=True, java=True, include_eol=self._include_eol) if "SE" in major_version.display_text: - # Dynamically get Java versions from the available minor versions - java_versions = self._get_java_versions_from_minor_versions(minor_java_container_versions) - se_containers = [minor_java_container_versions[0]] if minor_java_container_versions else [] - for java in java_versions: - se_java_containers = [c for c in minor_java_container_versions if c.value.startswith(java)] - se_containers = se_containers + se_java_containers - minor_java_container_versions = se_containers + # The displayed Java SE runtimes are the "friendly" auto-update names (e.g. + # JAVA|21-java21) carried by a single aggregate auto-update container, whose + # 'runtimes' array enumerates every available Java major version. Select that + # container by its is_auto_update flag instead of by position, which the API + # does not guarantee. The per-patch containers (e.g. JAVA|21.0.9) are + # non-auto-update and are filtered out of the table output downstream. Fall + # back to the first minor version if no auto-update container is present. + auto_update_containers = [] + for container in minor_java_container_versions: + container_settings = container.stack_settings.linux_container_settings + settings_data = self._get_container_settings_data(container_settings) + if settings_data.get('isAutoUpdate') or getattr(container_settings, 'is_auto_update', False): + auto_update_containers.append(container) + if auto_update_containers: + minor_java_container_versions = auto_update_containers + elif minor_java_container_versions: + minor_java_container_versions = [minor_java_container_versions[0]] if minor_java_container_versions: for minor in minor_java_container_versions: linux_container_settings = minor.stack_settings.linux_container_settings 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 cc19fbcb865..ec6a96e6502 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 @@ -5,6 +5,8 @@ import unittest from unittest import mock import os +import types +from collections.abc import Mapping from azure.core.exceptions import HttpResponseError @@ -1605,5 +1607,194 @@ def test_get_visit_url_falls_back_when_no_cache(self, get_url_mock): get_url_mock.assert_called_once_with(params.cmd, 'myRG', 'myApp', None) +class _TypespecContainerSettings(Mapping): + """Mimics an azure-mgmt-web typespec/DPG container settings model. + + The current SDK returns models that behave like a read-only ``Mapping`` keyed + by the raw camelCase API field names (e.g. ``runtimes``, ``isAutoUpdate``, + ``java25Runtime``). They also expose the few fields the SDK explicitly models + as snake_case attributes (``java8_runtime``/``java11_runtime``). Crucially, + unknown fields are NOT surfaced via the old msrest ``additional_properties`` + dict -- that attribute stays empty. The list-runtimes regression came from + reading only the typed attributes / ``additional_properties`` (which together + cover at most Java 8/11) instead of the Mapping data, silently dropping + Java 17/21/25. + """ + + def __init__(self, data, *, java8=None, java11=None, is_auto_update=False, + end_of_life_date=None): + self._data = dict(data) + self.java8_runtime = java8 + self.java11_runtime = java11 + self.is_auto_update = is_auto_update + self.end_of_life_date = end_of_life_date + self.is_hidden = False + self.is_deprecated = False + # Typespec models leave additional_properties empty (msrest-only concept). + self.additional_properties = [] + + def __getitem__(self, key): + return self._data[key] + + def __iter__(self): + return iter(self._data) + + def __len__(self): + return len(self._data) + + +class TestStackRuntimeJavaSELinux(unittest.TestCase): + """Regression tests for `az webapp list-runtimes` Linux Java SE parsing. + + The displayed Linux Java SE runtimes come from a single aggregate auto-update + container whose ``runtimes`` array enumerates every available Java major + version (8/11/17/21/25). The azure-mgmt-web SDK now returns typespec models + that preserve those fields via the Mapping interface rather than the typed + java8/java11 attributes or msrest ``additional_properties``. These tests guard + against the regression where newer Java versions were dropped because only the + typed attributes / ``additional_properties`` were consulted, and against the + aggregate container's position in the response mattering. + """ + + EXPECTED = { + 'JAVA|25-java25', 'JAVA|21-java21', 'JAVA|17-java17', 'JAVA|11-java11', 'JAVA|8-jre8', + } + + FULL_RUNTIMES = [ + {'runtimeVersion': '8', 'runtime': 'JAVA|8-jre8'}, + {'runtimeVersion': '11', 'runtime': 'JAVA|11-java11'}, + {'runtimeVersion': '17', 'runtime': 'JAVA|17-java17'}, + {'runtimeVersion': '21', 'runtime': 'JAVA|21-java21'}, + {'runtimeVersion': '25', 'runtime': 'JAVA|25-java25'}, + ] + + @staticmethod + def _minor(value, container_settings): + stack_settings = types.SimpleNamespace( + linux_container_settings=container_settings, + linux_runtime_settings=None, + windows_container_settings=None, + windows_runtime_settings=None, + ) + return types.SimpleNamespace(value=value, stack_settings=stack_settings) + + def _patch_minors(self): + # Per-patch Java SE minors are always present in the API response, one per + # build. They are NOT auto-update and must never drive the displayed output. + return [ + self._minor('25.0.1', _TypespecContainerSettings( + {'runtimes': [{'runtimeVersion': '25', 'runtime': 'JAVA|25.0.1'}]})), + self._minor('21.0.9', _TypespecContainerSettings( + {'runtimes': [{'runtimeVersion': '21', 'runtime': 'JAVA|21.0.9'}]})), + self._minor('17.0.17', _TypespecContainerSettings( + {'runtimes': [{'runtimeVersion': '17', 'runtime': 'JAVA|17.0.17'}]})), + self._minor('11.0.29', _TypespecContainerSettings( + {'runtimes': [{'runtimeVersion': '11', 'runtime': 'JAVA|11.0.29'}]})), + self._minor('1.8.472', _TypespecContainerSettings( + {'runtimes': [{'runtimeVersion': '8', 'runtime': 'JAVA|1.8.472'}]})), + ] + + @staticmethod + def _java_se_stack(minors): + major = types.SimpleNamespace( + display_text='Java SE (Embedded Web Server)', + minor_versions=minors, + ) + return types.SimpleNamespace(display_text='Java Containers', major_versions=[major]) + + @staticmethod + def _new_helper(): + from azure.cli.command_modules.appservice.custom import _StackRuntimeHelper + helper = _StackRuntimeHelper.__new__(_StackRuntimeHelper) + helper._linux = True + helper._windows = False + helper._include_eol = False + helper._stacks = [] + helper.windows_config_mappings = {'node': None} + return helper + + def _java_se_configs(self, stack): + helper = self._new_helper() + helper._parse_raw_stacks([stack]) + rows = helper.get_stacks_as_table(runtime_filter='java', support_filter=None) + return {r['config'] for r in rows if r['runtime'] == 'Java'} + + def test_aggregate_runtimes_array_complete(self): + # Primary path: the aggregate auto-update container carries the full + # 'runtimes' array via the typespec Mapping interface. + aggregate = self._minor('SE', _TypespecContainerSettings( + {'isAutoUpdate': True, 'runtimes': self.FULL_RUNTIMES}, is_auto_update=True)) + stack = self._java_se_stack([aggregate] + self._patch_minors()) + self.assertEqual(self._java_se_configs(stack), self.EXPECTED) + + def test_aggregate_javaNNRuntime_keys(self): + # Fallback path: no 'runtimes' array, but the Mapping exposes individual + # javaNNRuntime camelCase keys for every available major version. + aggregate = self._minor('SE', _TypespecContainerSettings( + { + 'isAutoUpdate': True, + 'java8Runtime': 'JAVA|8-jre8', + 'java11Runtime': 'JAVA|11-java11', + 'java17Runtime': 'JAVA|17-java17', + 'java21Runtime': 'JAVA|21-java21', + 'java25Runtime': 'JAVA|25-java25', + }, + is_auto_update=True)) + stack = self._java_se_stack([aggregate] + self._patch_minors()) + self.assertEqual(self._java_se_configs(stack), self.EXPECTED) + + def test_aggregate_not_first_selected_by_auto_update(self): + # The aggregate auto-update container must be chosen by its is_auto_update + # flag, not its position -- here it is returned last, after the per-patch minors. + aggregate = self._minor('SE', _TypespecContainerSettings( + {'isAutoUpdate': True, 'runtimes': self.FULL_RUNTIMES}, is_auto_update=True)) + stack = self._java_se_stack(self._patch_minors() + [aggregate]) + self.assertEqual(self._java_se_configs(stack), self.EXPECTED) + + def test_typed_attrs_only_expose_java_8_11_but_mapping_has_all(self): + # Reproduces the exact regression: the SDK types only java8_runtime / + # java11_runtime, and additional_properties is empty, but the full data is + # available through the Mapping. The fix must read the Mapping, not just the + # typed attributes, otherwise Java 17/21/25 are silently dropped. + aggregate = self._minor('SE', _TypespecContainerSettings( + {'isAutoUpdate': True, 'runtimes': self.FULL_RUNTIMES}, + java8='JAVA|8-jre8', java11='JAVA|11-java11', is_auto_update=True)) + # Sanity-check the model: typed attrs cover only 8/11, additional_properties empty. + self.assertEqual(aggregate.stack_settings.linux_container_settings.additional_properties, []) + stack = self._java_se_stack([aggregate] + self._patch_minors()) + self.assertEqual(self._java_se_configs(stack), self.EXPECTED) + + def test_runtimes_array_entries_flagged_auto_update(self): + # Entries derived from the aggregate must be flagged auto-update so they + # survive the table filter that drops non-auto-update java rows. + aggregate = self._minor('SE', _TypespecContainerSettings( + {'isAutoUpdate': True, 'runtimes': self.FULL_RUNTIMES}, is_auto_update=True)) + stack = self._java_se_stack([aggregate] + self._patch_minors()) + helper = self._new_helper() + helper._parse_raw_stacks([stack]) + java_runtimes = [s for s in helper._stacks if s.display_name in self.EXPECTED] + self.assertEqual({s.display_name for s in java_runtimes}, self.EXPECTED) + self.assertTrue(all(s.is_auto_update for s in java_runtimes)) + + def test_get_container_settings_data_reads_mapping(self): + from azure.cli.command_modules.appservice.custom import _StackRuntimeHelper + settings = _TypespecContainerSettings( + {'isAutoUpdate': True, 'java25Runtime': 'JAVA|25-java25', 'runtimes': self.FULL_RUNTIMES}, + java8='JAVA|8-jre8', java11='JAVA|11-java11', is_auto_update=True) + data = _StackRuntimeHelper._get_container_settings_data(settings) + self.assertTrue(data.get('isAutoUpdate')) + self.assertEqual(data.get('java25Runtime'), 'JAVA|25-java25') + self.assertEqual(len(data.get('runtimes')), 5) + + def test_get_java_runtimes_from_container_settings_reads_mapping(self): + from azure.cli.command_modules.appservice.custom import _StackRuntimeHelper + settings = _TypespecContainerSettings( + {'isAutoUpdate': True, 'runtimes': self.FULL_RUNTIMES}, + java8='JAVA|8-jre8', java11='JAVA|11-java11', is_auto_update=True) + runtimes = _StackRuntimeHelper._get_java_runtimes_from_container_settings(settings) + self.assertEqual({name for name, _, _ in runtimes}, self.EXPECTED) + self.assertTrue(all(is_auto for _, _, is_auto in runtimes)) + + if __name__ == '__main__': - unittest.main() + unittest.main() \ No newline at end of file