Skip to content
Closed
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
33 changes: 33 additions & 0 deletions controllers/ingress/dryrun.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package ingress

import (
"context"
"fmt"

networking "k8s.io/api/networking/v1"
"sigs.k8s.io/aws-load-balancer-controller/pkg/annotations"
"sigs.k8s.io/aws-load-balancer-controller/pkg/k8s"
"sigs.k8s.io/controller-runtime/pkg/client"
)

const (
dryRunPlanAnnotation = annotations.AnnotationPrefixIngress + "/" + annotations.IngressSuffixDryRunPlan
)

// patchDryRunPlanAnnotation writes the serialized stack JSON to the dry-run-plan
// annotation on the given ingress. It skips the patch if the value is unchanged
// to avoid unnecessary API calls and reconcile loops.
func patchDryRunPlanAnnotation(ctx context.Context, k8sClient client.Client, ing *networking.Ingress, planJSON string) error {
if ing.Annotations[dryRunPlanAnnotation] == planJSON {
return nil
}
ingOld := ing.DeepCopy()
if ing.Annotations == nil {
ing.Annotations = map[string]string{}
}
ing.Annotations[dryRunPlanAnnotation] = planJSON
if err := k8sClient.Patch(ctx, ing, client.MergeFrom(ingOld)); err != nil {
return fmt.Errorf("failed to patch dry-run plan annotation on ingress %s: %w", k8s.NamespacedName(ing), err)
}
return nil
}
96 changes: 96 additions & 0 deletions controllers/ingress/dryrun_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package ingress

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
networking "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)

