@@ -13,6 +13,7 @@ import (
1313 cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
1414 cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1"
1515 envoygatewayv1alpha1 "github.com/envoyproxy/gateway/api/v1alpha1"
16+ "github.com/prometheus/client_golang/prometheus"
1617 corev1 "k8s.io/api/core/v1"
1718 discoveryv1 "k8s.io/api/discovery/v1"
1819 "k8s.io/apimachinery/pkg/api/equality"
@@ -422,6 +423,25 @@ type listenerCertStatus struct {
422423 // pending means the listener is only waiting on a certificate to be issued,
423424 // so the reconcile should check back to let it recover.
424425 pending bool
426+ // notAfter is the certificate's expiry time, populated only when the
427+ // certificate is healthy and its expiry is known. Used to set the expiry
428+ // gauge so operators can alert before a cert expires.
429+ notAfter * metav1.Time
430+ // secretName is the downstream Secret that holds the certificate material.
431+ // Carried here so the expiry gauge can be labelled with the secret name
432+ // without recomputing it outside listenerCertHealth.
433+ secretName string
434+ }
435+
436+ // clearListenerCertMetrics removes every certificate-health gauge series for a
437+ // gateway. Used both before re-recording each reconcile and on gateway deletion
438+ // so a removed listener never leaves a series stuck at its last value. The gating
439+ // counter is left alone because it is cumulative.
440+ func clearListenerCertMetrics (namespace , name string ) {
441+ labels := prometheus.Labels {jsonKeyNamespace : namespace , jsonKeyName : name }
442+ gatewayListenerCertWithheld .DeletePartialMatch (labels )
443+ gatewayListenerCertExpiryTime .DeletePartialMatch (labels )
444+ gatewayListenerCertManaged .DeletePartialMatch (labels )
425445}
426446
427447// evaluateListenerCertHealth reports which listeners have a usable certificate.
@@ -443,6 +463,11 @@ func (r *GatewayReconciler) evaluateListenerCertHealth(
443463 hasSharedSecret := r .Config .Gateway .HasDefaultListenerTLSSecret ()
444464 now := time .Now ()
445465
466+ // Drop this gateway's previous certificate metrics up front and re-record the
467+ // current state below, so series for listeners that recovered, changed
468+ // hostname, or were removed don't linger at a stale value.
469+ clearListenerCertMetrics (upstreamGateway .Namespace , upstreamGateway .Name )
470+
446471 for _ , l := range upstreamGateway .Spec .Listeners {
447472 // Only listeners that own a per-hostname certificate are gated.
448473 if l .TLS == nil || l .TLS .Options [certificateIssuerTLSOption ] == "" || l .Hostname == nil {
@@ -458,11 +483,42 @@ func (r *GatewayReconciler) evaluateListenerCertHealth(
458483 continue
459484 }
460485
461- health [l .Name ] = r .listenerCertHealth (ctx , downstreamClient , downstreamNamespace , upstreamGateway .Name , l .Name , hostname , now )
462- if ! health [l .Name ].healthy {
463- logger .Info ("listener certificate unhealthy" ,
486+ status := r .listenerCertHealth (ctx , downstreamClient , downstreamNamespace , upstreamGateway .Name , l .Name , hostname , now )
487+ health [l .Name ] = status
488+
489+ // Mark this listener as managed regardless of its health, so the
490+ // SLI ratio (withheld / managed) can be computed fleet-wide.
491+ gatewayListenerCertManaged .WithLabelValues (
492+ upstreamGateway .Namespace , upstreamGateway .Name , string (l .Name ), hostname ,
493+ ).Set (1 )
494+
495+ if ! status .healthy {
496+ logArgs := []any {
464497 "listener" , l .Name , "hostname" , hostname ,
465- "reason" , health [l .Name ].reason , "message" , health [l .Name ].message )
498+ "reason" , status .reason , "message" , status .message ,
499+ }
500+ // Include the expiry timestamp when available so log queries can
501+ // identify exactly when the cert stopped being valid.
502+ if status .notAfter != nil {
503+ logArgs = append (logArgs , "cert_not_after" , status .notAfter .UTC ().Format (time .RFC3339 ))
504+ }
505+ logger .Info ("listener certificate unhealthy" , logArgs ... )
506+
507+ gatewayListenerCertWithheld .WithLabelValues (
508+ upstreamGateway .Namespace , upstreamGateway .Name ,
509+ string (l .Name ), hostname , string (status .reason ),
510+ ).Set (1 )
511+ gatewayListenerCertGatingTotal .WithLabelValues (
512+ upstreamGateway .Namespace , upstreamGateway .Name ,
513+ string (l .Name ), hostname , string (status .reason ),
514+ ).Inc ()
515+ } else if status .notAfter != nil {
516+ // Record the expiry timestamp for healthy certs so operators can
517+ // alert before the next expiry rather than after.
518+ gatewayListenerCertExpiryTime .WithLabelValues (
519+ upstreamGateway .Namespace , upstreamGateway .Name ,
520+ string (l .Name ), hostname , status .secretName ,
521+ ).Set (float64 (status .notAfter .Unix ()))
466522 }
467523 }
468524
@@ -491,39 +547,45 @@ func (r *GatewayReconciler) listenerCertHealth(
491547 if err := downstreamClient .Get (ctx , client.ObjectKey {Namespace : downstreamNamespace , Name : certName }, & cert ); err != nil {
492548 if apierrors .IsNotFound (err ) {
493549 return listenerCertStatus {
494- reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
495- message : certIssuanceFailingMessage (hostname ),
496- pending : true ,
550+ reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
551+ message : certIssuanceFailingMessage (hostname ),
552+ pending : true ,
553+ secretName : secretName ,
497554 }
498555 }
499556 // On a read error, hold the listener back but allow it to recover later.
500557 logger .Error (err , "failed to get listener Certificate" , "certificate" , certName )
501558 return listenerCertStatus {
502- reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
503- message : certIssuanceFailingMessage (hostname ),
504- pending : true ,
559+ reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
560+ message : certIssuanceFailingMessage (hostname ),
561+ pending : true ,
562+ secretName : secretName ,
505563 }
506564 }
507565
508566 if ! certIsReady (& cert ) {
509567 return listenerCertStatus {
510- reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
511- message : certIssuanceFailingMessage (hostname ),
512- pending : true ,
568+ reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
569+ message : certIssuanceFailingMessage (hostname ),
570+ pending : true ,
571+ secretName : secretName ,
513572 }
514573 }
515574
516575 // The certificate must be within its valid dates.
517576 if cert .Status .NotBefore != nil && cert .Status .NotBefore .After (now ) {
518577 return listenerCertStatus {
519- reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
520- message : certNotYetValidMessage (hostname ),
578+ reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
579+ message : certNotYetValidMessage (hostname ),
580+ secretName : secretName ,
521581 }
522582 }
523583 if cert .Status .NotAfter != nil && ! cert .Status .NotAfter .After (now .Add (listenerCertExpiryMargin )) {
524584 return listenerCertStatus {
525- reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
526- message : certExpiredMessage (hostname ),
585+ reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
586+ message : certExpiredMessage (hostname ),
587+ notAfter : cert .Status .NotAfter ,
588+ secretName : secretName ,
527589 }
528590 }
529591
@@ -534,34 +596,38 @@ func (r *GatewayReconciler) listenerCertHealth(
534596 if err := downstreamClient .Get (ctx , client.ObjectKey {Namespace : downstreamNamespace , Name : secretName }, & secret ); err != nil {
535597 if apierrors .IsNotFound (err ) {
536598 return listenerCertStatus {
537- reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
538- message : certMissingMessage (hostname ),
539- pending : true ,
599+ reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
600+ message : certMissingMessage (hostname ),
601+ pending : true ,
602+ secretName : secretName ,
540603 }
541604 }
542605 logger .Error (err , "failed to get listener Secret" , "secret" , secretName )
543606 return listenerCertStatus {
544- reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
545- message : certMissingMessage (hostname ),
546- pending : true ,
607+ reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
608+ message : certMissingMessage (hostname ),
609+ pending : true ,
610+ secretName : secretName ,
547611 }
548612 }
549613
550614 keyPair , err := tls .X509KeyPair (secret .Data ["tls.crt" ], secret .Data ["tls.key" ])
551615 if err != nil {
552616 return listenerCertStatus {
553- reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
554- message : certMissingMessage (hostname ),
617+ reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
618+ message : certMissingMessage (hostname ),
619+ secretName : secretName ,
555620 }
556621 }
557622 if leaf := keyPair .Leaf ; leaf != nil && ! leaf .NotAfter .After (now ) {
558623 return listenerCertStatus {
559- reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
560- message : certExpiredMessage (hostname ),
624+ reason : gatewayv1 .ListenerReasonInvalidCertificateRef ,
625+ message : certExpiredMessage (hostname ),
626+ secretName : secretName ,
561627 }
562628 }
563629
564- return listenerCertStatus {healthy : true }
630+ return listenerCertStatus {healthy : true , notAfter : cert . Status . NotAfter , secretName : secretName }
565631}
566632
567633// certIsReady reports whether a cert-manager Certificate has Ready=True.
@@ -615,11 +681,18 @@ func (r *GatewayReconciler) getDesiredDownstreamGateway(
615681 // Leave out a listener whose certificate isn't usable. A bad certificate
616682 // then only affects its own hostname, and every other hostname on the
617683 // gateway keeps serving.
618- if status , gated := listenerCertHealth [l .Name ]; gated && ! status .healthy {
619- logger .Info ("skipping downstream gateway listener with unhealthy certificate" ,
620- "upstream_listener_index" , listenerIndex , "listener" , l .Name ,
621- "reason" , status .reason , "message" , status .message )
622- continue
684+ if status , gated := listenerCertHealth [l .Name ]; gated {
685+ if ! status .healthy {
686+ logger .Info ("skipping downstream gateway listener with unhealthy certificate" ,
687+ "upstream_listener_index" , listenerIndex , "listener" , l .Name ,
688+ "reason" , status .reason , "message" , status .message )
689+ continue
690+ }
691+ // The listener was evaluated (it owns a per-hostname cert) and is
692+ // healthy: it is admitted to the downstream gateway. Log this so
693+ // operators can confirm recovery without correlating metric state.
694+ logger .Info ("admitting downstream gateway listener with healthy certificate" ,
695+ "upstream_listener_index" , listenerIndex , "listener" , l .Name )
623696 }
624697
625698 // Per-listener TLS decision: hostnames covered by the wildcard
@@ -1366,10 +1439,11 @@ func (r *GatewayReconciler) finalizeGateway(
13661439 logger := log .FromContext (ctx )
13671440 logger .Info ("finalizing gateway" )
13681441
1369- // Remove the per-gateway programmed gauge so deleted gateways do not leave
1370- // stale label sets in the metric. This prevents sum(nso_gateway_programmed_total)
1371- // from over-counting the fleet after gateways are removed.
1442+ // Remove per-gateway metric series so deleted gateways do not leave stale
1443+ // label sets that misrepresent fleet state.
13721444 gatewayProgrammedTotal .DeleteLabelValues (upstreamGateway .Namespace , upstreamGateway .Name )
1445+ // Clear this gateway's cert-health series now that it is gone.
1446+ clearListenerCertMetrics (upstreamGateway .Namespace , upstreamGateway .Name )
13731447
13741448 // Clean up DNS records created by this gateway
13751449 if r .Config .Gateway .EnableDNSIntegration {
0 commit comments