Skip to content

Commit 9d6aef2

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 5ca4fa5 commit 9d6aef2

3 files changed

Lines changed: 178 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
@@ -8061,20 +8061,29 @@ def resolve(self, runtime, version=None, functions_version=None, is_linux=False,
80618061
return matched_runtime_version
80628062
matched_runtime_version = next((r for r in runtimes if r.version == version), None)
80638063
if not matched_runtime_version:
8064-
# help convert previously acceptable versions into correct ones if match not found
8065-
old_to_new_version = {
8066-
"11": "11.0",
8067-
"8": "8.0",
8068-
"8.0": "8",
8069-
"7": "7.0",
8070-
"6.0": "6",
8071-
"1.8": "8.0",
8072-
"17": "17.0"
8073-
}
8074-
new_version = old_to_new_version.get(version)
8075-
matched_runtime_version = next((r for r in runtimes if r.version == new_version), None)
8076-
if matched_runtime_version is not None:
8077-
version = new_version
8064+
# The runtime stacks API and the value persisted on a site can disagree on the
8065+
# decimal-suffix convention (e.g. API returns "21.0" while linux_fx_version stores
8066+
# "Java|21"; .NET-isolated returns "8" while a Bicep template stored "8.0"). To
8067+
# avoid emitting a misleading "Invalid version" warning, derive a small set of
8068+
# candidate alternatives and accept any that actually exists in the API-returned
8069+
# runtimes list. This stays self-healing as new major versions ship.
8070+
candidate_versions = []
8071+
if version == "1.8":
8072+
# Legacy Java naming retained for backwards compatibility.
8073+
candidate_versions.append("8.0")
8074+
elif version.isdigit():
8075+
# Bare integer ("21") -> decimal form ("21.0").
8076+
candidate_versions.append("{}.0".format(version))
8077+
elif re.fullmatch(r"\d+\.0", version):
8078+
# Decimal form ("8.0") -> bare integer ("8").
8079+
candidate_versions.append(version[:-2])
8080+
8081+
for candidate in candidate_versions:
8082+
alt_match = next((r for r in runtimes if r.version == candidate), None)
8083+
if alt_match is not None:
8084+
matched_runtime_version = alt_match
8085+
version = candidate
8086+
break
80788087

80798088
self.validate_end_of_life_date(
80808089
runtime,

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

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@
1717
remove_remote_build_app_settings,
1818
config_source_control,
1919
validate_app_settings_in_scm,
20-
update_container_settings_functionapp)
20+
update_container_settings_functionapp,
21+
_FunctionAppStackRuntimeHelper)
2122
from azure.cli.core.profiles import ResourceType
22-
from azure.cli.core.azclierror import (AzureInternalError, UnclassifiedUserFault)
23+
from azure.cli.core.azclierror import (AzureInternalError, UnclassifiedUserFault, ValidationError)
2324
from azure.cli.core.azclierror import ResourceNotFoundError
2425

