From 216e5745c63ebeff3e2779a4fcd194b2f4d09871 Mon Sep 17 00:00:00 2001 From: Amber Amos Date: Fri, 7 Aug 2026 08:12:57 -0700 Subject: [PATCH 1/3] Standardize node runtime for windows and linux --- .../cli/command_modules/appservice/custom.py | 12 +++- .../latest/test_webapp_commands_thru_mock.py | 64 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) 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 a3b809afa62..66066212de0 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -7707,6 +7707,13 @@ def _format_windows_display_text(cls, display_text): t = re.sub(r"\(.*\)", "", t) # remove "(LTS)" return t.replace(" ", "|", 1).replace(" ", "") + @staticmethod + def _standardize_node_runtime_name(runtime_name): + match = re.fullmatch(r'node\|(\d+)(?:-?lts)?', runtime_name, re.IGNORECASE) + if match and int(match.group(1)) >= 26: + return "NODE|{}".format(match.group(1)) + return runtime_name + @classmethod def _is_valid_runtime_setting(cls, runtime_setting, include_eol=False): # Using datetime module imported at the top level @@ -7908,6 +7915,7 @@ def _parse_major_version_windows(self, major_version, parsed_results, config_map eol_date = self._format_eol_date(getattr(settings, 'end_of_life_date', None)) if "Java" not in minor_version.display_text: runtime_name = self._format_windows_display_text(minor_version.display_text) + runtime_name = self._standardize_node_runtime_name(runtime_name) runtime = self.Runtime(display_name=runtime_name, linux=False, os="Windows", runtime_family=runtime_family, @@ -8019,9 +8027,9 @@ def _parse_major_version_linux(self, major_version, parsed_results, seen_runtime major_version, linux=True, java=False, include_eol=self._include_eol) for minor_version in minor_versions: settings = minor_version.stack_settings.linux_runtime_settings - runtime_name = settings.runtime_version + runtime_name = self._standardize_node_runtime_name(settings.runtime_version) runtime = self.Runtime(display_name=runtime_name, - configs={"linux_fx_version": runtime_name}, + configs={"linux_fx_version": settings.runtime_version}, linux=True, os="Linux", runtime_family=runtime_family, 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 ec6a96e6502..03c79e85a59 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 @@ -1643,6 +1643,70 @@ def __len__(self): return len(self._data) +class TestStackRuntimeNodeStandardization(unittest.TestCase): + @staticmethod + def _new_helper(): + from azure.cli.command_modules.appservice.custom import _StackRuntimeHelper + helper = _StackRuntimeHelper.__new__(_StackRuntimeHelper) + helper._linux = True + helper._windows = True + helper._include_eol = False + helper._stacks = [] + helper.windows_config_mappings = {'node': 'WEBSITE_NODE_DEFAULT_VERSION'} + return helper + + @staticmethod + def _node_stack(version, display_text, linux_runtime): + git_hub_action_settings = types.SimpleNamespace(is_supported=True, supported_version="{}.x".format(version)) + linux_settings = types.SimpleNamespace( + runtime_version=linux_runtime, + is_hidden=False, + is_deprecated=False, + end_of_life_date=None, + git_hub_action_settings=git_hub_action_settings, + ) + windows_settings = types.SimpleNamespace( + runtime_version="~{}".format(version), + is_hidden=False, + is_deprecated=False, + end_of_life_date=None, + git_hub_action_settings=git_hub_action_settings, + ) + minor = types.SimpleNamespace( + display_text=display_text, + stack_settings=types.SimpleNamespace( + linux_container_settings=None, + linux_runtime_settings=linux_settings, + windows_container_settings=None, + windows_runtime_settings=windows_settings, + ), + ) + major = types.SimpleNamespace(display_text=display_text, minor_versions=[minor]) + return types.SimpleNamespace(display_text='Node', major_versions=[major]) + + def test_node_26_uses_standard_identifier_on_both_platforms(self): + helper = self._new_helper() + helper._parse_raw_stacks([self._node_stack('26', 'Node 26 LTS', 'NODE|26-lts')]) + + self.assertEqual( + [(runtime.os, runtime.display_name) for runtime in helper._stacks], + [('Linux', 'NODE|26'), ('Windows', 'NODE|26')]) + self.assertEqual( + [(row['os'], row['config']) for row in helper.get_stacks_as_table(runtime_filter='node')], + [('Linux', 'NODE|26'), ('Windows', 'NODE|26')]) + self.assertEqual(helper.resolve('NODE|26', linux=True).configs['linux_fx_version'], 'NODE|26-lts') + self.assertEqual( + helper.resolve('NODE|26', linux=False).configs['WEBSITE_NODE_DEFAULT_VERSION'], '~26') + + def test_older_node_identifiers_remain_unchanged(self): + helper = self._new_helper() + helper._parse_raw_stacks([self._node_stack('24', 'Node 24 LTS', 'NODE|24-lts')]) + + self.assertEqual( + [(runtime.os, runtime.display_name) for runtime in helper._stacks], + [('Linux', 'NODE|24-lts'), ('Windows', 'NODE|24LTS')]) + + class TestStackRuntimeJavaSELinux(unittest.TestCase): """Regression tests for `az webapp list-runtimes` Linux Java SE parsing. From dca47d4e5704423ec517d31858df35c8e52bc2eb Mon Sep 17 00:00:00 2001 From: Amber Amos Date: Fri, 7 Aug 2026 12:10:23 -0700 Subject: [PATCH 2/3] update resolve --- src/azure-cli/azure/cli/command_modules/appservice/custom.py | 2 +- .../appservice/tests/latest/test_webapp_commands_thru_mock.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 66066212de0..f80449d9f07 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -7618,7 +7618,7 @@ def remove_delimiters(cls, runtime): return cls.DEFAULT_DELIMETER.join(filter(None, runtime)) def resolve(self, display_name, linux=False): - display_name = display_name.lower() + display_name = self._standardize_node_runtime_name(display_name).lower() stack = next((s for s in self.stacks if s.linux == linux and s.display_name.lower() == display_name), None) if stack is None: # help convert previously acceptable stack names into correct ones if runtime not found old_to_new_windows = { 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 03c79e85a59..419f1ea54d9 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 @@ -1697,6 +1697,10 @@ def test_node_26_uses_standard_identifier_on_both_platforms(self): self.assertEqual(helper.resolve('NODE|26', linux=True).configs['linux_fx_version'], 'NODE|26-lts') self.assertEqual( helper.resolve('NODE|26', linux=False).configs['WEBSITE_NODE_DEFAULT_VERSION'], '~26') + self.assertEqual( + helper.resolve('NODE|26-lts', linux=True).configs['linux_fx_version'], 'NODE|26-lts') + self.assertEqual( + helper.resolve('NODE|26LTS', linux=False).configs['WEBSITE_NODE_DEFAULT_VERSION'], '~26') def test_older_node_identifiers_remain_unchanged(self): helper = self._new_helper() From c15b1292ca82352138f8df1ec3f24e892f4bf1d0 Mon Sep 17 00:00:00 2001 From: Amber Amos Date: Thu, 13 Aug 2026 12:23:03 -0700 Subject: [PATCH 3/3] Fix ambiguous resolution, run normalization on github actions, use standardized name for the config --- .../cli/command_modules/appservice/custom.py | 25 +++++++++++----- .../latest/test_webapp_commands_thru_mock.py | 30 +++++++++++++++---- 2 files changed, 43 insertions(+), 12 deletions(-) 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 f80449d9f07..6b38a7308bc 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -7559,7 +7559,6 @@ def _get_raw_stacks_from_api(self): def _parse_raw_stacks(self, stacks): # Track seen runtime display names to avoid duplicates in Linux parsing. # Linux Java containers (e.g., JBOSSEAP) can produce duplicate entries across major versions. - # Windows parsing doesn't have this issue due to its different structure. java_eol_map = self._build_java_eol_map(stacks) @@ -7580,6 +7579,16 @@ def _parse_raw_stacks(self, stacks): runtime_family=lang.display_text, java_eol_map=java_eol_map) + unique_runtimes = [] + seen_runtime_names = set() + for runtime in self._stacks: + runtime_key = (runtime.linux, runtime.display_name.lower()) + if runtime_key in seen_runtime_names: + continue + seen_runtime_names.add(runtime_key) + unique_runtimes.append(runtime) + self._stacks = unique_runtimes + def _build_java_eol_map(self, stacks): """Build Java version -> EOL date map from the 'Java' stack. @@ -7618,7 +7627,7 @@ def remove_delimiters(cls, runtime): return cls.DEFAULT_DELIMETER.join(filter(None, runtime)) def resolve(self, display_name, linux=False): - display_name = self._standardize_node_runtime_name(display_name).lower() + display_name = self.standardize_node_runtime_name(display_name).lower() stack = next((s for s in self.stacks if s.linux == linux and s.display_name.lower() == display_name), None) if stack is None: # help convert previously acceptable stack names into correct ones if runtime not found old_to_new_windows = { @@ -7708,7 +7717,7 @@ def _format_windows_display_text(cls, display_text): return t.replace(" ", "|", 1).replace(" ", "") @staticmethod - def _standardize_node_runtime_name(runtime_name): + def standardize_node_runtime_name(runtime_name): match = re.fullmatch(r'node\|(\d+)(?:-?lts)?', runtime_name, re.IGNORECASE) if match and int(match.group(1)) >= 26: return "NODE|{}".format(match.group(1)) @@ -7915,7 +7924,7 @@ def _parse_major_version_windows(self, major_version, parsed_results, config_map eol_date = self._format_eol_date(getattr(settings, 'end_of_life_date', None)) if "Java" not in minor_version.display_text: runtime_name = self._format_windows_display_text(minor_version.display_text) - runtime_name = self._standardize_node_runtime_name(runtime_name) + runtime_name = self.standardize_node_runtime_name(runtime_name) runtime = self.Runtime(display_name=runtime_name, linux=False, os="Windows", runtime_family=runtime_family, @@ -8027,9 +8036,9 @@ def _parse_major_version_linux(self, major_version, parsed_results, seen_runtime major_version, linux=True, java=False, include_eol=self._include_eol) for minor_version in minor_versions: settings = minor_version.stack_settings.linux_runtime_settings - runtime_name = self._standardize_node_runtime_name(settings.runtime_version) + runtime_name = self.standardize_node_runtime_name(settings.runtime_version) runtime = self.Runtime(display_name=runtime_name, - configs={"linux_fx_version": settings.runtime_version}, + configs={"linux_fx_version": runtime_name}, linux=True, os="Linux", runtime_family=runtime_family, @@ -11929,6 +11938,8 @@ def delete_function_key(cmd, resource_group_name, name, key_name, function_name= def add_github_actions(cmd, resource_group, name, repo, runtime=None, token=None, slot=None, # pylint: disable=too-many-statements,too-many-branches branch='master', login_with_github=False, force=False): runtime = _StackRuntimeHelper(cmd).remove_delimiters(runtime) # normalize "runtime:version" + if runtime: + runtime = _StackRuntimeHelper.standardize_node_runtime_name(runtime) if not token and not login_with_github: raise_missing_token_suggestion() elif not token: @@ -12868,7 +12879,7 @@ def _get_app_runtime_info_helper(cmd, app_runtime, app_runtime_version, is_linux if gh_props.get("github_actions_version"): if is_linux: return { - "display_name": app_runtime, + "display_name": matched_runtime.display_name, "github_actions_version": gh_props["github_actions_version"] } if gh_props.get("app_runtime_version").lower() == app_runtime_version.lower(): 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 419f1ea54d9..7437c4f1a44 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 @@ -71,7 +71,7 @@ def setUp(self): @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') @mock.patch('azure.cli.command_modules.appservice.custom.get_app_details') def test_webapp_github_actions_add(self, get_app_details_mock, web_client_factory_mock, site_availability_mock, *args): - runtime = "python:3.9" + runtime = "NODE:26-lts" rg = "group" is_linux = True cmd = _get_test_cmd() @@ -82,7 +82,7 @@ def test_webapp_github_actions_add(self, get_app_details_mock, web_client_factor with mock.patch('azure.cli.command_modules.appservice.custom._runtime_supports_github_actions', autospec=True) as m: add_github_actions(cmd, rg, "name", "repo", runtime, "token") - m.assert_called_with(cmd, runtime.replace(":", "|"), is_linux) + m.assert_called_with(cmd, "NODE|26", is_linux) @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) def test_set_deployment_user_creds(self, client_factory_mock): @@ -1685,6 +1685,8 @@ def _node_stack(version, display_text, linux_runtime): return types.SimpleNamespace(display_text='Node', major_versions=[major]) def test_node_26_uses_standard_identifier_on_both_platforms(self): + from azure.cli.command_modules.appservice.custom import _get_app_runtime_info_helper + helper = self._new_helper() helper._parse_raw_stacks([self._node_stack('26', 'Node 26 LTS', 'NODE|26-lts')]) @@ -1694,14 +1696,21 @@ def test_node_26_uses_standard_identifier_on_both_platforms(self): self.assertEqual( [(row['os'], row['config']) for row in helper.get_stacks_as_table(runtime_filter='node')], [('Linux', 'NODE|26'), ('Windows', 'NODE|26')]) - self.assertEqual(helper.resolve('NODE|26', linux=True).configs['linux_fx_version'], 'NODE|26-lts') + self.assertEqual(helper.resolve('NODE|26', linux=True).configs['linux_fx_version'], 'NODE|26') self.assertEqual( helper.resolve('NODE|26', linux=False).configs['WEBSITE_NODE_DEFAULT_VERSION'], '~26') - self.assertEqual( - helper.resolve('NODE|26-lts', linux=True).configs['linux_fx_version'], 'NODE|26-lts') + self.assertEqual(helper.resolve('NODE|26-lts', linux=True).configs['linux_fx_version'], 'NODE|26') self.assertEqual( helper.resolve('NODE|26LTS', linux=False).configs['WEBSITE_NODE_DEFAULT_VERSION'], '~26') + with mock.patch('azure.cli.command_modules.appservice.custom._StackRuntimeHelper', return_value=helper): + runtime_info = _get_app_runtime_info_helper(mock.Mock(), 'NODE|26-lts', '', True) + + self.assertEqual(runtime_info, { + 'display_name': 'NODE|26', + 'github_actions_version': '26.x', + }) + def test_older_node_identifiers_remain_unchanged(self): helper = self._new_helper() helper._parse_raw_stacks([self._node_stack('24', 'Node 24 LTS', 'NODE|24-lts')]) @@ -1710,6 +1719,17 @@ def test_older_node_identifiers_remain_unchanged(self): [(runtime.os, runtime.display_name) for runtime in helper._stacks], [('Linux', 'NODE|24-lts'), ('Windows', 'NODE|24LTS')]) + def test_node_26_transition_identifiers_are_deduplicated(self): + helper = self._new_helper() + helper._parse_raw_stacks([ + self._node_stack('26', 'Node 26 LTS', 'NODE|26-lts'), + self._node_stack('26', 'Node 26', 'NODE|26'), + ]) + + self.assertEqual( + [(runtime.os, runtime.display_name) for runtime in helper._stacks], + [('Linux', 'NODE|26'), ('Windows', 'NODE|26')]) + class TestStackRuntimeJavaSELinux(unittest.TestCase): """Regression tests for `az webapp list-runtimes` Linux Java SE parsing.