|
16 | 16 | remove_remote_build_app_settings, |
17 | 17 | config_source_control, |
18 | 18 | validate_app_settings_in_scm, |
19 | | - update_container_settings_functionapp) |
| 19 | + update_container_settings_functionapp, |
| 20 | + _FunctionAppStackRuntimeHelper) |
20 | 21 | 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) |
22 | 23 |
|
23 | 24 | TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) |
24 | 25 |
|
@@ -740,3 +741,154 @@ def test_flex_parse_raw_stacks_prefers_functions_worker_runtime_when_present(sel |
740 | 741 |
|
741 | 742 | # assert |
742 | 743 | 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