Skip to content

Commit cd1b27b

Browse files
k8s-infra-cherrypick-robotA10ssalexanderstephan
authored
[release-1.36] feat: add support for load balancer, listener, and pool tags annotations (#3154)
* feat: add support for load balancer, listener, and pool tags annotations * feat: refactor load balancer tag handling to use SplitTrim for better parsing * feat: streamline load balancer listener tag management for improved consistency * feat: address PR #3058 review feedback for LB tag annotations - Simplify mergeTags: drop unreachable nil check and redundant else - Collapse repeated SplitTrim guard blocks at all tag call sites - Correct annotation comments to comma-separated (not JSON) format - Add TestMergeTags and listener-tag cases to TestBuildListenerCreateOpt - Document load-balancer-tags, listener-tags and pool-tags annotations * Address feedback * Add withLBNameTag helper * Rename desiredTags completely * Change structure * Outdated comments * Fix mergeTags to treat empty/empty as no-op * Prevent duplicate tags * Also within annotations * Make sure tags are always populated * Sanitize user tags * Reserved service prefix * Introduce generic helpers --------- Co-authored-by: A10ss <la10ss@163.com> Co-authored-by: Alexander Stephan <alexander.stephan@sap.com>
1 parent bae07f4 commit cd1b27b

5 files changed

Lines changed: 368 additions & 25 deletions

File tree

docs/openstack-cloud-controller-manager/expose-applications-using-loadbalancer-type-service.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,20 @@ Request Body:
239239

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

242+
- `loadbalancer.openstack.org/load-balancer-tags`
243+
244+
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`.
245+
246+
> NOTE: Tags starting with `kube_service_` are reserved for OCCM's own ownership and shared-load-balancer tracking. Any such tag supplied through this annotation is ignored (a warning is logged) to avoid conflicting with the tags OCCM manages. This applies to all three tag annotations.
247+
248+
- `loadbalancer.openstack.org/listener-tags`
249+
250+
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`. Tags starting with the reserved `kube_service_` prefix are ignored (see the note above).
251+
252+
- `loadbalancer.openstack.org/pool-tags`
253+
254+
A comma-separated list of tags to add to the load balancer pool resources. Example: `env=prod,team=network`. Tags starting with the reserved `kube_service_` prefix are ignored (see the note above).
255+
242256
- `loadbalancer.openstack.org/hostname`
243257

244258
This annotations explicitly sets a hostname in the status of the load balancer service.

pkg/openstack/loadbalancer.go

Lines changed: 94 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ const (
9696
defaultProxyHostnameSuffix = "nip.io"
9797
ServiceAnnotationLoadBalancerID = "loadbalancer.openstack.org/load-balancer-id"
9898

99+
// ServiceAnnotationLoadBalancerTags is used to set additional tags on the loadbalancer resource itself (comma-separated list).
100+
ServiceAnnotationLoadBalancerTags = "loadbalancer.openstack.org/load-balancer-tags"
101+
// ServiceAnnotationListenerTags is used to set additional tags on the loadbalancer listener resources (comma-separated list).
102+
ServiceAnnotationListenerTags = "loadbalancer.openstack.org/listener-tags"
103+
// ServiceAnnotationPoolTags is used to set additional tags on the loadbalancer pool resources (comma-separated list).
104+
ServiceAnnotationPoolTags = "loadbalancer.openstack.org/pool-tags"
105+
99106
// Octavia resources name formats
100107
servicePrefix = "kube_service_"
101108
lbFormat = "%s%s_%s_%s"
@@ -146,6 +153,9 @@ type serviceConfig struct {
146153
healthMonitorMaxRetries int
147154
healthMonitorMaxRetriesDown int
148155
preferredIPFamily corev1.IPFamily // preferred (the first) IP family indicated in service's `spec.ipFamilies`
156+
lbTags string
157+
listenerTags string
158+
poolTags string
149159
}
150160

151161
type listenerKey struct {
@@ -197,6 +207,43 @@ func getLoadbalancerByName(ctx context.Context, client *gophercloud.ServiceClien
197207
return &validLBs[0], nil
198208
}
199209

210+
// stripReservedTags drops any user-supplied tag that begins with servicePrefix.
211+
//
212+
// Tags with this prefix are how OCCM marks resource ownership: the ownership tag
213+
// written to a resource is always a GetLoadBalancerName value, which always
214+
// starts with servicePrefix. Every ownership check in this file matches tags
215+
// exclusively on that form -- either an exact match against the prefixed lbName
216+
// (e.g. slices.Contains(tags, lbName)) or a strings.HasPrefix(tag, servicePrefix)
217+
// scan (used for shared-LB counting and deletion decisions).
218+
//
219+
// Because of that, keeping user tags out of the servicePrefix subspace fully
220+
// partitions the tag namespace: ownership tags live in the kube_service_* space,
221+
// user tags live in its complement, and no user tag can ever satisfy an ownership
222+
// check. This prevents a Service from injecting a tag matching another (possibly
223+
// cross-tenant) load balancer's name to hijack ownership, shared-LB limits, or
224+
// deletion. INVARIANT: any new tag-based ownership check must also key on
225+
// servicePrefix, or this guarantee no longer holds.
226+
func stripReservedTags(tags []string) []string {
227+
result := make([]string, 0, len(tags))
228+
for _, t := range tags {
229+
if strings.HasPrefix(t, servicePrefix) {
230+
klog.Warningf("Ignoring reserved tag %q: tags starting with %q are reserved for OCCM ownership tracking", t, servicePrefix)
231+
continue
232+
}
233+
result = append(result, t)
234+
}
235+
return result
236+
}
237+
238+
// withLBNameTag returns the LB name (OCCM's ownership tag) followed by the
239+
// comma-separated tags from the given Service annotation value. Any user tag
240+
// using the reserved servicePrefix is stripped, and duplicate tags are removed
241+
// (including ones that duplicate the LB name) so no tag can appear twice.
242+
func withLBNameTag(lbName, annotation string) []string {
243+
userTags := stripReservedTags(cpoutil.SplitTrim(annotation, ','))
244+
return cpoutil.Unique(append([]string{lbName}, userTags...))
245+
}
246+
200247
func popListener(existingListeners []listeners.Listener, id string) []listeners.Listener {
201248
newListeners := []listeners.Listener{}
202249
for _, existingListener := range existingListeners {
@@ -235,7 +282,7 @@ func (lbaas *LbaasV2) createOctaviaLoadBalancer(ctx context.Context, name, clust
235282
}
236283

237284
if svcConf.supportLBTags {
238-
createOpts.Tags = []string{svcConf.lbName}
285+
createOpts.Tags = withLBNameTag(svcConf.lbName, svcConf.lbTags)
239286
}
240287

241288
if svcConf.flavorID != "" {
@@ -910,24 +957,39 @@ func (lbaas *LbaasV2) ensureOctaviaPool(ctx context.Context, lbID string, name s
910957
// if LBMethod is not defined, fallback on default OCCM's default method
911958
poolLbMethod = lbaas.opts.LBMethod
912959
}
913-
if pool != nil && pool.LBMethod != poolLbMethod {
914-
klog.InfoS("Updating LoadBalancer LBMethod", "poolID", pool.ID, "listenerID", listener.ID, "lbID", lbID)
915-
err = openstackutil.UpdatePool(ctx, lbaas.lb, lbID, pool.ID, v2pools.UpdateOpts{LBMethod: v2pools.LBMethod(poolLbMethod)})
916-
if err != nil {
917-
err = PreserveGopherError(err)
918-
msg := fmt.Sprintf("Error updating LB method for LoadBalancer: %v", err)
919-
klog.Errorf(msg, "poolID", pool.ID, "listenerID", listener.ID, "lbID", lbID)
920-
lbaas.eventRecorder.Event(service, corev1.EventTypeWarning, eventLBLbMethodUnknown, msg)
921-
} else {
922-
pool.LBMethod = poolLbMethod
960+
poolTags := cpoutil.Unique(stripReservedTags(cpoutil.SplitTrim(svcConf.poolTags, ',')))
961+
962+
if pool != nil {
963+
updateOpts := v2pools.UpdateOpts{}
964+
if pool.LBMethod != poolLbMethod {
965+
updateOpts.LBMethod = v2pools.LBMethod(poolLbMethod)
966+
}
967+
if svcConf.supportLBTags && len(poolTags) > 0 {
968+
klog.V(4).Infof("Desired pool tags: %+v from service annotation key: %s", poolTags, ServiceAnnotationPoolTags)
969+
if tags, changed := cpoutil.Merge(pool.Tags, poolTags); changed {
970+
updateOpts.Tags = &tags
971+
}
972+
}
973+
if updateOpts != (v2pools.UpdateOpts{}) {
974+
klog.InfoS("Updating pool", "poolID", pool.ID, "listenerID", listener.ID, "lbID", lbID, "lbMethod", updateOpts.LBMethod, "tags", updateOpts.Tags)
975+
err = openstackutil.UpdatePool(ctx, lbaas.lb, lbID, pool.ID, updateOpts)
976+
if err != nil {
977+
err = PreserveGopherError(err)
978+
msg := fmt.Sprintf("Error updating pool for LoadBalancer: %v", err)
979+
klog.Errorf(msg, "poolID", pool.ID, "listenerID", listener.ID, "lbID", lbID)
980+
lbaas.eventRecorder.Event(service, corev1.EventTypeWarning, eventLBLbMethodUnknown, msg)
981+
}
923982
}
924983
}
925984

926985
if pool == nil {
927986
createOpt := lbaas.buildPoolCreateOpt(listener.Protocol, service, svcConf, name)
928987
createOpt.ListenerID = listener.ID
929-
988+
if svcConf.supportLBTags {
989+
createOpt.Tags = poolTags
990+
}
930991
klog.InfoS("Creating pool", "listenerID", listener.ID, "protocol", createOpt.Protocol)
992+
klog.V(4).Infof("Pool create options: %+v", createOpt)
931993
pool, err = openstackutil.CreatePool(ctx, lbaas.lb, createOpt, lbID)
932994
if err != nil {
933995
return nil, err
@@ -1099,11 +1161,11 @@ func (lbaas *LbaasV2) ensureOctaviaListener(ctx context.Context, lbID string, na
10991161
updateOpts := listeners.UpdateOpts{}
11001162

11011163
if svcConf.supportLBTags {
1102-
if !slices.Contains(listener.Tags, svcConf.lbName) {
1103-
var newTags []string
1104-
copy(newTags, listener.Tags)
1105-
newTags = append(newTags, svcConf.lbName)
1106-
updateOpts.Tags = &newTags
1164+
// Ensure the LB name tag plus any desired tags from the Service annotation.
1165+
listenerTags := withLBNameTag(svcConf.lbName, svcConf.listenerTags)
1166+
if tags, changed := cpoutil.Merge(listener.Tags, listenerTags); changed {
1167+
klog.V(4).Infof("Will update listener tags, current listener tags: %+v, desired tags: %+v", listener.Tags, tags)
1168+
updateOpts.Tags = &tags
11071169
listenerChanged = true
11081170
}
11091171
}
@@ -1177,7 +1239,8 @@ func (lbaas *LbaasV2) buildListenerCreateOpt(ctx context.Context, port corev1.Se
11771239
}
11781240

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

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

1609+
annotations := service.GetAnnotations()
1610+
svcConf.lbTags = annotations[ServiceAnnotationLoadBalancerTags]
1611+
svcConf.listenerTags = annotations[ServiceAnnotationListenerTags]
1612+
svcConf.poolTags = annotations[ServiceAnnotationPoolTags]
1613+
15461614
// Get service node-selector annotations
15471615
svcConf.nodeSelectors = getKeyValueFromServiceAnnotation(service, ServiceAnnotationLoadBalancerNodeSelector, lbaas.opts.NodeSelector)
15481616
for key, value := range svcConf.nodeSelectors {
@@ -1826,15 +1894,16 @@ func (lbaas *LbaasV2) ensureOctaviaLoadBalancer(ctx context.Context, clusterName
18261894
// save address into the annotation
18271895
lbaas.updateServiceAnnotation(service, ServiceAnnotationLoadBalancerAddress, addr)
18281896

1829-
// add LB name to load balancer tags.
1897+
// Ensure the LB name tag plus any tags from the Service annotation in a single update.
18301898
if svcConf.supportLBTags {
1831-
lbTags := loadbalancer.Tags
1832-
if !slices.Contains(lbTags, lbName) {
1833-
lbTags = append(lbTags, lbName)
1834-
klog.InfoS("Updating load balancer tags", "lbID", loadbalancer.ID, "tags", lbTags)
1835-
if err := openstackutil.UpdateLoadBalancerTags(ctx, lbaas.lb, loadbalancer.ID, lbTags); err != nil {
1836-
return nil, err
1899+
lbTags := withLBNameTag(lbName, svcConf.lbTags)
1900+
klog.V(4).Infof("Desired load balancer tags: %v (LB name plus annotation %s)", lbTags, ServiceAnnotationLoadBalancerTags)
1901+
if tags, changed := cpoutil.Merge(loadbalancer.Tags, lbTags); changed {
1902+
klog.InfoS("Updating load balancer tags", "lbID", loadbalancer.ID, "tags", tags)
1903+
if err := openstackutil.UpdateLoadBalancerTags(ctx, lbaas.lb, loadbalancer.ID, tags); err != nil {
1904+
return nil, fmt.Errorf("failed to update load balancer %s tags: %w", loadbalancer.ID, err)
18371905
}
1906+
loadbalancer.Tags = tags
18381907
}
18391908
}
18401909

pkg/openstack/loadbalancer_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,72 @@ type testGetRulesToCreateAndDelete struct {
152152
toDelete []rules.SecGroupRule
153153
}
154154

155+
func TestWithLBNameTag(t *testing.T) {
156+
lbName := servicePrefix + "cluster_ns_svc"
157+
testCases := []struct {
158+
name string
159+
annotation string
160+
expected []string
161+
}{
162+
{
163+
name: "empty annotation returns only the LB name",
164+
annotation: "",
165+
expected: []string{lbName},
166+
},
167+
{
168+
name: "user tags are appended after the LB name",
169+
annotation: "team=foo,env=prod",
170+
expected: []string{lbName, "team=foo", "env=prod"},
171+
},
172+
{
173+
name: "user tag using the reserved prefix is stripped",
174+
annotation: servicePrefix + "cluster_ns_other,team=foo",
175+
expected: []string{lbName, "team=foo"},
176+
},
177+
{
178+
name: "user tag equal to this LB name is stripped, not duplicated",
179+
annotation: lbName + ",team=foo",
180+
expected: []string{lbName, "team=foo"},
181+
},
182+
{
183+
name: "duplicate user tags are removed",
184+
annotation: "team=foo,team=foo",
185+
expected: []string{lbName, "team=foo"},
186+
},
187+
{
188+
name: "whitespace-padded reserved tag is trimmed then stripped",
189+
annotation: " " + servicePrefix + "cluster_ns_other ,team=foo",
190+
expected: []string{lbName, "team=foo"},
191+
},
192+
{
193+
name: "bare reserved prefix is stripped",
194+
annotation: servicePrefix + ",team=foo",
195+
expected: []string{lbName, "team=foo"},
196+
},
197+
{
198+
name: "multiple reserved tags are all stripped",
199+
annotation: servicePrefix + "a," + servicePrefix + "b,team=foo",
200+
expected: []string{lbName, "team=foo"},
201+
},
202+
{
203+
name: "prefix appearing mid-string is not stripped",
204+
annotation: "owner=" + servicePrefix + "x,team=foo",
205+
expected: []string{lbName, "owner=" + servicePrefix + "x", "team=foo"},
206+
},
207+
{
208+
name: "only reserved tags leaves just the LB name",
209+
annotation: servicePrefix + "a," + servicePrefix + "b",
210+
expected: []string{lbName},
211+
},
212+
}
213+
214+
for _, tc := range testCases {
215+
t.Run(tc.name, func(t *testing.T) {
216+
assert.Equal(t, tc.expected, withLBNameTag(lbName, tc.annotation))
217+
})
218+
}
219+
}
220+
155221
func TestGetRulesToCreateAndDelete(t *testing.T) {
156222
tests := []testGetRulesToCreateAndDelete{
157223
{
@@ -2485,6 +2551,85 @@ func TestBuildListenerCreateOpt(t *testing.T) {
24852551
Tags: nil,
24862552
},
24872553
},
2554+
{
2555+
name: "Test with LB tags support and no listener-tags annotation",
2556+
port: corev1.ServicePort{
2557+
Protocol: "TCP",
2558+
Port: 80,
2559+
},
2560+
svcConf: &serviceConfig{
2561+
connLimit: 100,
2562+
lbName: "my-lb",
2563+
supportLBTags: true,
2564+
},
2565+
expectedCreateOpt: listeners.CreateOpts{
2566+
Name: "Test with LB tags support and no listener-tags annotation",
2567+
Protocol: listeners.ProtocolTCP,
2568+
ProtocolPort: 80,
2569+
ConnLimit: &svcConf.connLimit,
2570+
Tags: []string{"my-lb"},
2571+
},
2572+
},
2573+
{
2574+
name: "Test with LB tags support and listener-tags annotation",
2575+
port: corev1.ServicePort{
2576+
Protocol: "TCP",
2577+
Port: 80,
2578+
},
2579+
svcConf: &serviceConfig{
2580+
connLimit: 100,
2581+
lbName: "my-lb",
2582+
supportLBTags: true,
2583+
listenerTags: "foo, bar",
2584+
},
2585+
expectedCreateOpt: listeners.CreateOpts{
2586+
Name: "Test with LB tags support and listener-tags annotation",
2587+
Protocol: listeners.ProtocolTCP,
2588+
ProtocolPort: 80,
2589+
ConnLimit: &svcConf.connLimit,
2590+
Tags: []string{"my-lb", "foo", "bar"},
2591+
},
2592+
},
2593+
{
2594+
name: "Test with listener-tags annotation duplicating the LB name",
2595+
port: corev1.ServicePort{
2596+
Protocol: "TCP",
2597+
Port: 80,
2598+
},
2599+
svcConf: &serviceConfig{
2600+
connLimit: 100,
2601+
lbName: "my-lb",
2602+
supportLBTags: true,
2603+
listenerTags: "foo, my-lb, bar",
2604+
},
2605+
expectedCreateOpt: listeners.CreateOpts{
2606+
Name: "Test with listener-tags annotation duplicating the LB name",
2607+
Protocol: listeners.ProtocolTCP,
2608+
ProtocolPort: 80,
2609+
ConnLimit: &svcConf.connLimit,
2610+
Tags: []string{"my-lb", "foo", "bar"},
2611+
},
2612+
},
2613+
{
2614+
name: "Test with duplicate tags within the listener-tags annotation",
2615+
port: corev1.ServicePort{
2616+
Protocol: "TCP",
2617+
Port: 80,
2618+
},
2619+
svcConf: &serviceConfig{
2620+
connLimit: 100,
2621+
lbName: "my-lb",
2622+
supportLBTags: true,
2623+
listenerTags: "foo, foo, bar, foo",
2624+
},
2625+
expectedCreateOpt: listeners.CreateOpts{
2626+
Name: "Test with duplicate tags within the listener-tags annotation",
2627+
Protocol: listeners.ProtocolTCP,
2628+
ProtocolPort: 80,
2629+
ConnLimit: &svcConf.connLimit,
2630+
Tags: []string{"my-lb", "foo", "bar"},
2631+
},
2632+
},
24882633
}
24892634

24902635
for _, tc := range testCases {

0 commit comments

Comments
 (0)