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
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,18 @@ Request Body:

If this annotation is specified, the other annotations which define the load balancer features will be ignored.

- `loadbalancer.openstack.org/load-balancer-tags`

A comma-separated list of tags to add to the load balancer resource in addition to the tags managed by OCCM. Example: `env=prod,team=network`.

- `loadbalancer.openstack.org/listener-tags`

A comma-separated list of tags to add to the load balancer listener resources in addition to the tags managed by OCCM. Example: `env=prod,team=network`.

- `loadbalancer.openstack.org/pool-tags`

A comma-separated list of tags to add to the load balancer pool resources. Example: `env=prod,team=network`.

- `loadbalancer.openstack.org/hostname`

This annotations explicitly sets a hostname in the status of the load balancer service.
Expand Down
113 changes: 88 additions & 25 deletions pkg/openstack/loadbalancer.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ const (
defaultProxyHostnameSuffix = "nip.io"
ServiceAnnotationLoadBalancerID = "loadbalancer.openstack.org/load-balancer-id"

// ServiceAnnotationLoadBalancerTags is used to set additional tags on the loadbalancer resource itself (comma-separated list).
ServiceAnnotationLoadBalancerTags = "loadbalancer.openstack.org/load-balancer-tags"
// ServiceAnnotationListenerTags is used to set additional tags on the loadbalancer listener resources (comma-separated list).
ServiceAnnotationListenerTags = "loadbalancer.openstack.org/listener-tags"
// ServiceAnnotationPoolTags is used to set additional tags on the loadbalancer pool resources (comma-separated list).
ServiceAnnotationPoolTags = "loadbalancer.openstack.org/pool-tags"

// Octavia resources name formats
servicePrefix = "kube_service_"
lbFormat = "%s%s_%s_%s"
Expand Down Expand Up @@ -146,6 +153,9 @@ type serviceConfig struct {
healthMonitorMaxRetries int
healthMonitorMaxRetriesDown int
preferredIPFamily corev1.IPFamily // preferred (the first) IP family indicated in service's `spec.ipFamilies`
lbTags string
listenerTags string
poolTags string
}

type listenerKey struct {
Expand Down Expand Up @@ -197,6 +207,37 @@ func getLoadbalancerByName(ctx context.Context, client *gophercloud.ServiceClien
return &validLBs[0], nil
}

// dedupTags returns tags with duplicates removed, preserving first-seen order.
func dedupTags(tags []string) []string {
seen := sets.NewString()
result := make([]string, 0, len(tags))
for _, t := range tags {
if !seen.Has(t) {
seen.Insert(t)
result = append(result, t)
}
}
return result
}

// withLBNameTag returns the LB name (OCCM's ownership tag) followed by the
// comma-separated tags from the given Service annotation value, with duplicate
// tags removed (including ones that duplicate the LB name) so no tag can appear
// twice.
func withLBNameTag(lbName, annotation string) []string {
return dedupTags(append([]string{lbName}, cpoutil.SplitTrim(annotation, ',')...))
}

// mergeTags merges existedTags and newTags, returns true if all newTags are already in existedTags.
func mergeTags(existedTags []string, newTags []string) (bool, []string) {
tagSet := sets.NewString(existedTags...)
if tagSet.HasAll(newTags...) {
return true, nil
}

return false, tagSet.Union(sets.NewString(newTags...)).List()
}

func popListener(existingListeners []listeners.Listener, id string) []listeners.Listener {
newListeners := []listeners.Listener{}
for _, existingListener := range existingListeners {
Expand Down Expand Up @@ -235,7 +276,7 @@ func (lbaas *LbaasV2) createOctaviaLoadBalancer(ctx context.Context, name, clust
}

if svcConf.supportLBTags {
createOpts.Tags = []string{svcConf.lbName}
createOpts.Tags = withLBNameTag(svcConf.lbName, svcConf.lbTags)
}

if svcConf.flavorID != "" {
Expand Down Expand Up @@ -910,24 +951,39 @@ func (lbaas *LbaasV2) ensureOctaviaPool(ctx context.Context, lbID string, name s
// if LBMethod is not defined, fallback on default OCCM's default method
poolLbMethod = lbaas.opts.LBMethod
}
if pool != nil && pool.LBMethod != poolLbMethod {
Comment thread
alexanderstephan marked this conversation as resolved.
klog.InfoS("Updating LoadBalancer LBMethod", "poolID", pool.ID, "listenerID", listener.ID, "lbID", lbID)
err = openstackutil.UpdatePool(ctx, lbaas.lb, lbID, pool.ID, v2pools.UpdateOpts{LBMethod: v2pools.LBMethod(poolLbMethod)})
if err != nil {
err = PreserveGopherError(err)
msg := fmt.Sprintf("Error updating LB method for LoadBalancer: %v", err)
klog.Errorf(msg, "poolID", pool.ID, "listenerID", listener.ID, "lbID", lbID)
lbaas.eventRecorder.Event(service, corev1.EventTypeWarning, eventLBLbMethodUnknown, msg)
} else {
pool.LBMethod = poolLbMethod
Comment thread
alexanderstephan marked this conversation as resolved.
poolTags := dedupTags(cpoutil.SplitTrim(svcConf.poolTags, ','))

if pool != nil {
updateOpts := v2pools.UpdateOpts{}
if pool.LBMethod != poolLbMethod {
updateOpts.LBMethod = v2pools.LBMethod(poolLbMethod)
}
if svcConf.supportLBTags && len(poolTags) > 0 {
klog.V(4).Infof("Desired pool tags: %+v from service annotation key: %s", poolTags, ServiceAnnotationPoolTags)
if ok, tags := mergeTags(pool.Tags, poolTags); !ok {
updateOpts.Tags = &tags
}
}
if updateOpts != (v2pools.UpdateOpts{}) {
klog.InfoS("Updating pool", "poolID", pool.ID, "listenerID", listener.ID, "lbID", lbID, "lbMethod", updateOpts.LBMethod, "tags", updateOpts.Tags)
err = openstackutil.UpdatePool(ctx, lbaas.lb, lbID, pool.ID, updateOpts)
if err != nil {
err = PreserveGopherError(err)
msg := fmt.Sprintf("Error updating pool for LoadBalancer: %v", err)
klog.Errorf(msg, "poolID", pool.ID, "listenerID", listener.ID, "lbID", lbID)
lbaas.eventRecorder.Event(service, corev1.EventTypeWarning, eventLBLbMethodUnknown, msg)
}
}
}

if pool == nil {
createOpt := lbaas.buildPoolCreateOpt(listener.Protocol, service, svcConf, name)
createOpt.ListenerID = listener.ID

if svcConf.supportLBTags {
createOpt.Tags = poolTags
}
klog.InfoS("Creating pool", "listenerID", listener.ID, "protocol", createOpt.Protocol)
klog.V(4).Infof("Pool create options: %+v", createOpt)
pool, err = openstackutil.CreatePool(ctx, lbaas.lb, createOpt, lbID)
if err != nil {
return nil, err
Expand Down Expand Up @@ -1099,11 +1155,11 @@ func (lbaas *LbaasV2) ensureOctaviaListener(ctx context.Context, lbID string, na
updateOpts := listeners.UpdateOpts{}

if svcConf.supportLBTags {
if !slices.Contains(listener.Tags, svcConf.lbName) {
var newTags []string
copy(newTags, listener.Tags)
newTags = append(newTags, svcConf.lbName)
updateOpts.Tags = &newTags
// Ensure the LB name tag plus any desired tags from the Service annotation.
listenerTags := withLBNameTag(svcConf.lbName, svcConf.listenerTags)
if ok, tags := mergeTags(listener.Tags, listenerTags); !ok {
klog.V(4).Infof("Will update listener tags, current listener tags: %+v, desired tags: %+v", listener.Tags, tags)
updateOpts.Tags = &tags
listenerChanged = true
}
}
Expand Down Expand Up @@ -1177,7 +1233,8 @@ func (lbaas *LbaasV2) buildListenerCreateOpt(ctx context.Context, port corev1.Se
}

if svcConf.supportLBTags {
listenerCreateOpt.Tags = []string{svcConf.lbName}
// The LB name is always tagged, plus any desired tags from the Service annotation.
listenerCreateOpt.Tags = withLBNameTag(svcConf.lbName, svcConf.listenerTags)
}

if openstackutil.IsOctaviaFeatureSupported(ctx, lbaas.lb, openstackutil.OctaviaFeatureTimeout, lbaas.opts.LBProvider) {
Expand Down Expand Up @@ -1543,6 +1600,11 @@ func (lbaas *LbaasV2) makeSvcConf(ctx context.Context, serviceName string, servi
svcConf.poolLbMethod = getStringFromServiceAnnotation(service, ServiceAnnotationLoadBalancerLbMethod, "")
svcConf.supportLBTags = openstackutil.IsOctaviaFeatureSupported(ctx, lbaas.lb, openstackutil.OctaviaFeatureTags, lbaas.opts.LBProvider)

annotations := service.GetAnnotations()
svcConf.lbTags = annotations[ServiceAnnotationLoadBalancerTags]
svcConf.listenerTags = annotations[ServiceAnnotationListenerTags]
svcConf.poolTags = annotations[ServiceAnnotationPoolTags]

// Get service node-selector annotations
svcConf.nodeSelectors = getKeyValueFromServiceAnnotation(service, ServiceAnnotationLoadBalancerNodeSelector, lbaas.opts.NodeSelector)
for key, value := range svcConf.nodeSelectors {
Expand Down Expand Up @@ -1826,15 +1888,16 @@ func (lbaas *LbaasV2) ensureOctaviaLoadBalancer(ctx context.Context, clusterName
// save address into the annotation
lbaas.updateServiceAnnotation(service, ServiceAnnotationLoadBalancerAddress, addr)

// add LB name to load balancer tags.
Comment thread
alexanderstephan marked this conversation as resolved.
// Ensure the LB name tag plus any tags from the Service annotation in a single update.
if svcConf.supportLBTags {
lbTags := loadbalancer.Tags
if !slices.Contains(lbTags, lbName) {
lbTags = append(lbTags, lbName)
klog.InfoS("Updating load balancer tags", "lbID", loadbalancer.ID, "tags", lbTags)
if err := openstackutil.UpdateLoadBalancerTags(ctx, lbaas.lb, loadbalancer.ID, lbTags); err != nil {
return nil, err
lbTags := withLBNameTag(lbName, svcConf.lbTags)
klog.V(4).Infof("Desired load balancer tags: %v (LB name plus annotation %s)", lbTags, ServiceAnnotationLoadBalancerTags)
if ok, tags := mergeTags(loadbalancer.Tags, lbTags); !ok {
klog.InfoS("Updating load balancer tags", "lbID", loadbalancer.ID, "tags", tags)
if err := openstackutil.UpdateLoadBalancerTags(ctx, lbaas.lb, loadbalancer.ID, tags); err != nil {
return nil, fmt.Errorf("failed to update load balancer %s tags: %w", loadbalancer.ID, err)
}
loadbalancer.Tags = tags
}
}

Expand Down
140 changes: 140 additions & 0 deletions pkg/openstack/loadbalancer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,67 @@ type testGetRulesToCreateAndDelete struct {
toDelete []rules.SecGroupRule
}

func TestMergeTags(t *testing.T) {
testCases := []struct {
name string
existedTags []string
newTags []string
expectedOK bool
expectedTags []string
}{
{
name: "nil existing tags returns desired tags",
existedTags: nil,
newTags: []string{"a", "b"},
expectedOK: false,
expectedTags: []string{"a", "b"},
},
{
name: "empty existing tags returns desired tags",
existedTags: []string{},
newTags: []string{"a"},
expectedOK: false,
expectedTags: []string{"a"},
},
{
name: "all desired tags already present",
existedTags: []string{"a", "b", "c"},
newTags: []string{"a", "b"},
expectedOK: true,
expectedTags: nil,
},
{
name: "some desired tags missing are merged and sorted",
existedTags: []string{"b", "d"},
newTags: []string{"a", "c"},
expectedOK: false,
expectedTags: []string{"a", "b", "c", "d"},
},
{
name: "empty desired tags with existing tags is a no-op",
existedTags: []string{"a"},
newTags: []string{},
expectedOK: true,
expectedTags: nil,
},
{
name: "empty existing and empty desired tags is a no-op",
existedTags: []string{},
newTags: []string{},
expectedOK: true,
expectedTags: nil,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ok, tags := mergeTags(tc.existedTags, tc.newTags)
assert.Equal(t, tc.expectedOK, ok)
assert.Equal(t, tc.expectedTags, tags)
})
}
}

func TestGetRulesToCreateAndDelete(t *testing.T) {
tests := []testGetRulesToCreateAndDelete{
{
Expand Down Expand Up @@ -2485,6 +2546,85 @@ func TestBuildListenerCreateOpt(t *testing.T) {
Tags: nil,
},
},
{
name: "Test with LB tags support and no listener-tags annotation",
port: corev1.ServicePort{
Protocol: "TCP",
Port: 80,
},
svcConf: &serviceConfig{
connLimit: 100,
lbName: "my-lb",
supportLBTags: true,
},
expectedCreateOpt: listeners.CreateOpts{
Name: "Test with LB tags support and no listener-tags annotation",
Protocol: listeners.ProtocolTCP,
ProtocolPort: 80,
ConnLimit: &svcConf.connLimit,
Tags: []string{"my-lb"},
},
},
{
name: "Test with LB tags support and listener-tags annotation",
port: corev1.ServicePort{
Protocol: "TCP",
Port: 80,
},
svcConf: &serviceConfig{
connLimit: 100,
lbName: "my-lb",
supportLBTags: true,
listenerTags: "foo, bar",
},
expectedCreateOpt: listeners.CreateOpts{
Name: "Test with LB tags support and listener-tags annotation",
Protocol: listeners.ProtocolTCP,
ProtocolPort: 80,
ConnLimit: &svcConf.connLimit,
Tags: []string{"my-lb", "foo", "bar"},
},
},
{
name: "Test with listener-tags annotation duplicating the LB name",
port: corev1.ServicePort{
Protocol: "TCP",
Port: 80,
},
svcConf: &serviceConfig{
connLimit: 100,
lbName: "my-lb",
supportLBTags: true,
listenerTags: "foo, my-lb, bar",
},
expectedCreateOpt: listeners.CreateOpts{
Name: "Test with listener-tags annotation duplicating the LB name",
Protocol: listeners.ProtocolTCP,
ProtocolPort: 80,
ConnLimit: &svcConf.connLimit,
Tags: []string{"my-lb", "foo", "bar"},
},
},
{
name: "Test with duplicate tags within the listener-tags annotation",
port: corev1.ServicePort{
Protocol: "TCP",
Port: 80,
},
svcConf: &serviceConfig{
connLimit: 100,
lbName: "my-lb",
supportLBTags: true,
listenerTags: "foo, foo, bar, foo",
},
expectedCreateOpt: listeners.CreateOpts{
Name: "Test with duplicate tags within the listener-tags annotation",
Protocol: listeners.ProtocolTCP,
ProtocolPort: 80,
ConnLimit: &svcConf.connLimit,
Tags: []string{"my-lb", "foo", "bar"},
},
},
}

for _, tc := range testCases {
Expand Down