feat: gate releases on platform health
This commit is contained in:
@@ -3,6 +3,8 @@ package diagnostics
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -69,6 +71,17 @@ type AcceptanceAlertState struct {
|
||||
BlockingItemsTruncated bool `json:"blocking_items_truncated"`
|
||||
}
|
||||
|
||||
type AcceptancePlatformHealthState struct {
|
||||
State string `json:"state"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ObservedAt *time.Time `json:"observed_at,omitempty"`
|
||||
ReceivedAt *time.Time `json:"received_at,omitempty"`
|
||||
CheckCount int `json:"check_count"`
|
||||
WarningCount int `json:"warning_count"`
|
||||
CriticalCount int `json:"critical_count"`
|
||||
GateSatisfied bool `json:"gate_satisfied"`
|
||||
}
|
||||
|
||||
type AcceptanceBlockingAlert struct {
|
||||
ID string `json:"id"`
|
||||
RuleCode string `json:"rule_code"`
|
||||
@@ -88,24 +101,25 @@ type AcceptanceGate struct {
|
||||
}
|
||||
|
||||
type AcceptanceSnapshot struct {
|
||||
ID string `json:"id"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
CandidateVersion string `json:"candidate_version"`
|
||||
BuildDigest string `json:"build_digest"`
|
||||
Environment string `json:"environment"`
|
||||
RunType string `json:"run_type"`
|
||||
Scope string `json:"scope"`
|
||||
RedactionPolicy string `json:"redaction_policy"`
|
||||
RuntimeBuild buildinfo.Info `json:"runtime_build"`
|
||||
Database AcceptanceDatabaseState `json:"database"`
|
||||
IntegrationIssues IntegrationIssueSummary `json:"integration_issues"`
|
||||
ExternalDependencies []AcceptanceDependencyState `json:"external_dependencies"`
|
||||
Outbox AcceptanceOutboxState `json:"outbox"`
|
||||
Alerts AcceptanceAlertState `json:"alerts"`
|
||||
Gates []AcceptanceGate `json:"gates"`
|
||||
OverallGateSatisfied bool `json:"overall_gate_satisfied"`
|
||||
Disclaimer string `json:"disclaimer"`
|
||||
ID string `json:"id"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
CandidateVersion string `json:"candidate_version"`
|
||||
BuildDigest string `json:"build_digest"`
|
||||
Environment string `json:"environment"`
|
||||
RunType string `json:"run_type"`
|
||||
Scope string `json:"scope"`
|
||||
RedactionPolicy string `json:"redaction_policy"`
|
||||
RuntimeBuild buildinfo.Info `json:"runtime_build"`
|
||||
Database AcceptanceDatabaseState `json:"database"`
|
||||
IntegrationIssues IntegrationIssueSummary `json:"integration_issues"`
|
||||
ExternalDependencies []AcceptanceDependencyState `json:"external_dependencies"`
|
||||
Outbox AcceptanceOutboxState `json:"outbox"`
|
||||
Alerts AcceptanceAlertState `json:"alerts"`
|
||||
PlatformHealth AcceptancePlatformHealthState `json:"platform_health"`
|
||||
Gates []AcceptanceGate `json:"gates"`
|
||||
OverallGateSatisfied bool `json:"overall_gate_satisfied"`
|
||||
Disclaimer string `json:"disclaimer"`
|
||||
}
|
||||
|
||||
func (s *Service) ExportAcceptanceSnapshot(ctx context.Context, principal identity.Principal, tenantPublicID, key, requestID string, input AcceptanceSnapshotInput) (AcceptanceSnapshot, bool, error) {
|
||||
@@ -275,6 +289,11 @@ func buildAcceptanceSnapshot(ctx context.Context, tx pgx.Tx, tenantPublicID, ten
|
||||
); err != nil {
|
||||
return AcceptanceSnapshot{}, err
|
||||
}
|
||||
platformHealth, err := loadAcceptancePlatformHealth(ctx, tx, now)
|
||||
if err != nil {
|
||||
return AcceptanceSnapshot{}, err
|
||||
}
|
||||
snapshot.PlatformHealth = platformHealth
|
||||
|
||||
runtimeSatisfied := input.Environment == "LAB" || (snapshot.RuntimeBuild.Version == input.CandidateVersion && snapshot.RuntimeBuild.Commit != "unknown" && snapshot.RuntimeBuild.BuildTime != "unknown")
|
||||
dependenciesSatisfied := true
|
||||
@@ -290,6 +309,7 @@ func buildAcceptanceSnapshot(ctx context.Context, tx pgx.Tx, tenantPublicID, ten
|
||||
{Code: "integration_issues_clear", Satisfied: snapshot.IntegrationIssues.ReleaseGateSatisfied, Summary: "不得存在未关闭的高或严重级联调问题"},
|
||||
{Code: "outbox_clear", Satisfied: outboxSatisfied, Summary: "租户及平台级待处理、处理中和死信任务必须清零"},
|
||||
{Code: "critical_alerts_clear", Satisfied: alertsSatisfied, Summary: "不得存在未解决的高或严重级告警"},
|
||||
{Code: "platform_health_current", Satisfied: snapshot.PlatformHealth.GateSatisfied, Summary: acceptancePlatformHealthSummary(snapshot.PlatformHealth)},
|
||||
}
|
||||
snapshot.OverallGateSatisfied = true
|
||||
for _, gate := range snapshot.Gates {
|
||||
@@ -298,6 +318,60 @@ func buildAcceptanceSnapshot(ctx context.Context, tx pgx.Tx, tenantPublicID, ten
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func loadAcceptancePlatformHealth(ctx context.Context, tx pgx.Tx, now time.Time) (AcceptancePlatformHealthState, error) {
|
||||
state := AcceptancePlatformHealthState{State: "MISSING"}
|
||||
var observedAt, receivedAt time.Time
|
||||
var checksJSON []byte
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT status,observed_at,received_at,checks
|
||||
FROM platform_health_snapshot ORDER BY observed_at DESC,id DESC LIMIT 1`).Scan(
|
||||
&state.Status, &observedAt, &receivedAt, &checksJSON,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return state, nil
|
||||
}
|
||||
if err != nil {
|
||||
return AcceptancePlatformHealthState{}, err
|
||||
}
|
||||
var checks []PlatformHealthCheck
|
||||
if err = json.Unmarshal(checksJSON, &checks); err != nil {
|
||||
return AcceptancePlatformHealthState{}, err
|
||||
}
|
||||
return evaluateAcceptancePlatformHealth(state.Status, observedAt, receivedAt, checks, now), nil
|
||||
}
|
||||
|
||||
func evaluateAcceptancePlatformHealth(status string, observedAt, receivedAt time.Time, checks []PlatformHealthCheck, now time.Time) AcceptancePlatformHealthState {
|
||||
state := AcceptancePlatformHealthState{
|
||||
State: "CURRENT", Status: status, ObservedAt: &observedAt, ReceivedAt: &receivedAt, CheckCount: len(checks),
|
||||
}
|
||||
for _, check := range checks {
|
||||
switch check.Status {
|
||||
case "warning":
|
||||
state.WarningCount++
|
||||
case "critical":
|
||||
state.CriticalCount++
|
||||
}
|
||||
}
|
||||
if observedAt.Before(now.Add(-platformHealthMaxAge)) || observedAt.After(now.Add(platformHealthFuture)) {
|
||||
state.State = "STALE"
|
||||
}
|
||||
state.GateSatisfied = state.State == "CURRENT" && state.Status == "healthy" && state.WarningCount == 0 && state.CriticalCount == 0
|
||||
return state
|
||||
}
|
||||
|
||||
func acceptancePlatformHealthSummary(state AcceptancePlatformHealthState) string {
|
||||
switch state.State {
|
||||
case "MISSING":
|
||||
return "尚未收到云端主机健康快照"
|
||||
case "STALE":
|
||||
return "云端主机健康快照必须在15分钟内更新"
|
||||
}
|
||||
if state.Status != "healthy" || state.WarningCount > 0 || state.CriticalCount > 0 {
|
||||
return fmt.Sprintf("云端主机存在%d项预警和%d项严重异常", state.WarningCount, state.CriticalCount)
|
||||
}
|
||||
return fmt.Sprintf("云端主机健康快照当前有效,共%d项检查", state.CheckCount)
|
||||
}
|
||||
|
||||
func acceptanceRuntimeSummary(environment string) string {
|
||||
if environment == "LAB" {
|
||||
return "实验室环境允许开发构建,但候选版本与镜像摘要必须明确"
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestAcceptanceSnapshotIsAuditedIdempotentAndTenantScoped(t *testing.T) {
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first=%+v created=%t err=%v", first, created, err)
|
||||
}
|
||||
if first.ID == "" || first.Database.MigrationVersion < 36 || first.Database.Dirty || first.RedactionPolicy != "ops-acceptance-minimal-v1" || len(first.ExternalDependencies) != 1 || len(first.Gates) != 6 {
|
||||
if first.ID == "" || first.Database.MigrationVersion < 39 || first.Database.Dirty || first.RedactionPolicy != "ops-acceptance-minimal-v1" || len(first.ExternalDependencies) != 1 || len(first.Gates) != 7 {
|
||||
t.Fatalf("unexpected snapshot=%+v", first)
|
||||
}
|
||||
if first.ExternalDependencies[0].Dependency != "oidc_jwks" || first.ExternalDependencies[0].ServiceName != "api" {
|
||||
@@ -74,6 +74,9 @@ func TestAcceptanceSnapshotIsAuditedIdempotentAndTenantScoped(t *testing.T) {
|
||||
if !foundBlockingAlert || first.Alerts.Blocking == 0 || first.Gates[5].Code != "critical_alerts_clear" || first.Gates[5].Satisfied {
|
||||
t.Fatalf("blocking alert evidence=%+v gates=%+v", first.Alerts, first.Gates)
|
||||
}
|
||||
if first.Gates[6].Code != "platform_health_current" || first.PlatformHealth.State == "" {
|
||||
t.Fatalf("platform health=%+v gates=%+v", first.PlatformHealth, first.Gates)
|
||||
}
|
||||
replayed, createdAgain, err := service.ExportAcceptanceSnapshot(ctx, admin, tenantPublicID, key, requestID, input)
|
||||
if err != nil || createdAgain || replayed.ID != first.ID || !replayed.GeneratedAt.Equal(first.GeneratedAt) {
|
||||
t.Fatalf("replayed=%+v created=%t err=%v", replayed, createdAgain, err)
|
||||
|
||||
@@ -3,6 +3,7 @@ package diagnostics
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidAcceptanceSnapshotInputLimitsAuditReason(t *testing.T) {
|
||||
@@ -24,6 +25,52 @@ func TestValidAcceptanceSnapshotInputLimitsAuditReason(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAcceptancePlatformHealthBlocksStaleAndUnhealthyReports(t *testing.T) {
|
||||
now := time.Date(2026, 8, 26, 14, 0, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
status string
|
||||
observedAt time.Time
|
||||
checks []PlatformHealthCheck
|
||||
state string
|
||||
satisfied bool
|
||||
}{
|
||||
{
|
||||
name: "current healthy", status: "healthy", observedAt: now.Add(-5 * time.Minute),
|
||||
checks: []PlatformHealthCheck{{Name: "public_https", Status: "healthy", Value: "http_200"}}, state: "CURRENT", satisfied: true,
|
||||
},
|
||||
{
|
||||
name: "stale healthy", status: "healthy", observedAt: now.Add(-16 * time.Minute),
|
||||
checks: []PlatformHealthCheck{{Name: "public_https", Status: "healthy", Value: "http_200"}}, state: "STALE", satisfied: false,
|
||||
},
|
||||
{
|
||||
name: "current warning", status: "warning", observedAt: now.Add(-5 * time.Minute),
|
||||
checks: []PlatformHealthCheck{{Name: "backup", Status: "warning", Value: "late"}}, state: "CURRENT", satisfied: false,
|
||||
},
|
||||
{
|
||||
name: "current critical", status: "critical", observedAt: now.Add(-5 * time.Minute),
|
||||
checks: []PlatformHealthCheck{{Name: "disk", Status: "critical", Value: "full"}}, state: "CURRENT", satisfied: false,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
state := evaluateAcceptancePlatformHealth(test.status, test.observedAt, now, test.checks, now)
|
||||
if state.State != test.state || state.GateSatisfied != test.satisfied {
|
||||
t.Fatalf("state=%+v", state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptancePlatformHealthSummaryCoversMissingAndCounts(t *testing.T) {
|
||||
if summary := acceptancePlatformHealthSummary(AcceptancePlatformHealthState{State: "MISSING"}); !strings.Contains(summary, "尚未收到") {
|
||||
t.Fatalf("missing summary=%q", summary)
|
||||
}
|
||||
if summary := acceptancePlatformHealthSummary(AcceptancePlatformHealthState{State: "CURRENT", Status: "warning", WarningCount: 2, CriticalCount: 1}); !strings.Contains(summary, "2项预警") || !strings.Contains(summary, "1项严重异常") {
|
||||
t.Fatalf("unhealthy summary=%q", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptanceSnapshotAllowsAllProductionExternalDependencies(t *testing.T) {
|
||||
input := AcceptanceSnapshotInput{
|
||||
CandidateVersion: "0.64.0-rc.1", BuildDigest: "sha256:" + strings.Repeat("b", 64),
|
||||
|
||||
Reference in New Issue
Block a user