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
5 changes: 5 additions & 0 deletions pkg/model/gcemodel/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ func (b *NetworkModelBuilder) Build(c *fi.CloudupModelBuilderContext) error {
SourceSubnetworkIPRangesToNAT: s(gcetasks.SourceSubnetworkIPRangesSpecificSubnets),
Subnetworks: subnetworks,
}
if b.IsIPv6Only() {
// NAT64 lets IPv6-only pods reach IPv4-only destinations — the GCE
// equivalent of AWS's 64:ff9b::/96 NAT64 route.
r.SourceSubnetworkIPRangesToNAT64 = s(gcetasks.SourceSubnetworkIPRangesAllIPv6)
}
c.AddTask(r)
}
}
Expand Down
122 changes: 122 additions & 0 deletions pkg/model/gcemodel/network_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
Copyright 2026 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package gcemodel

import (
"testing"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/kops/pkg/apis/kops"
"k8s.io/kops/pkg/model"
"k8s.io/kops/pkg/model/iam"
"k8s.io/kops/upup/pkg/fi"
"k8s.io/kops/upup/pkg/fi/cloudup/gcetasks"
)

// makeNetworkBuilder constructs a minimal NetworkModelBuilder for the given cluster.
// The cluster must have at least one private subnet to trigger router creation.
func makeNetworkBuilder(cluster *kops.Cluster) *NetworkModelBuilder {
return &NetworkModelBuilder{
GCEModelContext: &GCEModelContext{
ProjectID: "test-project",
KopsModelContext: &model.KopsModelContext{
IAMModelContext: iam.IAMModelContext{Cluster: cluster},
Region: "us-central1",
},
},
Lifecycle: fi.LifecycleSync,
}
}

// makeCluster builds a minimal cluster spec with one unshared private subnet.
// nonMasqueradeCIDR controls whether IsIPv6Only() returns true:
// - IPv6 CIDR (e.g. "fd00::/56") → IPv6-only cluster
// - IPv4 CIDR (e.g. "100.64.0.0/10") → normal cluster
func makeCluster(nonMasqueradeCIDR string) *kops.Cluster {
return &kops.Cluster{
ObjectMeta: metav1.ObjectMeta{Name: "test.k8s.local"},
Spec: kops.ClusterSpec{
Networking: kops.NetworkingSpec{
NonMasqueradeCIDR: nonMasqueradeCIDR,
// One unshared private subnet (no ID, no external egress) so the
// CloudNAT router task is always created.
Subnets: []kops.ClusterSubnetSpec{
{
Name: "private-us-central1-a",
Type: kops.SubnetTypePrivate,
},
},
},
},
}
}

// findRouterTask returns the single gcetasks.Router in the task map, or nil.
func findRouterTask(tasks map[string]fi.CloudupTask) *gcetasks.Router {
for _, task := range tasks {
if r, ok := task.(*gcetasks.Router); ok {
return r
}
}
return nil
}

func TestNetworkModelBuilder_RouterNAT64(t *testing.T) {
tests := []struct {
name string
nonMasqueradeCIDR string
wantNAT64 *string // nil means the field must be nil
}{
{
name: "IPv6-only cluster sets NAT64",
nonMasqueradeCIDR: "fd00::/56",
wantNAT64: fi.PtrTo(gcetasks.SourceSubnetworkIPRangesAllIPv6),
},
{
name: "non-IPv6 cluster leaves NAT64 nil",
nonMasqueradeCIDR: "100.64.0.0/10",
wantNAT64: nil,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cluster := makeCluster(tc.nonMasqueradeCIDR)
b := makeNetworkBuilder(cluster)
ctx := &fi.CloudupModelBuilderContext{Tasks: map[string]fi.CloudupTask{}}

if err := b.Build(ctx); err != nil {
t.Fatalf("Build() returned error: %v", err)
}

router := findRouterTask(ctx.Tasks)
if router == nil {
t.Fatalf("no Router task found in %d tasks; private subnet should have triggered CloudNAT router creation", len(ctx.Tasks))
}

got := router.SourceSubnetworkIPRangesToNAT64
switch {
case tc.wantNAT64 == nil && got != nil:
t.Errorf("SourceSubnetworkIPRangesToNAT64: got %q, want nil", fi.ValueOf(got))
case tc.wantNAT64 != nil && got == nil:
t.Errorf("SourceSubnetworkIPRangesToNAT64: got nil, want %q", fi.ValueOf(tc.wantNAT64))
case tc.wantNAT64 != nil && got != nil && *got != *tc.wantNAT64:
t.Errorf("SourceSubnetworkIPRangesToNAT64: got %q, want %q", *got, *tc.wantNAT64)
}
})
}
}
43 changes: 27 additions & 16 deletions upup/pkg/fi/cloudup/gcetasks/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ const (
// SourceSubnetworkIPRangesSpecificSubnets is specified when we should NAT only specific listed subnets.
SourceSubnetworkIPRangesSpecificSubnets = "LIST_OF_SUBNETWORKS"

// SourceSubnetworkIPRangesAllIPv6 enables NAT64 for all IPv6 subnetwork ranges.
// In dual-stack subnets GCE only applies NAT64 to IPv6-only VMs, which is exactly our scenario.
SourceSubnetworkIPRangesAllIPv6 = "ALL_IPV6_SUBNETWORKS"

// subnetNatAllIPRanges specifies that we should NAT all IP ranges in the subnet.
subnetNatAllIPRanges = "ALL_IP_RANGES"
)
Expand All @@ -51,8 +55,9 @@ type Router struct {
Network *Network
Region *string

NATIPAllocationOption *string
SourceSubnetworkIPRangesToNAT *string
NATIPAllocationOption *string
SourceSubnetworkIPRangesToNAT *string
SourceSubnetworkIPRangesToNAT64 *string

Subnetworks []*Subnet
}
Expand Down Expand Up @@ -93,6 +98,9 @@ func (r *Router) Find(c *fi.CloudupContext) (*Router, error) {
NATIPAllocationOption: &nat.NatIpAllocateOption,
SourceSubnetworkIPRangesToNAT: &nat.SourceSubnetworkIpRangesToNat,
}
if nat.SourceSubnetworkIpRangesToNat64 != "" {
actual.SourceSubnetworkIPRangesToNAT64 = &nat.SourceSubnetworkIpRangesToNat64
}

