Skip to content
Merged
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
39 changes: 34 additions & 5 deletions internal/controller/gateway_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ func (r *GatewayReconciler) ensureDownstreamGateway(

if err := downstreamClient.Get(ctx, client.ObjectKeyFromObject(downstreamGateway), downstreamGateway); client.IgnoreNotFound(err) != nil {
result.Err = fmt.Errorf("failed to get downstream gateway: %w", err)
return result, nil
}

verifiedHostnames, claimedHostnames, notClaimedHostnames, err := r.ensureHostnamesClaimed(
Expand Down Expand Up @@ -270,6 +271,9 @@ func (r *GatewayReconciler) ensureDownstreamGateway(
return result, nil
}
} else {
// Sync cert-manager annotations: add desired, remove stale
syncCertManagerAnnotations(downstreamGateway, desiredDownstreamGateway)

if !equality.Semantic.DeepEqual(downstreamGateway.Annotations, desiredDownstreamGateway.Annotations) ||
!equality.Semantic.DeepEqual(downstreamGateway.Spec, desiredDownstreamGateway.Spec) {
// Take care not to clobber other annotations
Expand Down Expand Up @@ -358,6 +362,14 @@ func (r *GatewayReconciler) getDesiredDownstreamGateway(
var listeners []gatewayv1.Listener

for listenerIndex, l := range upstreamGateway.Spec.Listeners {
// Only process listeners with verified custom hostnames; unclaimed
// hostnames must not influence cert-manager annotations or downstream
// listener configuration.
if l.Hostname != nil && !slices.Contains(claimedHostnames, string(*l.Hostname)) {
logger.Info("skipping downstream gateway listener with unclaimed hostname", "upstream_listener_index", listenerIndex, "hostname", *l.Hostname)
continue
}

if l.TLS != nil && l.TLS.Options[certificateIssuerTLSOption] != "" {
if r.Config.Gateway.PerGatewayCertificateIssuer {
if !metav1.HasAnnotation(downstreamGateway.ObjectMeta, "cert-manager.io/issuer") {
Expand Down Expand Up @@ -388,12 +400,7 @@ func (r *GatewayReconciler) getDesiredDownstreamGateway(
}
}

// Add custom hostnames if they are verified
if l.Hostname != nil {
if !slices.Contains(claimedHostnames, string(*l.Hostname)) {
logger.Info("skipping downstream gateway listener with unclaimed hostname", "upstream_listener_index", listenerIndex, "hostname", *l.Hostname)
continue
}
listenerCopy := l.DeepCopy()
if l.TLS != nil && l.TLS.Options[certificateIssuerTLSOption] != "" {
// Translate upstream TLS settings to downstream TLS settings
Expand Down Expand Up @@ -426,6 +433,28 @@ func (r *GatewayReconciler) getDesiredDownstreamGateway(
return &downstreamGateway
}

// certManagerAnnotationKeys are the annotations managed by the gateway controller
// for cert-manager integration. These are synced from the desired state and stale
// entries are removed when custom hostnames become unclaimed.
var certManagerAnnotationKeys = []string{
"cert-manager.io/issuer",
"cert-manager.io/cluster-issuer",
"cert-manager.io/secret-template",
}

// syncCertManagerAnnotations ensures the downstream gateway's cert-manager
// annotations match the desired state: desired keys are set, and keys that
// are no longer needed are removed.
func syncCertManagerAnnotations(downstream, desired *gatewayv1.Gateway) {
for _, key := range certManagerAnnotationKeys {
if v, ok := desired.Annotations[key]; ok {
metav1.SetMetaDataAnnotation(&downstream.ObjectMeta, key, v)
} else {
delete(downstream.Annotations, key)
}
}
}

func (r *GatewayReconciler) reconcileGatewayStatus(
upstreamClient client.Client,
upstreamGateway *gatewayv1.Gateway,
Expand Down
209 changes: 209 additions & 0 deletions internal/controller/gateway_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,215 @@ func newGateway(
return gw
}

func TestGetDesiredDownstreamGateway_UnclaimedHostnameNoAnnotations(t *testing.T) {
logger := zap.New(zap.UseFlagOptions(&zap.Options{Development: true}))
ctx := log.IntoContext(context.Background(), logger)

tests := []struct {
name string
perGatewayCertIssuer bool
listeners []gatewayv1.Listener
claimedHostnames []string
expectIssuerAnnotation bool
expectClusterAnnotation bool
expectListeners int
}{
{
name: "unclaimed hostname with TLS option does not produce annotations",
perGatewayCertIssuer: true,
listeners: []gatewayv1.Listener{
{
Name: "custom-https",
Port: 443,
Protocol: gatewayv1.HTTPSProtocolType,
Hostname: ptr.To(gatewayv1.Hostname("unclaimed.example.com")),
TLS: &gatewayv1.GatewayTLSConfig{
Options: map[gatewayv1.AnnotationKey]gatewayv1.AnnotationValue{
certificateIssuerTLSOption: "letsencrypt",
},
},
},
},
claimedHostnames: nil,
expectIssuerAnnotation: false,
expectClusterAnnotation: false,
expectListeners: 0,
},
{
name: "claimed hostname with TLS option produces issuer annotation (per-gateway mode)",
perGatewayCertIssuer: true,
listeners: []gatewayv1.Listener{
{
Name: "custom-https",
Port: 443,
Protocol: gatewayv1.HTTPSProtocolType,
Hostname: ptr.To(gatewayv1.Hostname("claimed.example.com")),
TLS: &gatewayv1.GatewayTLSConfig{
Options: map[gatewayv1.AnnotationKey]gatewayv1.AnnotationValue{
certificateIssuerTLSOption: "letsencrypt",
},
},
},
},
claimedHostnames: []string{"claimed.example.com"},
expectIssuerAnnotation: true,
expectClusterAnnotation: false,
expectListeners: 1,
},
{
name: "claimed hostname with TLS option produces cluster-issuer annotation (cluster mode)",
perGatewayCertIssuer: false,
listeners: []gatewayv1.Listener{
{
Name: "custom-https",
Port: 443,
Protocol: gatewayv1.HTTPSProtocolType,
Hostname: ptr.To(gatewayv1.Hostname("claimed.example.com")),
TLS: &gatewayv1.GatewayTLSConfig{
Options: map[gatewayv1.AnnotationKey]gatewayv1.AnnotationValue{
certificateIssuerTLSOption: "letsencrypt",
},
},
},
},
claimedHostnames: []string{"claimed.example.com"},
expectIssuerAnnotation: false,
expectClusterAnnotation: true,
expectListeners: 1,
},
{
name: "mix: only claimed hostname contributes annotations",
perGatewayCertIssuer: true,
listeners: []gatewayv1.Listener{
{
Name: "unclaimed-https",
Port: 443,
Protocol: gatewayv1.HTTPSProtocolType,
Hostname: ptr.To(gatewayv1.Hostname("unclaimed.example.com")),
TLS: &gatewayv1.GatewayTLSConfig{
Options: map[gatewayv1.AnnotationKey]gatewayv1.AnnotationValue{
certificateIssuerTLSOption: "letsencrypt",
},
},
},
{
Name: "claimed-https",
Port: 443,
Protocol: gatewayv1.HTTPSProtocolType,
Hostname: ptr.To(gatewayv1.Hostname("claimed.example.com")),
TLS: &gatewayv1.GatewayTLSConfig{
Options: map[gatewayv1.AnnotationKey]gatewayv1.AnnotationValue{
certificateIssuerTLSOption: "letsencrypt",
},
},
},
},
claimedHostnames: []string{"claimed.example.com"},
expectIssuerAnnotation: true,
expectClusterAnnotation: false,
expectListeners: 1,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reconciler := &GatewayReconciler{
Config: config.NetworkServicesOperator{
Gateway: config.GatewayConfig{
DownstreamGatewayClassName: "envoy",
PerGatewayCertificateIssuer: tt.perGatewayCertIssuer,
},
},
}

upstream := &gatewayv1.Gateway{
ObjectMeta: metav1.ObjectMeta{Name: "test-gw", Namespace: "default"},
Spec: gatewayv1.GatewaySpec{Listeners: tt.listeners},
}

desired := reconciler.getDesiredDownstreamGateway(ctx, "test-cluster", upstream, tt.claimedHostnames)

_, hasIssuer := desired.Annotations["cert-manager.io/issuer"]
_, hasClusterIssuer := desired.Annotations["cert-manager.io/cluster-issuer"]

assert.Equal(t, tt.expectIssuerAnnotation, hasIssuer, "cert-manager.io/issuer annotation presence")
assert.Equal(t, tt.expectClusterAnnotation, hasClusterIssuer, "cert-manager.io/cluster-issuer annotation presence")
assert.Len(t, desired.Spec.Listeners, tt.expectListeners, "downstream listener count")
})
}
}

func TestSyncCertManagerAnnotations(t *testing.T) {
t.Parallel()

tests := []struct {
name string
existingAnnotations map[string]string
desiredAnnotations map[string]string
wantAnnotations map[string]string
}{
{
name: "stale issuer annotation removed",
existingAnnotations: map[string]string{
"cert-manager.io/issuer": "old-issuer",
"other-annotation": "keep-me",
},
desiredAnnotations: map[string]string{},
wantAnnotations: map[string]string{
"other-annotation": "keep-me",
},
},
{
name: "issuer annotation updated to cluster-issuer",
existingAnnotations: map[string]string{
"cert-manager.io/issuer": "old-issuer",
},
desiredAnnotations: map[string]string{
"cert-manager.io/cluster-issuer": "new-cluster-issuer",
},
wantAnnotations: map[string]string{
"cert-manager.io/cluster-issuer": "new-cluster-issuer",
},
},
{
name: "no-op when annotations match",
existingAnnotations: map[string]string{"cert-manager.io/issuer": "my-issuer"},
desiredAnnotations: map[string]string{"cert-manager.io/issuer": "my-issuer"},
wantAnnotations: map[string]string{"cert-manager.io/issuer": "my-issuer"},
},
{
name: "all three cert-manager annotations cleaned up",
existingAnnotations: map[string]string{
"cert-manager.io/issuer": "old",
"cert-manager.io/cluster-issuer": "old",
"cert-manager.io/secret-template": "old",
"unrelated": "preserved",
},
desiredAnnotations: map[string]string{},
wantAnnotations: map[string]string{
"unrelated": "preserved",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

existing := &gatewayv1.Gateway{
ObjectMeta: metav1.ObjectMeta{Annotations: tt.existingAnnotations},
}
desired := &gatewayv1.Gateway{
ObjectMeta: metav1.ObjectMeta{Annotations: tt.desiredAnnotations},
}

syncCertManagerAnnotations(existing, desired)

assert.Equal(t, tt.wantAnnotations, existing.Annotations)
})
}
}

func newHTTPRoute(namespace, name string, opts ...func(*gatewayv1.HTTPRoute)) *gatewayv1.HTTPRoute {
route := &gatewayv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Expand Down
28 changes: 28 additions & 0 deletions internal/controller/httpproxy_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,9 @@ func (r *HTTPProxyReconciler) reconcileHTTPProxyHostnameStatus(
availabilityStatuses := buildAvailabilityStatuses(acceptedHostnames, inUseHostnames, httpProxyCopy.Generation)
dnsStatuses := r.buildDNSStatuses(ctx, cl, gateway, httpProxyCopy.Generation)
certificateStatuses := r.buildCertificateStatuses(ctx, cl, clusterName, gateway, httpProxyCopy)
previousHostnameStatuses := httpProxyCopy.Status.HostnameStatuses
httpProxyCopy.Status.HostnameStatuses = mergeHostnameStatuses(availabilityStatuses, dnsStatuses, certificateStatuses)
preserveHostnameConditionTransitions(httpProxyCopy.Status.HostnameStatuses, previousHostnameStatuses)

r.setCertificatesReadyCondition(httpProxyCopy, certificateStatuses, gateway)
}
Expand Down Expand Up @@ -1273,6 +1275,32 @@ func mergeHostnameStatuses(statusSets ...[]networkingv1alpha.HostnameStatus) []n
return result
}

// preserveHostnameConditionTransitions retains the original LastTransitionTime
// for hostname conditions whose Status has not changed. Without this, freshly-
// built HostnameStatus objects always get LastTransitionTime=now, which causes
// a spurious status diff on every reconcile and drives an infinite loop.
func preserveHostnameConditionTransitions(
newStatuses, oldStatuses []networkingv1alpha.HostnameStatus,
) {
oldByHostname := make(map[string]networkingv1alpha.HostnameStatus, len(oldStatuses))
for _, hs := range oldStatuses {
oldByHostname[hs.Hostname] = hs
}

for i, newHS := range newStatuses {
oldHS, ok := oldByHostname[newHS.Hostname]
if !ok {
continue
}
for j, newCond := range newHS.Conditions {
oldCond := apimeta.FindStatusCondition(oldHS.Conditions, newCond.Type)
if oldCond != nil && oldCond.Status == newCond.Status {
newStatuses[i].Conditions[j].LastTransitionTime = oldCond.LastTransitionTime
}
}
}
}

func (r *HTTPProxyReconciler) reconcileConnectorEnvoyPatchPolicy(
ctx context.Context,
upstreamClient client.Client,
Expand Down
Loading
Loading