Skip to content

Commit 99984ad

Browse files
authored
Add support for vpc.controlPlaneOnPrivateSubnets (#8793)
* Add support for vpc.controlPlaneOnPrivateSubnets When eksctl creates a VPC, it passes both the public and the private subnets to the EKS API, so the control plane's cross-account ENIs are placed in public subnets as well. Restricting them to private subnets afterwards requires `eksctl utils update-cluster-vpc-config`, which calls the EKS API directly and leaves the cluster's CloudFormation stack out of sync. Add a `vpc.controlPlaneOnPrivateSubnets` field so that only the private subnets are passed to the EKS API at creation time. Public subnets are still created and used for NAT gateways and internet-facing load balancers, so this does not make the cluster fully private. The field is honoured for eksctl-created VPCs (IPv4 and IPv6, including fully-private clusters) and for pre-existing VPCs. It is rejected when combined with `vpc.controlPlaneSubnetIDs`, and when the configured private subnets do not cover at least two availability zones, which EKS requires. That last check is best-effort: subnets given only by ID have their zone resolved from EC2 after validation runs, so they are allowed through and rejected by the EKS API instead. Outposts are exempt, since the control plane there is already private-only. `eksctl utils update-cluster-vpc-config` rejects the field rather than silently ignoring it, and points at `vpc.controlPlaneSubnetIDs` instead. Control plane subnet selection is now built through a single helper. autoMode is passed into it rather than derived, so that it keeps applying only to VPCs that eksctl creates: the pre-existing VPC path has never restricted the control plane for Auto Mode clusters, and deriving it would have changed that behaviour for configurations that do not use the new field. Issue #8792 Signed-off-by: guessi <guessi@gmail.com> * Address review feedback on controlPlaneOnPrivateSubnets - Count distinct availability zones in c.AvailabilityZones when validating controlPlaneOnPrivateSubnets for eksctl-created VPCs, instead of assuming SetSubnets guarantees per-zone coverage. Duplicate zones collapse into a single private subnet in vpc.SetSubnets, and validateAvailabilityZones permits duplicates, so this was previously unchecked. - Rework the update-cluster-vpc-config tests for controlPlaneOnPrivateSubnets to assert against cmdutils.NewUpdateClusterVPCLoader(...).Load() directly instead of executing the full command, which reached AWS via NewProviderForExistingCluster. - Change eksctl utils update-cluster-vpc-config to warn and ignore vpc.controlPlaneOnPrivateSubnets instead of returning a hard error, consistent with how eksctl update nodegroup handles other unsupported fields in a config file. - Update the userdocs claim that eksctl validates AZ coverage before creating anything to note the pre-existing-subnets-by-ID exception, where AZs are resolved from EC2 after validation runs. * Don't reject controlPlaneOnPrivateSubnets when AZs are auto-selected validateControlPlaneOnPrivateSubnets ran before eks.SetAvailabilityZones, so when availabilityZones was left unset it saw an empty c.AvailabilityZones and rejected the config with 'got 0 ([])', even though eksctl always selects distinct zones itself on that path. Only enforce the distinct-zone count when availabilityZones is explicitly set, and flip the test that had locked in the rejection to expect success instead. Document the auto-selection exception in the userdocs alongside the existing pre-existing-subnets-by-ID one, as a nested list since the two exceptions no longer read clearly as a single sentence. Signed-off-by: guessi <guessi@gmail.com> * Assert the update-cluster-vpc-config warning and document it The loader test only asserted that no error was returned, which passes just as well if the warn-and-ignore behaviour is dropped entirely. Capture logger output and assert the warning text, and assert the field is not translated into controlPlaneSubnetIDs. Verified the assertion fails when the warning is changed. Document that the field only takes effect at cluster creation time and is warned about and ignored by eksctl utils update-cluster-vpc-config, which the code did but the docs did not mention. Signed-off-by: guessi <guessi@gmail.com> --------- Signed-off-by: guessi <guessi@gmail.com>
1 parent 03c9a0a commit 99984ad

17 files changed

Lines changed: 581 additions & 21 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# An example config for restricting the EKS control plane's cross-account ENIs to private
2+
# subnets at cluster creation time.
3+
# To create the cluster, run `eksctl create cluster -f 49-control-plane-on-private-subnets.yaml`
4+
#
5+
# Public subnets are still created and used for the NAT gateways and for internet-facing load
6+
# balancers; only the subnets passed to the EKS API are restricted. Requires at least two
7+
# private subnets across at least two availability zones.
8+
9+
apiVersion: eksctl.io/v1alpha5
10+
kind: ClusterConfig
11+
metadata:
12+
name: cluster-49
13+
region: us-west-2
14+
15+
availabilityZones:
16+
- us-west-2a
17+
- us-west-2b
18+
19+
vpc:
20+
controlPlaneOnPrivateSubnets: true
21+
nat:
22+
gateway: HighlyAvailable
23+
clusterEndpoints:
24+
publicAccess: true
25+
privateAccess: true
26+
27+
managedNodeGroups:
28+
- name: mng1
29+
privateNetworking: true

pkg/apis/eksctl.io/v1alpha5/assets/schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1181,6 +1181,11 @@
11811181
"description": "controls how the control plane routes egress traffic. Valid values: \"AWS_MANAGED\" (default), \"CUSTOMER_ROUTED\"",
11821182
"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;"
11831183
},
1184+
"controlPlaneOnPrivateSubnets": {
1185+
"type": "boolean",
1186+
"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.",
1187+
"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."
1188+
},
11841189
"controlPlaneSecurityGroupIDs": {
11851190
"items": {
11861191
"type": "string"
@@ -1277,6 +1282,7 @@
12771282
"clusterEndpoints",
12781283
"publicAccessCIDRs",
12791284
"controlPlaneSubnetIDs",
1285+
"controlPlaneOnPrivateSubnets",
12801286
"controlPlaneSecurityGroupIDs",
12811287
"controlPlaneEgressMode"
12821288
],

pkg/apis/eksctl.io/v1alpha5/types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,12 @@ func (c *ClusterConfig) IsControlPlaneOnOutposts() bool {
927927
return c.Outpost != nil && c.Outpost.ControlPlaneOutpostARN != ""
928928
}
929929

930+
// IsControlPlaneOnPrivateSubnets returns true if the control plane's cross-account ENIs
931+
// should be restricted to private subnets only.
932+
func (c *ClusterConfig) IsControlPlaneOnPrivateSubnets() bool {
933+
return c.VPC != nil && IsEnabled(c.VPC.ControlPlaneOnPrivateSubnets)
934+
}
935+
930936
// GetOutpost returns the Outpost info.
931937
func (c *ClusterConfig) GetOutpost() *Outpost {
932938
return c.Outpost

pkg/apis/eksctl.io/v1alpha5/validation.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/kris-nova/logger"
1919

2020
corev1 "k8s.io/api/core/v1"
21+
"k8s.io/apimachinery/pkg/util/sets"
2122
"k8s.io/apimachinery/pkg/util/validation"
2223
kubeletapis "k8s.io/kubelet/pkg/apis"
2324

@@ -501,6 +502,10 @@ func (c *ClusterConfig) ValidateVPCConfig() error {
501502
return errors.New("only one of vpc.securityGroup and vpc.controlPlaneSecurityGroupIDs can be specified")
502503
}
503504

505+
if err := c.validateControlPlaneOnPrivateSubnets(); err != nil {
506+
return err
507+
}
508+
504509
if (c.VPC.IPv6Cidr != "" || c.VPC.IPv6Pool != "") && !c.IPv6Enabled() {
505510
return fmt.Errorf("Ipv6Cidr and Ipv6CidrPool are only supported when IPFamily is set to IPv6")
506511
}
@@ -554,6 +559,79 @@ func (c *ClusterConfig) ValidateVPCConfig() error {
554559
return nil
555560
}
556561

562+
// validateControlPlaneOnPrivateSubnets validates vpc.controlPlaneOnPrivateSubnets against
563+
// the rest of the VPC configuration.
564+
func (c *ClusterConfig) validateControlPlaneOnPrivateSubnets() error {
565+
if !IsEnabled(c.VPC.ControlPlaneOnPrivateSubnets) {
566+
return nil
567+
}
568+
569+
if len(c.VPC.ControlPlaneSubnetIDs) > 0 {
570+
return errors.New("only one of vpc.controlPlaneSubnetIDs and vpc.controlPlaneOnPrivateSubnets can be specified")
571+
}
572+
573+
// The control plane is already restricted to private subnets on Outposts, where a
574+
// single subnet in a single zone is expected, so the checks below do not apply.
575+
if c.IsControlPlaneOnOutposts() {
576+
return nil
577+
}
578+
579+
// Subnets are nil when eksctl creates the VPC. Private subnets are then derived from
580+
// availabilityZones by vpc.SetSubnets, which runs after validation and keys private
581+
// subnets by zone name, so duplicate zones collapse into a single subnet.
582+
// validateAvailabilityZones only checks the count of c.AvailabilityZones and explicitly
583+
// permits duplicates, so distinct zones must be counted here instead.
584+
//
585+
// availabilityZones is optional: when it is left unset, eksctl selects the zones itself
586+
// in eks.SetAvailabilityZones, which runs after validation, so c.AvailabilityZones is
587+
// still empty here. Auto-selection always yields distinct zones, so there is nothing to
588+
// validate on that path.
589+
if c.VPC.Subnets == nil {
590+
if len(c.AvailabilityZones) > 0 {
591+
if azs := sets.New(c.AvailabilityZones...); azs.Len() < MinRequiredAvailabilityZones {
592+
return fmt.Errorf("vpc.controlPlaneOnPrivateSubnets requires at least %d distinct availability zones, got %d (%v)", MinRequiredAvailabilityZones, azs.Len(), c.AvailabilityZones)
593+
}
594+
}
595+
return nil
596+
}
597+
598+
if numPrivate := len(c.VPC.Subnets.Private); numPrivate < MinRequiredSubnets {
599+
return fmt.Errorf("vpc.controlPlaneOnPrivateSubnets requires at least %d private subnets, got %d", MinRequiredSubnets, numPrivate)
600+
}
601+
602+
if azs := distinctSubnetAZs(c.VPC.Subnets.Private); len(azs) < MinRequiredAvailabilityZones {
603+
return fmt.Errorf("vpc.controlPlaneOnPrivateSubnets requires private subnets in at least %d availability zones, got %d (%v)", MinRequiredAvailabilityZones, len(azs), azs)
604+
}
605+
606+
return nil
607+
}
608+
609+
// distinctSubnetAZs returns the unique availability zones covered by the given subnets.
610+
// A subnet's zone is taken from its AZ field, falling back to the mapping key, which is an
611+
// AZ name in the common form.
612+
//
613+
// This is best-effort: subnets given only by ID have their zone resolved from EC2 later, so
614+
// their real zone is unknown here and the mapping key is used instead. Such a configuration
615+
// is allowed through and is rejected by the EKS API if the subnets turn out to share a zone.
616+
// The check is deliberately permissive rather than risk rejecting a valid pre-existing VPC.
617+
func distinctSubnetAZs(subnets AZSubnetMapping) []string {
618+
seen := make(map[string]struct{}, len(subnets))
619+
azs := make([]string, 0, len(subnets))
620+
for key, spec := range subnets {
621+
az := spec.AZ
622+
if az == "" {
623+
az = key
624+
}
625+
if _, ok := seen[az]; ok {
626+
continue
627+
}
628+
seen[az] = struct{}{}
629+
azs = append(azs, az)
630+
}
631+
slices.Sort(azs)
632+
return azs
633+
}
634+
557635
func (c *ClusterConfig) unsupportedVPCCNIAddonVersion() (bool, error) {
558636
for _, addon := range c.Addons {
559637
if addon.Name == VPCCNIAddon {

pkg/apis/eksctl.io/v1alpha5/validation_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1534,6 +1534,142 @@ var _ = Describe("ClusterConfig validation", func() {
15341534
})
15351535
})
15361536

1537+
Context("controlPlaneOnPrivateSubnets", func() {
1538+
privateSubnets := func(azs ...string) api.AZSubnetMapping {
1539+
m := api.NewAZSubnetMapping()
1540+
for i, az := range azs {
1541+
m.Set(fmt.Sprintf("subnet-alias-%d", i), api.AZSubnetSpec{
1542+
ID: fmt.Sprintf("subnet-%d", i),
1543+
AZ: az,
1544+
})
1545+
}
1546+
return m
1547+
}
1548+
1549+
When("it is enabled and eksctl creates the VPC with two distinct availability zones", func() {
1550+
It("does not reject the config, since subnets are derived from availabilityZones later", func() {
1551+
cfg.VPC.Subnets = nil
1552+
cfg.AvailabilityZones = []string{"us-west-2a", "us-west-2b"}
1553+
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
1554+
err = cfg.ValidateVPCConfig()
1555+
Expect(err).NotTo(HaveOccurred())
1556+
})
1557+
})
1558+
1559+
When("it is enabled and eksctl creates the VPC with a duplicated availability zone", func() {
1560+
It("returns an error, since the duplicate collapses into a single private subnet", func() {
1561+
cfg.VPC.Subnets = nil
1562+
cfg.AvailabilityZones = []string{"us-west-2a", "us-west-2a"}
1563+
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
1564+
err = cfg.ValidateVPCConfig()
1565+
Expect(err).To(MatchError("vpc.controlPlaneOnPrivateSubnets requires at least 2 distinct availability zones, got 1 ([us-west-2a us-west-2a])"))
1566+
})
1567+
})
1568+
1569+
When("it is enabled and eksctl creates the VPC without availability zones set", func() {
1570+
It("does not reject the config, since eksctl selects distinct zones itself later", func() {
1571+
cfg.VPC.Subnets = nil
1572+
cfg.AvailabilityZones = nil
1573+
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
1574+
err = cfg.ValidateVPCConfig()
1575+
Expect(err).NotTo(HaveOccurred())
1576+
})
1577+
})
1578+
1579+
When("it is enabled with two private subnets across two AZs", func() {
1580+
It("does not return an error", func() {
1581+
cfg.VPC.Subnets = &api.ClusterSubnets{
1582+
Private: privateSubnets("us-west-2a", "us-west-2b"),
1583+
}
1584+
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
1585+
err = cfg.ValidateVPCConfig()
1586+
Expect(err).NotTo(HaveOccurred())
1587+
})
1588+
})
1589+
1590+
When("it is enabled together with controlPlaneSubnetIDs", func() {
1591+
It("returns an error", func() {
1592+
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
1593+
cfg.VPC.ControlPlaneSubnetIDs = []string{"subnet-1234", "subnet-5678"}
1594+
err = cfg.ValidateVPCConfig()
1595+
Expect(err).To(MatchError("only one of vpc.controlPlaneSubnetIDs and vpc.controlPlaneOnPrivateSubnets can be specified"))
1596+
})
1597+
})
1598+
1599+
When("it is enabled but the VPC has no private subnets", func() {
1600+
It("returns an error instead of silently using public subnets", func() {
1601+
cfg.VPC.Subnets = &api.ClusterSubnets{
1602+
Public: privateSubnets("us-west-2a", "us-west-2b"),
1603+
}
1604+
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
1605+
err = cfg.ValidateVPCConfig()
1606+
Expect(err).To(MatchError("vpc.controlPlaneOnPrivateSubnets requires at least 2 private subnets, got 0"))
1607+
})
1608+
})
1609+
1610+
When("it is enabled with only one private subnet", func() {
1611+
It("returns an error", func() {
1612+
cfg.VPC.Subnets = &api.ClusterSubnets{
1613+
Private: privateSubnets("us-west-2a"),
1614+
}
1615+
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
1616+
err = cfg.ValidateVPCConfig()
1617+
Expect(err).To(MatchError("vpc.controlPlaneOnPrivateSubnets requires at least 2 private subnets, got 1"))
1618+
})
1619+
})
1620+
1621+
When("it is enabled with two private subnets in the same AZ", func() {
1622+
It("returns an error, since EKS requires two availability zones", func() {
1623+
cfg.VPC.Subnets = &api.ClusterSubnets{
1624+
Private: privateSubnets("us-west-2a", "us-west-2a"),
1625+
}
1626+
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
1627+
err = cfg.ValidateVPCConfig()
1628+
Expect(err).To(MatchError("vpc.controlPlaneOnPrivateSubnets requires private subnets in at least 2 availability zones, got 1 ([us-west-2a])"))
1629+
})
1630+
})
1631+
1632+
When("private subnets are given only by ID", func() {
1633+
It("allows the config through, since their zones are resolved from EC2 later", func() {
1634+
subnets := api.NewAZSubnetMapping()
1635+
subnets.Set("alias-a", api.AZSubnetSpec{ID: "subnet-aaa"})
1636+
subnets.Set("alias-b", api.AZSubnetSpec{ID: "subnet-bbb"})
1637+
cfg.VPC.ID = "vpc-123"
1638+
cfg.VPC.Subnets = &api.ClusterSubnets{Private: subnets}
1639+
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
1640+
err = cfg.ValidateVPCConfig()
1641+
Expect(err).NotTo(HaveOccurred())
1642+
})
1643+
})
1644+
1645+
When("one private subnet is keyed by AZ and another repeats that AZ explicitly", func() {
1646+
It("returns an error", func() {
1647+
subnets := api.NewAZSubnetMapping()
1648+
subnets.Set("us-west-2a", api.AZSubnetSpec{ID: "subnet-aaa"})
1649+
subnets.Set("alias-b", api.AZSubnetSpec{ID: "subnet-bbb", AZ: "us-west-2a"})
1650+
cfg.VPC.ID = "vpc-123"
1651+
cfg.VPC.Subnets = &api.ClusterSubnets{Private: subnets}
1652+
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
1653+
err = cfg.ValidateVPCConfig()
1654+
Expect(err).To(MatchError("vpc.controlPlaneOnPrivateSubnets requires private subnets in at least 2 availability zones, got 1 ([us-west-2a])"))
1655+
})
1656+
})
1657+
1658+
When("it is enabled on Outposts", func() {
1659+
It("does not enforce the multi-AZ requirement", func() {
1660+
cfg.VPC.Subnets = &api.ClusterSubnets{
1661+
Private: privateSubnets("us-west-2a"),
1662+
}
1663+
cfg.VPC.ControlPlaneOnPrivateSubnets = api.Enabled()
1664+
cfg.Outpost = &api.Outpost{
1665+
ControlPlaneOutpostARN: "arn:aws:outposts:us-west-2:1234:outpost/op-1234",
1666+
}
1667+
err = cfg.ValidateVPCConfig()
1668+
Expect(err).NotTo(HaveOccurred())
1669+
})
1670+
})
1671+
})
1672+
15371673
Context("ipv6 CIDRs", func() {
15381674
When("IPv6Cidr or IPv6CidrPool is provided and ipv6 is not set", func() {
15391675
It("returns an error", func() {

pkg/apis/eksctl.io/v1alpha5/vpc.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,14 @@ type (
178178
// ControlPlaneSubnetIDs configures the subnets for the control plane.
179179
// +optional
180180
ControlPlaneSubnetIDs []string `json:"controlPlaneSubnetIDs,omitempty"`
181+
// ControlPlaneOnPrivateSubnets restricts the control plane (the cross-account ENIs
182+
// that EKS places in the cluster subnets) to private subnets only, excluding public
183+
// subnets. It applies both when eksctl creates the VPC and when a pre-existing VPC
184+
// is used. Cannot be combined with ControlPlaneSubnetIDs. Requires at least two
185+
// private subnets spanning at least two availability zones, which must have NAT or
186+
// the relevant VPC endpoints for nodes to reach the API server.
187+
// +optional
188+
ControlPlaneOnPrivateSubnets *bool `json:"controlPlaneOnPrivateSubnets,omitempty"`
181189
// ControlPlaneSecurityGroupIDs configures the security groups for the control plane.
182190
// +optional
183191
ControlPlaneSecurityGroupIDs []string `json:"controlPlaneSecurityGroupIDs,omitempty"`

pkg/apis/eksctl.io/v1alpha5/zz_generated.deepcopy.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)