Skip to content

Commit c77d947

Browse files
scotwellsclaude
andcommitted
feat(gateway): add operational telemetry for certificate gating
The certificate gating this change introduces was invisible in metrics — operators could not see or alert on how many hostnames were dark. Surface it. - Gateway controller: gauges for withheld listeners, certificate expiry time, and managed listeners, plus a gating counter. Labelled by gateway, listener, and hostname so an operator can find the exact affected hostname during an incident; series are cleared when a listener recovers, is removed, or the gateway is deleted, so a stale value never reports a phantom dark hostname. - Extension server: gauges for the data-plane backstop's current state (chains dropped, listeners left untouched) so an active backstop is visible. - Logs and traces: certificate expiry on the unhealthy-listener log, the affected listener names on the prune log and trace span, and a new log when a withheld listener recovers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsQQoiXVgC1VaiWye4eEz8
1 parent f3b44c2 commit c77d947

4 files changed

Lines changed: 218 additions & 37 deletions

File tree

internal/controller/gateway_controller.go

Lines changed: 110 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {

internal/controller/metrics.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ import (
1111
"github.com/prometheus/client_golang/prometheus/promauto"
1212
)
1313

14+
// Metric label name constants for TLS certificate health metrics.
15+
const (
16+
metricLabelListener = "listener"
17+
metricLabelHostname = "hostname"
18+
metricLabelSecret = "secret"
19+
metricLabelReason = "reason"
20+
)
21+
1422
var (
1523
// replicatorConflictsTotal counts resource-version conflicts observed by the
1624
// gateway-resource-replicator controller. Conflicts arise when the upstream or
@@ -53,4 +61,58 @@ var (
5361
},
5462
[]string{jsonKeyNamespace, jsonKeyName},
5563
)
64+
65+
// gatewayListenerCertWithheld is 1 for each upstream Gateway listener that NSO
66+
// is currently withholding from the downstream because its TLS certificate is
67+
// unusable. The series for a listener is removed when the listener recovers,
68+
// is removed from the Gateway, or the Gateway is deleted.
69+
//
70+
// Use sum(nso_gateway_listener_cert_withheld) to count how many listeners are
71+
// currently dark across the fleet, or filter by namespace/name/listener/hostname
72+
// to find the specific affected object during an incident.
73+
gatewayListenerCertWithheld = promauto.NewGaugeVec(
74+
prometheus.GaugeOpts{
75+
Name: "nso_gateway_listener_cert_withheld",
76+
Help: "1 when a Gateway listener is withheld from the downstream because its TLS certificate is unusable, 0 after it recovers.",
77+
},
78+
[]string{jsonKeyNamespace, jsonKeyName, metricLabelListener, metricLabelHostname, metricLabelReason},
79+
)
80+
81+
// gatewayListenerCertGatingTotal counts every reconcile cycle in which a
82+
// Gateway listener is withheld due to an unusable certificate. A rising rate
83+
// means new cert failures are arriving, not just that existing ones persist.
84+
gatewayListenerCertGatingTotal = promauto.NewCounterVec(
85+
prometheus.CounterOpts{
86+
Name: "nso_gateway_listener_cert_gating_total",
87+
Help: "Total reconcile cycles in which a Gateway listener was withheld because its TLS certificate was unusable.",
88+
},
89+
[]string{jsonKeyNamespace, jsonKeyName, metricLabelListener, metricLabelHostname, metricLabelReason},
90+
)
91+
92+
// gatewayListenerCertExpiryTime is the Unix timestamp (seconds) at which a
93+
// managed Gateway listener's TLS certificate expires. Only set for listeners
94+
// with a healthy certificate whose expiry is known. Use this to alert before
95+
// a cert expires and NSO begins gating the listener.
96+
//
97+
// Query time-to-expiry in days:
98+
// (nso_gateway_listener_cert_expiry_time - time()) / 86400
99+
gatewayListenerCertExpiryTime = promauto.NewGaugeVec(
100+
prometheus.GaugeOpts{
101+
Name: "nso_gateway_listener_cert_expiry_time",
102+
Help: "Unix timestamp when the managed TLS certificate for this Gateway listener expires. Only present when the certificate is healthy.",
103+
},
104+
[]string{jsonKeyNamespace, jsonKeyName, metricLabelListener, metricLabelHostname, metricLabelSecret},
105+
)
106+
107+
// gatewayListenerCertManaged counts the total number of Gateway listeners
108+
// that NSO evaluates for certificate health each reconcile. Together with
109+
// gatewayListenerCertWithheld this gives the fraction of managed listeners
110+
// that are currently serving (the SLI ratio).
111+
gatewayListenerCertManaged = promauto.NewGaugeVec(
112+
prometheus.GaugeOpts{
113+
Name: "nso_gateway_listener_cert_managed",
114+
Help: "1 for each Gateway listener whose TLS certificate is managed and evaluated by NSO, regardless of health.",
115+
},
116+
[]string{jsonKeyNamespace, jsonKeyName, metricLabelListener, metricLabelHostname},
117+
)
56118
)

