diff --git a/src/aks-preview/HISTORY.rst b/src/aks-preview/HISTORY.rst index f2e66c74cc1..1383204ba8e 100644 --- a/src/aks-preview/HISTORY.rst +++ b/src/aks-preview/HISTORY.rst @@ -13,6 +13,7 @@ Pending +++++++ * `az aks nodepool update`: Preserve the existing GPU management mode when `--enable-managed-gpu` is omitted, including when enabling, updating, or disabling the cluster autoscaler. * `az aks alert-config add`: Reject an empty `--name` before looking up existing configurations instead of reporting that it already exists. +* `az aks nodepool add`: Support public IP configuration on secondary network interfaces, including mutually exclusive IP tags or a public IP prefix. 22.0.0b7 +++++++++ diff --git a/src/aks-preview/azext_aks_preview/_help.py b/src/aks-preview/azext_aks_preview/_help.py index 459677d3e7c..e4bf3176e98 100644 --- a/src/aks-preview/azext_aks_preview/_help.py +++ b/src/aks-preview/azext_aks_preview/_help.py @@ -2667,11 +2667,14 @@ short-summary: Set the localDNS Profile for a nodepool with a JSON config file. - name: --secondary-network-interfaces --secondary-nics type: string - short-summary: Secondary network interface configurations as a JSON string or `@filename`. + short-summary: Create-only secondary network interface configurations as inline JSON or `@filename`. long-summary: |- Specify secondary NICs to attach to each node. Accepts inline JSON or `@filename`. - Example: '[{"type":"Standard","vnetSubnetId":"/subscriptions/.../subnets/mysubnet","enableAcceleratedNetworking":true}]' Supported NIC types are "Standard" (requires vnetSubnetId) and "Dynamic". + A Standard NIC can include publicIPAddressConfiguration. Setting publicIPAddressVersion to "IPv4" enables public IP allocation for that NIC. + Within publicIPAddressConfiguration, optionally specify either ipTags or publicIPPrefixID. These properties are mutually exclusive. + Inline example: '[{"type":"Standard","vnetSubnetId":"/subscriptions/.../subnets/mysubnet","enableAcceleratedNetworking":true,"publicIPAddressConfiguration":{"publicIPAddressVersion":"IPv4","ipTags":[{"ipTagType":"RoutingPreference","tag":"Internet"}]}}]' + To load the same JSON array from a file, use `@filename`. - name: --enable-managed-dranet type: bool short-summary: Enable Managed DRANET on the node pool. diff --git a/src/aks-preview/azext_aks_preview/_params.py b/src/aks-preview/azext_aks_preview/_params.py index 26248c3ce73..84c45b6db65 100644 --- a/src/aks-preview/azext_aks_preview/_params.py +++ b/src/aks-preview/azext_aks_preview/_params.py @@ -2550,8 +2550,12 @@ def load_arguments(self, _): c.argument( 'secondary_network_interfaces', options_list=['--secondary-network-interfaces', '--secondary-nics'], - help='Secondary network interface configurations as a JSON string or `@filename` to load from a file. ' - 'Example: \'[{"type":"Standard","vnetSubnetId":"/subscriptions/.../subnets/mysubnet"}]\'', + help='Create-only secondary network interface configurations as inline JSON or `@filename`. ' + 'For a Standard NIC, set publicIPAddressConfiguration.publicIPAddressVersion to "IPv4" ' + 'to allocate a public IP, with either ipTags or publicIPPrefixID, but not both. ' + 'Example: \'[{"type":"Standard","vnetSubnetId":"/subscriptions/.../subnets/mysubnet",' + '"publicIPAddressConfiguration":{"publicIPAddressVersion":"IPv4",' + '"ipTags":[{"ipTagType":"RoutingPreference","tag":"Internet"}]}}]\'', is_preview=True, ) c.argument( diff --git a/src/aks-preview/azext_aks_preview/agentpool_decorator.py b/src/aks-preview/azext_aks_preview/agentpool_decorator.py index 10bbaa96ea2..19a000e5e96 100644 --- a/src/aks-preview/azext_aks_preview/agentpool_decorator.py +++ b/src/aks-preview/azext_aks_preview/agentpool_decorator.py @@ -1053,10 +1053,32 @@ def get_secondary_network_interfaces(self): f"--secondary-network-interfaces: element at index {idx} " f"must be a JSON object, got {type(item).__name__}." ) + public_ip_config = item.get("publicIPAddressConfiguration") + if public_ip_config is not None: + if not isinstance(public_ip_config, dict): + raise InvalidArgumentValueError( + "--secondary-network-interfaces: publicIPAddressConfiguration " + f"at index {idx} must be a JSON object." + ) + ip_tags = public_ip_config.get("ipTags") + if ip_tags is not None: + if not isinstance(ip_tags, list): + raise InvalidArgumentValueError( + "--secondary-network-interfaces: ipTags in " + f"publicIPAddressConfiguration at index {idx} must be a JSON array." + ) + for tag_idx, ip_tag in enumerate(ip_tags): + if not isinstance(ip_tag, dict): + raise InvalidArgumentValueError( + "--secondary-network-interfaces: ipTags element at index " + f"{tag_idx} in publicIPAddressConfiguration at index {idx} " + "must be a JSON object." + ) result.append(self.models.AgentPoolNetworkInterface( type=item.get("type"), vnet_subnet_id=item.get("vnetSubnetId"), enable_accelerated_networking=item.get("enableAcceleratedNetworking"), + public_ip_address_configuration=public_ip_config, )) return result diff --git a/src/aks-preview/azext_aks_preview/tests/latest/test_agentpool_decorator.py b/src/aks-preview/azext_aks_preview/tests/latest/test_agentpool_decorator.py index db3b8d247ff..915e93f697c 100644 --- a/src/aks-preview/azext_aks_preview/tests/latest/test_agentpool_decorator.py +++ b/src/aks-preview/azext_aks_preview/tests/latest/test_agentpool_decorator.py @@ -1348,6 +1348,7 @@ def common_get_secondary_network_interfaces(self): self.assertEqual(len(result), 1) self.assertEqual(result[0].type, "Standard") self.assertEqual(result[0].vnet_subnet_id, "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/subnet1") + self.assertIsNone(result[0].public_ip_address_configuration) # invalid JSON - not a list ctx_3 = AKSPreviewAgentPoolContext( @@ -1378,7 +1379,17 @@ def common_get_secondary_network_interfaces(self): # @file input import tempfile import json - nics_data = [{"type": "Dynamic"}, {"type": "Standard", "vnetSubnetId": "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/subnet1"}] + nics_data = [ + {"type": "Dynamic"}, + { + "type": "Standard", + "vnetSubnetId": "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/subnet1", + "publicIPAddressConfiguration": { + "publicIPAddressVersion": "IPv4", + "publicIPPrefixID": "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/publicIPPrefixes/prefix1", + }, + }, + ] with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(nics_data, f) tmp_path = f.name @@ -1398,10 +1409,115 @@ def common_get_secondary_network_interfaces(self): self.assertIsNone(result[0].vnet_subnet_id) self.assertEqual(result[1].type, "Standard") self.assertEqual(result[1].vnet_subnet_id, "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/subnet1") + self.assertEqual( + result[1].as_dict()["publicIPAddressConfiguration"], + { + "publicIPAddressVersion": "IPv4", + "publicIPPrefixID": "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/publicIPPrefixes/prefix1", + }, + ) finally: import os os.unlink(tmp_path) + def common_get_secondary_network_interfaces_with_public_ip_tags(self): + import json + + ctx = AKSPreviewAgentPoolContext( + self.cmd, + AKSAgentPoolParamDict({ + "secondary_network_interfaces": json.dumps([{ + "type": "Standard", + "vnetSubnetId": "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/subnet1", + "enableAcceleratedNetworking": False, + "publicIPAddressConfiguration": { + "publicIPAddressVersion": "IPv4", + "ipTags": [{ + "ipTagType": "RoutingPreference", + "tag": "Internet", + }], + }, + }]), + }), + self.models, + DecoratorMode.CREATE, + self.agentpool_decorator_mode, + ) + + self.assertEqual( + ctx.get_secondary_network_interfaces()[0].as_dict(), + { + "type": "Standard", + "vnetSubnetId": "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/subnet1", + "enableAcceleratedNetworking": False, + "publicIPAddressConfiguration": { + "publicIPAddressVersion": "IPv4", + "ipTags": [{ + "ipTagType": "RoutingPreference", + "tag": "Internet", + }], + }, + }, + ) + + def common_get_secondary_network_interfaces_allows_null_public_ip_fields(self): + ctx = AKSPreviewAgentPoolContext( + self.cmd, + AKSAgentPoolParamDict({ + "secondary_network_interfaces": [{ + "type": "Standard", + "publicIPAddressConfiguration": None, + }, { + "type": "Standard", + "publicIPAddressConfiguration": {"ipTags": None}, + }, { + "type": "Standard", + "publicIPAddressConfiguration": {}, + }], + }), + self.models, + DecoratorMode.CREATE, + self.agentpool_decorator_mode, + ) + + result = ctx.get_secondary_network_interfaces() + self.assertIsNone(result[0].public_ip_address_configuration) + self.assertEqual( + result[1].as_dict()["publicIPAddressConfiguration"], + {"ipTags": None}, + ) + self.assertEqual( + result[2].as_dict()["publicIPAddressConfiguration"], + {}, + ) + + def common_get_secondary_network_interfaces_rejects_malformed_public_ip_fields(self): + malformed_values = [ + ({"publicIPAddressConfiguration": []}, "publicIPAddressConfiguration"), + ({"publicIPAddressConfiguration": {"ipTags": {}}}, "ipTags"), + ({"publicIPAddressConfiguration": {"ipTags": ["Internet"]}}, "ipTags"), + ] + + for nested_fields, expected_error_field in malformed_values: + with self.subTest(nested_fields=nested_fields): + ctx = AKSPreviewAgentPoolContext( + self.cmd, + AKSAgentPoolParamDict({ + "secondary_network_interfaces": [{ + "type": "Standard", + **nested_fields, + }], + }), + self.models, + DecoratorMode.CREATE, + self.agentpool_decorator_mode, + ) + with self.assertRaisesRegex( + InvalidArgumentValueError, + expected_error_field, + ): + ctx.get_secondary_network_interfaces() + class AKSPreviewAgentPoolContextStandaloneModeTestCase( AKSPreviewAgentPoolContextCommonTestCase @@ -1522,6 +1638,15 @@ def test_get_final_soak_duration(self): def test_get_secondary_network_interfaces(self): self.common_get_secondary_network_interfaces() + def test_get_secondary_network_interfaces_with_public_ip_tags(self): + self.common_get_secondary_network_interfaces_with_public_ip_tags() + + def test_get_secondary_network_interfaces_allows_null_public_ip_fields(self): + self.common_get_secondary_network_interfaces_allows_null_public_ip_fields() + + def test_get_secondary_network_interfaces_rejects_malformed_public_ip_fields(self): + self.common_get_secondary_network_interfaces_rejects_malformed_public_ip_fields() + class AKSPreviewAgentPoolContextManagedClusterModeTestCase( AKSPreviewAgentPoolContextCommonTestCase @@ -2149,6 +2274,62 @@ def common_set_up_managed_dranet(self): dec_agentpool_2 = dec_2.set_up_agentpool_network_profile(agentpool_2) self.assertEqual(dec_agentpool_2.network_profile.dranet.mode, "Managed") + def common_construct_agentpool_profile_serializes_secondary_nic_public_ip(self): + import inspect + import json + + from azext_aks_preview.custom import aks_agentpool_add + + raw_param_dict = { + name: parameter.default + for name, parameter in inspect.signature(aks_agentpool_add).parameters.items() + if parameter.default is not parameter.empty + } + raw_param_dict.update({ + "resource_group_name": "test_rg_name", + "cluster_name": "test_cluster_name", + "nodepool_name": "test_nodepool_name", + "secondary_network_interfaces": json.dumps([{ + "type": "Standard", + "vnetSubnetId": "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/subnet1", + "publicIPAddressConfiguration": { + "publicIPAddressVersion": "IPv4", + "ipTags": [{ + "ipTagType": "RoutingPreference", + "tag": "Internet", + }], + }, + }]), + }) + decorator = AKSPreviewAgentPoolAddDecorator( + self.cmd, + self.client, + raw_param_dict, + self.resource_type, + self.agentpool_decorator_mode, + ) + + with patch( + "azext_aks_preview.agentpool_decorator.cf_agent_pools", + return_value=Mock(list=Mock(return_value=[])), + ): + payload = decorator.construct_agentpool_profile_preview().as_dict() + + self.assertEqual( + payload["properties"]["networkProfile"]["secondaryNetworkInterfaces"], + [{ + "type": "Standard", + "vnetSubnetId": "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/subnet1", + "publicIPAddressConfiguration": { + "publicIPAddressVersion": "IPv4", + "ipTags": [{ + "ipTagType": "RoutingPreference", + "tag": "Internet", + }], + }, + }], + ) + def common_set_up_virtual_machines_profile(self): dec_1 = AKSPreviewAgentPoolAddDecorator( self.cmd, @@ -2593,6 +2774,9 @@ def test_set_up_agentpool_gateway_profile(self): def test_set_up_managed_dranet(self): self.common_set_up_managed_dranet() + def test_construct_agentpool_profile_serializes_secondary_nic_public_ip(self): + self.common_construct_agentpool_profile_serializes_secondary_nic_public_ip() + def test_set_up_virtual_machines_profile(self): self.common_set_up_virtual_machines_profile()