package healthcheck import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" ) const maximumResponseBytes = 1 << 20 var ErrWorkloadStopped = errors.New("workload stopped before becoming healthy") // RunningProbe 在每次健康采样前核对容器是否仍处于运行状态。 type RunningProbe func(context.Context) (bool, error) // ActuatorReport 是一次成功健康采样的非敏感摘要。 type ActuatorReport struct { Status string Components map[string]string } // ActuatorChecker 等待 Spring Boot Actuator 顶层状态进入 UP。 // 它只采样健康状态,不负责重启容器。 type ActuatorChecker struct { client *http.Client interval time.Duration } // Check 执行一次健康采样,供事务恢复时核对已经记录意图的健康步骤。 func (c *ActuatorChecker) Check(ctx context.Context, endpoint string, running RunningProbe) (ActuatorReport, bool, error) { if running == nil { return ActuatorReport{}, false, errors.New("running probe is required") } if err := validateEndpoint(endpoint); err != nil { return ActuatorReport{}, false, err } return c.sample(ctx, endpoint, running) } // NewActuatorChecker 创建固定间隔的 Actuator 检查器。 func NewActuatorChecker(client *http.Client, interval time.Duration) (*ActuatorChecker, error) { if client == nil { return nil, errors.New("HTTP client is required") } if interval <= 0 { return nil, errors.New("health check interval must be positive") } return &ActuatorChecker{client: client, interval: interval}, nil } // Wait 在 timeout 范围内持续采样。容器停止时立即失败;其他未就绪结果保留到截止时间。 func (c *ActuatorChecker) Wait(ctx context.Context, endpoint string, timeout time.Duration, running RunningProbe) (ActuatorReport, error) { if timeout <= 0 { return ActuatorReport{}, errors.New("health check timeout must be positive") } if running == nil { return ActuatorReport{}, errors.New("running probe is required") } if err := validateEndpoint(endpoint); err != nil { return ActuatorReport{}, err } waitContext, cancel := context.WithTimeout(ctx, timeout) defer cancel() var lastErr error for { report, ready, err := c.sample(waitContext, endpoint, running) if ready { return report, nil } if errors.Is(err, ErrWorkloadStopped) { return ActuatorReport{}, err } lastErr = err timer := time.NewTimer(c.interval) select { case <-waitContext.Done(): timer.Stop() return ActuatorReport{}, fmt.Errorf("Actuator did not become UP within %s: %w", timeout, errors.Join(waitContext.Err(), lastErr)) case <-timer.C: } } } func validateEndpoint(endpoint string) error { parsed, err := url.ParseRequestURI(endpoint) if err != nil || parsed.Scheme != "http" || parsed.Host == "" { return fmt.Errorf("invalid Actuator HTTP endpoint: %q", endpoint) } return nil } func (c *ActuatorChecker) sample(ctx context.Context, endpoint string, running RunningProbe) (ActuatorReport, bool, error) { isRunning, err := running(ctx) if err != nil { return ActuatorReport{}, false, fmt.Errorf("inspect workload before health check: %w", err) } if !isRunning { return ActuatorReport{}, false, ErrWorkloadStopped } request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return ActuatorReport{}, false, fmt.Errorf("create Actuator request: %w", err) } request.Header.Set("Accept", "application/json") response, err := c.client.Do(request) if err != nil { return ActuatorReport{}, false, fmt.Errorf("request Actuator health: %w", err) } defer response.Body.Close() body, err := io.ReadAll(io.LimitReader(response.Body, maximumResponseBytes+1)) if err != nil { return ActuatorReport{}, false, fmt.Errorf("read Actuator response: %w", err) } if len(body) > maximumResponseBytes { return ActuatorReport{}, false, errors.New("Actuator response exceeds size limit") } if response.StatusCode != http.StatusOK { return ActuatorReport{}, false, fmt.Errorf("Actuator returned HTTP status %d", response.StatusCode) } var payload actuatorPayload if err := json.Unmarshal(body, &payload); err != nil { return ActuatorReport{}, false, fmt.Errorf("decode Actuator response: %w", err) } report := ActuatorReport{Status: payload.Status, Components: make(map[string]string, len(payload.Components))} for name, component := range payload.Components { report.Components[name] = component.Status } if payload.Status != "UP" { return report, false, fmt.Errorf("Actuator status is %q", payload.Status) } return report, true, nil } type actuatorPayload struct { Status string `json:"status"` Components map[string]actuatorComponent `json:"components"` } type actuatorComponent struct { Status string `json:"status"` }