Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/azure-cli/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
37 changes: 23 additions & 14 deletions src/azure-cli/azure/cli/command_modules/appservice/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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__), '..'))

Expand Down Expand Up @@ -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)



Loading