for _, subnet := range nat.Subnetworks {
if strings.Join(subnet.SourceIpRangesToNat, ",") != subnetNatAllIPRanges {
Expand Down Expand Up @@ -155,9 +163,10 @@ func (*Router) RenderGCE(t *gce.GCEAPITarget, a, e, changes *Router) error {
Network: e.Network.URL(project),
Nats: []*compute.RouterNat{
{
Name: *e.Name,
NatIpAllocateOption: *e.NATIPAllocationOption,
SourceSubnetworkIpRangesToNat: *e.SourceSubnetworkIPRangesToNAT,
Name: *e.Name,
NatIpAllocateOption: *e.NATIPAllocationOption,
SourceSubnetworkIpRangesToNat: *e.SourceSubnetworkIPRangesToNAT,
SourceSubnetworkIpRangesToNat64: fi.ValueOf(e.SourceSubnetworkIPRangesToNAT64),
},
},
}
Expand Down Expand Up @@ -185,12 +194,13 @@ func (*Router) RenderGCE(t *gce.GCEAPITarget, a, e, changes *Router) error {
}

type terraformRouterNat struct {
Name *string `cty:"name"`
Region *string `cty:"region"`
Router *terraformWriter.Literal `cty:"router"`
NATIPAllocateOption *string `cty:"nat_ip_allocate_option"`
SourceSubnetworkIPRangesToNat *string `cty:"source_subnetwork_ip_ranges_to_nat"`
Subnetworks []*terraformRouterNatSubnetwork `cty:"subnetwork"`
Name *string `cty:"name"`
Region *string `cty:"region"`
Router *terraformWriter.Literal `cty:"router"`
NATIPAllocateOption *string `cty:"nat_ip_allocate_option"`
SourceSubnetworkIPRangesToNat *string `cty:"source_subnetwork_ip_ranges_to_nat"`
SourceSubnetworkIPRangesToNat64 *string `cty:"source_subnetwork_ip_ranges_to_nat64"`
Subnetworks []*terraformRouterNatSubnetwork `cty:"subnetwork"`
}

type terraformRouterNatSubnetwork struct {
Expand Down Expand Up @@ -218,11 +228,12 @@ func (*Router) RenderTerraform(t *terraform.TerraformTarget, a, e, changes *Rout
}

trn := &terraformRouterNat{
Name: e.Name,
Region: e.Region,
Router: e.TerraformLink(),
NATIPAllocateOption: e.NATIPAllocationOption,
SourceSubnetworkIPRangesToNat: e.SourceSubnetworkIPRangesToNAT,
Name: e.Name,
Region: e.Region,
Router: e.TerraformLink(),
NATIPAllocateOption: e.NATIPAllocationOption,
SourceSubnetworkIPRangesToNat: e.SourceSubnetworkIPRangesToNAT,
SourceSubnetworkIPRangesToNat64: e.SourceSubnetworkIPRangesToNAT64,
}
for _, subnet := range e.Subnetworks {
trn.Subnetworks = append(trn.Subnetworks, &terraformRouterNatSubnetwork{
Expand Down