Skip to content

Commit 32347f7

Browse files
committed
aws/validations: introduce pre-flight validations for EnsureLoadBalancer
Introduce pre-flight validations adding pre-flight checks for EnsureLoadBalancer with tasks to validate Service object constraints prior making calls to the provider. This aims to prevent changes to the resources when invalid configuration is provided. Currently only NLB target group attributes validations is added as part of this change. feat/tg-attr: support target group attrib annotation on NLB
1 parent d576cd4 commit 32347f7

File tree

2 files changed

+384
-0
lines changed

2 files changed

+384
-0
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package aws
18+
19+
import (
20+
"fmt"
21+
22+
v1 "k8s.io/api/core/v1"
23+
)
24+
25+
// validationInput is the input parameters for validations.
26+
// TODO: ensure validations receive copy of values preventing mutation.
27+
type awsValidationInput struct {
28+
apiService *v1.Service
29+
annotations map[string]string
30+
}
31+
32+
// ensureLoadBalancerValidation validates the Service configuration early on EnsureLoadBalancer.
33+
// It validates the Service annotations and other constraints provided by the user are valid and supported by the controller.
34+
// It does not validate the AWS constraints.
35+
//
36+
// input:
37+
// v: awsValidationInput containing the required configuration to validate the Service object.
38+
//
39+
// returns:
40+
// - error: validation errors.
41+
func ensureLoadBalancerValidation(v *awsValidationInput) error {
42+
// Validate Service annotations.
43+
if err := validateServiceAnnotations(v); err != nil {
44+
return err
45+
}
46+
47+
// TODO: migrate other validations from EnsureLoadBalancer to this function.
48+
return nil
49+
}
50+
51+
// validateServiceAnnotations validates the service annotations constraints provided by the user
52+
// are valid and supported by the controller.
53+
func validateServiceAnnotations(v *awsValidationInput) error {
54+
isNLB := isNLB(v.annotations)
55+
56+
// ServiceAnnotationLoadBalancerTargetGroupAttributes
57+
if _, present := v.annotations[ServiceAnnotationLoadBalancerTargetGroupAttributes]; present {
58+
if !isNLB {
59+
return fmt.Errorf("target group annotations attribute is only supported for NLB")
60+
}
61+
if err := validateServiceAnnotationTargetGroupAttributes(v); err != nil {
62+
return err
63+
}
64+
}
65+
return nil
66+
}
67+
68+
// validateServiceAnnotationTargetGroupAttributes validates the target group attributes set through annotation:
69+
// Annotation: service.beta.kubernetes.io/aws-load-balancer-target-group-attributes
70+
//
71+
// input:
72+
// v: awsValidationInput containing the required configuration to validate the Service object.
73+
//
74+
// returns:
75+
// - error: validation errors.
76+
func validateServiceAnnotationTargetGroupAttributes(v *awsValidationInput) error {
77+
errPrefix := "error validating target group attributes"
78+
79+
// Attributes are in format key=value separated by comma.
80+
annotationGroupAttributes := getKeyValuePropertiesFromAnnotation(v.annotations, ServiceAnnotationLoadBalancerTargetGroupAttributes)
81+
targetGroupAttributes := make(map[string]string, len(annotationGroupAttributes))
82+
83+
for attrKey, attrValue := range annotationGroupAttributes {
84+
if _, ok := targetGroupAttributes[attrKey]; ok {
85+
return fmt.Errorf("%s: %q is set twice in the annotation", errPrefix, attrKey)
86+
}
87+
if len(attrValue) == 0 {
88+
return fmt.Errorf("%s: attribute value is empty for %q", errPrefix, attrKey)
89+
}
90+
91+
switch attrKey {
92+
case targetGroupAttributePreserveClientIPEnabled:
93+
if attrValue != "true" && attrValue != "false" {
94+
return fmt.Errorf("%s: invalid attribute value for %q: %s", errPrefix, attrKey, attrValue)
95+
}
96+
// AWS restriction: Client IP preservation can't be disabled for UDP and TCP_UDP target groups.
97+
for _, port := range v.apiService.Spec.Ports {
98+
if (port.Protocol == v1.ProtocolUDP || port.Protocol == "TCP_UDP") && attrValue == "false" {
99+
return fmt.Errorf("%s: client IP preservation can't be disabled for UDP ports", errPrefix)
100+
}
101+
}
102+
targetGroupAttributes[attrKey] = attrValue
103+
104+
case targetGroupAttributeProxyProtocolV2Enabled:
105+
if attrValue != "true" && attrValue != "false" {
106+
return fmt.Errorf("%s: invalid attribute value for %q: %s", errPrefix, attrKey, attrValue)
107+
}
108+
targetGroupAttributes[attrKey] = attrValue
109+
110+
default:
111+
return fmt.Errorf("%s: the attribute %q is not supported by the controller or is invalid", errPrefix, attrKey)
112+
}
113+
}
114+
115+
return nil
116+
}
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package aws
18+
19+
import (
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
v1 "k8s.io/api/core/v1"
24+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
25+
)
26+
27+
func TestValidateServiceAnnotationTargetGroupAttributes(t *testing.T) {
28+
tests := []struct {
29+
name string
30+
annotations map[string]string
31+
servicePorts []v1.ServicePort
32+
expectedError string
33+
}{
34+
{
35+
name: "no target group attributes annotation",
36+
annotations: map[string]string{},
37+
servicePorts: []v1.ServicePort{
38+
{Port: 80, Protocol: v1.ProtocolTCP},
39+
},
40+
expectedError: "",
41+
},
42+
{
43+
name: "empty target group attributes annotation",
44+
annotations: map[string]string{
45+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "",
46+
},
47+
servicePorts: []v1.ServicePort{
48+
{Port: 80, Protocol: v1.ProtocolTCP},
49+
},
50+
expectedError: "",
51+
},
52+
{
53+
name: "valid preserve_client_ip.enabled=true",
54+
annotations: map[string]string{
55+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "preserve_client_ip.enabled=true",
56+
},
57+
servicePorts: []v1.ServicePort{
58+
{Port: 80, Protocol: v1.ProtocolTCP},
59+
},
60+
expectedError: "",
61+
},
62+
{
63+
name: "valid preserve_client_ip.enabled=false",
64+
annotations: map[string]string{
65+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "preserve_client_ip.enabled=false",
66+
},
67+
servicePorts: []v1.ServicePort{
68+
{Port: 80, Protocol: v1.ProtocolTCP},
69+
},
70+
expectedError: "",
71+
},
72+
{
73+
name: "valid proxy_protocol_v2.enabled=true",
74+
annotations: map[string]string{
75+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "proxy_protocol_v2.enabled=true",
76+
},
77+
servicePorts: []v1.ServicePort{
78+
{Port: 80, Protocol: v1.ProtocolTCP},
79+
},
80+
expectedError: "",
81+
},
82+
{
83+
name: "valid proxy_protocol_v2.enabled=false",
84+
annotations: map[string]string{
85+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "proxy_protocol_v2.enabled=false",
86+
},
87+
servicePorts: []v1.ServicePort{
88+
{Port: 80, Protocol: v1.ProtocolTCP},
89+
},
90+
expectedError: "",
91+
},
92+
{
93+
name: "valid multiple attributes",
94+
annotations: map[string]string{
95+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "preserve_client_ip.enabled=true,proxy_protocol_v2.enabled=false",
96+
},
97+
servicePorts: []v1.ServicePort{
98+
{Port: 80, Protocol: v1.ProtocolTCP},
99+
},
100+
expectedError: "",
101+
},
102+
{
103+
name: "duplicate attribute in annotation (last one wins - no error expected)",
104+
annotations: map[string]string{
105+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "preserve_client_ip.enabled=true,preserve_client_ip.enabled=false",
106+
},
107+
servicePorts: []v1.ServicePort{
108+
{Port: 80, Protocol: v1.ProtocolTCP},
109+
},
110+
expectedError: "", // getKeyValuePropertiesFromAnnotation overwrites, so no duplicate detection
111+
},
112+
{
113+
name: "empty attribute value",
114+
annotations: map[string]string{
115+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "preserve_client_ip.enabled=",
116+
},
117+
servicePorts: []v1.ServicePort{
118+
{Port: 80, Protocol: v1.ProtocolTCP},
119+
},
120+
expectedError: "attribute value is empty for \"preserve_client_ip.enabled\"",
121+
},
122+
{
123+
name: "invalid preserve_client_ip.enabled value",
124+
annotations: map[string]string{
125+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "preserve_client_ip.enabled=invalid",
126+
},
127+
servicePorts: []v1.ServicePort{
128+
{Port: 80, Protocol: v1.ProtocolTCP},
129+
},
130+
expectedError: "invalid attribute value for \"preserve_client_ip.enabled\": invalid",
131+
},
132+
{
133+
name: "invalid proxy_protocol_v2.enabled value",
134+
annotations: map[string]string{
135+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "proxy_protocol_v2.enabled=yes",
136+
},
137+
servicePorts: []v1.ServicePort{
138+
{Port: 80, Protocol: v1.ProtocolTCP},
139+
},
140+
expectedError: "invalid attribute value for \"proxy_protocol_v2.enabled\": yes",
141+
},
142+
{
143+
name: "unsupported attribute",
144+
annotations: map[string]string{
145+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "unsupported_attribute=value",
146+
},
147+
servicePorts: []v1.ServicePort{
148+
{Port: 80, Protocol: v1.ProtocolTCP},
149+
},
150+
expectedError: "the attribute \"unsupported_attribute\" is not supported by the controller or is invalid",
151+
},
152+
{
153+
name: "preserve_client_ip.enabled=false with UDP port should fail",
154+
annotations: map[string]string{
155+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "preserve_client_ip.enabled=false",
156+
},
157+
servicePorts: []v1.ServicePort{
158+
{Port: 53, Protocol: v1.ProtocolUDP},
159+
},
160+
expectedError: "client IP preservation can't be disabled for UDP ports",
161+
},
162+
{
163+
name: "preserve_client_ip.enabled=false with TCP_UDP port should fail",
164+
annotations: map[string]string{
165+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "preserve_client_ip.enabled=false",
166+
},
167+
servicePorts: []v1.ServicePort{
168+
{Port: 53, Protocol: "TCP_UDP"},
169+
},
170+
expectedError: "client IP preservation can't be disabled for UDP ports",
171+
},
172+
{
173+
name: "preserve_client_ip.enabled=true with UDP port should succeed",
174+
annotations: map[string]string{
175+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "preserve_client_ip.enabled=true",
176+
},
177+
servicePorts: []v1.ServicePort{
178+
{Port: 53, Protocol: v1.ProtocolUDP},
179+
},
180+
expectedError: "",
181+
},
182+
{
183+
name: "preserve_client_ip.enabled=false with mixed TCP and UDP ports should fail",
184+
annotations: map[string]string{
185+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "preserve_client_ip.enabled=false",
186+
},
187+
servicePorts: []v1.ServicePort{
188+
{Port: 80, Protocol: v1.ProtocolTCP},
189+
{Port: 53, Protocol: v1.ProtocolUDP},
190+
},
191+
expectedError: "client IP preservation can't be disabled for UDP ports",
192+
},
193+
{
194+
name: "multiple attributes with preserve_client_ip.enabled=false and UDP port should fail",
195+
annotations: map[string]string{
196+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "preserve_client_ip.enabled=false,proxy_protocol_v2.enabled=true",
197+
},
198+
servicePorts: []v1.ServicePort{
199+
{Port: 53, Protocol: v1.ProtocolUDP},
200+
},
201+
expectedError: "client IP preservation can't be disabled for UDP ports",
202+
},
203+
{
204+
name: "case sensitivity - preserve_client_ip.enabled with True should fail",
205+
annotations: map[string]string{
206+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "preserve_client_ip.enabled=True",
207+
},
208+
servicePorts: []v1.ServicePort{
209+
{Port: 80, Protocol: v1.ProtocolTCP},
210+
},
211+
expectedError: "invalid attribute value for \"preserve_client_ip.enabled\": True",
212+
},
213+
{
214+
name: "case sensitivity - proxy_protocol_v2.enabled with FALSE should fail",
215+
annotations: map[string]string{
216+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "proxy_protocol_v2.enabled=FALSE",
217+
},
218+
servicePorts: []v1.ServicePort{
219+
{Port: 80, Protocol: v1.ProtocolTCP},
220+
},
221+
expectedError: "invalid attribute value for \"proxy_protocol_v2.enabled\": FALSE",
222+
},
223+
{
224+
name: "whitespace in attribute values should fail",
225+
annotations: map[string]string{
226+
ServiceAnnotationLoadBalancerTargetGroupAttributes: "preserve_client_ip.enabled= true ",
227+
},
228+
servicePorts: []v1.ServicePort{
229+
{Port: 80, Protocol: v1.ProtocolTCP},
230+
},
231+
expectedError: "invalid attribute value for \"preserve_client_ip.enabled\":",
232+
},
233+
}
234+
235+
for _, tt := range tests {
236+
t.Run(tt.name, func(t *testing.T) {
237+
// Create a test service with the specified ports
238+
service := &v1.Service{
239+
ObjectMeta: metav1.ObjectMeta{
240+
Name: "test-service",
241+
Namespace: "test-namespace",
242+
Annotations: tt.annotations,
243+
},
244+
Spec: v1.ServiceSpec{
245+
Type: v1.ServiceTypeLoadBalancer,
246+
Ports: tt.servicePorts,
247+
},
248+
}
249+
250+
// Create validation input
251+
input := &awsValidationInput{
252+
apiService: service,
253+
annotations: tt.annotations,
254+
}
255+
256+
// Execute the validation
257+
err := validateServiceAnnotationTargetGroupAttributes(input)
258+
259+
// Verify the result
260+
if tt.expectedError == "" {
261+
assert.NoError(t, err, "Expected no error for test case: %s", tt.name)
262+
} else {
263+
assert.Error(t, err, "Expected error for test case: %s", tt.name)
264+
assert.Contains(t, err.Error(), tt.expectedError, "Error message should contain expected text for test case: %s", tt.name)
265+
}
266+
})
267+
}
268+
}

0 commit comments

Comments
 (0)