Skip to content

Commit d693fc9

Browse files
authored
Merge pull request kubernetes-sigs#4734 from shuqz/ingress-plan-annotation
[feat i2g] Add IngressPlanAnnotation feature gate for dry-run plan
2 parents b56c60d + db95332 commit d693fc9

7 files changed

Lines changed: 171 additions & 0 deletions

File tree

controllers/ingress/dryrun.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package ingress
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
networking "k8s.io/api/networking/v1"
8+
"sigs.k8s.io/aws-load-balancer-controller/pkg/annotations"
9+
"sigs.k8s.io/aws-load-balancer-controller/pkg/k8s"
10+
"sigs.k8s.io/controller-runtime/pkg/client"
11+
)
12+
13+
const (
14+
dryRunPlanAnnotation = annotations.AnnotationPrefixIngress + "/" + annotations.IngressSuffixDryRunPlan
15+
)
16+
17+
// patchDryRunPlanAnnotation writes the serialized stack JSON to the dry-run-plan
18+
// annotation on the given ingress. It skips the patch if the value is unchanged
19+
// to avoid unnecessary API calls and reconcile loops.
20+
func patchDryRunPlanAnnotation(ctx context.Context, k8sClient client.Client, ing *networking.Ingress, planJSON string) error {
21+
if ing.Annotations[dryRunPlanAnnotation] == planJSON {
22+
return nil
23+
}
24+
ingOld := ing.DeepCopy()
25+
if ing.Annotations == nil {
26+
ing.Annotations = map[string]string{}
27+
}
28+
ing.Annotations[dryRunPlanAnnotation] = planJSON
29+
if err := k8sClient.Patch(ctx, ing, client.MergeFrom(ingOld)); err != nil {
30+
return fmt.Errorf("failed to patch dry-run plan annotation on ingress %s: %w", k8s.NamespacedName(ing), err)
31+
}
32+
return nil
33+
}
34+
35+
// clearDryRunPlanAnnotation removes the dry-run-plan annotation from an ingress
36+
// if it's currently set. This is a no-op when the annotation is absent, so the
37+
// patch is skipped and no API call is made. Used by the group controller to
38+
// clean up stale plan annotations on non-primary members when the group's
39+
// membership shifts (so the migration console sees exactly one holder).
40+
func clearDryRunPlanAnnotation(ctx context.Context, k8sClient client.Client, ing *networking.Ingress) error {
41+
if _, ok := ing.Annotations[dryRunPlanAnnotation]; !ok {
42+
return nil
43+
}
44+
ingOld := ing.DeepCopy()
45+
delete(ing.Annotations, dryRunPlanAnnotation)
46+
if err := k8sClient.Patch(ctx, ing, client.MergeFrom(ingOld)); err != nil {
47+
return fmt.Errorf("failed to clear dry-run plan annotation on ingress %s: %w", k8s.NamespacedName(ing), err)
48+
}
49+
return nil
50+
}

controllers/ingress/dryrun_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package ingress
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
networking "k8s.io/api/networking/v1"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"k8s.io/apimachinery/pkg/runtime"
12+
"k8s.io/apimachinery/pkg/types"
13+
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
14+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
15+
)
16+
17+
func Test_patchDryRunPlanAnnotation(t *testing.T) {
18+
tests := []struct {
19+
name string
20+
ingAnnotations map[string]string
21+
planJSON string
22+
wantAnnotation string
23+
wantOtherAnnotations map[string]string
24+
}{
25+
{
26+
name: "writes annotation when not present",
27+
ingAnnotations: nil,
28+
planJSON: `{"id":"test-stack"}`,
29+
wantAnnotation: `{"id":"test-stack"}`,
30+
},
31+
{
32+
name: "skips patch when value unchanged",
33+
ingAnnotations: map[string]string{
34+
dryRunPlanAnnotation: `{"id":"test-stack"}`,
35+
},
36+
planJSON: `{"id":"test-stack"}`,
37+
wantAnnotation: `{"id":"test-stack"}`,
38+
},
39+
{
40+
name: "updates annotation when value changed",
41+
ingAnnotations: map[string]string{
42+
dryRunPlanAnnotation: `{"id":"old-stack"}`,
43+
},
44+
planJSON: `{"id":"new-stack"}`,
45+
wantAnnotation: `{"id":"new-stack"}`,
46+
},
47+
{
48+
name: "preserves existing annotations",
49+
ingAnnotations: map[string]string{
50+
"alb.ingress.kubernetes.io/scheme": "internet-facing",
51+
},
52+
planJSON: `{"id":"test-stack"}`,
53+
wantAnnotation: `{"id":"test-stack"}`,
54+
wantOtherAnnotations: map[string]string{
55+
"alb.ingress.kubernetes.io/scheme": "internet-facing",
56+
},
57+
},
58+
}
59+
60+
for _, tt := range tests {
61+
t.Run(tt.name, func(t *testing.T) {
62+
ing := &networking.Ingress{
63+
ObjectMeta: metav1.ObjectMeta{
64+
Name: "test-ingress",
65+
Namespace: "default",
66+
Annotations: tt.ingAnnotations,
67+
},
68+
}
69+
70+
scheme := runtime.NewScheme()
71+
require.NoError(t, clientgoscheme.AddToScheme(scheme))
72+
73+
k8sClient := fake.NewClientBuilder().
74+
WithScheme(scheme).
75+
WithObjects(ing).
76+
Build()
77+
78+
err := patchDryRunPlanAnnotation(context.Background(), k8sClient, ing, tt.planJSON)
79+
require.NoError(t, err)
80+
81+
// Verify the annotation was persisted
82+
updatedIng := &networking.Ingress{}
83+
err = k8sClient.Get(context.Background(), types.NamespacedName{
84+
Name: "test-ingress",
85+
Namespace: "default",
86+
}, updatedIng)
87+
require.NoError(t, err)
88+
assert.Equal(t, tt.wantAnnotation, updatedIng.Annotations[dryRunPlanAnnotation])
89+
90+
// Verify other annotations were not clobbered
91+
for k, v := range tt.wantOtherAnnotations {
92+
assert.Equal(t, v, updatedIng.Annotations[k], "annotation %s should be preserved", k)
93+
}
94+
})
95+
}
96+
}