2526
TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..'))
@@ -849,3 +850,154 @@ def test_flex_parse_raw_stacks_prefers_functions_worker_runtime_when_present(sel
849850

850851
# assert
851852
self.assertEqual(matched.name, 'dotnet-isolated')
853+
854+
855+
def _make_runtime(name, version, linux=True, supported_func_versions=None):
856+
"""Build a minimal _FunctionAppStackRuntimeHelper.Runtime for resolve() tests."""
857+
return _FunctionAppStackRuntimeHelper.Runtime(
858+
name=name,
859+
version=version,
860+
linux=linux,
861+
supported_func_versions=supported_func_versions or ["~4"],
862+
)
863+
864+
865+
class TestFunctionAppStackRuntimeHelperResolve(unittest.TestCase):
866+
"""Tests for _FunctionAppStackRuntimeHelper.resolve() version normalization.
867+
868+
The Functions Stacks API and the value persisted on a site can disagree on the
869+
decimal-suffix convention (e.g. API returns "21.0" while linux_fx_version stores
870+
"Java|21"; .NET-isolated returns "8" while Bicep/portal stored "8.0"). resolve()
871+
normalizes between these forms instead of using a hand-maintained mapping that
872+
needs a code change for every new major version.
873+
"""
874+
875+
def _build_helper(self, stacks):
876+
with mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory'):
877+
helper = _FunctionAppStackRuntimeHelper(_get_test_cmd(), linux=True, windows=False)
878+
# Pre-populate stacks so _load_stacks() short-circuits and no API call is made.
879+
helper._stacks = stacks
880+
return helper
881+
882+
# -- bare integer ("21") matches API decimal form ("21.0") ---------------
883+
884+
def test_resolve_java_bare_int_matches_decimal_form(self):
885+
"""Repro: az functionapp config appsettings set on a Linux Java 21 app
886+
emitted a misleading 'Invalid version: 21' warning because linux_fx_version
887+
stores 'Java|21' but the API returns version '21.0'."""
888+
stacks = [
889+
_make_runtime("java", "25.0"),
890+
_make_runtime("java", "21.0"),
891+
_make_runtime("java", "17.0"),
892+
_make_runtime("java", "11.0"),
893+
_make_runtime("java", "8.0"),
894+
]
895+
helper = self._build_helper(stacks)
896+
897+
result = helper.resolve("java", "21", functions_version="~4", is_linux=True)
898+
899+
self.assertIsNotNone(result)
900+
self.assertEqual(result.version, "21.0")
901+
902+
def test_resolve_java_25_bare_int_matches_decimal_form(self):
903+
"""Future-proofing: the fix must handle Java majors that did not exist
904+
when this code was written, without a code change."""
905+
stacks = [
906+
_make_runtime("java", "25.0"),
907+
_make_runtime("java", "21.0"),
908+
]
909+
helper = self._build_helper(stacks)
910+
911+
result = helper.resolve("java", "25", functions_version="~4", is_linux=True)
912+
913+
self.assertIsNotNone(result)
914+
self.assertEqual(result.version, "25.0")
915+
916+
# -- decimal form ("8.0") matches API bare integer ("8") -----------------
917+
918+
def test_resolve_dotnet_isolated_decimal_matches_bare_int(self):
919+
""".NET-isolated runtime versions appear in the Stacks API as bare
920+
integers ('8'), but Bicep/portal-provisioned apps sometimes store '8.0'.
921+
Normalization must work in both directions."""
922+
stacks = [
923+
_make_runtime("dotnet-isolated", "8"),
924+
_make_runtime("dotnet-isolated", "9"),
925+
]
926+
helper = self._build_helper(stacks)
927+
928+
result = helper.resolve("dotnet-isolated", "8.0", functions_version="~4", is_linux=True)
929+
930+
self.assertIsNotNone(result)
931+
self.assertEqual(result.version, "8")
932+
933+
# -- legacy Java naming --------------------------------------------------
934+
935+
def test_resolve_java_1_8_matches_8_0(self):
936+
"""Legacy Java naming '1.8' should still resolve to the API's '8.0'."""
937+
stacks = [
938+
_make_runtime("java", "8.0"),
939+
_make_runtime("java", "11.0"),
940+
]
941+
helper = self._build_helper(stacks)
942+
943+
result = helper.resolve("java", "1.8", functions_version="~4", is_linux=True)
944+
945+
self.assertIsNotNone(result)
946+
self.assertEqual(result.version, "8.0")
947+
948+
# -- direct match (already-canonical input) ------------------------------
949+
950+
def test_resolve_exact_version_match_unchanged(self):
951+
"""If the input already matches the API value verbatim, normalization
952+
must not run and the Runtime should be returned as-is."""
953+
stacks = [
954+
_make_runtime("java", "21.0"),
955+
]
956+
helper = self._build_helper(stacks)
957+
958+
result = helper.resolve("java", "21.0", functions_version="~4", is_linux=True)
959+
960+
self.assertIsNotNone(result)
961+
self.assertEqual(result.version, "21.0")
962+
963+
# -- genuinely invalid versions still error ------------------------------
964+
965+
def test_resolve_unknown_major_version_still_raises(self):
966+
"""An unknown bare-integer major must NOT be silently accepted just
967+
because rule 1 generates a candidate. The candidate is only used if it
968+
actually exists in the API-returned runtime list."""
969+
stacks = [
970+
_make_runtime("java", "21.0"),
971+
_make_runtime("java", "17.0"),
972+
]
973+
helper = self._build_helper(stacks)
974+
975+
with self.assertRaises(ValidationError) as ctx:
976+
helper.resolve("java", "99", functions_version="~4", is_linux=True)
977+
self.assertIn("Invalid version: 99", str(ctx.exception))
978+
979+
def test_resolve_unknown_minor_version_still_raises(self):
980+
"""Versions that don't fit any normalization rule (e.g. '21.5') must
981+
fall through to the existing 'Invalid version' error."""
982+
stacks = [
983+
_make_runtime("java", "21.0"),
984+
]
985+
helper = self._build_helper(stacks)
986+
987+
with self.assertRaises(ValidationError) as ctx:
988+
helper.resolve("java", "21.5", functions_version="~4", is_linux=True)
989+
self.assertIn("Invalid version: 21.5", str(ctx.exception))
990+
991+
def test_resolve_disable_version_error_returns_none(self):
992+
"""disable_version_error=True must continue to suppress the ValidationError
993+
for unknown versions (existing behavior preserved)."""
994+
stacks = [
995+
_make_runtime("java", "21.0"),
996+
]
997+
helper = self._build_helper(stacks)
998+
999+
result = helper.resolve(
1000+
"java", "99", functions_version="~4",
1001+
is_linux=True, disable_version_error=True)
1002+
1003+
self.assertIsNone(result)

0 commit comments

Comments
 (0)