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
29 changes: 29 additions & 0 deletions examples/49-control-plane-on-private-subnets.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# An example config for restricting the EKS control plane's cross-account ENIs to private
# subnets at cluster creation time.
# To create the cluster, run `eksctl create cluster -f 49-control-plane-on-private-subnets.yaml`
#
# Public subnets are still created and used for the NAT gateways and for internet-facing load
# balancers; only the subnets passed to the EKS API are restricted. Requires at least two
# private subnets across at least two availability zones.

apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: cluster-49
region: us-west-2

availabilityZones:
- us-west-2a
- us-west-2b

vpc:
controlPlaneOnPrivateSubnets: true
nat:
gateway: HighlyAvailable
clusterEndpoints:
publicAccess: true
privateAccess: true

managedNodeGroups:
- name: mng1
privateNetworking: true
6 changes: 6 additions & 0 deletions pkg/apis/eksctl.io/v1alpha5/assets/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,11 @@
"description": "controls how the control plane routes egress traffic. Valid values: \"AWS_MANAGED\" (default), \"CUSTOMER_ROUTED\"",
"x-intellij-html-description": "controls how the control plane routes egress traffic. Valid values: &quot;AWS<em>MANAGED&quot; (default), &quot;CUSTOMER</em>ROUTED&quot;"
},
"controlPlaneOnPrivateSubnets": {
"type": "boolean",
"description": "restricts the control plane (the cross-account ENIs that EKS places in the cluster subnets) to private subnets only, excluding public subnets. It applies both when eksctl creates the VPC and when a pre-existing VPC is used. Cannot be combined with ControlPlaneSubnetIDs. Requires at least two private subnets spanning at least two availability zones, which must have NAT or the relevant VPC endpoints for nodes to reach the API server.",
"x-intellij-html-description": "restricts the control plane (the cross-account ENIs that EKS places in the cluster subnets) to private subnets only, excluding public subnets. It applies both when eksctl creates the VPC and when a pre-existing VPC is used. Cannot be combined with ControlPlaneSubnetIDs. Requires at least two private subnets spanning at least two availability zones, which must have NAT or the relevant VPC endpoints for nodes to reach the API server."
},
"controlPlaneSecurityGroupIDs": {
"items": {
"type": "string"
Expand Down Expand Up @@ -1259,6 +1264,7 @@
"clusterEndpoints",
"publicAccessCIDRs",
"controlPlaneSubnetIDs",
"controlPlaneOnPrivateSubnets",
"controlPlaneSecurityGroupIDs",
"controlPlaneEgressMode"
],
Expand Down
6 changes: 6 additions & 0 deletions pkg/apis/eksctl.io/v1alpha5/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,12 @@ func (c *ClusterConfig) IsControlPlaneOnOutposts() bool {
return c.Outpost != nil && c.Outpost.ControlPlaneOutpostARN != ""
}

// IsControlPlaneOnPrivateSubnets returns true if the control plane's cross-account ENIs
// should be restricted to private subnets only.
func (c *ClusterConfig) IsControlPlaneOnPrivateSubnets() bool {
return c.VPC != nil && IsEnabled(c.VPC.ControlPlaneOnPrivateSubnets)
}

// GetOutpost returns the Outpost info.
func (c *ClusterConfig) GetOutpost() *Outpost {
return c.Outpost
Expand Down
65 changes: 65 additions & 0 deletions pkg/apis/eksctl.io/v1alpha5/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,10 @@ func (c *ClusterConfig) ValidateVPCConfig() error {
return errors.New("only one of vpc.securityGroup and vpc.controlPlaneSecurityGroupIDs can be specified")
}

if err := c.validateControlPlaneOnPrivateSubnets(); err != nil {
return err
}

if (c.VPC.IPv6Cidr != "" || c.VPC.IPv6Pool != "") && !c.IPv6Enabled() {
return fmt.Errorf("Ipv6Cidr and Ipv6CidrPool are only supported when IPFamily is set to IPv6")
}
Expand Down Expand Up @@ -554,6 +558,67 @@ func (c *ClusterConfig) ValidateVPCConfig() error {
return nil
}

// validateControlPlaneOnPrivateSubnets validates vpc.controlPlaneOnPrivateSubnets against
// the rest of the VPC configuration.
func (c *ClusterConfig) validateControlPlaneOnPrivateSubnets() error {
if !IsEnabled(c.VPC.ControlPlaneOnPrivateSubnets) {
return nil
}

if len(c.VPC.ControlPlaneSubnetIDs) > 0 {
return errors.New("only one of vpc.controlPlaneSubnetIDs and vpc.controlPlaneOnPrivateSubnets can be specified")
}

// The control plane is already restricted to private subnets on Outposts, where a
// single subnet in a single zone is expected, so the checks below do not apply.
if c.IsControlPlaneOnOutposts() {
return nil
}

// Subnets are nil when eksctl creates the VPC. Private subnets are then derived from
// availabilityZones by vpc.SetSubnets, which runs after validation and always covers
// every requested zone, so there is nothing to check yet.
if c.VPC.Subnets == nil {
return nil
}

if numPrivate := len(c.VPC.Subnets.Private); numPrivate < MinRequiredSubnets {
return fmt.Errorf("vpc.controlPlaneOnPrivateSubnets requires at least %d private subnets, got %d", MinRequiredSubnets, numPrivate)
}

if azs := distinctSubnetAZs(c.VPC.Subnets.Private); len(azs) < MinRequiredAvailabilityZones {
return fmt.Errorf("vpc.controlPlaneOnPrivateSubnets requires private subnets in at least %d availability zones, got %d (%v)", MinRequiredAvailabilityZones, len(azs), azs)
}

return nil
}

// distinctSubnetAZs returns the unique availability zones covered by the given subnets.
// A subnet's zone is taken from its AZ field, falling back to the mapping key, which is an
// AZ name in the common form.
//
// This is best-effort: subnets given only by ID have their zone resolved from EC2 later, so
// their real zone is unknown here and the mapping key is used instead. Such a configuration
// is allowed through and is rejected by the EKS API if the subnets turn out to share a zone.
// The check is deliberately permissive rather than risk rejecting a valid pre-existing VPC.
func distinctSubnetAZs(subnets AZSubnetMapping) []string {
seen := make(map[string]struct{}, len(subnets))
azs := make([]string, 0, len(subnets))
for key, spec := range subnets {
az := spec.AZ
if az == "" {
az = key
}
if _, ok := seen[az]; ok {
continue
}
seen[az] = struct{}{}
azs = append(azs, az)
}
slices.Sort(azs)
return azs
}

func (c *ClusterConfig) unsupportedVPCCNIAddonVersion() (bool, error) {
for _, addon := range c.Addons {
if addon.Name == VPCCNIAddon {
Expand Down
115 changes: 115 additions & 0 deletions pkg/apis/eksctl.io/v1alpha5/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1526,6 +1526,121 @@ var _ = Describe("ClusterConfig validation", func() {
})
})

Context("controlPlaneOnPrivateSubnets", func() {
privateSubnets := func(azs ...string) api.AZSubnetMapping {
m := api.NewAZSubnetMapping()
for i, az := range azs {
m.Set(fmt.Sprintf("subnet-alias-%d", i), api.AZSubnetSpec{
ID: fmt.Sprintf("subnet-%d", i),
AZ: az,
})
}
return m
}

When("it is enabled and eksctl creates the VPC", func() {
It("does not reject the config, since subnets are derived from availabilityZones later", func() {
cfg.VPC.Subnets = nil
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
err = cfg.ValidateVPCConfig()
Expect(err).NotTo(HaveOccurred())
})
})

When("it is enabled with two private subnets across two AZs", func() {
It("does not return an error", func() {
cfg.VPC.Subnets = &api.ClusterSubnets{
Private: privateSubnets("us-west-2a", "us-west-2b"),
}
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
err = cfg.ValidateVPCConfig()
Expect(err).NotTo(HaveOccurred())
})
})

When("it is enabled together with controlPlaneSubnetIDs", func() {
It("returns an error", func() {
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
cfg.VPC.ControlPlaneSubnetIDs = []string{"subnet-1234", "subnet-5678"}
err = cfg.ValidateVPCConfig()
Expect(err).To(MatchError("only one of vpc.controlPlaneSubnetIDs and vpc.controlPlaneOnPrivateSubnets can be specified"))
})
})

When("it is enabled but the VPC has no private subnets", func() {
It("returns an error instead of silently using public subnets", func() {
cfg.VPC.Subnets = &api.ClusterSubnets{
Public: privateSubnets("us-west-2a", "us-west-2b"),
}
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
err = cfg.ValidateVPCConfig()
Expect(err).To(MatchError("vpc.controlPlaneOnPrivateSubnets requires at least 2 private subnets, got 0"))
})
})

When("it is enabled with only one private subnet", func() {
It("returns an error", func() {
cfg.VPC.Subnets = &api.ClusterSubnets{
Private: privateSubnets("us-west-2a"),
}
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
err = cfg.ValidateVPCConfig()
Expect(err).To(MatchError("vpc.controlPlaneOnPrivateSubnets requires at least 2 private subnets, got 1"))
})
})

When("it is enabled with two private subnets in the same AZ", func() {
It("returns an error, since EKS requires two availability zones", func() {
cfg.VPC.Subnets = &api.ClusterSubnets{
Private: privateSubnets("us-west-2a", "us-west-2a"),
}
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
err = cfg.ValidateVPCConfig()
Expect(err).To(MatchError("vpc.controlPlaneOnPrivateSubnets requires private subnets in at least 2 availability zones, got 1 ([us-west-2a])"))
})
})

When("private subnets are given only by ID", func() {
It("allows the config through, since their zones are resolved from EC2 later", func() {
subnets := api.NewAZSubnetMapping()
subnets.Set("alias-a", api.AZSubnetSpec{ID: "subnet-aaa"})
subnets.Set("alias-b", api.AZSubnetSpec{ID: "subnet-bbb"})
cfg.VPC.ID = "vpc-123"
cfg.VPC.Subnets = &api.ClusterSubnets{Private: subnets}
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
err = cfg.ValidateVPCConfig()
Expect(err).NotTo(HaveOccurred())
})
})

When("one private subnet is keyed by AZ and another repeats that AZ explicitly", func() {
It("returns an error", func() {
subnets := api.NewAZSubnetMapping()
subnets.Set("us-west-2a", api.AZSubnetSpec{ID: "subnet-aaa"})
subnets.Set("alias-b", api.AZSubnetSpec{ID: "subnet-bbb", AZ: "us-west-2a"})
cfg.VPC.ID = "vpc-123"
cfg.VPC.Subnets = &api.ClusterSubnets{Private: subnets}
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
err = cfg.ValidateVPCConfig()
Expect(err).To(MatchError("vpc.controlPlaneOnPrivateSubnets requires private subnets in at least 2 availability zones, got 1 ([us-west-2a])"))
})
})

When("it is enabled on Outposts", func() {
It("does not enforce the multi-AZ requirement", func() {
cfg.VPC.Subnets = &api.ClusterSubnets{
Private: privateSubnets("us-west-2a"),
}
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
cfg.Outpost = &api.Outpost{
ControlPlaneOutpostARN: "arn:aws:outposts:us-west-2:1234:outpost/op-1234",
}
err = cfg.ValidateVPCConfig()
Expect(err).NotTo(HaveOccurred())
})
})
})

Context("ipv6 CIDRs", func() {
When("IPv6Cidr or IPv6CidrPool is provided and ipv6 is not set", func() {
It("returns an error", func() {
Expand Down
8 changes: 8 additions & 0 deletions pkg/apis/eksctl.io/v1alpha5/vpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,14 @@ type (
// ControlPlaneSubnetIDs configures the subnets for the control plane.
// +optional
ControlPlaneSubnetIDs []string `json:"controlPlaneSubnetIDs,omitempty"`
// ControlPlaneOnPrivateSubnets restricts the control plane (the cross-account ENIs
// that EKS places in the cluster subnets) to private subnets only, excluding public
// subnets. It applies both when eksctl creates the VPC and when a pre-existing VPC
// is used. Cannot be combined with ControlPlaneSubnetIDs. Requires at least two
// private subnets spanning at least two availability zones, which must have NAT or
// the relevant VPC endpoints for nodes to reach the API server.
// +optional
ControlPlaneOnPrivateSubnets *bool `json:"controlPlaneOnPrivateSubnets,omitempty"`
// ControlPlaneSecurityGroupIDs configures the security groups for the control plane.
// +optional
ControlPlaneSecurityGroupIDs []string `json:"controlPlaneSecurityGroupIDs,omitempty"`
Expand Down
5 changes: 5 additions & 0 deletions pkg/apis/eksctl.io/v1alpha5/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 62 additions & 0 deletions pkg/cfn/builder/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/weaveworks/eksctl/pkg/cfn/builder"
"github.com/weaveworks/eksctl/pkg/cfn/builder/fakes"
"github.com/weaveworks/eksctl/pkg/testutils/mockprovider"
"github.com/weaveworks/eksctl/pkg/vpc"
)

var _ = Describe("Cluster Template Builder", func() {
Expand Down Expand Up @@ -100,6 +101,67 @@ var _ = Describe("Cluster Template Builder", func() {
})
})

Context("when VPC.ControlPlaneOnPrivateSubnets is true", func() {
BeforeEach(func() {
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
})

It("should add only the private subnets to the control plane's VPC config", func() {
subnetIDs := clusterTemplate.Resources["ControlPlane"].Properties.ResourcesVpcConfig.SubnetIDs
Expect(subnetIDs).To(ConsistOf(
map[string]interface{}{"Ref": "SubnetPrivateUSWEST2A"},
map[string]interface{}{"Ref": "SubnetPrivateUSWEST2B"},
))
})

Context("and ControlPlaneSubnetIDs is also set", func() {
BeforeEach(func() {
cfg.VPC.ControlPlaneSubnetIDs = []string{"subnet-1234", "subnet-5678"}
})

It("should give ControlPlaneSubnetIDs precedence in the template", func() {
subnetIDs := clusterTemplate.Resources["ControlPlane"].Properties.ResourcesVpcConfig.SubnetIDs
Expect(subnetIDs).To(ConsistOf("subnet-1234", "subnet-5678"))
})
})
})

Context("when VPC.ControlPlaneOnPrivateSubnets is not set", func() {
It("should add both public and private subnets to the control plane's VPC config", func() {
subnetIDs := clusterTemplate.Resources["ControlPlane"].Properties.ResourcesVpcConfig.SubnetIDs
Expect(subnetIDs).To(ConsistOf(
map[string]interface{}{"Ref": "SubnetPublicUSWEST2A"},
map[string]interface{}{"Ref": "SubnetPublicUSWEST2B"},
map[string]interface{}{"Ref": "SubnetPrivateUSWEST2A"},
map[string]interface{}{"Ref": "SubnetPrivateUSWEST2B"},
))
})
})

Context("when subnets are derived from availabilityZones by vpc.SetSubnets", func() {
BeforeEach(func() {
// This is the primary path: the user supplies only availabilityZones and
// eksctl creates the VPC and subnets.
cfg.VPC = api.NewClusterVPC(false)
cfg.VPC.ClusterEndpoints = api.ClusterEndpointAccessDefaults()
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
Expect(vpc.SetSubnets(cfg.VPC, cfg.AvailabilityZones, nil)).To(Succeed())
})

It("should add only the generated private subnets to the control plane's VPC config", func() {
Expect(addErr).NotTo(HaveOccurred())
subnetIDs := clusterTemplate.Resources["ControlPlane"].Properties.ResourcesVpcConfig.SubnetIDs
Expect(subnetIDs).To(ConsistOf(
map[string]interface{}{"Ref": "SubnetPrivateUSWEST2A"},
map[string]interface{}{"Ref": "SubnetPrivateUSWEST2B"},
))

By("still creating the public subnets for NAT and load balancers")
Expect(clusterTemplate.Resources).To(HaveKey("SubnetPublicUSWEST2A"))
Expect(clusterTemplate.Resources).To(HaveKey("SubnetPublicUSWEST2B"))
})
})

Context("when control plane tier is set with SupportType", func() {
BeforeEach(func() {
cfg.ControlPlaneScalingConfig = &api.ControlPlaneScalingConfig{
Expand Down
5 changes: 2 additions & 3 deletions pkg/cfn/builder/vpc_existing.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,8 @@ func NewExistingVPCResourceSet(rs *resourceSet, clusterConfig *api.ClusterConfig
clusterConfig: clusterConfig,
ec2API: ec2API,
vpcID: gfnt.NewString(clusterConfig.VPC.ID),
subnetDetails: &SubnetDetails{
controlPlaneOnOutposts: clusterConfig.IsControlPlaneOnOutposts(),
},
// autoMode is not applied to a pre-existing VPC; see newSubnetDetails.
subnetDetails: newSubnetDetails(clusterConfig, false),
}
}

Expand Down
Loading
Loading