diff --git a/src/azure-cli/HISTORY.rst b/src/azure-cli/HISTORY.rst index c0d13ee1b65..7601af402f4 100644 --- a/src/azure-cli/HISTORY.rst +++ b/src/azure-cli/HISTORY.rst @@ -41,6 +41,7 @@ Release History * `az webapp create`: Add error message that clearly lists all valid options and specifies how to discover available runtimes (#33252) * `az appservice plan create`: Make `P0V3` as default SKU when `--sku` is omitted for linux webapp (#33237) * `az appservice plan create`: Add `PREMIUM0V3` tier for elastic scale (#33237) +* Fix #33379: `az functionapp config appsettings set`: Stop emitting a misleading "Invalid version" warning for Java/.NET runtime versions stored without a matching decimal suffix (e.g. `Java|21` vs API `21.0`, or `8.0` vs API `8`); normalize between the two forms instead of using a hardcoded version map **Cloud** 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 6eac25c36ab..428ecad71b2 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -7948,20 +7948,29 @@ def resolve(self, runtime, version=None, functions_version=None, is_linux=False, return matched_runtime_version matched_runtime_version = next((r for r in runtimes if r.version == version), None) if not matched_runtime_version: - # help convert previously acceptable versions into correct ones if match not found - old_to_new_version = { - "11": "11.0", - "8": "8.0", - "8.0": "8", - "7": "7.0", - "6.0": "6", - "1.8": "8.0", - "17": "17.0" - } - new_version = old_to_new_version.get(version) - matched_runtime_version = next((r for r in runtimes if r.version == new_version), None) - if matched_runtime_version is not None: - version = new_version + # The runtime stacks API and the value persisted on a site can disagree on the + # decimal-suffix convention (e.g. API returns "21.0" while linux_fx_version stores + # "Java|21"; .NET-isolated returns "8" while a Bicep template stored "8.0"). To + # avoid emitting a misleading "Invalid version" warning, derive a small set of + # candidate alternatives and accept any that actually exists in the API-returned + # runtimes list. This stays self-healing as new major versions ship. + candidate_versions = [] + if version == "1.8": + # Legacy Java naming retained for backwards compatibility. + candidate_versions.append("8.0") + elif version.isdigit(): + # Bare integer ("21") -> decimal form ("21.0"). + candidate_versions.append("{}.0".format(version)) + elif re.fullmatch(r"\d+\.0", version): + # Decimal form ("8.0") -> bare integer ("8"). + candidate_versions.append(version[:-2]) + + for candidate in candidate_versions: + alt_match = next((r for r in runtimes if r.version == candidate), None) + if alt_match is not None: + matched_runtime_version = alt_match + version = candidate + break self.validate_end_of_life_date( runtime, diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py index 9f766ab8e2b..b2892b2ae8d 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py @@ -16,9 +16,10 @@ remove_remote_build_app_settings, config_source_control, validate_app_settings_in_scm, - update_container_settings_functionapp) + update_container_settings_functionapp, + _FunctionAppStackRuntimeHelper) from azure.cli.core.profiles import ResourceType -from azure.cli.core.azclierror import (AzureInternalError, UnclassifiedUserFault) +from azure.cli.core.azclierror import (AzureInternalError, UnclassifiedUserFault, ValidationError) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) @@ -681,3 +682,157 @@ def test_config_source_control(self, # assert self.assertEqual(response.git_hub_action_configuration.container_configuration.password, None) + + +def _make_runtime(name, version, linux=True, supported_func_versions=None): + """Build a minimal _FunctionAppStackRuntimeHelper.Runtime for resolve() tests.""" + return _FunctionAppStackRuntimeHelper.Runtime( + name=name, + version=version, + linux=linux, + supported_func_versions=supported_func_versions or ["~4"], + ) + + +class TestFunctionAppStackRuntimeHelperResolve(unittest.TestCase): + """Tests for _FunctionAppStackRuntimeHelper.resolve() version normalization. + + The Functions Stacks API and the value persisted on a site can disagree on the + decimal-suffix convention (e.g. API returns "21.0" while linux_fx_version stores + "Java|21"; .NET-isolated returns "8" while Bicep/portal stored "8.0"). resolve() + normalizes between these forms instead of using a hand-maintained mapping that + needs a code change for every new major version. + """ + + def _build_helper(self, stacks): + with mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory'): + helper = _FunctionAppStackRuntimeHelper(_get_test_cmd(), linux=True, windows=False) + # Pre-populate stacks so _load_stacks() short-circuits and no API call is made. + helper._stacks = stacks + return helper + + # -- bare integer ("21") matches API decimal form ("21.0") --------------- + + def test_resolve_java_bare_int_matches_decimal_form(self): + """Repro: az functionapp config appsettings set on a Linux Java 21 app + emitted a misleading 'Invalid version: 21' warning because linux_fx_version + stores 'Java|21' but the API returns version '21.0'.""" + stacks = [ + _make_runtime("java", "25.0"), + _make_runtime("java", "21.0"), + _make_runtime("java", "17.0"), + _make_runtime("java", "11.0"), + _make_runtime("java", "8.0"), + ] + helper = self._build_helper(stacks) + + result = helper.resolve("java", "21", functions_version="~4", is_linux=True) + + self.assertIsNotNone(result) + self.assertEqual(result.version, "21.0") + + def test_resolve_java_25_bare_int_matches_decimal_form(self): + """Future-proofing: the fix must handle Java majors that did not exist + when this code was written, without a code change.""" + stacks = [ + _make_runtime("java", "25.0"), + _make_runtime("java", "21.0"), + ] + helper = self._build_helper(stacks) + + result = helper.resolve("java", "25", functions_version="~4", is_linux=True) + + self.assertIsNotNone(result) + self.assertEqual(result.version, "25.0") + + # -- decimal form ("8.0") matches API bare integer ("8") ----------------- + + def test_resolve_dotnet_isolated_decimal_matches_bare_int(self): + """.NET-isolated runtime versions appear in the Stacks API as bare + integers ('8'), but Bicep/portal-provisioned apps sometimes store '8.0'. + Normalization must work in both directions.""" + stacks = [ + _make_runtime("dotnet-isolated", "8"), + _make_runtime("dotnet-isolated", "9"), + ] + helper = self._build_helper(stacks) + + result = helper.resolve("dotnet-isolated", "8.0", functions_version="~4", is_linux=True) + + self.assertIsNotNone(result) + self.assertEqual(result.version, "8") + + # -- legacy Java naming -------------------------------------------------- + + def test_resolve_java_1_8_matches_8_0(self): + """Legacy Java naming '1.8' should still resolve to the API's '8.0'.""" + stacks = [ + _make_runtime("java", "8.0"), + _make_runtime("java", "11.0"), + ] + helper = self._build_helper(stacks) + + result = helper.resolve("java", "1.8", functions_version="~4", is_linux=True) + + self.assertIsNotNone(result) + self.assertEqual(result.version, "8.0") + + # -- direct match (already-canonical input) ------------------------------ + + def test_resolve_exact_version_match_unchanged(self): + """If the input already matches the API value verbatim, normalization + must not run and the Runtime should be returned as-is.""" + stacks = [ + _make_runtime("java", "21.0"), + ] + helper = self._build_helper(stacks) + + result = helper.resolve("java", "21.0", functions_version="~4", is_linux=True) + + self.assertIsNotNone(result) + self.assertEqual(result.version, "21.0") + + # -- genuinely invalid versions still error ------------------------------ + + def test_resolve_unknown_major_version_still_raises(self): + """An unknown bare-integer major must NOT be silently accepted just + because rule 1 generates a candidate. The candidate is only used if it + actually exists in the API-returned runtime list.""" + stacks = [ + _make_runtime("java", "21.0"), + _make_runtime("java", "17.0"), + ] + helper = self._build_helper(stacks) + + with self.assertRaises(ValidationError) as ctx: + helper.resolve("java", "99", functions_version="~4", is_linux=True) + self.assertIn("Invalid version: 99", str(ctx.exception)) + + def test_resolve_unknown_minor_version_still_raises(self): + """Versions that don't fit any normalization rule (e.g. '21.5') must + fall through to the existing 'Invalid version' error.""" + stacks = [ + _make_runtime("java", "21.0"), + ] + helper = self._build_helper(stacks) + + with self.assertRaises(ValidationError) as ctx: + helper.resolve("java", "21.5", functions_version="~4", is_linux=True) + self.assertIn("Invalid version: 21.5", str(ctx.exception)) + + def test_resolve_disable_version_error_returns_none(self): + """disable_version_error=True must continue to suppress the ValidationError + for unknown versions (existing behavior preserved).""" + stacks = [ + _make_runtime("java", "21.0"), + ] + helper = self._build_helper(stacks) + + result = helper.resolve( + "java", "99", functions_version="~4", + is_linux=True, disable_version_error=True) + + self.assertIsNone(result) + + +