controllers/ingress/group_controller.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ func NewGroupReconciler(cloud services.Cloud, k8sClient client.Client, eventReco
9292

9393
groupLoader: groupLoader,
9494
groupFinalizerManager: groupFinalizerManager,
95+
featureGates: controllerConfig.FeatureGates,
9596
logger: logger,
9697
metricsCollector: metricsCollector,
9798
controllerName: controllerName,
@@ -114,6 +115,7 @@ type groupReconciler struct {
114115

115116
groupLoader ingress.GroupLoader
116117
groupFinalizerManager ingress.FinalizerManager
118+
featureGates config.FeatureGates
117119
logger logr.Logger
118120
metricsCollector lbcmetrics.MetricCollector
119121
controllerName string
@@ -230,6 +232,24 @@ func (r *groupReconciler) buildAndDeployModel(ctx context.Context, ingGroup ingr
230232
}
231233
r.logger.Info("successfully built model", "model", stackJSON)
232234

235+
if r.featureGates.Enabled(config.IngressPlanAnnotation) && len(ingGroup.Members) > 0 {
236+
if err := patchDryRunPlanAnnotation(ctx, r.k8sClient, ingGroup.Members[0].Ing, stackJSON); err != nil {
237+
r.logger.Error(err, "failed to patch dry-run plan annotation", "ingress", k8s.NamespacedName(ingGroup.Members[0].Ing))
238+
}
239+
// Clear the dry-run-plan annotation from every non-primary member so a
240+
// group that moves its holder across reconciles (e.g. a member with a
241+
// lower group.order is added) doesn't leave stale plans behind. The
242+
// migration console's discovery step errors out when it finds multiple
243+
// ingresses in a group carrying the annotation — this cleanup keeps
244+
// that invariant. We swallow individual failures to avoid blocking the
245+
// main reconcile; the next pass retries.
246+
for _, m := range ingGroup.Members[1:] {
247+
if err := clearDryRunPlanAnnotation(ctx, r.k8sClient, m.Ing); err != nil {
248+
r.logger.Error(err, "failed to clear stale dry-run plan annotation", "ingress", k8s.NamespacedName(m.Ing))
249+
}
250+
}
251+
}
252+
233253
deployModelFn := func() {
234254
err = r.stackDeployer.Deploy(ctx, stack, r.metricsCollector, "ingress")
235255
}

docs/deploy/configurations.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,4 @@ There are a set of key=value pairs that describe AWS load balancer controller fe
201201
| GatewayListenerSet | string | true | Enable or disable the usage of ListenerSets in the Gateway API |
202202
| ALBTargetControlAgent | string | false | Enable or disable the ALB Target Control Agent |
203203
| EnableCertificateManagement | string | false | Whether to enable the [Certificate Management feature](../guide/ingress/certificate_management.md). |
204+
| IngressPlanAnnotation | string | false | If enabled, the controller writes the serialized model stack JSON to the `alb.ingress.kubernetes.io/dry-run-plan` annotation on ingress. For grouped ingresses, the annotation is written to the first member (lowest group order). |

helm/aws-load-balancer-controller/values.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@ controllerConfig:
428428
# ALBGatewayAPI: true
429429
# GatewayListenerSet: true
430430
# GlobalAcceleratorController: false
431+
# IngressPlanAnnotation: false
431432
# EnhancedDefaultBehavior: false
432433
# EnableDefaultTagsLowPriority: false
433434
# ALBTargetControlAgent: false

pkg/annotations/constants.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const (
8181
IngressSuffixTargetControlPort = "target-control-port"
8282
IngressSuffixCreateCertificate = "create-acm-cert"
8383
IngressSuffixACMCaARN = "acm-pca-arn"
84+
IngressSuffixDryRunPlan = "dry-run-plan"
8485

8586
// NLB annotation suffixes
8687
// prefixes service.beta.kubernetes.io, service.kubernetes.io

pkg/config/feature_gates.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const (
4040
ALBTargetControlAgent Feature = "ALBTargetControlAgent"
4141
GatewayListenerSet Feature = "GatewayListenerSet"
4242
EnableCertificateManagement Feature = "EnableCertificateManagement"
43+
IngressPlanAnnotation Feature = "IngressPlanAnnotation"
4344
)
4445

4546
type FeatureGates interface {
@@ -94,6 +95,7 @@ func NewFeatureGates() FeatureGates {
9495
ALBTargetControlAgent: generateDefaultFeatureStatus(false),
9596
GatewayListenerSet: generateDefaultFeatureStatus(true),
9697
EnableCertificateManagement: generateDefaultFeatureStatus(false),
98+
IngressPlanAnnotation: generateDefaultFeatureStatus(false),
9799
},
98100
}
99101
}

0 commit comments

Comments
 (0)