diff --git a/src/azure-cli/HISTORY.rst b/src/azure-cli/HISTORY.rst index c222025df06..a2491a3159d 100644 --- a/src/azure-cli/HISTORY.rst +++ b/src/azure-cli/HISTORY.rst @@ -24,6 +24,7 @@ Release History * `az aks nodepool rollback`: Show an accurate warning when only the node OS upgrade channel is enabled (#33854) * Implement enable/disable flags for user-defined scheduler configuration (#33934) * `az aks update`: Fix Azure Container Storage configuration detection for lowercase and boolean extension settings (#33938) +* `az aks create`, `az aks update`: Add `--outbound-type-sku` to select the managed NAT gateway SKU (`Standard` or `StandardV2`) with `--outbound-type managedNATGateway`, the GA shape of NAT Gateway V2. `StandardV2` also supports IPv6 (`--nat-gateway-managed-outbound-ipv6-count`), user-provided public IPs (`--nat-gateway-outbound-ips`), and user-provided IP prefixes (`--nat-gateway-outbound-ip-prefixes`). **App Config** diff --git a/src/azure-cli/azure/cli/command_modules/acs/_consts.py b/src/azure-cli/azure/cli/command_modules/acs/_consts.py index 227790750ee..b3248a70e78 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_consts.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_consts.py @@ -88,6 +88,10 @@ CONST_OUTBOUND_TYPE_USER_ASSIGNED_NAT_GATEWAY = "userAssignedNATGateway" CONST_OUTBOUND_TYPE_NONE = "none" +# managed NAT gateway SKU +CONST_NAT_GATEWAY_SKU_STANDARD = "Standard" +CONST_NAT_GATEWAY_SKU_STANDARD_V2 = "StandardV2" + # load balancer backend pool type CONST_LOAD_BALANCER_BACKEND_POOL_TYPE_NODE_IP = "nodeIP" CONST_LOAD_BALANCER_BACKEND_POOL_TYPE_NODE_IP_CONFIGURATION = "nodeIPConfiguration" diff --git a/src/azure-cli/azure/cli/command_modules/acs/_help.py b/src/azure-cli/azure/cli/command_modules/acs/_help.py index e0b7ae94fcd..108cc6d538e 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_help.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_help.py @@ -186,6 +186,10 @@ type: int short-summary: NAT gateway idle timeout in minutes. long-summary: Desired idle timeout for NAT gateway outbound flows, default is 4 minutes. Please specify a value in the range of [4, 120]. Valid for Standard SKU load balancer cluster with managedNATGateway outbound type only. + - name: --outbound-type-sku + type: string + short-summary: SKU of the managed NAT Gateway (Standard or StandardV2). + long-summary: Only valid with --outbound-type managedNATGateway. Omit to default to StandardV2 where the region supports it, otherwise Standard. StandardV2 adds zone resiliency, IPv6 support, and higher bandwidth. - name: --outbound-type type: string short-summary: How outbound traffic will be configured for a cluster. @@ -867,6 +871,10 @@ type: int short-summary: NAT gateway idle timeout in minutes. long-summary: Desired idle timeout for NAT gateway outbound flows, default is 4 minutes. Please specify a value in the range of [4, 120]. Valid for Standard SKU load balancer cluster with managedNATGateway outbound type only. + - name: --outbound-type-sku + type: string + short-summary: SKU of the managed NAT Gateway (Standard or StandardV2). + long-summary: Only valid with --outbound-type managedNATGateway. Migrate an existing Standard (V1) cluster to StandardV2 by passing StandardV2. StandardV2 adds zone resiliency, IPv6 support, and higher bandwidth. Downgrade from StandardV2 to Standard is not supported. - name: --outbound-type type: string short-summary: How outbound traffic will be configured for a cluster. diff --git a/src/azure-cli/azure/cli/command_modules/acs/_natgateway.py b/src/azure-cli/azure/cli/command_modules/acs/_natgateway.py index 7ab8e6f1f52..465de7c0191 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_natgateway.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_natgateway.py @@ -6,37 +6,117 @@ from types import SimpleNamespace -def create_nat_gateway_profile(managed_outbound_ip_count, idle_timeout, models: SimpleNamespace): +def create_nat_gateway_profile( + managed_outbound_ip_count, + idle_timeout, + models: SimpleNamespace, + managed_outbound_ipv6_count=None, + outbound_ip_ids=None, + outbound_ip_prefix_ids=None, + nat_gateway_sku=None, +): """parse and build NAT gateway profile""" - if not is_nat_gateway_profile_provided(managed_outbound_ip_count, idle_timeout): + if not is_nat_gateway_profile_provided( + managed_outbound_ip_count, idle_timeout, + managed_outbound_ipv6_count, outbound_ip_ids, outbound_ip_prefix_ids, + nat_gateway_sku, + ): return None profile = models.ManagedClusterNATGatewayProfile() - return configure_nat_gateway_profile(managed_outbound_ip_count, idle_timeout, profile, models) + return configure_nat_gateway_profile( + managed_outbound_ip_count, idle_timeout, profile, models, + managed_outbound_ipv6_count, outbound_ip_ids, outbound_ip_prefix_ids, + nat_gateway_sku, + ) -def update_nat_gateway_profile(managed_outbound_ip_count, idle_timeout, profile, models: SimpleNamespace): +def update_nat_gateway_profile( + managed_outbound_ip_count, + idle_timeout, + profile, + models: SimpleNamespace, + managed_outbound_ipv6_count=None, + outbound_ip_ids=None, + outbound_ip_prefix_ids=None, + nat_gateway_sku=None, +): """parse and update an existing NAT gateway profile""" - if not is_nat_gateway_profile_provided(managed_outbound_ip_count, idle_timeout): + if not is_nat_gateway_profile_provided( + managed_outbound_ip_count, idle_timeout, + managed_outbound_ipv6_count, outbound_ip_ids, outbound_ip_prefix_ids, + nat_gateway_sku, + ): return profile if not profile: profile = models.ManagedClusterNATGatewayProfile() - return configure_nat_gateway_profile(managed_outbound_ip_count, idle_timeout, profile, models) + return configure_nat_gateway_profile( + managed_outbound_ip_count, idle_timeout, profile, models, + managed_outbound_ipv6_count, outbound_ip_ids, outbound_ip_prefix_ids, + nat_gateway_sku, + ) -def is_nat_gateway_profile_provided(managed_outbound_ip_count, idle_timeout): - return any([managed_outbound_ip_count is not None, idle_timeout]) +def is_nat_gateway_profile_provided( + managed_outbound_ip_count, + idle_timeout, + managed_outbound_ipv6_count=None, + outbound_ip_ids=None, + outbound_ip_prefix_ids=None, + nat_gateway_sku=None, +): + return any([ + managed_outbound_ip_count is not None, + idle_timeout, + managed_outbound_ipv6_count is not None, + outbound_ip_ids is not None, + outbound_ip_prefix_ids is not None, + nat_gateway_sku is not None, + ]) -def configure_nat_gateway_profile(managed_outbound_ip_count, idle_timeout, profile, models: SimpleNamespace): +def configure_nat_gateway_profile( + managed_outbound_ip_count, + idle_timeout, + profile, + models: SimpleNamespace, + managed_outbound_ipv6_count=None, + outbound_ip_ids=None, + outbound_ip_prefix_ids=None, + nat_gateway_sku=None, +): """configure a NAT Gateway with customer supplied values""" - if managed_outbound_ip_count is not None: + if managed_outbound_ip_count is not None or managed_outbound_ipv6_count is not None: ManagedClusterManagedOutboundIPProfile = models.ManagedClusterManagedOutboundIPProfile - profile.managed_outbound_ip_profile = ManagedClusterManagedOutboundIPProfile( - count=managed_outbound_ip_count - ) + if not profile.managed_outbound_ip_profile: + profile.managed_outbound_ip_profile = ManagedClusterManagedOutboundIPProfile() + if managed_outbound_ip_count is not None: + profile.managed_outbound_ip_profile.count = managed_outbound_ip_count + elif profile.managed_outbound_ip_profile.count is None: + # SDK 41.6 no longer materializes the IPv4 default; keep the documented default of 1. + profile.managed_outbound_ip_profile.count = 1 + if managed_outbound_ipv6_count is not None: + profile.managed_outbound_ip_profile.count_ipv6 = managed_outbound_ipv6_count if idle_timeout: profile.idle_timeout_in_minutes = idle_timeout + if outbound_ip_ids is not None: + ManagedClusterNATGatewayProfileOutboundIPs = models.ManagedClusterNATGatewayProfileOutboundIPs + if ManagedClusterNATGatewayProfileOutboundIPs is None: + raise ValueError("The selected AKS API profile/SDK does not support setting NAT gateway outbound IPs.") + ip_id_list = [x.strip() for x in outbound_ip_ids.split(',') if x.strip()] + profile.outbound_i_ps = ManagedClusterNATGatewayProfileOutboundIPs(public_i_ps=ip_id_list) + + if outbound_ip_prefix_ids is not None: + ManagedClusterNATGatewayProfileOutboundIpPrefixes = models.ManagedClusterNATGatewayProfileOutboundIpPrefixes + prefix_id_list = [x.strip() for x in outbound_ip_prefix_ids.split(',') if x.strip()] + profile.outbound_ip_prefixes = ManagedClusterNATGatewayProfileOutboundIpPrefixes( + public_ip_prefixes=prefix_id_list + ) + + if nat_gateway_sku is not None: + # GA shape: V2 is expressed as outboundType=managedNATGateway + natGatewayProfile.sku. + profile.sku = nat_gateway_sku + return profile diff --git a/src/azure-cli/azure/cli/command_modules/acs/_params.py b/src/azure-cli/azure/cli/command_modules/acs/_params.py index 09056477334..84ec10966b4 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_params.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_params.py @@ -46,6 +46,7 @@ CONST_OS_SKU_CBLMARINER, CONST_OS_SKU_MARINER, CONST_OS_SKU_UBUNTU, CONST_OS_SKU_UBUNTU2204, CONST_OS_SKU_UBUNTU2404, CONST_OS_SKU_WINDOWS2019, CONST_OS_SKU_WINDOWS2022, CONST_OS_SKU_WINDOWS2025, + CONST_NAT_GATEWAY_SKU_STANDARD, CONST_NAT_GATEWAY_SKU_STANDARD_V2, CONST_OUTBOUND_TYPE_LOAD_BALANCER, CONST_OUTBOUND_TYPE_MANAGED_NAT_GATEWAY, CONST_OUTBOUND_TYPE_USER_ASSIGNED_NAT_GATEWAY, CONST_OUTBOUND_TYPE_USER_DEFINED_ROUTING, CONST_OUTBOUND_TYPE_NONE, @@ -119,7 +120,11 @@ validate_load_balancer_outbound_ips, validate_load_balancer_outbound_ports, validate_load_balancer_sku, validate_max_surge, validate_max_unavailable, validate_nat_gateway_idle_timeout, - validate_nat_gateway_managed_outbound_ip_count, validate_network_policy, + validate_nat_gateway_managed_outbound_ip_count, + validate_nat_gateway_managed_outbound_ipv6_count, + validate_nat_gateway_v2_params, validate_nat_gateway_v2_params_for_update, + validate_network_policy, + validate_outbound_type_sku, validate_outbound_type_sku_for_update, validate_nodepool_id, validate_nodepool_labels, validate_nodepool_name, validate_nodepool_tags, validate_nodes_count, validate_os_sku, validate_pod_subnet_id, validate_ppg, validate_priority, @@ -197,6 +202,7 @@ # consts for ManagedCluster load_balancer_skus = [CONST_LOAD_BALANCER_SKU_BASIC, CONST_LOAD_BALANCER_SKU_STANDARD] +nat_gateway_skus = [CONST_NAT_GATEWAY_SKU_STANDARD, CONST_NAT_GATEWAY_SKU_STANDARD_V2] sku_names = [CONST_MANAGED_CLUSTER_SKU_NAME_BASE, CONST_MANAGED_CLUSTER_SKU_NAME_AUTOMATIC] sku_tiers = [CONST_MANAGED_CLUSTER_SKU_TIER_FREE, CONST_MANAGED_CLUSTER_SKU_TIER_STANDARD, CONST_MANAGED_CLUSTER_SKU_TIER_PREMIUM] network_plugins = [CONST_NETWORK_PLUGIN_KUBENET, CONST_NETWORK_PLUGIN_AZURE, CONST_NETWORK_PLUGIN_NONE] @@ -428,8 +434,21 @@ def load_arguments(self, _): c.argument('load_balancer_backend_pool_type', arg_type=get_enum_type(backend_pool_types)) c.argument('nrg_lockdown_restriction_level', arg_type=get_enum_type(nrg_lockdown_restriction_levels)) c.argument('nat_gateway_managed_outbound_ip_count', type=int, validator=validate_nat_gateway_managed_outbound_ip_count) + c.argument('nat_gateway_managed_outbound_ipv6_count', + options_list=['--nat-gateway-managed-outbound-ipv6-count', '--nat-gw-ipv6-count'], + type=int, validator=validate_nat_gateway_managed_outbound_ipv6_count, + help='NAT gateway managed outbound IPv6 count. Only valid with --outbound-type ' + 'managedNATGateway and --outbound-type-sku StandardV2.') c.argument('nat_gateway_idle_timeout', type=int, validator=validate_nat_gateway_idle_timeout) - c.argument('outbound_type', arg_type=get_enum_type(outbound_types)) + c.argument('nat_gateway_sku', options_list=['--outbound-type-sku'], arg_type=get_enum_type(nat_gateway_skus), validator=validate_outbound_type_sku) + c.argument('nat_gateway_outbound_ip_ids', options_list=['--nat-gateway-outbound-ips', '--nat-gw-ips'], + help='Comma-separated public IP resource IDs for the cluster NAT gateway. ' + 'Only valid with --outbound-type-sku StandardV2.') + c.argument('nat_gateway_outbound_ip_prefix_ids', + options_list=['--nat-gateway-outbound-ip-prefixes', '--nat-gw-prefixes'], + help='Comma-separated public IP prefix resource IDs for the cluster NAT gateway. ' + 'Only valid with --outbound-type-sku StandardV2.') + c.argument('outbound_type', arg_type=get_enum_type(outbound_types), validator=validate_nat_gateway_v2_params) c.argument('network_plugin', arg_type=get_enum_type(network_plugins)) c.argument('network_plugin_mode', arg_type=get_enum_type(network_plugin_modes)) c.argument('network_policy', validator=validate_network_policy) @@ -695,11 +714,24 @@ def load_arguments(self, _): c.argument("load_balancer_sku", arg_type=get_enum_type([CONST_LOAD_BALANCER_SKU_STANDARD]), validator=validate_load_balancer_sku) c.argument('nrg_lockdown_restriction_level', arg_type=get_enum_type(nrg_lockdown_restriction_levels)) c.argument('nat_gateway_managed_outbound_ip_count', type=int, validator=validate_nat_gateway_managed_outbound_ip_count) + c.argument('nat_gateway_managed_outbound_ipv6_count', + options_list=['--nat-gateway-managed-outbound-ipv6-count', '--nat-gw-ipv6-count'], + type=int, validator=validate_nat_gateway_managed_outbound_ipv6_count, + help='NAT gateway managed outbound IPv6 count. Only valid with --outbound-type ' + 'managedNATGateway and --outbound-type-sku StandardV2.') c.argument('nat_gateway_idle_timeout', type=int, validator=validate_nat_gateway_idle_timeout) + c.argument('nat_gateway_sku', options_list=['--outbound-type-sku'], arg_type=get_enum_type(nat_gateway_skus), validator=validate_outbound_type_sku_for_update) + c.argument('nat_gateway_outbound_ip_ids', options_list=['--nat-gateway-outbound-ips', '--nat-gw-ips'], + help='Comma-separated public IP resource IDs for the cluster NAT gateway. ' + 'Only valid with --outbound-type-sku StandardV2.') + c.argument('nat_gateway_outbound_ip_prefix_ids', + options_list=['--nat-gateway-outbound-ip-prefixes', '--nat-gw-prefixes'], + help='Comma-separated public IP prefix resource IDs for the cluster NAT gateway. ' + 'Only valid with --outbound-type-sku StandardV2.') c.argument('network_dataplane', arg_type=get_enum_type(network_dataplanes)) c.argument('network_plugin', arg_type=get_enum_type(network_plugins)) c.argument('network_policy', arg_type=get_enum_type(network_policies)) - c.argument('outbound_type', arg_type=get_enum_type(outbound_types)) + c.argument('outbound_type', arg_type=get_enum_type(outbound_types), validator=validate_nat_gateway_v2_params_for_update) c.argument('auto_upgrade_channel', arg_type=get_enum_type(auto_upgrade_channels)) c.argument('cluster_autoscaler_profile', nargs='+', options_list=["--cluster-autoscaler-profile", "--ca-profile"], help="Comma-separated list of key=value pairs for configuring cluster autoscaler. Pass an empty string to clear the profile.") diff --git a/src/azure-cli/azure/cli/command_modules/acs/_validators.py b/src/azure-cli/azure/cli/command_modules/acs/_validators.py index 05bde5da70c..5d2cfad1d67 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_validators.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_validators.py @@ -356,6 +356,99 @@ def validate_nat_gateway_idle_timeout(namespace): raise InvalidArgumentValueError("--nat-gateway-idle-timeout must be in the range [4,120]") +def validate_nat_gateway_managed_outbound_ipv6_count(namespace): + """validate NAT gateway profile managed outbound IPv6 count (StandardV2, dual-stack only)""" + ipv6_count = namespace.nat_gateway_managed_outbound_ipv6_count + if ipv6_count is not None: + if ipv6_count < 1 or ipv6_count > 16: + raise InvalidArgumentValueError( + "--nat-gateway-managed-outbound-ipv6-count must be in the range [1,16]" + ) + + +def validate_outbound_type_sku(namespace): + """Validate --outbound-type-sku on create (managed NAT gateway SKU). + + The SKU only applies to the managed NAT gateway outbound type and, on create, drives building a + NAT gateway profile, so --outbound-type must be set explicitly to managedNATGateway; omitting it + defaults the cluster to loadBalancer and produces an incompatible request. + """ + if getattr(namespace, 'nat_gateway_sku', None) is None: + return + if namespace.outbound_type != 'managedNATGateway': + raise InvalidArgumentValueError( + "--outbound-type-sku is only valid with --outbound-type managedNATGateway; " + "specify --outbound-type managedNATGateway explicitly." + ) + + +def validate_outbound_type_sku_for_update(namespace): + """Validate --outbound-type-sku on update (managed NAT gateway SKU). + + Unlike create, --outbound-type may be omitted when the cluster is already managed NAT gateway; + only an explicit non-managed-NAT-gateway outbound type is rejected here. The update decorator + additionally verifies the cluster's existing outbound type. + """ + if getattr(namespace, 'nat_gateway_sku', None) is None: + return + if namespace.outbound_type is not None and namespace.outbound_type != 'managedNATGateway': + raise InvalidArgumentValueError( + "--outbound-type-sku is only valid with --outbound-type managedNATGateway." + ) + + +def validate_nat_gateway_v2_params(namespace): + """Validate the V2-only NAT gateway params on create. + + The V2-only params (managed IPv6 count, BYO outbound IPs / IP prefixes) require the managed NAT + gateway outbound type at the StandardV2 tier; the Standard (V1) SKU cannot carry them. Omitting + --outbound-type-sku is tolerated and defaults to StandardV2 where the region supports it, so only + an explicit Standard SKU is rejected here (region availability is enforced by the RP). On create + --outbound-type must be set explicitly to managedNATGateway. + """ + v2_params = [ + getattr(namespace, 'nat_gateway_managed_outbound_ipv6_count', None), + getattr(namespace, 'nat_gateway_outbound_ip_ids', None), + getattr(namespace, 'nat_gateway_outbound_ip_prefix_ids', None), + ] + if not any(p is not None for p in v2_params): + return + # Omitting the sku defaults to StandardV2 where supported, so only an explicit Standard is rejected. + if namespace.outbound_type != 'managedNATGateway' or getattr(namespace, 'nat_gateway_sku', None) == 'Standard': + raise InvalidArgumentValueError( + "--nat-gateway-managed-outbound-ipv6-count, --nat-gateway-outbound-ips and " + "--nat-gateway-outbound-ip-prefixes are only valid with --outbound-type managedNATGateway " + "and --outbound-type-sku StandardV2; specify --outbound-type managedNATGateway explicitly." + ) + + +def validate_nat_gateway_v2_params_for_update(namespace): + """Validate the V2-only NAT gateway params on update. + + The V2-only params (managed IPv6 count, BYO outbound IPs / IP prefixes) require the StandardV2 + tier. Unlike create, an omitted --outbound-type-sku is not a "default to StandardV2": on update + the RP preserves the cluster's existing SKU (which may be Standard), so the V2-only params + require an explicit --outbound-type-sku StandardV2. --outbound-type may be omitted when the + cluster is already managed NAT gateway; an explicit non-managed-NAT-gateway outbound type is + also rejected. + """ + v2_params = [ + getattr(namespace, 'nat_gateway_managed_outbound_ipv6_count', None), + getattr(namespace, 'nat_gateway_outbound_ip_ids', None), + getattr(namespace, 'nat_gateway_outbound_ip_prefix_ids', None), + ] + if not any(p is not None for p in v2_params): + return + # On update the RP preserves the existing SKU (possibly Standard), so require an explicit StandardV2. + if (namespace.outbound_type is not None and namespace.outbound_type != 'managedNATGateway') or \ + getattr(namespace, 'nat_gateway_sku', None) != 'StandardV2': + raise InvalidArgumentValueError( + "--nat-gateway-managed-outbound-ipv6-count, --nat-gateway-outbound-ips and " + "--nat-gateway-outbound-ip-prefixes are only valid with --outbound-type managedNATGateway " + "and --outbound-type-sku StandardV2." + ) + + def validate_nodes_count(namespace): """Validates that min_count and max_count is set between 0-1000""" if namespace.min_count is not None: diff --git a/src/azure-cli/azure/cli/command_modules/acs/custom.py b/src/azure-cli/azure/cli/command_modules/acs/custom.py index a0008c954d2..7ed30032ead 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/custom.py +++ b/src/azure-cli/azure/cli/command_modules/acs/custom.py @@ -882,7 +882,11 @@ def aks_create( load_balancer_idle_timeout=None, load_balancer_backend_pool_type=None, nat_gateway_managed_outbound_ip_count=None, + nat_gateway_managed_outbound_ipv6_count=None, nat_gateway_idle_timeout=None, + nat_gateway_sku=None, + nat_gateway_outbound_ip_ids=None, + nat_gateway_outbound_ip_prefix_ids=None, outbound_type=None, network_plugin=None, network_plugin_mode=None, @@ -1113,7 +1117,11 @@ def aks_update( load_balancer_backend_pool_type=None, load_balancer_sku=None, nat_gateway_managed_outbound_ip_count=None, + nat_gateway_managed_outbound_ipv6_count=None, nat_gateway_idle_timeout=None, + nat_gateway_sku=None, + nat_gateway_outbound_ip_ids=None, + nat_gateway_outbound_ip_prefix_ids=None, outbound_type=None, auto_upgrade_channel=None, node_os_upgrade_channel=None, diff --git a/src/azure-cli/azure/cli/command_modules/acs/linter_exclusions.yml b/src/azure-cli/azure/cli/command_modules/acs/linter_exclusions.yml index b3eb404069f..4530e07e60f 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/linter_exclusions.yml +++ b/src/azure-cli/azure/cli/command_modules/acs/linter_exclusions.yml @@ -10,6 +10,18 @@ aks create: enable_hosted_system: rule_exclusions: - missing_parameter_test_coverage + nat_gateway_sku: + rule_exclusions: + - missing_parameter_test_coverage + nat_gateway_managed_outbound_ipv6_count: + rule_exclusions: + - missing_parameter_test_coverage + nat_gateway_outbound_ip_ids: + rule_exclusions: + - missing_parameter_test_coverage + nat_gateway_outbound_ip_prefix_ids: + rule_exclusions: + - missing_parameter_test_coverage appgw_watch_namespace: rule_exclusions: - option_length_too_long @@ -134,6 +146,18 @@ aks enable-addons: - option_length_too_long aks update: parameters: + nat_gateway_sku: + rule_exclusions: + - missing_parameter_test_coverage + nat_gateway_managed_outbound_ipv6_count: + rule_exclusions: + - missing_parameter_test_coverage + nat_gateway_outbound_ip_ids: + rule_exclusions: + - missing_parameter_test_coverage + nat_gateway_outbound_ip_prefix_ids: + rule_exclusions: + - missing_parameter_test_coverage disable_secret_rotation: rule_exclusions: - option_length_too_long diff --git a/src/azure-cli/azure/cli/command_modules/acs/managed_cluster_decorator.py b/src/azure-cli/azure/cli/command_modules/acs/managed_cluster_decorator.py index 6a0480a8850..98bebf062d6 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/managed_cluster_decorator.py +++ b/src/azure-cli/azure/cli/command_modules/acs/managed_cluster_decorator.py @@ -238,6 +238,16 @@ def nat_gateway_models(self) -> SimpleNamespace: if hasattr(self, "ManagedClusterManagedOutboundIPProfile") else None ) # backward compatibility + nat_gateway_models["ManagedClusterNATGatewayProfileOutboundIPs"] = ( + self.ManagedClusterNATGatewayProfileOutboundIPs + if hasattr(self, "ManagedClusterNATGatewayProfileOutboundIPs") + else None + ) # backward compatibility + nat_gateway_models["ManagedClusterNATGatewayProfileOutboundIpPrefixes"] = ( + self.ManagedClusterNATGatewayProfileOutboundIpPrefixes + if hasattr(self, "ManagedClusterNATGatewayProfileOutboundIpPrefixes") + else None + ) # backward compatibility self.__nat_gateway_models = SimpleNamespace(**nat_gateway_models) return self.__nat_gateway_models @@ -2289,6 +2299,44 @@ def get_nat_gateway_idle_timeout(self) -> Union[int, None]: # this parameter does not need validation return nat_gateway_idle_timeout + def get_nat_gateway_sku(self) -> Union[str, None]: + """Obtain the value of nat_gateway_sku (--outbound-type-sku). + + The managed NAT gateway SKU (Standard or StandardV2). GA shape: V2 is expressed via + outboundType=managedNATGateway + natGatewayProfile.sku=StandardV2. Region availability + and downgrade rules are enforced server-side by the RP. + + :return: str or None + """ + return self.raw_param.get("nat_gateway_sku") + + def get_nat_gateway_managed_outbound_ipv6_count(self) -> Union[int, None]: + """Obtain the value of nat_gateway_managed_outbound_ipv6_count. + + Only valid with the StandardV2 SKU on dual-stack clusters. + + :return: int or None + """ + return self.raw_param.get("nat_gateway_managed_outbound_ipv6_count") + + def get_nat_gateway_outbound_ip_ids(self) -> Union[str, None]: + """Obtain the value of nat_gateway_outbound_ip_ids (--nat-gateway-outbound-ips). + + Only valid with the StandardV2 SKU. + + :return: str or None + """ + return self.raw_param.get("nat_gateway_outbound_ip_ids") + + def get_nat_gateway_outbound_ip_prefix_ids(self) -> Union[str, None]: + """Obtain the value of nat_gateway_outbound_ip_prefix_ids (--nat-gateway-outbound-ip-prefixes). + + Only valid with the StandardV2 SKU. + + :return: str or None + """ + return self.raw_param.get("nat_gateway_outbound_ip_prefix_ids") + def get_pod_cidrs_and_service_cidrs_and_ip_families(self) -> Tuple[ Union[List[str], None], Union[List[str], None], @@ -7064,6 +7112,10 @@ def set_up_network_profile(self, mc: ManagedCluster) -> ManagedCluster: self.context.get_nat_gateway_managed_outbound_ip_count(), self.context.get_nat_gateway_idle_timeout(), models=self.models.nat_gateway_models, + managed_outbound_ipv6_count=self.context.get_nat_gateway_managed_outbound_ipv6_count(), + outbound_ip_ids=self.context.get_nat_gateway_outbound_ip_ids(), + outbound_ip_prefix_ids=self.context.get_nat_gateway_outbound_ip_prefix_ids(), + nat_gateway_sku=self.context.get_nat_gateway_sku(), ) load_balancer_sku = self.context.get_load_balancer_sku() if load_balancer_sku != CONST_LOAD_BALANCER_SKU_BASIC: @@ -8842,6 +8894,22 @@ def update_nat_gateway_profile(self, mc: ManagedCluster) -> ManagedCluster: "Unexpectedly get an empty network profile in the process of updating nat gateway profile." ) outbound_type = self.context.get_outbound_type() + # The managed NAT gateway SKU and V2 params build a NAT gateway profile, so they are only + # valid when the cluster's effective outbound type is managedNATGateway. --outbound-type may + # be omitted on update, so reject here against the resolved type instead of silently dropping + # them on e.g. a loadBalancer cluster. + if outbound_type != CONST_OUTBOUND_TYPE_MANAGED_NAT_GATEWAY and ( + self.context.get_nat_gateway_sku() is not None or + self.context.get_nat_gateway_managed_outbound_ipv6_count() is not None or + self.context.get_nat_gateway_outbound_ip_ids() is not None or + self.context.get_nat_gateway_outbound_ip_prefix_ids() is not None + ): + raise InvalidArgumentValueError( + "--outbound-type-sku, --nat-gateway-managed-outbound-ipv6-count, " + "--nat-gateway-outbound-ips and --nat-gateway-outbound-ip-prefixes are only valid " + "when the cluster's outbound type is managedNATGateway; set " + "--outbound-type managedNATGateway to change the outbound type." + ) if outbound_type and outbound_type != CONST_OUTBOUND_TYPE_MANAGED_NAT_GATEWAY: mc.network_profile.nat_gateway_profile = None else: @@ -8850,6 +8918,10 @@ def update_nat_gateway_profile(self, mc: ManagedCluster) -> ManagedCluster: idle_timeout=self.context.get_nat_gateway_idle_timeout(), profile=mc.network_profile.nat_gateway_profile, models=self.models.nat_gateway_models, + managed_outbound_ipv6_count=self.context.get_nat_gateway_managed_outbound_ipv6_count(), + outbound_ip_ids=self.context.get_nat_gateway_outbound_ip_ids(), + outbound_ip_prefix_ids=self.context.get_nat_gateway_outbound_ip_prefix_ids(), + nat_gateway_sku=self.context.get_nat_gateway_sku(), ) return mc diff --git a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_aks_commands.py b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_aks_commands.py index 243dc8ea95f..284e956af5c 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_aks_commands.py +++ b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_aks_commands.py @@ -1512,6 +1512,48 @@ def test_aks_create_and_update_with_managed_nat_gateway_outbound(self, resource_ self.check('networkProfile.natGatewayProfile.managedOutboundIpProfile.count', 2), ]) + @live_only() # live-only until a StandardV2 NAT gateway recording is captured + @AllowLargeResponse(8192) + @AKSCustomResourceGroupPreparer(random_name_length=17, name_prefix='clitest', location='westus2') + def test_aks_create_and_update_with_managed_nat_gateway_v2(self, resource_group, resource_group_location): + # NAT Gateway V2 GA: outboundType=managedNATGateway + --outbound-type-sku StandardV2. + # Record with: azdev test test_aks_create_and_update_with_managed_nat_gateway_v2 --live + aks_name = self.create_random_name('cliakstest', 16) + self.kwargs.update({ + 'resource_group': resource_group, + 'name': aks_name, + 'ssh_key_value': self.generate_ssh_keys(), + 'location': resource_group_location + }) + + create_cmd = 'aks create --resource-group={resource_group} --name={name} ' \ + '--vm-set-type VirtualMachineScaleSets -c 1 ' \ + '--outbound-type=managedNATGateway ' \ + '--outbound-type-sku=StandardV2 ' \ + '--nat-gateway-managed-outbound-ip-count=1 ' \ + '--nat-gateway-idle-timeout=4 ' \ + '--generate-ssh-keys ' \ + '--location={location}' + self.cmd(create_cmd, checks=[ + self.check('provisioningState', 'Succeeded'), + self.check('networkProfile.outboundType', 'managedNATGateway'), + self.check('networkProfile.natGatewayProfile.sku', 'StandardV2'), + self.check('networkProfile.natGatewayProfile.idleTimeoutInMinutes', 4), + self.check('networkProfile.natGatewayProfile.managedOutboundIpProfile.count', 1), + ]) + + # Update keeps the StandardV2 SKU and applies the new managed outbound IP count / idle timeout. + update_cmd = 'aks update --resource-group={resource_group} --name={name} ' \ + '--nat-gateway-managed-outbound-ip-count=2 ' \ + '--nat-gateway-idle-timeout=30 ' + self.cmd(update_cmd, checks=[ + self.check('provisioningState', 'Succeeded'), + self.check('networkProfile.outboundType', 'managedNATGateway'), + self.check('networkProfile.natGatewayProfile.sku', 'StandardV2'), + self.check('networkProfile.natGatewayProfile.idleTimeoutInMinutes', 30), + self.check('networkProfile.natGatewayProfile.managedOutboundIpProfile.count', 2), + ]) + @AllowLargeResponse() @AKSCustomResourceGroupPreparer(random_name_length=17, name_prefix='clitest', location='eastus') def test_aks_update_outbound_from_slb_to_natgateway(self, resource_group, resource_group_location): diff --git a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_natgateway.py b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_natgateway.py index 55a638ce7c9..81f06d81fad 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_natgateway.py +++ b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_natgateway.py @@ -90,5 +90,76 @@ def test_nonempty_arguments(self): result = natgateway.is_nat_gateway_profile_provided(0, None) self.assertTrue(result) + +class TestNatGatewayV2(unittest.TestCase): + def setUp(self): + self.cli_ctx = MockCLI() + self.cmd = MockCmd(self.cli_ctx) + self.nat_gateway_models = AKSManagedClusterModels( + self.cmd, ResourceType.MGMT_CONTAINERSERVICE + ).nat_gateway_models + + def test_is_provided_by_sku_and_v2_params(self): + self.assertTrue(natgateway.is_nat_gateway_profile_provided(None, None, nat_gateway_sku="StandardV2")) + self.assertTrue(natgateway.is_nat_gateway_profile_provided(None, None, managed_outbound_ipv6_count=1)) + self.assertTrue(natgateway.is_nat_gateway_profile_provided(None, None, outbound_ip_ids="/sub/ip1")) + self.assertTrue(natgateway.is_nat_gateway_profile_provided(None, None, outbound_ip_prefix_ids="/sub/pfx1")) + self.assertFalse(natgateway.is_nat_gateway_profile_provided(None, None)) + + def test_create_with_sku_and_v2_params(self): + profile = natgateway.create_nat_gateway_profile( + 2, 10, models=self.nat_gateway_models, + managed_outbound_ipv6_count=1, + outbound_ip_ids="/sub/ip1, /sub/ip2", + outbound_ip_prefix_ids="/sub/pfx1", + nat_gateway_sku="StandardV2", + ) + self.assertEqual(profile.sku, "StandardV2") + self.assertEqual(profile.managed_outbound_ip_profile.count, 2) + self.assertEqual(profile.managed_outbound_ip_profile.count_ipv6, 1) + self.assertEqual(profile.outbound_i_ps.public_i_ps, ["/sub/ip1", "/sub/ip2"]) + self.assertEqual(profile.outbound_ip_prefixes.public_ip_prefixes, ["/sub/pfx1"]) + self.assertEqual(profile.idle_timeout_in_minutes, 10) + + def test_create_ipv6_only_defaults_ipv4_count(self): + # Only an IPv6 count is provided; the IPv4 count must fall back to the documented default of 1. + profile = natgateway.create_nat_gateway_profile( + None, None, models=self.nat_gateway_models, + managed_outbound_ipv6_count=3, + nat_gateway_sku="StandardV2", + ) + self.assertEqual(profile.managed_outbound_ip_profile.count, 1) + self.assertEqual(profile.managed_outbound_ip_profile.count_ipv6, 3) + + def test_update_ipv6_only_preserves_existing_ipv4_count(self): + origin = self.nat_gateway_models.ManagedClusterNATGatewayProfile( + managed_outbound_ip_profile=self.nat_gateway_models.ManagedClusterManagedOutboundIPProfile(count=5), + ) + profile = natgateway.update_nat_gateway_profile( + None, None, origin, models=self.nat_gateway_models, + managed_outbound_ipv6_count=2, + ) + self.assertEqual(profile.managed_outbound_ip_profile.count, 5) + self.assertEqual(profile.managed_outbound_ip_profile.count_ipv6, 2) + + def test_create_sku_only(self): + profile = natgateway.create_nat_gateway_profile( + None, None, models=self.nat_gateway_models, nat_gateway_sku="StandardV2" + ) + self.assertIsNotNone(profile) + self.assertEqual(profile.sku, "StandardV2") + + def test_update_sets_sku_on_existing_profile(self): + origin = self.nat_gateway_models.ManagedClusterNATGatewayProfile( + managed_outbound_ip_profile=self.nat_gateway_models.ManagedClusterManagedOutboundIPProfile(count=1), + idle_timeout_in_minutes=4, + ) + profile = natgateway.update_nat_gateway_profile( + None, None, origin, models=self.nat_gateway_models, nat_gateway_sku="StandardV2" + ) + self.assertEqual(profile.sku, "StandardV2") + self.assertEqual(profile.managed_outbound_ip_profile.count, 1) + + if __name__ == "__main__": unittest.main() diff --git a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_validators.py b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_validators.py index 6f5421bc3bf..db7d20f569d 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_validators.py +++ b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_validators.py @@ -2046,5 +2046,62 @@ def test_no_ssh_key_still_skips(self): self.assertIsNone(namespace.ssh_key_value) +class TestNatGatewayV2Validators(unittest.TestCase): + def _ns(self, **kwargs): + defaults = { + "nat_gateway_sku": None, + "outbound_type": None, + "nat_gateway_managed_outbound_ipv6_count": None, + "nat_gateway_outbound_ip_ids": None, + "nat_gateway_outbound_ip_prefix_ids": None, + } + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + def test_sku_create_requires_explicit_managed_nat_gateway(self): + validators.validate_outbound_type_sku(self._ns(nat_gateway_sku="StandardV2", outbound_type="managedNATGateway")) + with self.assertRaises(InvalidArgumentValueError): + validators.validate_outbound_type_sku(self._ns(nat_gateway_sku="StandardV2", outbound_type=None)) + with self.assertRaises(InvalidArgumentValueError): + validators.validate_outbound_type_sku(self._ns(nat_gateway_sku="StandardV2", outbound_type="loadBalancer")) + validators.validate_outbound_type_sku(self._ns(outbound_type="loadBalancer")) + + def test_sku_update_allows_omitted_outbound_type(self): + validators.validate_outbound_type_sku_for_update(self._ns(nat_gateway_sku="StandardV2", outbound_type=None)) + validators.validate_outbound_type_sku_for_update(self._ns(nat_gateway_sku="StandardV2", outbound_type="managedNATGateway")) + with self.assertRaises(InvalidArgumentValueError): + validators.validate_outbound_type_sku_for_update(self._ns(nat_gateway_sku="StandardV2", outbound_type="loadBalancer")) + + def test_v2_params_create_requires_managed_nat_gateway_and_v2_sku(self): + validators.validate_nat_gateway_v2_params(self._ns(nat_gateway_managed_outbound_ipv6_count=1, outbound_type="managedNATGateway")) + with self.assertRaises(InvalidArgumentValueError): + validators.validate_nat_gateway_v2_params(self._ns(nat_gateway_managed_outbound_ipv6_count=1, outbound_type=None)) + with self.assertRaises(InvalidArgumentValueError): + validators.validate_nat_gateway_v2_params(self._ns(nat_gateway_outbound_ip_ids="/sub/ip", outbound_type="loadBalancer")) + with self.assertRaises(InvalidArgumentValueError): + validators.validate_nat_gateway_v2_params(self._ns(nat_gateway_managed_outbound_ipv6_count=1, outbound_type="managedNATGateway", nat_gateway_sku="Standard")) + validators.validate_nat_gateway_v2_params(self._ns(outbound_type="loadBalancer")) + + def test_v2_params_update_requires_v2_sku(self): + # On update the RP preserves the existing SKU (possibly Standard), so V2 params require an + # explicit StandardV2; a sku-less update is rejected. + validators.validate_nat_gateway_v2_params_for_update(self._ns(nat_gateway_managed_outbound_ipv6_count=1, outbound_type=None, nat_gateway_sku="StandardV2")) + validators.validate_nat_gateway_v2_params_for_update(self._ns(nat_gateway_managed_outbound_ipv6_count=1, outbound_type="managedNATGateway", nat_gateway_sku="StandardV2")) + with self.assertRaises(InvalidArgumentValueError): + validators.validate_nat_gateway_v2_params_for_update(self._ns(nat_gateway_managed_outbound_ipv6_count=1, outbound_type=None)) + with self.assertRaises(InvalidArgumentValueError): + validators.validate_nat_gateway_v2_params_for_update(self._ns(nat_gateway_managed_outbound_ipv6_count=1, outbound_type=None, nat_gateway_sku="Standard")) + with self.assertRaises(InvalidArgumentValueError): + validators.validate_nat_gateway_v2_params_for_update(self._ns(nat_gateway_outbound_ip_ids="/sub/ip", outbound_type="loadBalancer", nat_gateway_sku="StandardV2")) + + def test_ipv6_count_range(self): + validators.validate_nat_gateway_managed_outbound_ipv6_count(self._ns(nat_gateway_managed_outbound_ipv6_count=1)) + validators.validate_nat_gateway_managed_outbound_ipv6_count(self._ns(nat_gateway_managed_outbound_ipv6_count=16)) + with self.assertRaises(InvalidArgumentValueError): + validators.validate_nat_gateway_managed_outbound_ipv6_count(self._ns(nat_gateway_managed_outbound_ipv6_count=0)) + with self.assertRaises(InvalidArgumentValueError): + validators.validate_nat_gateway_managed_outbound_ipv6_count(self._ns(nat_gateway_managed_outbound_ipv6_count=17)) + + if __name__ == "__main__": unittest.main()