@@ -87,6 +87,15 @@ type CollectionPolicyReconciler struct {
8787 // OOM reconciler for periodic sweep of missed OOM events
8888 oomReconciler * collector.OOMReconciler
8989 oomReconcilerCancel context.CancelFunc
90+
91+ // Periodic Prometheus availability probe. Without this the prometheus
92+ // component health is set once at startup (via waitForPrometheusAvailability)
93+ // and never re-evaluated, so a transient outage at boot leaves the operator
94+ // permanently reporting prometheus as unhealthy even after recovery.
95+ prometheusProbeMu sync.Mutex
96+ prometheusProbeURL string
97+ prometheusProbeCancel context.CancelFunc
98+ prometheusProbeDone chan struct {} // closed by the goroutine on exit; nil until first start
9099}
91100
92101// pendingCollector represents a collector that was unavailable at registration time
@@ -1059,6 +1068,7 @@ func (r *CollectionPolicyReconciler) restartCollectors(
10591068 )
10601069
10611070 prometheusAvailable := r .waitForPrometheusAvailability (ctx , newConfig .PrometheusURL )
1071+ r .startPeriodicPrometheusHealthCheck (newConfig .PrometheusURL )
10621072 if ! prometheusAvailable {
10631073 logger .Info (
10641074 "Prometheus is not available after waiting, will continue with restart but metrics may be limited" ,
@@ -1887,6 +1897,10 @@ func (r *CollectionPolicyReconciler) initializeCollectors(
18871897 )
18881898 logger .Info ("Prometheus is available, continuing with full metrics collection" )
18891899 }
1900+
1901+ // Start (or refresh) the periodic probe so health recovers from transient
1902+ // outages rather than being frozen on the one-shot startup result.
1903+ r .startPeriodicPrometheusHealthCheck (config .PrometheusURL )
18901904 } else {
18911905 r .TelemetryLogger .Report (
18921906 gen .LogLevel_LOG_LEVEL_WARN ,
@@ -1898,6 +1912,8 @@ func (r *CollectionPolicyReconciler) initializeCollectors(
18981912 "zxporter_version" : version .Get ().String (),
18991913 },
19001914 )
1915+ // No URL — make sure any prior probe is stopped.
1916+ r .startPeriodicPrometheusHealthCheck ("" )
19011917 }
19021918
19031919 // Setup collection manager and basic services
@@ -4022,11 +4038,7 @@ func (r *CollectionPolicyReconciler) waitForPrometheusAvailability(
40224038 Transport : tr ,
40234039 }
40244040
4025- // Endpoint to verify prometheus is ready
4026- healthEndpoint := fmt .Sprintf ("%s/-/ready" , prometheusURL )
4027- if ! strings .HasPrefix (prometheusURL , "http" ) {
4028- healthEndpoint = fmt .Sprintf ("http://%s/-/ready" , prometheusURL )
4029- }
4041+ healthEndpoint := prometheusHealthEndpoint (prometheusURL )
40304042
40314043 for i := 0 ; i < maxRetries ; i ++ {
40324044 select {
@@ -4162,3 +4174,118 @@ func (r *CollectionPolicyReconciler) updateHealthStatus(
41624174 r .HealthManager .UpdateStatus (health .ComponentPrometheus , status , message , metadata )
41634175 }
41644176}
4177+
4178+ // startPeriodicPrometheusHealthCheck launches (or restarts) a goroutine that
4179+ // pings Prometheus every minute and updates ComponentPrometheus health. Without
4180+ // this, the one-shot startup check leaves the status frozen until something
4181+ // else happens to refresh it. Pass "" to stop the probe.
4182+ //
4183+ // The goroutine is parented on context.Background() rather than the caller's
4184+ // reconcile context: controller-runtime cancels the reconcile ctx as soon as
4185+ // Reconcile returns, which would kill the probe immediately. The stored cancel
4186+ // func is the only intended way to stop it (matches the OOM reconciler pattern).
4187+ //
4188+ // Idempotency: if a probe for the same URL is already running we no-op. We
4189+ // detect a dead goroutine (panic, lost cancel, etc.) via the done channel —
4190+ // if it's already closed we treat the probe as gone and start a fresh one.
4191+ func (r * CollectionPolicyReconciler ) startPeriodicPrometheusHealthCheck (url string ) {
4192+ r .prometheusProbeMu .Lock ()
4193+ defer r .prometheusProbeMu .Unlock ()
4194+
4195+ if r .prometheusProbeURL == url && r .probeStillAliveLocked () {
4196+ return
4197+ }
4198+ if r .prometheusProbeCancel != nil {
4199+ r .prometheusProbeCancel ()
4200+ }
4201+ r .prometheusProbeURL = url
4202+ r .prometheusProbeCancel = nil
4203+ r .prometheusProbeDone = nil
4204+ if url == "" {
4205+ return
4206+ }
4207+
4208+ endpoint := prometheusHealthEndpoint (url )
4209+ ctx , cancel := context .WithCancel (context .Background ())
4210+ done := make (chan struct {})
4211+ r .prometheusProbeCancel = cancel
4212+ r .prometheusProbeDone = done
4213+
4214+ go r .runPrometheusProbe (ctx , done , url , endpoint )
4215+ }
4216+
4217+ // prometheusHealthEndpoint returns the full /-/ready URL, defaulting to http://
4218+ // when the caller passed a bare host:port. Shared by waitForPrometheusAvailability
4219+ // and the periodic probe so both build the same endpoint from the same input.
4220+ func prometheusHealthEndpoint (url string ) string {
4221+ if ! strings .HasPrefix (url , "http" ) {
4222+ return "http://" + url + "/-/ready"
4223+ }
4224+ return url + "/-/ready"
4225+ }
4226+
4227+ // probeStillAliveLocked returns true if the existing probe goroutine is still
4228+ // running. Caller must hold prometheusProbeMu.
4229+ func (r * CollectionPolicyReconciler ) probeStillAliveLocked () bool {
4230+ if r .prometheusProbeCancel == nil || r .prometheusProbeDone == nil {
4231+ return false
4232+ }
4233+ select {
4234+ case <- r .prometheusProbeDone :
4235+ return false
4236+ default :
4237+ return true
4238+ }
4239+ }
4240+
4241+ // runPrometheusProbe is the probe goroutine body. Defers a recover so a panic
4242+ // in the HTTP client does not silently leave the probe dead with no cleanup,
4243+ // and always closes done so probeStillAliveLocked can detect exit.
4244+ func (r * CollectionPolicyReconciler ) runPrometheusProbe (ctx context.Context , done chan struct {}, url , endpoint string ) {
4245+ defer close (done )
4246+ defer func () {
4247+ if rec := recover (); rec != nil {
4248+ r .Log .Error (fmt .Errorf ("%v" , rec ), "Prometheus probe goroutine panicked" , "url" , url )
4249+ }
4250+ }()
4251+
4252+ // Clone DefaultTransport so the probe inherits proxy/TLS settings (matches
4253+ // waitForPrometheusAvailability). Re-using DefaultTransport directly would
4254+ // share the connection pool with every other consumer in the binary.
4255+ tr := http .DefaultTransport .(* http.Transport ).Clone ()
4256+ client := & http.Client {Timeout : 5 * time .Second , Transport : tr }
4257+ ticker := time .NewTicker (60 * time .Second )
4258+ defer ticker .Stop ()
4259+
4260+ for {
4261+ select {
4262+ case <- ctx .Done ():
4263+ return
4264+ case <- ticker .C :
4265+ status , msg := health .HealthStatusHealthy , "Prometheus available"
4266+ meta := map [string ]string {"url" : url }
4267+ // Build the request from ctx so a URL change or shutdown cancels
4268+ // any in-flight call immediately rather than waiting for the timeout.
4269+ req , err := http .NewRequestWithContext (ctx , http .MethodGet , endpoint , nil )
4270+ if err != nil {
4271+ status , msg = health .HealthStatusDegraded , "Prometheus probe request build failed"
4272+ meta ["error" ] = err .Error ()
4273+ r .updateHealthStatus (status , msg , meta )
4274+ continue
4275+ }
4276+ resp , err := client .Do (req )
4277+ switch {
4278+ case err != nil :
4279+ status , msg = health .HealthStatusDegraded , "Prometheus probe failed"
4280+ meta ["error" ] = err .Error ()
4281+ case resp .StatusCode != http .StatusOK :
4282+ status , msg = health .HealthStatusDegraded , "Prometheus probe returned non-OK status"
4283+ meta ["status_code" ] = fmt .Sprintf ("%d" , resp .StatusCode )
4284+ }
4285+ if resp != nil {
4286+ _ = resp .Body .Close ()
4287+ }
4288+ r .updateHealthStatus (status , msg , meta )
4289+ }
4290+ }
4291+ }
0 commit comments