feat: backend container executor implement
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const healthyActuatorSample = `{"status":"UP","components":{"db":{"status":"UP","components":{"dorisDataSource":{"status":"UP"},"postgresqlDataSource":{"status":"UP"}}},"diskSpace":{"status":"UP"},"ping":{"status":"UP"},"redis":{"status":"UP"}}}`
|
||||
|
||||
func TestActuatorCheckerAcceptsConfirmedResponseShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := newHTTPClient(func(request *http.Request) *http.Response {
|
||||
if request.URL.Path != "/yms/actuator/health" {
|
||||
t.Errorf("unexpected health path: %s", request.URL.Path)
|
||||
}
|
||||
return response(http.StatusOK, healthyActuatorSample)
|
||||
})
|
||||
checker := newTestChecker(t, client)
|
||||
report, err := checker.Wait(context.Background(), "http://127.0.0.1:8081/yms/actuator/health", time.Second, alwaysRunning)
|
||||
if err != nil {
|
||||
t.Fatalf("wait for healthy Actuator: %v", err)
|
||||
}
|
||||
if report.Status != "UP" || report.Components["db"] != "UP" || report.Components["redis"] != "UP" {
|
||||
t.Fatalf("unexpected health report: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActuatorCheckerChecksOnceForTransactionRecovery(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := newHTTPClient(func(*http.Request) *http.Response {
|
||||
return response(http.StatusOK, healthyActuatorSample)
|
||||
})
|
||||
checker := newTestChecker(t, client)
|
||||
report, ready, err := checker.Check(context.Background(), "http://127.0.0.1:8081/yms/actuator/health", alwaysRunning)
|
||||
if err != nil || !ready || report.Status != "UP" {
|
||||
t.Fatalf("unexpected one-shot health result: report=%+v ready=%t err=%v", report, ready, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActuatorCheckerSamplesReadinessWithoutRestarting(t *testing.T) {
|
||||
t.Parallel()
|
||||
var requests atomic.Int32
|
||||
client := newHTTPClient(func(*http.Request) *http.Response {
|
||||
if requests.Add(1) < 3 {
|
||||
return response(http.StatusServiceUnavailable, `{"status":"DOWN"}`)
|
||||
}
|
||||
return response(http.StatusOK, healthyActuatorSample)
|
||||
})
|
||||
checker := newTestChecker(t, client)
|
||||
if _, err := checker.Wait(context.Background(), "http://127.0.0.1:8081/yms/actuator/health", time.Second, alwaysRunning); err != nil {
|
||||
t.Fatalf("wait for delayed readiness: %v", err)
|
||||
}
|
||||
if requests.Load() != 3 {
|
||||
t.Fatalf("unexpected request count: %d", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestActuatorCheckerStopsImmediatelyWhenContainerStops(t *testing.T) {
|
||||
t.Parallel()
|
||||
checker := newTestChecker(t, http.DefaultClient)
|
||||
_, err := checker.Wait(context.Background(), "http://127.0.0.1:8081/yms/actuator/health", time.Second,
|
||||
func(context.Context) (bool, error) { return false, nil })
|
||||
if !errors.Is(err, ErrWorkloadStopped) {
|
||||
t.Fatalf("expected stopped workload error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActuatorCheckerHonorsOverallTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := newHTTPClient(func(*http.Request) *http.Response {
|
||||
return response(http.StatusServiceUnavailable, `{"status":"DOWN"}`)
|
||||
})
|
||||
checker := newTestChecker(t, client)
|
||||
_, err := checker.Wait(context.Background(), "http://127.0.0.1:8081/yms/actuator/health", 25*time.Millisecond, alwaysRunning)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected deadline error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestChecker(t *testing.T, client *http.Client) *ActuatorChecker {
|
||||
t.Helper()
|
||||
checker, err := NewActuatorChecker(client, 5*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("create Actuator checker: %v", err)
|
||||
}
|
||||
return checker
|
||||
}
|
||||
|
||||
func alwaysRunning(context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return f(request)
|
||||
}
|
||||
|
||||
func newHTTPClient(handle func(*http.Request) *http.Response) *http.Client {
|
||||
return &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
return handle(request), nil
|
||||
})}
|
||||
}
|
||||
|
||||
func response(statusCode int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: statusCode,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user