Skip to content

Commit 90d5f86

Browse files
committed
[App Service] Fix az functionapp config appsettings set: normalize runtime version suffix
Replaces the hand-maintained old_to_new_version map in _FunctionAppStackRuntimeHelper.resolve with three generic numeric normalization rules that bridge bare-integer and X.0 forms in both directions, plus the legacy Java 1.8 -> 8.0 case. Each candidate is only accepted if it exists in the API-returned runtime list, so genuinely invalid versions still raise the existing 'Invalid version' error. Fixes the misleading 'Invalid version: 21 for runtime java' warning emitted when running az functionapp config appsettings set on Linux Java 21/25 apps. Same false positive could be reproduced on .NET-isolated apps when the persisted version was stored as 8.0 instead of 8.
1 parent 6e1ff98 commit 90d5f86

3 files changed

Lines changed: 181 additions & 16 deletions

File tree

src/azure-cli/HISTORY.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Release History
4141
* `az webapp create`: Add error message that clearly lists all valid options and specifies how to discover available runtimes (#33252)
4242
* `az appservice plan create`: Make `P0V3` as default SKU when `--sku` is omitted for linux webapp (#33237)
4343
* `az appservice plan create`: Add `PREMIUM0V3` tier for elastic scale (#33237)
44+
* 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
4445

4546
**Cloud**
4647

src/azure-cli/azure/cli/command_modules/appservice/custom.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7948,20 +7948,29 @@ def resolve(self, runtime, version=None, functions_version=None, is_linux=False,
79487948
return matched_runtime_version
79497949
matched_runtime_version = next((r for r in runtimes if r.version == version), None)
79507950
if not matched_runtime_version:
7951-
# help convert previously acceptable versions into correct ones if match not found
7952-
old_to_new_version = {
7953-
"11": "11.0",
7954-
"8": "8.0",
7955-
"8.0": "8",
7956-
"7": "7.0",
7957-
"6.0": "6",
7958-
"1.8": "8.0",
7959-
"17": "17.0"
7960-
}
7961-
new_version = old_to_new_version.get(version)
7962-
matched_runtime_version = next((r for r in runtimes if r.version == new_version), None)
7963-
if matched_runtime_version is not None:
7964-
version = new_version
7951+
# The runtime stacks API and the value persisted on a site can disagree on the
7952+
# decimal-suffix convention (e.g. API returns "21.0" while linux_fx_version stores
7953+
# "Java|21"; .NET-isolated returns "8" while a Bicep template stored "8.0"). To
7954+
# avoid emitting a misleading "Invalid version" warning, derive a small set of
7955+
# candidate alternatives and accept any that actually exists in the API-returned
7956+
# runtimes list. This stays self-healing as new major versions ship.
7957+
candidate_versions = []
7958+
if version == "1.8":
7959+
# Legacy Java naming retained for backwards compatibility.
7960+
candidate_versions.append("8.0")
7961+
elif version.isdigit():
7962+
# Bare integer ("21") -> decimal form ("21.0").
7963+
candidate_versions.append("{}.0".format(version))
7964+
elif re.fullmatch(r"\d+\.0", version):
7965+
# Decimal form ("8.0") -> bare integer ("8").
7966+
candidate_versions.append(version[:-2])
7967+
7968+
for candidate in candidate_versions:
7969+
alt_match = next((r for r in runtimes if r.version == candidate), None)
7970+
if alt_match is not None:
7971+
matched_runtime_version = alt_match
7972+
version = candidate
7973+
break
79657974

79667975
self.validate_end_of_life_date(
79677976
runtime,

src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616
remove_remote_build_app_settings,
1717
config_source_control,
1818
validate_app_settings_in_scm,
19-
update_container_settings_functionapp)
19+
update_container_settings_functionapp,
20+
_FunctionAppStackRuntimeHelper)
2021
from azure.cli.core.profiles import ResourceType
21-
from azure.cli.core.azclierror import (AzureInternalError, UnclassifiedUserFault)
22+
from azure.cli.core.azclierror import (AzureInternalError, UnclassifiedUserFault, ValidationError)
2223

2324
TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..'))
2425

@@ -681,3 +682,157 @@ def test_config_source_control(self,
681682

682683
# assert
683684
self.assertEqual(response.git_hub_action_configuration.container_configuration.password, None)
685+
686+
687+
def _make_runtime(name, version, linux=True, supported_func_versions=None):
688+
"""Build a minimal _FunctionAppStackRuntimeHelper.Runtime for resolve() tests."""
689+
return _FunctionAppStackRuntimeHelper.Runtime(
690+
name=name,
691+
version=version,
692+
linux=linux,
693+
supported_func_versions=supported_func_versions or ["~4"],
694+
)
695+
696+
697+
class TestFunctionAppStackRuntimeHelperResolve(unittest.TestCase):
698+
"""Tests for _FunctionAppStackRuntimeHelper.resolve() version normalization.
699+
700+
The Functions Stacks API and the value persisted on a site can disagree on the
701+
decimal-suffix convention (e.g. API returns "21.0" while linux_fx_version stores
702+
"Java|21"; .NET-isolated returns "8" while Bicep/portal stored "8.0"). resolve()
703+
normalizes between these forms instead of using a hand-maintained mapping that
704+
needs a code change for every new major version.
705+
"""
706+
707+
def _build_helper(self, stacks):
708+
with mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory'):
709+
helper = _FunctionAppStackRuntimeHelper(_get_test_cmd(), linux=True, windows=False)
710+
# Pre-populate stacks so _load_stacks() short-circuits and no API call is made.
711+
helper._stacks = stacks
712+
return helper
713+
714+
# -- bare integer ("21") matches API decimal form ("21.0") ---------------
715+
716+
def test_resolve_java_bare_int_matches_decimal_form(self):
717+
"""Repro: az functionapp config appsettings set on a Linux Java 21 app
718+
emitted a misleading 'Invalid version: 21' warning because linux_fx_version
719+
stores 'Java|21' but the API returns version '21.0'."""
720+
stacks = [
721+
_make_runtime("java", "25.0"),
722+
_make_runtime("java", "21.0"),
723+
_make_runtime("java", "17.0"),
724+
_make_runtime("java", "11.0"),
725+
_make_runtime("java", "8.0"),
726+
]
727+
helper = self._build_helper(stacks)
728+
729+
result = helper.resolve("java", "21", functions_version="~4", is_linux=True)
730+
731+
self.assertIsNotNone(result)
732+
self.assertEqual(result.version, "21.0")
733+
734+
def test_resolve_java_25_bare_int_matches_decimal_form(self):
735+
"""Future-proofing: the fix must handle Java majors that did not exist
736+
when this code was written, without a code change."""
737+
stacks = [
738+
_make_runtime("java", "25.0"),
739+
_make_runtime("java", "21.0"),
740+
]
741+
helper = self._build_helper(stacks)
742+
743+
result = helper.resolve("java", "25", functions_version="~4", is_linux=True)
744+
745+
self.assertIsNotNone(result)
746+
self.assertEqual(result.version, "25.0")
747+
748+
# -- decimal form ("8.0") matches API bare integer ("8") -----------------
749+
750+
def test_resolve_dotnet_isolated_decimal_matches_bare_int(self):
751+
""".NET-isolated runtime versions appear in the Stacks API as bare
752+
integers ('8'), but Bicep/portal-provisioned apps sometimes store '8.0'.
753+
Normalization must work in both directions."""
754+
stacks = [
755+
_make_runtime("dotnet-isolated", "8"),
756+
_make_runtime("dotnet-isolated", "9"),
757+
]
758+
helper = self._build_helper(stacks)
759+
760+
result = helper.resolve("dotnet-isolated", "8.0", functions_version="~4", is_linux=True)
761+
762+
self.assertIsNotNone(result)
763+
self.assertEqual(result.version, "8")
764+
765+
# -- legacy Java naming --------------------------------------------------
766+
767+
def test_resolve_java_1_8_matches_8_0(self):
768+
"""Legacy Java naming '1.8' should still resolve to the API's '8.0'."""
769+
stacks = [
770+
_make_runtime("java", "8.0"),
771+
_make_runtime("java", "11.0"),
772+
]
773+
helper = self._build_helper(stacks)
774+
775+
result = helper.resolve("java", "1.8", functions_version="~4", is_linux=True)
776+
777+
self.assertIsNotNone(result)
778+
self.assertEqual(result.version, "8.0")
779+
780+
# -- direct match (already-canonical input) ------------------------------
781+
782+
def test_resolve_exact_version_match_unchanged(self):
783+
"""If the input already matches the API value verbatim, normalization
784+
must not run and the Runtime should be returned as-is."""
785+
stacks = [
786+
_make_runtime("java", "21.0"),
787+
]
788+
helper = self._build_helper(stacks)
789+
790+
result = helper.resolve("java", "21.0", functions_version="~4", is_linux=True)
791+
792+
self.assertIsNotNone(result)
793+
self.assertEqual(result.version, "21.0")
794+
795+
# -- genuinely invalid versions still error ------------------------------
796+
797+
def test_resolve_unknown_major_version_still_raises(self):
798+
"""An unknown bare-integer major must NOT be silently accepted just
799+
because rule 1 generates a candidate. The candidate is only used if it
800+
actually exists in the API-returned runtime list."""
801+
stacks = [
802+
_make_runtime("java", "21.0"),
803+
_make_runtime("java", "17.0"),
804+
]
805+
helper = self._build_helper(stacks)
806+
807+
with self.assertRaises(ValidationError) as ctx:
808+
helper.resolve("java", "99", functions_version="~4", is_linux=True)
809+
self.assertIn("Invalid version: 99", str(ctx.exception))
810+
811+
def test_resolve_unknown_minor_version_still_raises(self):
812+
"""Versions that don't fit any normalization rule (e.g. '21.5') must
813+
fall through to the existing 'Invalid version' error."""
814+
stacks = [
815+
_make_runtime("java", "21.0"),
816+
]
817+
helper = self._build_helper(stacks)
818+
819+
with self.assertRaises(ValidationError) as ctx:
820+
helper.resolve("java", "21.5", functions_version="~4", is_linux=True)
821+
self.assertIn("Invalid version: 21.5", str(ctx.exception))
822+
823+
def test_resolve_disable_version_error_returns_none(self):
824+
"""disable_version_error=True must continue to suppress the ValidationError
825+
for unknown versions (existing behavior preserved)."""
826+
stacks = [
827+
_make_runtime("java", "21.0"),
828+
]
829+
helper = self._build_helper(stacks)
830+
831+
result = helper.resolve(
832+
"java", "99", functions_version="~4",
833+
is_linux=True, disable_version_error=True)
834+
835+
self.assertIsNone(result)
836+
837+
838+

0 commit comments

Comments
 (0)