Skip to content

Commit 7b002e5

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 aeee2b0 commit 7b002e5

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

80768085
self.validate_end_of_life_date(
80778086
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
@@ -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

@@ -740,3 +741,154 @@ def test_flex_parse_raw_stacks_prefers_functions_worker_runtime_when_present(sel
740741

741742
# assert
742743
self.assertEqual(matched.name, 'dotnet-isolated')
744+
745+
746+
def _make_runtime(name, version, linux=True, supported_func_versions=None):
747+
"""Build a minimal _FunctionAppStackRuntimeHelper.Runtime for resolve() tests."""
748+
return _FunctionAppStackRuntimeHelper.Runtime(
749+
name=name,
750+
version=version,
751+
linux=linux,
752+
supported_func_versions=supported_func_versions or ["~4"],
753+
)
754+
755+
756+
class TestFunctionAppStackRuntimeHelperResolve(unittest.TestCase):
757+
"""Tests for _FunctionAppStackRuntimeHelper.resolve() version normalization.
758+
759+
The Functions Stacks API and the value persisted on a site can disagree on the
760+
decimal-suffix convention (e.g. API returns "21.0" while linux_fx_version stores
761+
"Java|21"; .NET-isolated returns "8" while Bicep/portal stored "8.0"). resolve()
762+
normalizes between these forms instead of using a hand-maintained mapping that
763+
needs a code change for every new major version.
764+
"""
765+
766+
def _build_helper(self, stacks):
767+
with mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory'):
768+
helper = _FunctionAppStackRuntimeHelper(_get_test_cmd(), linux=True, windows=False)
769+
# Pre-populate stacks so _load_stacks() short-circuits and no API call is made.
770+
helper._stacks = stacks
771+
return helper
772+
773+
# -- bare integer ("21") matches API decimal form ("21.0") ---------------
774+
775+
def test_resolve_java_bare_int_matches_decimal_form(self):
776+
"""Repro: az functionapp config appsettings set on a Linux Java 21 app
777+
emitted a misleading 'Invalid version: 21' warning because linux_fx_version
778+
stores 'Java|21' but the API returns version '21.0'."""
779+
stacks = [
780+
_make_runtime("java", "25.0"),
781+
_make_runtime("java", "21.0"),
782+
_make_runtime("java", "17.0"),
783+
_make_runtime("java", "11.0"),
784+
_make_runtime("java", "8.0"),
785+
]
786+
helper = self._build_helper(stacks)
787+
788+
result = helper.resolve("java", "21", functions_version="~4", is_linux=True)
789+
790+
self.assertIsNotNone(result)
791+
self.assertEqual(result.version, "21.0")
792+
793+
def test_resolve_java_25_bare_int_matches_decimal_form(self):
794+
"""Future-proofing: the fix must handle Java majors that did not exist
795+
when this code was written, without a code change."""
796+
stacks = [
797+
_make_runtime("java", "25.0"),
798+
_make_runtime("java", "21.0"),
799+
]
800+
helper = self._build_helper(stacks)
801+
802+
result = helper.resolve("java", "25", functions_version="~4", is_linux=True)
803+
804+
self.assertIsNotNone(result)
805+
self.assertEqual(result.version, "25.0")
806+
807+
# -- decimal form ("8.0") matches API bare integer ("8") -----------------
808+
809+
def test_resolve_dotnet_isolated_decimal_matches_bare_int(self):
810+
""".NET-isolated runtime versions appear in the Stacks API as bare
811+
integers ('8'), but Bicep/portal-provisioned apps sometimes store '8.0'.
812+
Normalization must work in both directions."""
813+
stacks = [
814+
_make_runtime("dotnet-isolated", "8"),
815+
_make_runtime("dotnet-isolated", "9"),
816+
]
817+
helper = self._build_helper(stacks)
818+
819+
result = helper.resolve("dotnet-isolated", "8.0", functions_version="~4", is_linux=True)
820+
821+
self.assertIsNotNone(result)
822+
self.assertEqual(result.version, "8")
823+
824+
# -- legacy Java naming --------------------------------------------------
825+
826+
def test_resolve_java_1_8_matches_8_0(self):
827+
"""Legacy Java naming '1.8' should still resolve to the API's '8.0'."""
828+
stacks = [
829+
_make_runtime("java", "8.0"),
830+
_make_runtime("java", "11.0"),
831+
]
832+
helper = self._build_helper(stacks)
833+
834+
result = helper.resolve("java", "1.8", functions_version="~4", is_linux=True)
835+
836+
self.assertIsNotNone(result)
837+
self.assertEqual(result.version, "8.0")
838+
839+
# -- direct match (already-canonical input) ------------------------------
840+
841+
def test_resolve_exact_version_match_unchanged(self):
842+
"""If the input already matches the API value verbatim, normalization
843+
must not run and the Runtime should be returned as-is."""
844+
stacks = [
845+
_make_runtime("java", "21.0"),
846+
]
847+
helper = self._build_helper(stacks)
848+
849+
result = helper.resolve("java", "21.0", functions_version="~4", is_linux=True)
850+
851+
self.assertIsNotNone(result)
852+
self.assertEqual(result.version, "21.0")
853+
854+
# -- genuinely invalid versions still error ------------------------------
855+
856+
def test_resolve_unknown_major_version_still_raises(self):
857+
"""An unknown bare-integer major must NOT be silently accepted just
858+
because rule 1 generates a candidate. The candidate is only used if it
859+
actually exists in the API-returned runtime list."""
860+
stacks = [
861+
_make_runtime("java", "21.0"),
862+
_make_runtime("java", "17.0"),
863+
]
864+
helper = self._build_helper(stacks)
865+
866+
with self.assertRaises(ValidationError) as ctx:
867+
helper.resolve("java", "99", functions_version="~4", is_linux=True)
868+
self.assertIn("Invalid version: 99", str(ctx.exception))
869+
870+
def test_resolve_unknown_minor_version_still_raises(self):
871+
"""Versions that don't fit any normalization rule (e.g. '21.5') must
872+
fall through to the existing 'Invalid version' error."""
873+
stacks = [
874+
_make_runtime("java", "21.0"),
875+
]
876+
helper = self._build_helper(stacks)
877+
878+
with self.assertRaises(ValidationError) as ctx:
879+
helper.resolve("java", "21.5", functions_version="~4", is_linux=True)
880+
self.assertIn("Invalid version: 21.5", str(ctx.exception))
881+
882+
def test_resolve_disable_version_error_returns_none(self):
883+
"""disable_version_error=True must continue to suppress the ValidationError
884+
for unknown versions (existing behavior preserved)."""
885+
stacks = [
886+
_make_runtime("java", "21.0"),
887+
]
888+
helper = self._build_helper(stacks)
889+
890+
result = helper.resolve(
891+
"java", "99", functions_version="~4",
892+
is_linux=True, disable_version_error=True)
893+
894+
self.assertIsNone(result)

0 commit comments

Comments
 (0)