func Test_patchDryRunPlanAnnotation(t *testing.T) {
tests := []struct {
name string
ingAnnotations map[string]string
planJSON string
wantAnnotation string
wantOtherAnnotations map[string]string
}{
{
name: "writes annotation when not present",
ingAnnotations: nil,
planJSON: `{"id":"test-stack"}`,
wantAnnotation: `{"id":"test-stack"}`,
},
{
name: "skips patch when value unchanged",
ingAnnotations: map[string]string{
dryRunPlanAnnotation: `{"id":"test-stack"}`,
},
planJSON: `{"id":"test-stack"}`,
wantAnnotation: `{"id":"test-stack"}`,
},
{
name: "updates annotation when value changed",
ingAnnotations: map[string]string{
dryRunPlanAnnotation: `{"id":"old-stack"}`,
},
planJSON: `{"id":"new-stack"}`,
wantAnnotation: `{"id":"new-stack"}`,
},
{
name: "preserves existing annotations",
ingAnnotations: map[string]string{
"alb.ingress.kubernetes.io/scheme": "internet-facing",
},
planJSON: `{"id":"test-stack"}`,
wantAnnotation: `{"id":"test-stack"}`,
wantOtherAnnotations: map[string]string{
"alb.ingress.kubernetes.io/scheme": "internet-facing",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ing := &networking.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress",
Namespace: "default",
Annotations: tt.ingAnnotations,
},
}

scheme := runtime.NewScheme()
require.NoError(t, clientgoscheme.AddToScheme(scheme))

k8sClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ing).
Build()

err := patchDryRunPlanAnnotation(context.Background(), k8sClient, ing, tt.planJSON)
require.NoError(t, err)

// Verify the annotation was persisted
updatedIng := &networking.Ingress{}
err = k8sClient.Get(context.Background(), types.NamespacedName{
Name: "test-ingress",
Namespace: "default",
}, updatedIng)
require.NoError(t, err)
assert.Equal(t, tt.wantAnnotation, updatedIng.Annotations[dryRunPlanAnnotation])

// Verify other annotations were not clobbered
for k, v := range tt.wantOtherAnnotations {
assert.Equal(t, v, updatedIng.Annotations[k], "annotation %s should be preserved", k)
}
})
}
}
8 changes: 8 additions & 0 deletions controllers/ingress/group_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ func NewGroupReconciler(cloud services.Cloud, k8sClient client.Client, eventReco

groupLoader: groupLoader,
groupFinalizerManager: groupFinalizerManager,
featureGates: controllerConfig.FeatureGates,
logger: logger,
metricsCollector: metricsCollector,
controllerName: controllerName,
Expand All @@ -114,6 +115,7 @@ type groupReconciler struct {

groupLoader ingress.GroupLoader
groupFinalizerManager ingress.FinalizerManager
featureGates config.FeatureGates
logger logr.Logger
metricsCollector lbcmetrics.MetricCollector
controllerName string
Expand Down Expand Up @@ -230,6 +232,12 @@ func (r *groupReconciler) buildAndDeployModel(ctx context.Context, ingGroup ingr
}
r.logger.Info("successfully built model", "model", stackJSON)

if r.featureGates.Enabled(config.IngressPlanAnnotation) && len(ingGroup.Members) > 0 {
if err := patchDryRunPlanAnnotation(ctx, r.k8sClient, ingGroup.Members[0].Ing, stackJSON); err != nil {
r.logger.Error(err, "failed to patch dry-run plan annotation", "ingress", k8s.NamespacedName(ingGroup.Members[0].Ing))
}
}

deployModelFn := func() {
err = r.stackDeployer.Deploy(ctx, stack, r.metricsCollector, "ingress")
}
Expand Down
1 change: 1 addition & 0 deletions docs/deploy/configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,4 @@ There are a set of key=value pairs that describe AWS load balancer controller fe
| GatewayListenerSet | string | true | Enable or disable the usage of ListenerSets in the Gateway API |
| ALBTargetControlAgent | string | false | Enable or disable the ALB Target Control Agent |
| EnableCertificateManagement | string | false | Whether to enable the [Certificate Management feature](../guide/ingress/certificate_management.md). |
| 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). |
1 change: 1 addition & 0 deletions helm/aws-load-balancer-controller/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ controllerConfig:
# ALBGatewayAPI: true
# GatewayListenerSet: true
# GlobalAcceleratorController: false
# IngressPlanAnnotation: false
# EnhancedDefaultBehavior: false
# EnableDefaultTagsLowPriority: false
# ALBTargetControlAgent: false
Expand Down
1 change: 1 addition & 0 deletions pkg/annotations/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const (
IngressSuffixTargetControlPort = "target-control-port"
IngressSuffixCreateCertificate = "create-acm-cert"
IngressSuffixACMCaARN = "acm-pca-arn"
IngressSuffixDryRunPlan = "dry-run-plan"

// NLB annotation suffixes
// prefixes service.beta.kubernetes.io, service.kubernetes.io
Expand Down
2 changes: 2 additions & 0 deletions pkg/config/feature_gates.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const (
ALBTargetControlAgent Feature = "ALBTargetControlAgent"
GatewayListenerSet Feature = "GatewayListenerSet"
EnableCertificateManagement Feature = "EnableCertificateManagement"
IngressPlanAnnotation Feature = "IngressPlanAnnotation"
)

type FeatureGates interface {
Expand Down Expand Up @@ -94,6 +95,7 @@ func NewFeatureGates() FeatureGates {
ALBTargetControlAgent: generateDefaultFeatureStatus(false),
GatewayListenerSet: generateDefaultFeatureStatus(true),
EnableCertificateManagement: generateDefaultFeatureStatus(false),
IngressPlanAnnotation: generateDefaultFeatureStatus(false),
},
}
}
Expand Down
6 changes: 6 additions & 0 deletions pkg/gateway/constants/controller_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,10 @@ const (

// AnnotationDryRunEnabledValue is the value that enables dry-run mode on a Gateway.
AnnotationDryRunEnabledValue = "true"

// AnnotationIngressPlanHolder is the annotation on a Gateway that points to the
// namespace/name of the ingress holding the dry-run-plan for an explicit ingress group.
// Set by the migration tool so the in-cluster console can locate the ingress model.
// it is only needed for group ingress
AnnotationIngressPlanHolder = "gateway.k8s.aws/ingress-plan-holder"
)
48 changes: 48 additions & 0 deletions pkg/ingress2gateway/translate/translate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package translate
import (
"fmt"
"os"
"sort"
"strconv"
"strings"

corev1 "k8s.io/api/core/v1"
Expand All @@ -11,6 +13,7 @@ import (
"k8s.io/apimachinery/pkg/util/sets"
elbv2api "sigs.k8s.io/aws-load-balancer-controller/apis/elbv2/v1beta1"
gatewayv1beta1 "sigs.k8s.io/aws-load-balancer-controller/apis/gateway/v1beta1"
annotations "sigs.k8s.io/aws-load-balancer-controller/pkg/annotations"
gwconstants "sigs.k8s.io/aws-load-balancer-controller/pkg/gateway/constants"
"sigs.k8s.io/aws-load-balancer-controller/pkg/ingress2gateway"
"sigs.k8s.io/aws-load-balancer-controller/pkg/ingress2gateway/utils"
Expand Down Expand Up @@ -107,6 +110,18 @@ func Translate(in *ingress2gateway.InputResources) (*ingress2gateway.OutputResou
crossNSGroupName = group.name
}
gw := buildGateway(gatewayName, group.namespace, lbConfig, allPorts, crossNSGroupName)

// For explicit groups, find the member holding the dry-run plan and write
// a pointer annotation on the Gateway so the in-cluster console can locate it.
if group.isExplicit {
if holder := findPlanHolder(group.members); holder != "" {
if gw.Annotations == nil {
gw.Annotations = map[string]string{}
}
gw.Annotations[utils.IngressPlanHolderAnnotation] = holder
}
}

out.Gateways = append(out.Gateways, gw)

// --- SSL redirect route ---
Expand Down Expand Up @@ -240,3 +255,36 @@ func buildIngressClassParamsMap(classes []networking.IngressClass, params []elbv
}
return result
}

// findPlanHolder returns the namespace/name of the group member that the ingress
// controller writes the dry-run-plan annotation to. This is Members[0] after sorting
// by group.order (default 0), with ties broken by lexical namespace/name — the same
// logic used by the ingress controller's group loader.
func findPlanHolder(members []networking.Ingress) string {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you write unit tests for this?

sorted := make([]networking.Ingress, len(members))
copy(sorted, members)
sort.SliceStable(sorted, func(i, j int) bool {
orderI := getGroupOrder(sorted[i])
orderJ := getGroupOrder(sorted[j])
if orderI != orderJ {
return orderI < orderJ
}
nameI := fmt.Sprintf("%s/%s", sorted[i].Namespace, sorted[i].Name)
nameJ := fmt.Sprintf("%s/%s", sorted[j].Namespace, sorted[j].Name)
return nameI < nameJ
})
return fmt.Sprintf("%s/%s", sorted[0].Namespace, sorted[0].Name)
}

// getGroupOrder parses the group.order annotation from an ingress, defaulting to 0.
func getGroupOrder(ing networking.Ingress) int32 {
val, ok := ing.Annotations[ingressAnnotationKeyPrefix+annotations.IngressSuffixGroupOrder]
if !ok {
return 0
}
order, err := strconv.ParseInt(val, 10, 32)
if err != nil {
return 0
}
return int32(order)
}
5 changes: 5 additions & 0 deletions pkg/ingress2gateway/utils/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ const (
// MigrationTagKey is the AWS tag key used to track migration source.
MigrationTagKey = "gateway.k8s.aws/migrated-from"

// IngressPlanHolderAnnotation is the annotation key on a Gateway that points to the
// namespace/name of the ingress holding the dry-run-plan for an explicit ingress group.
// For gateway migrated from single ingress, we read AWS tag `migrated-from` for this purpose
IngressPlanHolderAnnotation = "gateway.k8s.aws/ingress-plan-holder"

// ProtocolHTTP is the HTTP protocol string used in listen-ports and ProtocolPort.
ProtocolHTTP = "HTTP"

Expand Down
Loading