Skip to content

Commit f08fe12

Browse files
authored
[1.14] ipam: Support for static IP allocation in AWS (#569)
1 parent 833ff8a commit f08fe12

File tree

18 files changed

+289
-2
lines changed

18 files changed

+289
-2
lines changed

pkg/alibabacloud/eni/node.go

+5
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,11 @@ func (n *Node) AllocateIPs(ctx context.Context, a *ipam.AllocationAction) error
306306
return err
307307
}
308308

309+
func (n *Node) AllocateStaticIP(ctx context.Context, staticIPTags ipamTypes.Tags) (string, error) {
310+
// TODO
311+
return "", nil
312+
}
313+
309314
// PrepareIPRelease prepares the release of ENI IPs.
310315
func (n *Node) PrepareIPRelease(excessIPs int, scopedLog *logrus.Entry) *ipam.ReleaseAction {
311316
r := &ipam.ReleaseAction{}

pkg/aws/ec2/ec2.go

+58-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import (
1414
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
1515
"github.com/aws/aws-sdk-go-v2/service/ec2"
1616
ec2_types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
17-
log "github.com/sirupsen/logrus"
1817

1918
"github.com/cilium/cilium/pkg/api/helpers"
2019
"github.com/cilium/cilium/pkg/aws/endpoints"
@@ -24,6 +23,8 @@ import (
2423
ipPkg "github.com/cilium/cilium/pkg/ip"
2524
"github.com/cilium/cilium/pkg/ipam/option"
2625
ipamTypes "github.com/cilium/cilium/pkg/ipam/types"
26+
"github.com/cilium/cilium/pkg/logging"
27+
"github.com/cilium/cilium/pkg/logging/logfields"
2728
"github.com/cilium/cilium/pkg/spanstat"
2829
)
2930

@@ -39,6 +40,8 @@ const (
3940
InvalidParameterValueStr = "InvalidParameterValue"
4041
)
4142

43+
var log = logging.DefaultLogger.WithField(logfields.LogSubsys, "ec2")
44+
4245
// Client represents an EC2 API client
4346
type Client struct {
4447
ec2Client *ec2.Client
@@ -388,6 +391,10 @@ func parseENI(iface *ec2_types.NetworkInterface, vpcs ipamTypes.VirtualNetworkMa
388391
eni.Prefixes = append(eni.Prefixes, aws.ToString(prefix.Ipv4Prefix))
389392
}
390393

394+
if iface.Association != nil && aws.ToString(iface.Association.PublicIp) != "" {
395+
eni.PublicIP = aws.ToString(iface.Association.PublicIp)
396+
}
397+
391398
for _, g := range iface.Groups {
392399
if g.GroupId != nil {
393400
eni.SecurityGroups = append(eni.SecurityGroups, aws.ToString(g.GroupId))
@@ -691,6 +698,56 @@ func (c *Client) UnassignENIPrefixes(ctx context.Context, eniID string, prefixes
691698
return err
692699
}
693700

701+
func (c *Client) AssociateEIP(ctx context.Context, instanceID string, eipTags ipamTypes.Tags) (string, error) {
702+
if len(eipTags) == 0 {
703+
return "", fmt.Errorf("no EIP tags were provided")
704+
}
705+
706+
filters := make([]ec2_types.Filter, 0, len(eipTags))
707+
for k, v := range eipTags {
708+
filters = append(filters, ec2_types.Filter{
709+
Name: aws.String(fmt.Sprintf("tag:%s", k)),
710+
Values: []string{v},
711+
})
712+
}
713+
714+
describeAddressesInput := &ec2.DescribeAddressesInput{
715+
Filters: filters,
716+
}
717+
c.limiter.Limit(ctx, "DescribeAddresses")
718+
sinceStart := spanstat.Start()
719+
addresses, err := c.ec2Client.DescribeAddresses(ctx, describeAddressesInput)
720+
c.metricsAPI.ObserveAPICall("DescribeAddresses", deriveStatus(err), sinceStart.Seconds())
721+
if err != nil {
722+
return "", err
723+
}
724+
log.Infof("Found %d EIPs corresponding to tags %v", len(addresses.Addresses), eipTags)
725+
726+
for _, address := range addresses.Addresses {
727+
// Only pick unassociated EIPs
728+
if address.AssociationId == nil {
729+
associateAddressInput := &ec2.AssociateAddressInput{
730+
AllocationId: address.AllocationId,
731+
AllowReassociation: aws.Bool(false),
732+
InstanceId: aws.String(instanceID),
733+
}
734+
c.limiter.Limit(ctx, "AssociateAddress")
735+
sinceStart = spanstat.Start()
736+
association, err := c.ec2Client.AssociateAddress(ctx, associateAddressInput)
737+
c.metricsAPI.ObserveAPICall("AssociateAddress", deriveStatus(err), sinceStart.Seconds())
738+
if err != nil {
739+
// In case there is an issue with associating the EIP, this will be retried in the next
740+
// iteration of the reconciliation loop
741+
return "", err
742+
}
743+
log.Infof("Associated EIP %s with instance %s (association ID: %s)", *address.PublicIp, instanceID, *association.AssociationId)
744+
return *address.PublicIp, nil
745+
}
746+
}
747+
748+
return "", fmt.Errorf("no unassociated EIPs found for tags %v", eipTags)
749+
}
750+
694751
func createAWSTagSlice(tags map[string]string) []ec2_types.Tag {
695752
awsTags := make([]ec2_types.Tag, 0, len(tags))
696753
for k, v := range tags {

pkg/aws/ec2/mock/mock.go

+29
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const (
4141
AssignPrivateIpAddresses
4242
UnassignPrivateIpAddresses
4343
TagENI
44+
AssociateEIP
4445
MaxOperation
4546
)
4647

@@ -531,6 +532,34 @@ func (e *API) UnassignENIPrefixes(ctx context.Context, eniID string, prefixes []
531532
return fmt.Errorf("Unable to find ENI with ID %s", eniID)
532533
}
533534

535+
func (e *API) AssociateEIP(ctx context.Context, instanceID string, eipTags ipamTypes.Tags) (string, error) {
536+
e.rateLimit()
537+
e.delaySim.Delay(AssociateEIP)
538+
539+
e.mutex.Lock()
540+
defer e.mutex.Unlock()
541+
542+
if err, ok := e.errors[AssociateEIP]; ok {
543+
return "", err
544+
}
545+
546+
ipAddr := "192.0.2.254"
547+
548+
// Assign the EIP to the ENI 0 of the instance
549+
for iid, enis := range e.enis {
550+
if iid == instanceID {
551+
for _, eni := range enis {
552+
if eni.Number == 0 {
553+
eni.PublicIP = ipAddr
554+
return ipAddr, nil
555+
}
556+
}
557+
}
558+
}
559+
560+
return "", fmt.Errorf("unable to find ENI 0 for instance %s", instanceID)
561+
}
562+
534563
func (e *API) GetInstances(ctx context.Context, vpcs ipamTypes.VirtualNetworkMap, subnets ipamTypes.SubnetMap) (*ipamTypes.InstanceMap, error) {
535564
instances := ipamTypes.NewInstanceMap()
536565

pkg/aws/eni/instances.go

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type EC2API interface {
3434
UnassignPrivateIpAddresses(ctx context.Context, eniID string, addresses []string) error
3535
AssignENIPrefixes(ctx context.Context, eniID string, prefixes int32) error
3636
UnassignENIPrefixes(ctx context.Context, eniID string, prefixes []string) error
37+
AssociateEIP(ctx context.Context, instanceID string, eipTags ipamTypes.Tags) (string, error)
3738
}
3839

3940
// InstancesManager maintains the list of instances. It must be kept up to date

pkg/aws/eni/node.go

+9
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,10 @@ func (n *Node) AllocateIPs(ctx context.Context, a *ipam.AllocationAction) error
295295
return n.manager.api.AssignPrivateIpAddresses(ctx, a.InterfaceID, int32(a.AvailableForAllocation))
296296
}
297297

298+
func (n *Node) AllocateStaticIP(ctx context.Context, staticIPTags ipamTypes.Tags) (string, error) {
299+
return n.manager.api.AssociateEIP(ctx, n.node.InstanceID(), staticIPTags)
300+
}
301+
298302
func (n *Node) getSecurityGroupIDs(ctx context.Context, eniSpec eniTypes.ENISpec) ([]string, error) {
299303
// 1. check explicit security groups associations via checking Spec.ENI.SecurityGroups
300304
// 2. check if Spec.ENI.SecurityGroupTags is passed and if so filter by those
@@ -600,6 +604,11 @@ func (n *Node) ResyncInterfacesAndIPs(ctx context.Context, scopedLog *logrus.Ent
600604
for _, ip := range e.Addresses {
601605
available[ip] = ipamTypes.AllocationIP{Resource: e.ID}
602606
}
607+
608+
if e.Number == 0 && e.PublicIP != "" {
609+
stats.AssignedStaticIP = e.PublicIP
610+
}
611+
603612
return nil
604613
})
605614
enis := len(n.enis)

pkg/aws/eni/node_manager_test.go

+84
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,84 @@ func (e *ENISuite) TestInstanceBeenDeleted(c *check.C) {
844844
c.Assert(node.Stats().ExcessIPs, check.Equals, 0)
845845
}
846846

847+
// TestNodeManagerStaticIP tests allocation with a static IP
848+
//
849+
// - m5.large (3x ENIs, 2x10-2 IPs)
850+
// - MinAllocate 0
851+
// - MaxAllocate 0
852+
// - PreAllocate 8
853+
// - FirstInterfaceIndex 0
854+
func (e *ENISuite) TestNodeManagerStaticIP(c *check.C) {
855+
const instanceID = "i-testNodeManagerStaticIP-0"
856+
857+
ec2api := ec2mock.NewAPI([]*ipamTypes.Subnet{testSubnet}, []*ipamTypes.VirtualNetwork{testVpc}, testSecurityGroups)
858+
instances := NewInstancesManager(ec2api)
859+
c.Assert(instances, check.Not(check.IsNil))
860+
eniID1, _, err := ec2api.CreateNetworkInterface(context.TODO(), 0, "s-1", "desc", []string{"sg1", "sg2"}, false)
861+
c.Assert(err, check.IsNil)
862+
_, err = ec2api.AttachNetworkInterface(context.TODO(), 0, instanceID, eniID1)
863+
c.Assert(err, check.IsNil)
864+
instances.Resync(context.TODO())
865+
mngr, err := ipam.NewNodeManager(instances, k8sapi, metricsapi, 10, false, false)
866+
c.Assert(err, check.IsNil)
867+
c.Assert(mngr, check.Not(check.IsNil))
868+
869+
staticIPTags := map[string]string{"some-eip-tag": "some-value"}
870+
cn := newCiliumNode("node1", withTestDefaults(), withInstanceID(instanceID), withInstanceType("m5.large"), withIPAMPreAllocate(8), withIPAMStaticIPTags(staticIPTags))
871+
mngr.Upsert(cn)
872+
c.Assert(testutils.WaitUntil(func() bool { return reachedAddressesNeeded(mngr, "node1", 0) }, 5*time.Second), check.IsNil)
873+
874+
node := mngr.Get("node1")
875+
c.Assert(node, check.Not(check.IsNil))
876+
c.Assert(node.Stats().AvailableIPs, check.Equals, 8)
877+
c.Assert(node.Stats().UsedIPs, check.Equals, 0)
878+
879+
// Use 1 IP
880+
mngr.Upsert(updateCiliumNode(cn, 8, 1))
881+
c.Assert(testutils.WaitUntil(func() bool { return reachedAddressesNeeded(mngr, "node1", 0) }, 5*time.Second), check.IsNil)
882+
883+
node = mngr.Get("node1")
884+
c.Assert(node, check.Not(check.IsNil))
885+
// Verify that the static IP has been successfully assigned
886+
c.Assert(node.Stats().AssignedStaticIP, check.Equals, "192.0.2.254")
887+
}
888+
889+
// TestNodeManagerStaticIPAlreadyAssociated verifies that when an ENI already has a public IP assigned to it, it is properly detected
890+
//
891+
// - m5.large (3x ENIs, 2x10-2 IPs)
892+
// - MinAllocate 0
893+
// - MaxAllocate 0
894+
// - PreAllocate 8
895+
// - FirstInterfaceIndex 0
896+
func (e *ENISuite) TestNodeManagerStaticIPAlreadyAssociated(c *check.C) {
897+
const instanceID = "i-testNodeManagerStaticIPAlreadyAssociated-0"
898+
899+
ec2api := ec2mock.NewAPI([]*ipamTypes.Subnet{testSubnet}, []*ipamTypes.VirtualNetwork{testVpc}, testSecurityGroups)
900+
instances := NewInstancesManager(ec2api)
901+
c.Assert(instances, check.Not(check.IsNil))
902+
eniID1, _, err := ec2api.CreateNetworkInterface(context.TODO(), 0, "s-1", "desc", []string{"sg1", "sg2"}, false)
903+
c.Assert(err, check.IsNil)
904+
_, err = ec2api.AttachNetworkInterface(context.TODO(), 0, instanceID, eniID1)
905+
c.Assert(err, check.IsNil)
906+
staticIP, err := ec2api.AssociateEIP(context.TODO(), instanceID, make(ipamTypes.Tags))
907+
c.Assert(err, check.IsNil)
908+
instances.Resync(context.TODO())
909+
mngr, err := ipam.NewNodeManager(instances, k8sapi, metricsapi, 10, false, false)
910+
c.Assert(err, check.IsNil)
911+
c.Assert(mngr, check.Not(check.IsNil))
912+
913+
cn := newCiliumNode("node1", withTestDefaults(), withInstanceID(instanceID), withInstanceType("m5.large"), withIPAMPreAllocate(8))
914+
mngr.Upsert(cn)
915+
c.Assert(testutils.WaitUntil(func() bool { return reachedAddressesNeeded(mngr, "node1", 0) }, 5*time.Second), check.IsNil)
916+
917+
node := mngr.Get("node1")
918+
c.Assert(node, check.Not(check.IsNil))
919+
c.Assert(node.Stats().AvailableIPs, check.Equals, 8)
920+
c.Assert(node.Stats().UsedIPs, check.Equals, 0)
921+
// Verify that the static IP which has already been assigned to the ENI has been successfully detected
922+
c.Assert(node.Stats().AssignedStaticIP, check.Equals, staticIP)
923+
}
924+
847925
func benchmarkAllocWorker(c *check.C, workers int64, delay time.Duration, rateLimit float64, burst int) {
848926
testSubnet1 := &ipamTypes.Subnet{ID: "s-1", AvailabilityZone: "us-west-1", VirtualNetworkID: "vpc-1", AvailableAddresses: 1000000}
849927
testSubnet2 := &ipamTypes.Subnet{ID: "s-2", AvailabilityZone: "us-west-1", VirtualNetworkID: "vpc-1", AvailableAddresses: 1000000}
@@ -1004,6 +1082,12 @@ func withIPAMMaxAboveWatermark(aboveWM int) func(*v2.CiliumNode) {
10041082
}
10051083
}
10061084

1085+
func withIPAMStaticIPTags(tags map[string]string) func(*v2.CiliumNode) {
1086+
return func(cn *v2.CiliumNode) {
1087+
cn.Spec.IPAM.StaticIPTags = tags
1088+
}
1089+
}
1090+
10071091
func withExcludeInterfaceTags(tags map[string]string) func(*v2.CiliumNode) {
10081092
return func(cn *v2.CiliumNode) {
10091093
cn.Spec.ENI.ExcludeInterfaceTags = tags

pkg/aws/eni/types/types.go

+3
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,9 @@ type ENI struct {
206206
//
207207
// +optional
208208
Tags map[string]string `json:"tags,omitempty"`
209+
210+
// +optional
211+
PublicIP string `json:"public-ip,omitempty"`
209212
}
210213

211214
func (e *ENI) DeepCopyInterface() types.Interface {

pkg/azure/ipam/node.go

+5
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ func (n *Node) AllocateIPs(ctx context.Context, a *ipam.AllocationAction) error
130130
}
131131
}
132132

133+
func (n *Node) AllocateStaticIP(ctx context.Context, staticIPTags ipamTypes.Tags) (string, error) {
134+
// TODO
135+
return "", nil
136+
}
137+
133138
// CreateInterface is called to create a new interface. This operation is
134139
// currently not supported on Azure.
135140
func (n *Node) CreateInterface(ctx context.Context, allocation *ipam.AllocationAction, scopedLog *logrus.Entry) (int, string, error) {

pkg/ipam/node.go

+32
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ type Statistics struct {
167167
// EmptyInterfaceSlots is the number of empty interface slots available
168168
// for interfaces to be attached
169169
EmptyInterfaceSlots int
170+
171+
AssignedStaticIP string
170172
}
171173

172174
// IsRunning returns true if the node is considered to be running
@@ -264,6 +266,13 @@ func (n *Node) getMaxAllocate() int {
264266
return instanceMax
265267
}
266268

269+
func (n *Node) getStaticIPTags() ipamTypes.Tags {
270+
if n.resource.Spec.IPAM.StaticIPTags != nil {
271+
return n.resource.Spec.IPAM.StaticIPTags
272+
}
273+
return ipamTypes.Tags{}
274+
}
275+
267276
// GetNeededAddresses returns the number of needed addresses that need to be
268277
// allocated or released. A positive number is returned to indicate allocation.
269278
// A negative number is returned to indicate release of addresses.
@@ -431,6 +440,10 @@ func (n *Node) recalculate() {
431440
}
432441

433442
n.available = a
443+
if stats.AssignedStaticIP != "" {
444+
n.stats.AssignedStaticIP = stats.AssignedStaticIP
445+
}
446+
n.stats.AssignedStaticIP = stats.AssignedStaticIP
434447
n.stats.UsedIPs = len(n.resource.Status.IPAM.Used)
435448

436449
// Get used IP count with prefixes included
@@ -887,6 +900,16 @@ func (n *Node) maintainIPPool(ctx context.Context) (instanceMutated bool, err er
887900
n.removeStaleReleaseIPs()
888901
}
889902

903+
if len(n.getStaticIPTags()) >= 1 {
904+
if n.stats.AssignedStaticIP == "" {
905+
ip, err := n.ops.AllocateStaticIP(ctx, n.getStaticIPTags())
906+
if err != nil {
907+
return false, err
908+
}
909+
n.stats.AssignedStaticIP = ip
910+
}
911+
}
912+
890913
a, err := n.determineMaintenanceAction()
891914
if err != nil {
892915
n.abortNoLongerExcessIPs(nil)
@@ -981,6 +1004,14 @@ func (n *Node) PopulateIPReleaseStatus(node *v2.CiliumNode) {
9811004
node.Status.IPAM.ReleaseIPs = releaseStatus
9821005
}
9831006

1007+
func (n *Node) PopulateStaticIPStatus(node *v2.CiliumNode) {
1008+
n.mutex.Lock()
1009+
defer n.mutex.Unlock()
1010+
if n.stats.AssignedStaticIP != "" {
1011+
node.Status.IPAM.AssignedStaticIP = n.stats.AssignedStaticIP
1012+
}
1013+
}
1014+
9841015
// syncToAPIServer synchronizes the contents of the CiliumNode resource
9851016
// [(*Node).resource)] with the K8s apiserver. This operation occurs on an
9861017
// interval to refresh the CiliumNode resource.
@@ -1029,6 +1060,7 @@ func (n *Node) syncToAPIServer() (err error) {
10291060

10301061
n.ops.PopulateStatusFields(node)
10311062
n.PopulateIPReleaseStatus(node)
1063+
n.PopulateStaticIPStatus(node)
10321064

10331065
err = n.update(origNode, node, retry, true)
10341066
if err == nil {

pkg/ipam/node_manager.go

+2
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ type NodeOperations interface {
7575
// to perform the actual allocation.
7676
AllocateIPs(ctx context.Context, allocation *AllocationAction) error
7777

78+
AllocateStaticIP(ctx context.Context, staticIPTags ipamTypes.Tags) (string, error)
79+
7880
// PrepareIPRelease is called to calculate whether any IP excess needs
7981
// to be resolved. It behaves identical to PrepareIPAllocation but
8082
// indicates a need to release IPs.

pkg/ipam/node_manager_test.go

+4
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ func (n *nodeOperationsMock) AllocateIPs(ctx context.Context, allocation *Alloca
123123
return nil
124124
}
125125

126+
func (n *nodeOperationsMock) AllocateStaticIP(ctx context.Context, staticIPTags ipamTypes.Tags) (string, error) {
127+
return "", nil
128+
}
129+
126130
func (n *nodeOperationsMock) PrepareIPRelease(excessIPs int, scopedLog *logrus.Entry) *ReleaseAction {
127131
n.mutex.RLock()
128132
excessIPs = math.IntMin(excessIPs, len(n.allocatedIPs))

0 commit comments

Comments
 (0)