internal/extensionserver/metrics/metrics.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,30 @@ var (
181181
},
182182
)
183183

184+
// TLSPrunedChainsActive is the number of TLS filter chains that were dropped
185+
// in the most recent PostTranslateModify response. Set to zero when the hook
186+
// runs cleanly. Use this to know whether the data-plane backstop is currently
187+
// active, as opposed to TLSPrunedChainsTotal which only accumulates.
188+
TLSPrunedChainsActive = promauto.NewGauge(
189+
prometheus.GaugeOpts{
190+
Name: "nso_extension_tls_pruned_chains_active",
191+
Help: "TLS filter chains dropped in the most recent PostTranslateModify response. Non-zero means the data-plane cert backstop is currently active.",
192+
},
193+
)
194+
195+
// TLSListenersLeftIntactActive is the number of listeners left completely
196+
// untouched in the most recent PostTranslateModify response because every
197+
// certificate on them was broken. Non-zero means the backstop could not save
198+
// the listener (it never empties a listener) — this is an important signal
199+
// that the controller-side gating did not suppress the listener and an Envoy
200+
// LDS NACK may follow.
201+
TLSListenersLeftIntactActive = promauto.NewGauge(
202+
prometheus.GaugeOpts{
203+
Name: "nso_extension_tls_listeners_left_intact_active",
204+
Help: "Listeners left intact in the most recent PostTranslateModify response because all their TLS chains were broken. Non-zero means the backstop could not protect the listener.",
205+
},
206+
)
207+
184208
// CacheSynced is 1 when the informer cache has synced and the extension server
185209
// is accepting gRPC connections, 0 during startup or if the cache lost sync.
186210
CacheSynced = promauto.NewGauge(

internal/extensionserver/server/server.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,21 +269,42 @@ func (s *Server) PostTranslateModify(
269269
_, tlsSpan := tr.Start(ctx, "tls.prune")
270270
keptSecrets, prunedChains, prunedSecrets, listenersLeftIntact, droppedTLSNames :=
271271
mutate.PruneInvalidTLSSecrets(listeners, req.GetSecrets(), time.Now())
272+
273+
// Collect the Envoy listener names that had chains pruned or were left
274+
// intact so they appear in the trace and the log alongside the SNI hostnames.
275+
var affectedListenerNames []string
276+
if prunedChains > 0 || listenersLeftIntact > 0 {
277+
for _, l := range listeners {
278+
if n := l.GetName(); n != "" {
279+
affectedListenerNames = append(affectedListenerNames, n)
280+
}
281+
}
282+
}
283+
272284
tlsSpan.SetAttributes(
273285
attribute.Int("tls.pruned_chains", prunedChains),
274286
attribute.Int("tls.pruned_secrets", prunedSecrets),
275287
attribute.Int("tls.listeners_left_intact", listenersLeftIntact),
288+
attribute.StringSlice("tls.dropped_names", droppedTLSNames),
276289
)
277290
tlsSpan.End()
291+
292+
// Counters accumulate across all hook invocations; use rate() for trending.
278293
extmetrics.TLSPrunedChainsTotal.Add(float64(prunedChains))
279294
extmetrics.TLSPrunedSecretsTotal.Add(float64(prunedSecrets))
280295
extmetrics.TLSListenersLeftIntactTotal.Add(float64(listenersLeftIntact))
296+
// Active gauges reflect the current state after each invocation so operators
297+
// can alert on "is the backstop firing right now" without needing rate().
298+
extmetrics.TLSPrunedChainsActive.Set(float64(prunedChains))
299+
extmetrics.TLSListenersLeftIntactActive.Set(float64(listenersLeftIntact))
300+
281301
if prunedChains > 0 || listenersLeftIntact > 0 {
282302
s.log.Warn("PostTranslateModify pruned invalid TLS chains",
283303
"pruned_chains", prunedChains,
284304
"pruned_secrets", prunedSecrets,
285305
"listeners_left_intact", listenersLeftIntact,
286-
"dropped", droppedTLSNames,
306+
"dropped_hostnames", droppedTLSNames,
307+
"affected_listeners", affectedListenerNames,
287308
)
288309
}
289310

0 commit comments

Comments
 (0)