|
17 | 17 | remove_remote_build_app_settings, |
18 | 18 | config_source_control, |
19 | 19 | validate_app_settings_in_scm, |
20 | | - update_container_settings_functionapp) |
| 20 | + update_container_settings_functionapp, |
| 21 | + _FunctionAppStackRuntimeHelper) |
21 | 22 | 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) |
23 | 24 | from azure.cli.core.azclierror import ResourceNotFoundError |
24 | 25 |
|
25 | 26 | 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 |
849 | 850 |
|
850 | 851 | # assert |
851 | 852 | 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