fix(acceptance): 阻断本地控制面漂移污染验收

本地 K3s 节点此前在 Kubelet 中登记为宿主机资源,负载可挤压 etcd 并在 server 重启后继续污染同一 Run。现在为三节点设置真实可调度预算、独立 etcd/数据卷和锁定依赖镜像,并持续核对 server 与 API/etcd 健康。\n\n每次验收生成独立 Run ID,报告使用独占或原子写入,负载错误记录具体阶段;数据库鉴权不可用返回带 Retry-After 的 503,避免基础设施故障被误报为 401。\n\n验证:Go 全量测试、go vet、gofmt、OpenAPI、bash -n、ShellCheck、报告测试和 k3d 配置解析均通过。
This commit is contained in:
2026-07-31 23:07:19 +08:00
parent 015ff8ea6c
commit a95184b5b6
20 changed files with 970 additions and 85 deletions
+70 -12
View File
@@ -119,6 +119,12 @@ func run(ctx context.Context, opts options) error {
return err
}
defer database.Close()
if err := verifyLocalClusterMarker(ctx, database, opts.clusterID); err != nil {
return err
}
if err := resetPreviousLocalRun(ctx, database, opts); err != nil {
return err
}
if err := verifyLocalDatabase(ctx, database, opts.clusterID); err != nil {
return err
}
@@ -212,7 +218,15 @@ func run(ctx context.Context, opts options) error {
if err != nil {
return err
}
if err := os.WriteFile(outputPath, append(payload, '\n'), 0o600); err != nil {
outputFile, err := os.OpenFile(outputPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
if _, err := outputFile.Write(append(payload, '\n')); err != nil {
_ = outputFile.Close()
return err
}
if err := outputFile.Close(); err != nil {
return err
}
fmt.Printf(
@@ -226,7 +240,60 @@ func run(ctx context.Context, opts options) error {
return nil
}
func resetPreviousLocalRun(ctx context.Context, database *store.Store, opts options) error {
mode, err := database.GetGatewayTrafficMode(ctx)
if err != nil {
return err
}
if mode.Mode == "live" {
return nil
}
if mode.Mode != "validation" || strings.TrimSpace(mode.RunID) == "" {
return fmt.Errorf("refusing bootstrap while traffic mode is %s", mode.Mode)
}
run, err := database.GetAcceptanceRun(ctx, mode.RunID)
if err != nil {
return err
}
if run.Config["localClusterId"] != opts.clusterID {
return errors.New("refusing to replace an acceptance run owned by another cluster")
}
var outstanding int
if err := database.Pool().QueryRow(ctx, `
SELECT count(*)
FROM gateway_tasks
WHERE acceptance_run_id = $1::uuid
AND status IN ('queued','running')`, run.ID).Scan(&outstanding); err != nil {
return err
}
if outstanding != 0 {
return fmt.Errorf("refusing to replace local acceptance run with %d outstanding tasks", outstanding)
}
_, err = database.AbortAcceptanceRun(ctx, store.PromoteAcceptanceRunInput{
RunID: mode.RunID,
Revision: mode.Revision,
ReleaseSHA: mode.ReleaseSHA,
APIImageDigest: mode.APIImageDigest,
WorkerImageDigest: mode.WorkerImageDigest,
})
return err
}
func verifyLocalDatabase(ctx context.Context, database *store.Store, clusterID string) error {
if err := verifyLocalClusterMarker(ctx, database, clusterID); err != nil {
return err
}
mode, err := database.GetGatewayTrafficMode(ctx)
if err != nil {
return err
}
if mode.Mode != "live" {
return fmt.Errorf("refusing bootstrap while traffic mode is %s", mode.Mode)
}
return nil
}
func verifyLocalClusterMarker(ctx context.Context, database *store.Store, clusterID string) error {
var marker string
err := database.Pool().QueryRow(ctx, `
SELECT COALESCE(value->>'clusterId', '')
@@ -238,13 +305,6 @@ WHERE setting_key = $1`, acceptancesnapshot.LocalClusterSettingKey).Scan(&marker
if marker != clusterID {
return errors.New("refusing bootstrap: local cluster marker mismatch")
}
mode, err := database.GetGatewayTrafficMode(ctx)
if err != nil {
return err
}
if mode.Mode != "live" {
return fmt.Errorf("refusing bootstrap while traffic mode is %s", mode.Mode)
}
return nil
}
@@ -486,10 +546,8 @@ func privateOutputPath(path string) (string, error) {
if err != nil {
return "", err
}
if info, err := os.Lstat(absolute); err == nil {
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return "", errors.New("runtime output must be a regular non-symlink file")
}
if _, err := os.Lstat(absolute); err == nil {
return "", errors.New("runtime output already exists")
} else if !os.IsNotExist(err) {
return "", err
}
+58 -19
View File
@@ -59,15 +59,16 @@ type options struct {
}
type report struct {
SchemaVersion string `json:"schemaVersion"`
RunID string `json:"runId"`
Profile string `json:"profile"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
Passed bool `json:"passed"`
Phases []phaseReport `json:"phases"`
Failure string `json:"failure,omitempty"`
SecretSafe bool `json:"secretSafe"`
SchemaVersion string `json:"schemaVersion"`
RunID string `json:"runId"`
Profile string `json:"profile"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
Passed bool `json:"passed"`
Phases []phaseReport `json:"phases"`
Failure string `json:"failure,omitempty"`
FailureOperation string `json:"failureOperation,omitempty"`
SecretSafe bool `json:"secretSafe"`
}
type phaseReport struct {
@@ -101,6 +102,25 @@ type httpStatusError struct {
Body string
}
type operationError struct {
Operation string
Err error
}
func (e *operationError) Error() string { return e.Operation + ": " + e.Err.Error() }
func (e *operationError) Unwrap() error { return e.Err }
func withOperation(operation string, err error) error {
if err == nil {
return nil
}
var existing *operationError
if errors.As(err, &existing) {
return err
}
return &operationError{Operation: operation, Err: err}
}
func (e *httpStatusError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.Status, e.Body)
}
@@ -137,11 +157,15 @@ func main() {
result.Passed = runErr == nil
if runErr != nil {
result.Failure = redactError(runErr.Error(), opts)
var operationErr *operationError
if errors.As(runErr, &operationErr) {
result.FailureOperation = operationErr.Operation
}
}
payload, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(payload))
if opts.reportPath != "" {
if err := os.WriteFile(opts.reportPath, append(payload, '\n'), 0o600); err != nil {
if err := writeReportExclusive(opts.reportPath, payload); err != nil {
fmt.Fprintln(os.Stderr, "write acceptance report failed:", err)
os.Exit(1)
}
@@ -151,6 +175,18 @@ func main() {
}
}
func writeReportExclusive(path string, payload []byte) error {
reportFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
if _, err := reportFile.Write(append(payload, '\n')); err != nil {
_ = reportFile.Close()
return err
}
return reportFile.Close()
}
func parseOptions() (options, error) {
var profile string
var reportPath string
@@ -347,14 +383,15 @@ func runGemini(
response, err = client.Do(req)
if err == nil {
if response.StatusCode != http.StatusOK {
err = responseStatusError(response)
err = withOperation("gemini_submit", responseStatusError(response))
} else {
var size int64
var outputHash string
size, outputHash, err = streamGeminiImageHash(response.Body)
err = withOperation("gemini_response", err)
_ = response.Body.Close()
if err == nil && expectedOutputBytes > 0 && (size != int64(expectedOutputBytes) || outputHash != expectedHash) {
err = fmt.Errorf("Gemini output mismatch: bytes=%d hash=%s", size, outputHash)
err = withOperation("gemini_response", fmt.Errorf("Gemini output mismatch: bytes=%d hash=%s", size, outputHash))
}
if err == nil {
mu.Lock()
@@ -367,6 +404,7 @@ func runGemini(
}
}
}
err = withOperation("gemini_submit", err)
latency := time.Since(requestStarted)
mu.Lock()
latencies[index] = latency
@@ -503,7 +541,7 @@ func runVideo(
response, err = client.Do(req)
if err == nil {
if response.StatusCode != http.StatusAccepted {
err = responseStatusError(response)
err = withOperation("video_submit", responseStatusError(response))
} else {
var accepted struct {
TaskID string `json:"taskId"`
@@ -517,6 +555,7 @@ func runVideo(
}
}
}
err = withOperation("video_submit", err)
mu.Lock()
latencies[index] = time.Since(requestStarted)
if !realUpstream && logicalIndex%4 == 0 {
@@ -742,7 +781,7 @@ func pollVideoTask(ctx context.Context, client *http.Client, opts options, taskI
endpoint := opts.gateways[index%len(opts.gateways)] + "/api/v1/ai/result/" + url.PathEscape(taskID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
return withOperation("video_poll", err)
}
opts.setHeaders(req, index, realUpstream)
response, err := client.Do(req)
@@ -758,20 +797,20 @@ func pollVideoTask(ctx context.Context, client *http.Client, opts options, taskI
case "succeeded", "success":
mediaURL := findMediaURL(payload)
if mediaURL == "" {
return fmt.Errorf("video task %s succeeded without a media URL", taskID)
return withOperation("video_poll", fmt.Errorf("video task %s succeeded without a media URL", taskID))
}
return validateVideoAsset(ctx, client, opts, mediaURL, index)
return withOperation("video_download", validateVideoAsset(ctx, client, opts, mediaURL, index))
case "failed", "cancelled", "canceled":
return fmt.Errorf("video task %s finished with status %s", taskID, status)
return withOperation("video_poll", fmt.Errorf("video task %s finished with status %s", taskID, status))
}
}
}
if err != nil {
return err
return withOperation("video_poll", err)
}
select {
case <-ctx.Done():
return ctx.Err()
return withOperation("video_poll", ctx.Err())
case <-ticker.C:
}
}
+30
View File
@@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -212,6 +213,35 @@ func TestAcceptanceReportErrorRedactsSecretsAndURLs(t *testing.T) {
}
}
func TestAcceptanceReportCannotOverwriteExistingRunArtifact(t *testing.T) {
path := filepath.Join(t.TempDir(), "load-report.json")
if err := writeReportExclusive(path, []byte(`{"runId":"first"}`)); err != nil {
t.Fatalf("write first report: %v", err)
}
if err := writeReportExclusive(path, []byte(`{"runId":"second"}`)); err == nil {
t.Fatal("second report unexpectedly overwrote the first report")
}
payload, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read report: %v", err)
}
if string(payload) != "{\"runId\":\"first\"}\n" {
t.Fatalf("report changed after rejected overwrite: %s", payload)
}
}
func TestAcceptanceFailurePreservesOperationAndHTTPStatus(t *testing.T) {
err := withOperation("video_poll", &httpStatusError{Status: http.StatusUnauthorized, Body: "unauthorized"})
var operationErr *operationError
if !errors.As(err, &operationErr) || operationErr.Operation != "video_poll" {
t.Fatalf("operation error=%#v", operationErr)
}
var statusErr *httpStatusError
if !errors.As(err, &statusErr) || statusErr.Status != http.StatusUnauthorized {
t.Fatalf("HTTP status error=%#v", statusErr)
}
}
func TestAcceptanceGatewayTLSNameSetsHostHeader(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "https://127.0.0.1/api/v1/healthz", nil)
opts := options{
+54
View File
@@ -5443,6 +5443,12 @@
"schema": {
"$ref": "#/definitions/httpapi.EasyAIGeneratedResponse"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
}
}
}
@@ -6323,6 +6329,12 @@
"schema": {
"$ref": "#/definitions/httpapi.VolcesErrorEnvelope"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/httpapi.VolcesErrorEnvelope"
}
}
}
}
@@ -7405,6 +7417,12 @@
"schema": {
"$ref": "#/definitions/httpapi.GeminiErrorEnvelope"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/httpapi.GeminiErrorEnvelope"
}
}
}
}
@@ -7478,6 +7496,12 @@
"schema": {
"$ref": "#/definitions/httpapi.GeminiErrorEnvelope"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/httpapi.GeminiErrorEnvelope"
}
}
}
}
@@ -7578,6 +7602,12 @@
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"504": {
"description": "Gateway Timeout",
"schema": {
@@ -8446,6 +8476,12 @@
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"504": {
"description": "Gateway Timeout",
"schema": {
@@ -8551,6 +8587,12 @@
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"504": {
"description": "Gateway Timeout",
"schema": {
@@ -8999,6 +9041,12 @@
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"504": {
"description": "Gateway Timeout",
"schema": {
@@ -9324,6 +9372,12 @@
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"504": {
"description": "Gateway Timeout",
"schema": {
+36
View File
@@ -7680,6 +7680,10 @@ paths:
description: Gone
schema:
$ref: '#/definitions/httpapi.EasyAIGeneratedResponse'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 查询 server-main EasyAIClient 兼容任务结果
@@ -8248,6 +8252,10 @@ paths:
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.VolcesErrorEnvelope'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/httpapi.VolcesErrorEnvelope'
security:
- BearerAuth: []
summary: 创建火山内容生成任务
@@ -8945,6 +8953,10 @@ paths:
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.GeminiErrorEnvelope'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/httpapi.GeminiErrorEnvelope'
security:
- BearerAuth: []
summary: Gemini generateContent 兼容接口
@@ -8993,6 +9005,10 @@ paths:
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.GeminiErrorEnvelope'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/httpapi.GeminiErrorEnvelope'
security:
- BearerAuth: []
summary: Gemini generateContent 兼容接口
@@ -9058,6 +9074,10 @@ paths:
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"504":
description: Gateway Timeout
schema:
@@ -9623,6 +9643,10 @@ paths:
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"504":
description: Gateway Timeout
schema:
@@ -9692,6 +9716,10 @@ paths:
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"504":
description: Gateway Timeout
schema:
@@ -9980,6 +10008,10 @@ paths:
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"504":
description: Gateway Timeout
schema:
@@ -10192,6 +10224,10 @@ paths:
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"504":
description: Gateway Timeout
schema:
+19 -4
View File
@@ -69,9 +69,10 @@ const userContextKey contextKey = "easyai-auth-user"
var ErrUnauthorized = errors.New("unauthorized")
type RequestAuthError struct {
Status int
Code string
Message string
Status int
Code string
Message string
RetryAfterSeconds int
}
func (e *RequestAuthError) Error() string { return e.Code }
@@ -80,6 +81,12 @@ func NewRequestAuthError(status int, code, message string) error {
return &RequestAuthError{Status: status, Code: code, Message: message}
}
func NewRetryableRequestAuthError(status int, code, message string, retryAfterSeconds int) error {
return &RequestAuthError{
Status: status, Code: code, Message: message, RetryAfterSeconds: retryAfterSeconds,
}
}
type Authenticator struct {
JWTSecret string
ServerMainBaseURL string
@@ -127,6 +134,9 @@ func (a *Authenticator) Require(permission Permission, next http.Handler) http.H
}
var requestError *RequestAuthError
if errors.As(err, &requestError) {
if requestError.RetryAfterSeconds > 0 {
w.Header().Set("Retry-After", strconv.Itoa(requestError.RetryAfterSeconds))
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(requestError.Status)
@@ -318,7 +328,12 @@ func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User,
return user, nil
}
if !errors.Is(err, ErrUnauthorized) {
return nil, err
return nil, NewRetryableRequestAuthError(
http.StatusServiceUnavailable,
"AUTH_STORE_UNAVAILABLE",
"authentication service temporarily unavailable",
2,
)
}
if strings.HasPrefix(apiKey, "sk-gw-") {
return nil, ErrUnauthorized
@@ -0,0 +1,31 @@
package auth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestAPIKeyStoreFailureReturnsRetryableServiceUnavailable(t *testing.T) {
authenticator := New("test-secret", "", "")
authenticator.LocalAPIKeyVerifier = func(context.Context, string) (*User, error) {
return nil, errors.New("database unavailable")
}
handler := authenticator.Require(PermissionBasic, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("unavailable authentication store must not call the protected handler")
}))
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer sk-gw-local")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d, want 503; body=%s", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("Retry-After") != "2" {
t.Fatalf("Retry-After=%q, want 2", recorder.Header().Get("Retry-After"))
}
}
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
@@ -19,6 +20,9 @@ func (s *Server) requireProtocolUser(protocol string, next http.Handler) http.Ha
var requestErr *auth.RequestAuthError
if errors.As(err, &requestErr) {
status, code, message = requestErr.Status, requestErr.Code, requestErr.Message
if requestErr.RetryAfterSeconds > 0 {
w.Header().Set("Retry-After", strconv.Itoa(requestErr.RetryAfterSeconds))
}
}
writeProtocolError(w, protocol, status, message, nil, code)
return
@@ -3,6 +3,7 @@ package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -13,6 +14,39 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestProtocolAPIKeyStoreFailureIs503InsteadOf401(t *testing.T) {
authenticator := auth.New("test-secret", "", "")
authenticator.LocalAPIKeyVerifier = func(context.Context, string) (*auth.User, error) {
return nil, errors.New("database unavailable")
}
server := &Server{auth: authenticator}
handler := server.requireProtocolUser(clients.ProtocolGeminiGenerateContent, http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {
t.Fatal("unavailable authentication store must not call the protocol handler")
},
))
request := httptest.NewRequest(http.MethodPost, "/v1beta/models/test:generateContent", nil)
request.Header.Set("Authorization", "Bearer sk-gw-local")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d, want 503; body=%s", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("Retry-After") != "2" {
t.Fatalf("Retry-After=%q, want 2", recorder.Header().Get("Retry-After"))
}
var body map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
errorBody := requireObject(t, body["error"])
if errorBody["code"] != float64(http.StatusServiceUnavailable) || errorBody["status"] != "UNAVAILABLE" {
t.Fatalf("unexpected Gemini unavailable response: %+v", body)
}
}
func TestProtocolErrorsUseOfficialShapesWithoutGatewayExtensions(t *testing.T) {
tests := []struct {
name string
@@ -106,6 +106,7 @@ func geminiGenerateContentModelFromPath(prefix string, requestPath string) (stri
// @Failure 401 {object} GeminiErrorEnvelope
// @Failure 403 {object} GeminiErrorEnvelope
// @Failure 502 {object} GeminiErrorEnvelope
// @Failure 503 {object} GeminiErrorEnvelope
// @Router /api/v1/models/{model}:generateContent [post]
// @Router /api/v1/models/{model}:streamGenerateContent [post]
func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
+1
View File
@@ -1420,6 +1420,7 @@ func openAIImagesDoc() {}
// @Failure 429 {object} ErrorEnvelope
// @Failure 504 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Router /api/v1/videos/generations [post]
// @Router /api/v1/song/generations [post]
// @Router /api/v1/music/generations [post]
@@ -28,6 +28,7 @@ const volcesContentsCompatibilityMarker = "volces_contents_generations_v3"
// @Failure 401 {object} VolcesErrorEnvelope
// @Failure 429 {object} VolcesErrorEnvelope
// @Failure 502 {object} VolcesErrorEnvelope
// @Failure 503 {object} VolcesErrorEnvelope
// @Router /api/v1/contents/generations/tasks [post]
func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
@@ -186,6 +187,7 @@ func (s *Server) createLegacyVolcesVideoGeneration(w http.ResponseWriter, r *htt
// @Success 200 {object} EasyAIGeneratedResponse
// @Failure 404 {object} EasyAIGeneratedResponse
// @Failure 410 {object} EasyAIGeneratedResponse
// @Failure 503 {object} ErrorEnvelope
// @Router /api/v1/ai/result/{taskID} [get]
func (s *Server) getEasyAITaskResult(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
@@ -1,8 +1,14 @@
# Local acceptance dependency lock. Checksums are lowercase SHA-256.
K3D_VERSION=v5.9.0
CRANE_VERSION=v0.21.7
K3D_DARWIN_ARM64_SHA256=fe106541d5d0a3f18debcd4d432a16f8c0ce3e6ddc06f8fbb6f696a122313e00
K3D_DARWIN_AMD64_SHA256=b4aabc37534f95b9c764e7823f2df923f50d57600837aa60a06266cce47db732
K3S_IMAGE=rancher/k3s:v1.36.2-k3s1
K3S_IMAGE=rancher/k3s:v1.36.2-k3s1@sha256:6a47cea22c4b834d4ba72c89d291696b79ebe406251f90b446e4dff03513dd87
CNPG_VERSION=1.29.1
CNPG_MANIFEST_URL=https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.29/releases/cnpg-1.29.1.yaml
CNPG_MANIFEST_SHA256=e0c5ff41bf5b01c0775bf225929b8423d543cd23e9742e4274fe7de8499cf93a
CNPG_CONTROLLER_IMAGE=ghcr.io/cloudnative-pg/cloudnative-pg:1.29.1@sha256:0dfff19ba7b52ca25851a1010028b6940fff2e233290465af1cfb08a5f3f4661
CNPG_POSTGRES_IMAGE=ghcr.io/cloudnative-pg/postgresql:18.4-standard-trixie@sha256:4587df73024408f5b2be9b4dd6ba2ccee8c9e5dc0c9a87c274c292291cc8a68c
K3S_COREDNS_IMAGE=rancher/mirrored-coredns-coredns:1.14.4@sha256:3e98f280fd601b37411c5fb7075fd9f337833c480f1644970b727ae0af067782
K3S_METRICS_SERVER_IMAGE=rancher/mirrored-metrics-server:v0.8.1@sha256:b2d2efaf5ac3b366ed0f839d2412a2c4279d4fc2a2a733f12c52133faed36c41
K3S_LOCAL_PATH_IMAGE=rancher/local-path-provisioner:v0.0.36@sha256:1eba82e9c386038b4af6d69cca7519fac738c28c42735ed48ce70c882ad0d80f
+34 -1
View File
@@ -4,7 +4,8 @@ metadata:
name: easyai-acceptance-local
servers: 3
agents: 0
image: rancher/k3s:v1.36.2-k3s1
image: EASYAI_ACCEPTANCE_K3S_IMAGE
subnet: EASYAI_ACCEPTANCE_CLUSTER_SUBNET
kubeAPI:
host: 127.0.0.1
hostPort: "16550"
@@ -19,6 +20,21 @@ volumes:
- volume: EASYAI_ACCEPTANCE_MEDIA_DIR:/var/lib/easyai-acceptance/media
nodeFilters:
- server:*
- volume: easyai-acceptance-local-server-0-etcd:/var/lib/rancher/k3s/server/db
nodeFilters:
- server:0
- volume: easyai-acceptance-local-server-1-etcd:/var/lib/rancher/k3s/server/db
nodeFilters:
- server:1
- volume: easyai-acceptance-local-server-2-etcd:/var/lib/rancher/k3s/server/db
nodeFilters:
- server:2
- volume: easyai-acceptance-local-server-0-storage:/var/lib/rancher/k3s/storage
nodeFilters:
- server:0
- volume: easyai-acceptance-local-server-1-storage:/var/lib/rancher/k3s/storage
nodeFilters:
- server:1
options:
k3s:
extraArgs:
@@ -48,3 +64,20 @@ options:
- arg: --node-taint=easyai.io/control-plane-only=true:NoSchedule
nodeFilters:
- server:2
- arg: --kubelet-arg=system-reserved=cpu=EASYAI_ACCEPTANCE_SITE_SYSTEM_CPU,memory=EASYAI_ACCEPTANCE_SITE_SYSTEM_MEMORY
nodeFilters:
- server:0
- server:1
- arg: --kubelet-arg=kube-reserved=cpu=250m,memory=512Mi
nodeFilters:
- server:0
- server:1
- arg: --kubelet-arg=system-reserved=cpu=EASYAI_ACCEPTANCE_WITNESS_SYSTEM_CPU,memory=EASYAI_ACCEPTANCE_WITNESS_SYSTEM_MEMORY
nodeFilters:
- server:2
- arg: --kubelet-arg=kube-reserved=cpu=250m,memory=512Mi
nodeFilters:
- server:2
- arg: --kubelet-arg=eviction-hard=memory.available<512Mi,nodefs.available<10%
nodeFilters:
- server:*
+24 -1
View File
@@ -25,11 +25,23 @@
| server-1 / hongkong | 4 CPU / 8 GiB | API、Worker、PostgreSQL |
| server-2 / los-angeles | 2 CPU / 4 GiB | 控制面和 etcd`NoSchedule` |
Docker Desktop 必须配置至少 24 GiB 内存;脚本只检查,不修改 Docker 设置。k3d、K3s 和
Docker Desktop 必须配置至少 24 GiB 内存,宿主工作盘必须至少保留 30 GiB 可用空间;
脚本只检查,不修改 Docker 或宿主设置。依赖预载后会先用锁定 K3s 镜像执行 10 秒容器
启动探针,应用镜像构建后再用本次验收的最小镜像复检,确保 Docker daemon 不只是 API
可读、而是真的能创建容器,再进入 K3d。
k3d、K3s 和
CNPG 版本及 SHA-256 位于
`deploy/kubernetes/local-acceptance/dependencies.lock`。高并发阶段使用宿主原生架构镜像,
最后才导入 release manifest 中精确的 `linux/amd64@sha256` 制品做启动、迁移和媒体冒烟。
K3s 启动时会通过 Kubelet `system-reserved`/`kube-reserved` 将两个业务节点的可调度资源
固定为 3 CPU/6656 MiB,将见证节点固定为 1 CPU/2560 MiB;随后 Docker 硬上限分别设置为
4C/8GiB、4C/8GiB、2C/4GiB。API CPU 为 250m/750mWorker 为 750m/1500mWorker
内存仍为 1536Mi/2Gi。etcd 与 PostgreSQL `local-path` 使用不同的命名卷,避免和业务媒体
目录共用 Docker writable layer。依赖镜像会在部署应用前按锁定 digest 预拉取并导入;若
Docker daemon 的系统代理不可用,则使用锁定版本的 `crane` 按同一 digest 拉取 OCI 内容,
再导入本地 Docker/K3s,不会退化为未校验 Tag。
```bash
scripts/acceptance/local-cluster.sh install-tools
scripts/acceptance/local-cluster.sh preflight
@@ -70,6 +82,10 @@ scripts/acceptance/local-cluster.sh up \
scripts/acceptance/run-local-acceptance.sh quick
# P24 基线应使用三个不同 Run ID 连续通过
scripts/acceptance/run-local-acceptance.sh quick
scripts/acceptance/run-local-acceptance.sh quick
scripts/acceptance/run-local-acceptance.sh full \
--release-manifest dist/releases/<完整SHA>.json
```
@@ -85,6 +101,13 @@ scripts/acceptance/run-local-acceptance.sh full \
7. 80% 两小时混合流量和 120% 十分钟主动限流。
8. 精确 amd64 release 镜像的迁移、启动与媒体冒烟。
每次 `quick``full` 或独立 `artifact-smoke` 都会先生成新的 Run ID;前一 Run 只有在队列
和 running 已归零后才会被本地安全终止。负载报告使用独占创建,已有文件不会被覆盖。
负载期间每 2 秒核对三个 K3s server 的容器 ID、restartCount、启动时间、IP、Node UID、
API readiness;任一 server 重启立即以 `local_control_plane_restarted` 终止。etcd 出现严重
心跳、fdatasync、ReadIndex 或请求超时则以 `local_etcd_latency` 阻断。本地 P24 基线先把
6K 图片归一化并发固定为 1,确认稳定后才允许单独测试更高媒体并发。
网络故障只能作用于带 `easyai.io/environment=local-acceptance` 标签的代理 Pod
```bash
+295 -12
View File
@@ -12,6 +12,11 @@ media_root="$private_root/media"
cluster_name=easyai-acceptance-local
context="k3d-$cluster_name"
namespace=easyai
cluster_network="k3d-$cluster_name"
cluster_subnet=172.30.0.0/16
identity_file="$state_root/control-plane-identity.json"
# shellcheck source=scripts/acceptance/local-control-plane.sh
source "$script_dir/local-control-plane.sh"
usage() {
cat <<'EOF'
@@ -19,11 +24,13 @@ Usage:
scripts/acceptance/local-cluster.sh install-tools
scripts/acceptance/local-cluster.sh preflight
scripts/acceptance/local-cluster.sh up --snapshot <acceptance-snapshot-v1.json>
scripts/acceptance/local-cluster.sh new-run
scripts/acceptance/local-cluster.sh status
scripts/acceptance/local-cluster.sh down --confirm
The full cluster requires Docker Desktop memory >= 24 GiB. Failed `up` runs are
kept intact for diagnosis. `down` is the only destructive lifecycle action.
The full cluster requires Docker Desktop memory >= 24 GiB and host free disk
>= 30 GiB. Failed `up` runs are kept intact for diagnosis. `down` is the only
destructive lifecycle action.
EOF
}
@@ -34,7 +41,9 @@ load_lock() {
}
# shellcheck source=/dev/null
source "$lock_file"
: "${K3D_VERSION:?}" "${K3S_IMAGE:?}" "${CNPG_MANIFEST_URL:?}" "${CNPG_MANIFEST_SHA256:?}"
: "${K3D_VERSION:?}" "${CRANE_VERSION:?}" "${K3S_IMAGE:?}" "${CNPG_MANIFEST_URL:?}" "${CNPG_MANIFEST_SHA256:?}"
: "${CNPG_CONTROLLER_IMAGE:?}" "${CNPG_POSTGRES_IMAGE:?}" "${K3S_COREDNS_IMAGE:?}"
: "${K3S_METRICS_SERVER_IMAGE:?}" "${K3S_LOCAL_PATH_IMAGE:?}"
}
require_command() {
@@ -60,6 +69,7 @@ install_tools() {
load_lock
require_command curl
require_command shasum
require_command go
install -d -m 0700 "$tools_root"
local os arch checksum url temporary
os=$(uname -s | tr '[:upper:]' '[:lower:]')
@@ -91,7 +101,9 @@ install_tools() {
}
chmod 0555 "$temporary"
mv "$temporary" "$tools_root/k3d"
echo "local_acceptance_tools=PASS k3d=$K3D_VERSION path=$tools_root/k3d"
GOBIN="$tools_root" go install "github.com/google/go-containerregistry/cmd/crane@$CRANE_VERSION"
chmod 0555 "$tools_root/crane"
echo "local_acceptance_tools=PASS k3d=$K3D_VERSION crane=$CRANE_VERSION path=$tools_root"
}
k3d_binary() {
@@ -106,6 +118,10 @@ docker_memory_bytes() {
docker info --format '{{.MemTotal}}'
}
host_available_disk_bytes() {
df -Pk "$repository_root" | awk 'NR == 2 {printf "%.0f\n", $4 * 1024}'
}
preflight() {
load_lock
require_command docker
@@ -117,7 +133,7 @@ preflight() {
require_command go
require_command node
require_command curl
local k3d memory_bytes architecture
local k3d memory_bytes architecture cpu_count available_disk_bytes available_disk_gib
k3d=$(k3d_binary) || {
echo 'k3d is missing; run install-tools' >&2
exit 1
@@ -126,6 +142,10 @@ preflight() {
echo "k3d must be pinned to $K3D_VERSION" >&2
exit 1
}
if [[ ! -x $tools_root/crane ]] || ! "$tools_root/crane" version | grep -Fq "${CRANE_VERSION#v}"; then
echo "crane must be pinned to $CRANE_VERSION; run install-tools" >&2
exit 1
fi
docker info >/dev/null
memory_bytes=$(docker_memory_bytes)
[[ $memory_bytes =~ ^[0-9]+$ && $memory_bytes -ge 25769803776 ]] || {
@@ -139,7 +159,18 @@ preflight() {
exit 1
}
docker buildx version >/dev/null
echo "local_acceptance_preflight=PASS docker_memory_bytes=$memory_bytes architecture=$architecture k3d=$K3D_VERSION"
cpu_count=$(docker info --format '{{.NCPU}}')
[[ $cpu_count =~ ^[0-9]+$ && $cpu_count -ge 10 ]] || {
echo "Docker Desktop CPUs=${cpu_count:-unknown}; local acceptance requires at least 10" >&2
exit 1
}
available_disk_bytes=$(host_available_disk_bytes)
[[ $available_disk_bytes =~ ^[0-9]+$ && $available_disk_bytes -ge 32212254720 ]] || {
available_disk_gib=$(awk -v bytes="${available_disk_bytes:-0}" 'BEGIN {printf "%.1f", bytes/1024/1024/1024}')
echo "host free disk is ${available_disk_gib} GiB; local acceptance requires at least 30 GiB" >&2
exit 1
}
echo "local_acceptance_preflight=PASS docker_memory_bytes=$memory_bytes docker_cpus=$cpu_count host_available_disk_bytes=$available_disk_bytes architecture=$architecture k3d=$K3D_VERSION"
}
ensure_private_material() {
@@ -185,9 +216,174 @@ generate_tls() {
}
render_k3d_config() {
local output=$1
local output=$1 host_cpu host_memory_bytes host_memory_mib
local site_system_cpu witness_system_cpu site_system_memory witness_system_memory
[[ $media_root != *'|'* ]]
sed "s|EASYAI_ACCEPTANCE_MEDIA_DIR|$media_root|g" "$manifest_root/k3d.yaml" >"$output"
host_cpu=$(docker info --format '{{.NCPU}}')
host_memory_bytes=$(docker_memory_bytes)
host_memory_mib=$((host_memory_bytes / 1024 / 1024))
site_system_cpu="$(((host_cpu - 4) * 1000 + 750))m"
witness_system_cpu="$(((host_cpu - 2) * 1000 + 750))m"
site_system_memory="$((host_memory_mib - 8192 + 1024))Mi"
witness_system_memory="$((host_memory_mib - 4096 + 1024))Mi"
sed \
-e "s|EASYAI_ACCEPTANCE_MEDIA_DIR|$media_root|g" \
-e "s|EASYAI_ACCEPTANCE_K3S_IMAGE|${K3S_IMAGE%%@*}|g" \
-e "s|EASYAI_ACCEPTANCE_CLUSTER_SUBNET|$cluster_subnet|g" \
-e "s|EASYAI_ACCEPTANCE_SITE_SYSTEM_CPU|$site_system_cpu|g" \
-e "s|EASYAI_ACCEPTANCE_WITNESS_SYSTEM_CPU|$witness_system_cpu|g" \
-e "s|EASYAI_ACCEPTANCE_SITE_SYSTEM_MEMORY|$site_system_memory|g" \
-e "s|EASYAI_ACCEPTANCE_WITNESS_SYSTEM_MEMORY|$witness_system_memory|g" \
"$manifest_root/k3d.yaml" >"$output"
}
cluster_volumes() {
printf '%s\n' \
easyai-acceptance-local-server-0-etcd \
easyai-acceptance-local-server-1-etcd \
easyai-acceptance-local-server-2-etcd \
easyai-acceptance-local-server-0-storage \
easyai-acceptance-local-server-1-storage
}
create_cluster_volumes() {
local volume
while read -r volume; do
if docker volume inspect "$volume" >/dev/null 2>&1; then
echo "stale local acceptance volume exists: $volume; run explicit down --confirm" >&2
return 1
fi
done < <(cluster_volumes)
while read -r volume; do
docker volume create --label easyai.io/environment=local-acceptance "$volume" >/dev/null
done < <(cluster_volumes)
}
remove_cluster_volumes() {
local volume
while read -r volume; do
docker volume rm "$volume" >/dev/null 2>&1 || true
done < <(cluster_volumes)
}
reconcile_cluster_network() {
docker network inspect "$cluster_network" >/dev/null 2>&1 || return 0
local actual_subnet attached_containers
actual_subnet=$(docker network inspect "$cluster_network" --format '{{(index .IPAM.Config 0).Subnet}}')
attached_containers=$(docker network inspect "$cluster_network" --format '{{len .Containers}}')
if [[ $actual_subnet == "$cluster_subnet" ]]; then
return 0
fi
if ((attached_containers != 0)); then
echo "stale local acceptance network has active containers: network=$cluster_network expected_subnet=$cluster_subnet actual_subnet=$actual_subnet" >&2
return 1
fi
docker network rm "$cluster_network" >/dev/null
}
remove_cluster_network() {
docker network inspect "$cluster_network" >/dev/null 2>&1 || return 0
local attached_containers
attached_containers=$(docker network inspect "$cluster_network" --format '{{len .Containers}}')
if ((attached_containers != 0)); then
echo "refusing to remove local acceptance network with active containers: network=$cluster_network containers=$attached_containers" >&2
return 1
fi
docker network rm "$cluster_network" >/dev/null
}
pull_dependency_images() {
local image
for image in "$K3S_IMAGE" "$CNPG_CONTROLLER_IMAGE" "$CNPG_POSTGRES_IMAGE" \
"$K3S_COREDNS_IMAGE" "$K3S_METRICS_SERVER_IMAGE" "$K3S_LOCAL_PATH_IMAGE"; do
pull_dependency_image "$image"
done
}
docker_pull_with_deadline() {
local image=$1 pid deadline
docker pull "$image" >/dev/null 2>&1 &
pid=$!
deadline=$((SECONDS + 10))
while kill -0 "$pid" >/dev/null 2>&1; do
if ((SECONDS >= deadline)); then
kill "$pid" >/dev/null 2>&1 || true
wait "$pid" >/dev/null 2>&1 || true
return 1
fi
sleep 1
done
wait "$pid"
}
docker_start_with_deadline() {
local container=$1 pid deadline
docker start "$container" >/dev/null 2>&1 &
pid=$!
deadline=$((SECONDS + 10))
while kill -0 "$pid" >/dev/null 2>&1; do
if ((SECONDS >= deadline)); then
kill "$pid" >/dev/null 2>&1 || true
wait "$pid" >/dev/null 2>&1 || true
return 1
fi
sleep 1
done
wait "$pid"
}
verify_docker_container_start() {
local image=$1 container="easyai-acceptance-start-probe-$$" exit_code
docker create --name "$container" --network none --entrypoint /bin/true "$image" >/dev/null
if ! docker_start_with_deadline "$container"; then
docker rm -f "$container" >/dev/null 2>&1 || true
echo 'gate=local_docker_container_start_unavailable Docker cannot start a minimal acceptance container within 10 seconds' >&2
return 1
fi
exit_code=$(docker inspect "$container" --format '{{.State.ExitCode}}')
docker rm -f "$container" >/dev/null
[[ $exit_code == 0 ]] || {
echo "gate=local_docker_container_start_failed minimal acceptance container exit_code=$exit_code" >&2
return 1
}
}
pull_dependency_image() {
local image=$1 name_tag repository digest cache_tag native_arch archive source_tag
name_tag=${image%%@*}
repository=${name_tag%:*}
digest=${image##*@sha256:}
[[ $digest =~ ^[0-9a-f]{64}$ ]]
cache_tag="$repository:easyai-lock-${digest:0:20}"
if docker image inspect "$image" >/dev/null 2>&1; then
docker tag "$image" "$name_tag"
docker tag "$image" "$cache_tag"
return
fi
if docker image inspect "$cache_tag" >/dev/null 2>&1; then
docker tag "$cache_tag" "$name_tag"
return
fi
if docker_pull_with_deadline "$image"; then
docker tag "$image" "$name_tag"
docker tag "$name_tag" "$cache_tag"
return
fi
native_arch=$(docker info --format '{{.Architecture}}')
case $native_arch in
aarch64|arm64) native_arch=arm64 ;;
x86_64) native_arch=amd64 ;;
*) return 1 ;;
esac
archive="$state_root/dependency-image.tar"
[[ ! -L $archive ]]
umask 077
"$tools_root/crane" pull --platform "linux/$native_arch" "$image" "$archive"
docker load -i "$archive" >/dev/null
: >"$archive"
source_tag="$repository:i-was-a-digest"
docker tag "$source_tag" "$name_tag"
docker tag "$source_tag" "$cache_tag"
}
wait_rollout() {
@@ -430,6 +626,15 @@ render_and_apply_application() {
done
kubectl --context "$context" -n "$namespace" scale \
deployment/easyai-web-ningbo deployment/easyai-web-hongkong --replicas=0 >/dev/null
kubectl --context "$context" -n "$namespace" set resources deployment/easyai-api-ningbo \
deployment/easyai-api-hongkong --containers=api \
--requests=cpu=250m,memory=512Mi --limits=cpu=750m,memory=2Gi >/dev/null
kubectl --context "$context" -n "$namespace" set resources deployment/easyai-worker-ningbo \
deployment/easyai-worker-hongkong --containers=worker \
--requests=cpu=750m,memory=1536Mi --limits=cpu=1500m,memory=2Gi >/dev/null
kubectl --context "$context" -n "$namespace" set env deployment/easyai-worker-ningbo \
deployment/easyai-worker-hongkong \
AI_GATEWAY_MEDIA_IMAGE_NORMALIZATION_CONCURRENCY=1 >/dev/null
}
bootstrap_runtime() {
@@ -439,7 +644,7 @@ bootstrap_runtime() {
local snapshot_hash release_sha runtime_file
snapshot_hash=$(jq -r '.snapshotSha256' "$snapshot")
release_sha=$(git -C "$repository_root" rev-parse HEAD)
runtime_file="$state_root/runtime-$snapshot_hash.json"
runtime_file="$state_root/runtime-$snapshot_hash-$(date -u '+%Y%m%dT%H%M%SZ')-$$.json"
kubectl --context "$context" -n "$namespace" delete pod easyai-local-bootstrap \
--ignore-not-found --wait=true >/dev/null
cat <<EOF | kubectl --context "$context" apply -f - >/dev/null
@@ -561,6 +766,8 @@ up_cluster() {
api_image="easyai-acceptance-api:$release_sha"
web_image="easyai-acceptance-web:$release_sha"
netem_image="easyai-acceptance-netem:$release_sha"
pull_dependency_images
verify_docker_container_start "${K3S_IMAGE%%@*}"
docker buildx build --load --platform "linux/$native_arch" --target api \
-t "$api_image" "$repository_root"
docker buildx build --load --platform "linux/$native_arch" --target web \
@@ -569,7 +776,10 @@ up_cluster() {
-t "$netem_image" "$repository_root"
api_digest=$(docker image inspect "$api_image" --format '{{.Id}}')
[[ $api_digest =~ ^sha256:[0-9a-f]{64}$ ]]
verify_docker_container_start "$netem_image"
reconcile_cluster_network
create_cluster_volumes
config="$state_root/k3d.rendered.yaml"
render_k3d_config "$config"
EASYAI_ACCEPTANCE_MEDIA_DIR="$media_root" "$k3d" cluster create --config "$config"
@@ -577,7 +787,14 @@ up_cluster() {
docker update --cpus 4 --memory 8g --memory-swap 8g "k3d-${cluster_name}-server-0" >/dev/null
docker update --cpus 4 --memory 8g --memory-swap 8g "k3d-${cluster_name}-server-1" >/dev/null
docker update --cpus 2 --memory 4g --memory-swap 4g "k3d-${cluster_name}-server-2" >/dev/null
"$k3d" image import -c "$cluster_name" "$api_image" "$web_image" "$netem_image"
verify_local_node_capacity "$context" || {
echo 'local K3s node allocatable capacity does not match 4/8, 4/8, 2/4 resource envelope' >&2
exit 1
}
"$k3d" image import -c "$cluster_name" \
"$api_image" "$web_image" "$netem_image" \
"${CNPG_CONTROLLER_IMAGE%%@*}" "${CNPG_POSTGRES_IMAGE%%@*}" \
"${K3S_COREDNS_IMAGE%%@*}" "${K3S_METRICS_SERVER_IMAGE%%@*}" "${K3S_LOCAL_PATH_IMAGE%%@*}"
local cnpg_manifest="$state_root/cnpg.yaml"
curl -fsSL "$CNPG_MANIFEST_URL" -o "$cnpg_manifest"
@@ -590,7 +807,8 @@ up_cluster() {
kubectl --context "$context" -n cnpg-system rollout status \
deployment/cnpg-controller-manager --timeout=10m
apply_runtime_secrets
kubectl --context "$context" -n "$namespace" apply -f "$manifest_root/database.yaml" >/dev/null
sed "s|imageName: .*|imageName: ${CNPG_POSTGRES_IMAGE%%@*}|" "$manifest_root/database.yaml" |
kubectl --context "$context" -n "$namespace" apply -f - >/dev/null
kubectl --context "$context" -n "$namespace" wait --for=condition=Ready \
cluster/easyai-postgres --timeout=15m >/dev/null
run_migrations "$api_image"
@@ -619,9 +837,58 @@ up_cluster() {
bootstrap_runtime "$api_image" "$api_digest" "$snapshot"
wait_internal_gateway
"$script_dir/network-fault.sh" baseline
sleep 1
record_local_control_plane_identity "$context" "$cluster_name" "$identity_file"
echo "local_acceptance_cluster=PASS context=$context gateways=https://127.0.0.1:18443,https://127.0.0.1:19443 ca_file=$state_root/ca.crt"
}
new_run() {
load_lock
preflight
[[ -z $(git -C "$repository_root" status --short) ]] || {
echo 'local acceptance requires a clean source tree for a fresh Run ID' >&2
exit 1
}
if ! private_file "$state_root/current-runtime-path" || ! private_file "$state_root/snapshot.json"; then
echo 'local acceptance runtime or snapshot is unavailable' >&2
exit 1
fi
verify_local_control_plane_identity "$context" "$cluster_name" "$identity_file" || {
echo 'gate=local_control_plane_restarted local K3s server identity changed; rebuild the cluster' >&2
exit 1
}
verify_local_node_capacity "$context" || {
echo 'gate=local_node_capacity_mismatch local node allocatable capacity drifted' >&2
exit 1
}
verify_local_apiserver_ready "$context" || {
echo 'gate=local_control_plane_unavailable local Kubernetes API is not ready' >&2
exit 1
}
local previous_runtime api_image api_digest previous_release current_release
previous_runtime=$(<"$state_root/current-runtime-path")
private_file "$previous_runtime" || {
echo 'local acceptance current runtime is unavailable' >&2
exit 1
}
previous_release=$(jq -r '.releaseSha' "$previous_runtime")
current_release=$(git -C "$repository_root" rev-parse HEAD)
[[ $previous_release == "$current_release" ]] || {
echo 'local acceptance source changed after cluster creation; rebuild the cluster' >&2
exit 1
}
api_digest=$(jq -r '.apiImageDigest' "$previous_runtime")
api_image=$(kubectl --context "$context" -n "$namespace" get deployment easyai-api-ningbo \
-o 'jsonpath={.spec.template.spec.containers[?(@.name=="api")].image}')
[[ -n $api_image && $api_digest =~ ^sha256:[0-9a-f]{64}$ ]]
bootstrap_runtime "$api_image" "$api_digest" "$state_root/snapshot.json"
verify_local_control_plane_identity "$context" "$cluster_name" "$identity_file" || {
echo 'gate=local_control_plane_restarted local K3s server changed while creating a fresh run' >&2
exit 1
}
echo "local_acceptance_new_run=PASS runtime_path=$state_root/current-runtime-path"
}
status_cluster() {
local k3d
k3d=$(k3d_binary) || {
@@ -634,6 +901,14 @@ status_cluster() {
}
kubectl --context "$context" get nodes -o wide
kubectl --context "$context" -n "$namespace" get cluster,pod,deployment
verify_local_control_plane_identity "$context" "$cluster_name" "$identity_file" || {
echo 'gate=local_control_plane_restarted local K3s server identity changed; rebuild required' >&2
exit 1
}
verify_local_node_capacity "$context" || {
echo 'gate=local_node_capacity_mismatch local node allocatable capacity drifted' >&2
exit 1
}
echo "local_acceptance_status=PASS context=$context"
}
@@ -644,7 +919,11 @@ down_cluster() {
}
local k3d
k3d=$(k3d_binary)
"$k3d" cluster delete "$cluster_name"
if "$k3d" cluster list --no-headers | awk '{print $1}' | grep -Fxq "$cluster_name"; then
"$k3d" cluster delete "$cluster_name"
fi
remove_cluster_volumes
remove_cluster_network
echo "local_acceptance_down=PASS preserved_private_root=$private_root"
}
@@ -661,6 +940,10 @@ case ${1:-} in
shift
up_cluster "$@"
;;
new-run)
[[ $# -eq 1 ]] || { usage >&2; exit 64; }
new_run
;;
status)
[[ $# -eq 1 ]] || { usage >&2; exit 64; }
status_cluster
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Shared read-only guards for the local three-server K3s acceptance cluster.
# The caller owns shell options and failure reporting.
capture_local_control_plane_identity() {
local context=$1 cluster_name=$2 containers nodes
containers=$(docker inspect \
"k3d-${cluster_name}-server-0" \
"k3d-${cluster_name}-server-1" \
"k3d-${cluster_name}-server-2" |
jq -c --arg network "k3d-$cluster_name" '[.[] | {
name:(.Name | ltrimstr("/")),
id:.Id,
restartCount:.RestartCount,
startedAt:.State.StartedAt,
ip:.NetworkSettings.Networks[$network].IPAddress
}] | sort_by(.name)') || return 1
nodes=$(kubectl --context "$context" get nodes -o json |
jq -c '[.items[] | {
name:.metadata.name,
uid:.metadata.uid,
site:.metadata.labels["easyai.io/site"],
cpu:.status.allocatable.cpu,
memory:.status.allocatable.memory
}] | sort_by(.name)') || return 1
jq -cn --argjson containers "$containers" --argjson nodes "$nodes" \
'{containers:$containers,nodes:$nodes}'
}
record_local_control_plane_identity() {
local context=$1 cluster_name=$2 output=$3 current temporary
current=$(capture_local_control_plane_identity "$context" "$cluster_name") || return 1
temporary="${output}.tmp.$$"
jq -cn \
--arg recordedAt "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
--argjson current "$current" \
'{schemaVersion:"local-control-plane-identity/v1",recordedAt:$recordedAt} + $current' \
>"$temporary"
chmod 0600 "$temporary"
mv "$temporary" "$output"
}
verify_local_control_plane_identity() {
local context=$1 cluster_name=$2 baseline=$3 expected current
[[ -f $baseline && ! -L $baseline ]] || return 1
expected=$(jq -cS '{containers,nodes}' "$baseline") || return 1
current=$(capture_local_control_plane_identity "$context" "$cluster_name" |
jq -cS '{containers,nodes}') || return 1
[[ $current == "$expected" ]]
}
verify_local_node_capacity() {
local context=$1
kubectl --context "$context" get nodes -o json | jq -e '
def cpu_m:
if endswith("m") then rtrimstr("m") | tonumber else tonumber * 1000 end;
def memory_mi:
if endswith("Ki") then rtrimstr("Ki") | tonumber / 1024
elif endswith("Mi") then rtrimstr("Mi") | tonumber
elif endswith("Gi") then rtrimstr("Gi") | tonumber * 1024
else 0 end;
([.items[] | select(.metadata.labels["easyai.io/workload"] == "true") |
select((.status.allocatable.cpu | cpu_m) == 3000 and
(.status.allocatable.memory | memory_mi) == 6656)] | length) == 2 and
([.items[] | select(.metadata.labels["easyai.io/site"] == "los-angeles") |
select((.status.allocatable.cpu | cpu_m) == 1000 and
(.status.allocatable.memory | memory_mi) == 2560)] | length) == 1 and
([.items[] | select(any(.status.conditions[]; .type == "Ready" and .status == "True"))] | length) == 3 and
([.items[] | select(any(.status.conditions[]; .type == "MemoryPressure" and .status == "True"))] | length) == 0
' >/dev/null
}
verify_local_apiserver_ready() {
local context=$1
kubectl --context "$context" get --raw='/readyz?verbose' 2>/dev/null |
grep -Fq 'readyz check passed'
}
verify_local_etcd_runtime_logs() {
local cluster_name=$1 baseline=$2 recorded_at name
[[ -f $baseline && ! -L $baseline ]] || return 1
recorded_at=$(jq -r '.recordedAt' "$baseline") || return 1
[[ $recorded_at == ????-??-??T*Z ]]
for name in \
"k3d-${cluster_name}-server-0" \
"k3d-${cluster_name}-server-1" \
"k3d-${cluster_name}-server-2"; do
if docker logs --since "$recorded_at" "$name" 2>&1 |
grep -Eqi 'heartbeat interval is too long|apply request took too long|slow fdatasync|failed to check local etcd status|etcdserver: request timed out|waiting for ReadIndex response took too long'; then
return 1
fi
done
}
+36 -10
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { lstat, mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
const shaPattern = /^[0-9a-f]{40}$/;
@@ -26,6 +26,29 @@ async function regularJSON(path) {
return JSON.parse(await readFile(absolute, 'utf8'));
}
async function writeNewJSON(path, value) {
const absolute = resolve(path);
await mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
await writeFile(absolute, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
}
async function replaceJSONAtomically(input, output, value) {
const inputPath = resolve(input);
const outputPath = resolve(output);
if (inputPath !== outputPath) {
await writeNewJSON(outputPath, value);
return;
}
const temporary = `${outputPath}.tmp-${process.pid}-${Date.now()}`;
try {
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
await rename(temporary, outputPath);
} catch (error) {
await unlink(temporary).catch(() => {});
throw error;
}
}
function assertSecretSafe(value, path = '$') {
if (Array.isArray(value)) {
value.forEach((item, index) => assertSecretSafe(item, `${path}[${index}]`));
@@ -85,6 +108,8 @@ async function loadReports(directory) {
profile: report.profile,
passed: report.passed,
phases: report.phases,
failure: report.failure,
failureOperation: report.failureOperation,
startedAt: report.startedAt,
finishedAt: report.finishedAt
});
@@ -150,8 +175,7 @@ async function buildLocal(values) {
secretSafe: true
};
validate(report);
await mkdir(dirname(resolve(values.output)), { recursive: true, mode: 0o700 });
await writeFile(resolve(values.output), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
await writeNewJSON(values.output, report);
process.stdout.write(`acceptance_report_local=PASS sha256=${createHash('sha256').update(JSON.stringify(report)).digest('hex')}\n`);
}
@@ -172,6 +196,10 @@ async function buildLocalPartial(values) {
throw new Error('partial local report inputs have incompatible CAS identity');
}
const completedLoadsPassed = loads.every((item) => item.passed === true);
const failureGate = values['failure-gate'] ?? 'local_execution_incomplete';
if (!/^[a-z0-9_]+$/.test(failureGate)) {
throw new Error('--failure-gate must be a stable lowercase gate ID');
}
const report = {
schemaVersion: 'acceptance-report/v1',
runId: runtime.runId,
@@ -202,7 +230,7 @@ async function buildLocalPartial(values) {
} : null,
gates: [
{
id: 'local_execution_complete',
id: failureGate,
passed: false,
detail: `stopped during ${values['failure-phase']}`
},
@@ -221,8 +249,7 @@ async function buildLocalPartial(values) {
secretSafe: true
};
validate(report);
await mkdir(dirname(resolve(values.output)), { recursive: true, mode: 0o700 });
await writeFile(resolve(values.output), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
await writeNewJSON(values.output, report);
process.stdout.write('acceptance_report_local_partial=PASS\n');
}
@@ -259,8 +286,7 @@ async function mergeProduction(values) {
};
local.createdAt = new Date().toISOString();
validate(local);
await mkdir(dirname(resolve(values.output)), { recursive: true, mode: 0o700 });
await writeFile(resolve(values.output), `${JSON.stringify(local, null, 2)}\n`, { mode: 0o600 });
await writeNewJSON(values.output, local);
process.stdout.write(`acceptance_report_production=PASS promotion_ready=${local.promotion.ready}\n`);
}
@@ -285,7 +311,7 @@ if (command === 'validate') {
}
report.promotion.trafficMode = 'live';
report.promotion.promotedAt = new Date().toISOString();
await writeFile(resolve(output), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
await replaceJSONAtomically(input, output, report);
process.stdout.write('acceptance_report_promoted=PASS\n');
} else if (command === 'attach-monitor') {
const input = values.input ?? '';
@@ -309,7 +335,7 @@ if (command === 'validate') {
report.promotion.ready = false;
report.promotion.trafficMode = 'validation';
}
await writeFile(resolve(output), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
await replaceJSONAtomically(input, output, report);
process.stdout.write(`acceptance_report_monitor=PASS monitor_passed=${monitor.passed === true}\n`);
} else {
throw new Error('usage: report.mjs {validate|build-local|build-local-partial|merge-production|mark-promoted|attach-monitor} [options]');
+124 -24
View File
@@ -7,13 +7,18 @@ private_root="$repository_root/.local-secrets/acceptance"
state_root="$private_root/state"
context=k3d-easyai-acceptance-local
namespace=easyai
cluster_name=easyai-acceptance-local
runtime_path_file="$state_root/current-runtime-path"
snapshot="$state_root/snapshot.json"
load_binary="$state_root/easyai-ai-gateway-acceptance-load"
identity_file="$state_root/control-plane-identity.json"
active_load_pid=
netem_active=false
current_phase=initialization
stable_profile=
failure_gate_id=local_execution_incomplete
# shellcheck source=scripts/acceptance/local-control-plane.sh
source "$script_dir/local-control-plane.sh"
usage() {
cat <<'EOF'
@@ -51,12 +56,16 @@ cleanup() {
fi
if [[ $status -ne 0 && -n ${runtime:-} && -n ${report_root:-} && -f ${runtime:-} && -f $snapshot ]]; then
restore_profile_best_effort "${stable_profile:-P24}"
if [[ -f ${failure_gate_file:-} && ! -L ${failure_gate_file:-} ]]; then
failure_gate_id=$(jq -r '.id' "$failure_gate_file" 2>/dev/null || printf '%s' "$failure_gate_id")
fi
local partial_args=(
"$script_dir/report.mjs" build-local-partial
--runtime "$runtime" \
--snapshot "$snapshot" \
--reports "$report_root" \
--failure-phase "$current_phase" \
--failure-gate "$failure_gate_id" \
--output "$report_root/acceptance-report.partial.json"
)
if [[ -n $stable_profile ]]; then
@@ -89,6 +98,7 @@ require_local_cluster() {
release_sha=$(jq -r '.releaseSha' "$runtime")
report_root="$repository_root/dist/acceptance/local/$run_id"
install -d -m 0700 "$report_root"
failure_gate_file="$report_root/failure-gate.json"
(
cd "$repository_root/apps/api"
env -u AI_GATEWAY_TEST_DATABASE_URL go build -trimpath \
@@ -96,6 +106,27 @@ require_local_cluster() {
)
}
record_failure_gate() {
local id=$1 detail=$2 temporary
[[ $id =~ ^[a-z0-9_]+$ ]]
failure_gate_id=$id
[[ -n ${failure_gate_file:-} ]] || return 0
if [[ ! -e $failure_gate_file ]]; then
temporary="${failure_gate_file}.tmp.$$"
jq -n --arg id "$id" --arg detail "$detail" \
'{schemaVersion:"acceptance-failure-gate/v1",id:$id,detail:$detail}' >"$temporary"
chmod 0600 "$temporary"
mv "$temporary" "$failure_gate_file"
fi
}
fail_gate() {
local id=$1 detail=$2
record_failure_gate "$id" "$detail"
echo "gate=$id $detail" >&2
return 1
}
database_query() {
local sql=$1 primary
primary=$(kubectl --context "$context" -n "$namespace" get cluster easyai-postgres \
@@ -115,6 +146,12 @@ load_environment() {
run_load() {
local profile=$1 report_path=$2
shift 2
local stdout_path="$report_path.stdout" stdout_temporary="${report_path}.stdout.tmp.$$"
local load_pid load_status=0 unavailable_samples=0 infrastructure_failure=false
[[ ! -e $report_path && ! -e $stdout_path && ! -e $stdout_temporary ]] ||
fail_gate acceptance_report_exists "refusing to overwrite an existing load report"
verify_local_control_plane_identity "$context" "$cluster_name" "$identity_file" ||
fail_gate local_control_plane_restarted "local K3s server identity changed before load"
AI_GATEWAY_ACCEPTANCE_GATEWAYS='https://127.0.0.1:18443,https://127.0.0.1:19443' \
AI_GATEWAY_ACCEPTANCE_GATEWAY_TLS_SERVER_NAME=gateway.easyai.local \
AI_GATEWAY_ACCEPTANCE_GATEWAY_CA_FILE="$state_root/ca.crt" \
@@ -125,8 +162,49 @@ run_load() {
AI_GATEWAY_ACCEPTANCE_GEMINI_MODEL="$acceptance_gemini_model" \
AI_GATEWAY_ACCEPTANCE_VIDEO_MODEL="$acceptance_video_model" \
"$load_binary" -profile "$profile" -report "$report_path" "$@" \
>"$report_path.stdout"
chmod 0600 "$report_path" "$report_path.stdout"
>"$stdout_temporary" &
load_pid=$!
active_load_pid=$load_pid
while kill -0 "$load_pid" >/dev/null 2>&1; do
if ! verify_local_control_plane_identity "$context" "$cluster_name" "$identity_file"; then
record_failure_gate local_control_plane_restarted "local K3s server restarted during load"
infrastructure_failure=true
kill "$load_pid" >/dev/null 2>&1 || true
break
fi
if verify_local_apiserver_ready "$context"; then
unavailable_samples=0
else
unavailable_samples=$((unavailable_samples + 1))
if ((unavailable_samples >= 3)); then
record_failure_gate local_control_plane_unavailable "Kubernetes API readiness failed for three consecutive samples"
infrastructure_failure=true
kill "$load_pid" >/dev/null 2>&1 || true
break
fi
fi
sleep 2
done
if wait "$load_pid"; then
load_status=0
else
load_status=$?
fi
active_load_pid=
if ! verify_local_control_plane_identity "$context" "$cluster_name" "$identity_file"; then
record_failure_gate local_control_plane_restarted "local K3s server identity changed at load completion"
infrastructure_failure=true
fi
[[ ! -e $stdout_path ]] || fail_gate acceptance_report_exists "load stdout report already exists"
mv "$stdout_temporary" "$stdout_path"
chmod 0600 "$stdout_path"
if [[ -f $report_path && ! -L $report_path ]]; then
chmod 0600 "$report_path"
fi
if [[ $infrastructure_failure == true ]]; then
return 1
fi
((load_status == 0)) || return "$load_status"
jq -e '.schemaVersion == "acceptance-load-report/v1" and .secretSafe == true and .passed == true' \
"$report_path" >/dev/null
}
@@ -147,15 +225,26 @@ sample_resources() {
verify_hard_gates() {
local unhealthy ready sync_state connections max_connections queue duplicate_remote duplicate_billing
verify_local_control_plane_identity "$context" "$cluster_name" "$identity_file" ||
fail_gate local_control_plane_restarted "local K3s server identity changed"
verify_local_apiserver_ready "$context" ||
fail_gate local_control_plane_unavailable "Kubernetes API readiness failed"
verify_local_node_capacity "$context" ||
fail_gate local_node_capacity_mismatch "node allocatable resources drifted from the acceptance envelope"
verify_local_etcd_runtime_logs "$cluster_name" "$identity_file" ||
fail_gate local_etcd_latency "etcd emitted a severe latency or timeout signal during the run"
ready=$(kubectl --context "$context" get nodes -o json |
jq '[.items[] | select(any(.status.conditions[]; .type=="Ready" and .status=="True"))] | length')
[[ $ready == 3 ]]
[[ $ready == 3 ]] || fail_gate local_nodes_not_ready "not all three local K3s nodes are Ready"
[[ $(kubectl --context "$context" get nodes -o json |
jq '[.items[] | select(any(.status.conditions[]; .type=="MemoryPressure" and .status=="True"))] | length') == 0 ]]
jq '[.items[] | select(any(.status.conditions[]; .type=="MemoryPressure" and .status=="True"))] | length') == 0 ]] ||
fail_gate local_node_memory_pressure "a local K3s node reports MemoryPressure"
[[ $(kubectl --context "$context" -n "$namespace" get cluster easyai-postgres \
-o 'jsonpath={.status.readyInstances}') == 2 ]]
-o 'jsonpath={.status.readyInstances}') == 2 ]] ||
fail_gate local_postgres_not_ready "local PostgreSQL is not 2/2 Ready"
sync_state=$(database_query "SELECT COALESCE(string_agg(sync_state,','),'') FROM pg_stat_replication;")
[[ ",$sync_state," == *,sync,* || ",$sync_state," == *,quorum,* ]]
[[ ",$sync_state," == *,sync,* || ",$sync_state," == *,quorum,* ]] ||
fail_gate local_postgres_not_synchronous "local PostgreSQL lost its synchronous replica"
unhealthy=$(kubectl --context "$context" -n "$namespace" get pods -o json |
jq '[.items[]
| select(.metadata.labels["app.kubernetes.io/name"] == "easyai-api" or
@@ -163,11 +252,11 @@ verify_hard_gates() {
| .status.containerStatuses[]?
| select(.restartCount > 0 or .lastState.terminated.reason == "OOMKilled" or
.state.terminated.reason == "OOMKilled")] | length')
[[ $unhealthy == 0 ]]
[[ $unhealthy == 0 ]] || fail_gate gateway_pod_restarted "an API or Worker container restarted or was OOMKilled"
while read -r _node _cpu _cpu_percent _memory memory_percent; do
memory_percent=${memory_percent%\%}
[[ $memory_percent =~ ^[0-9]+$ ]]
(( memory_percent < 80 ))
(( memory_percent < 80 )) || fail_gate local_node_memory_target "local node memory reached the 80 percent target"
done < <(kubectl --context "$context" top nodes --no-headers)
while read -r _pod _cpu memory; do
case $memory in
@@ -176,20 +265,23 @@ verify_hard_gates() {
*Ki) memory=$(awk -v value="${memory%Ki}" 'BEGIN {printf "%.0f", value/1024}') ;;
*) return 1 ;;
esac
(( memory < 1536 ))
(( memory < 1536 )) || fail_gate gateway_pod_memory_limit "an API or Worker Pod reached 1.5 GiB RSS"
done < <(kubectl --context "$context" -n "$namespace" top pods \
-l 'app.kubernetes.io/part-of=easyai-ai-gateway' --no-headers)
connections=$(database_query "SELECT count(*) FROM pg_stat_activity WHERE backend_type='client backend';")
max_connections=$(database_query "SELECT setting::int FROM pg_settings WHERE name='max_connections';")
(( connections < 150 && connections * 4 < max_connections * 3 ))
(( connections < 150 && connections * 4 < max_connections * 3 )) ||
fail_gate postgres_connection_budget "PostgreSQL client sessions exceeded the acceptance budget"
queue=$(database_query "SELECT count(*) FILTER (WHERE status='queued')||':'||count(*) FILTER (WHERE status='running') FROM gateway_tasks WHERE acceptance_run_id='$run_id'::uuid;")
[[ $queue == 0:0 ]]
[[ $queue == 0:0 ]] || fail_gate acceptance_queue_not_drained "acceptance queue did not return to zero"
duplicate_remote=$(database_query "SELECT count(*) FROM (SELECT remote_task_id FROM gateway_tasks WHERE acceptance_run_id='$run_id'::uuid AND remote_task_id IS NOT NULL GROUP BY remote_task_id HAVING count(*)>1) d;")
duplicate_billing=$(database_query "SELECT count(*) FROM (SELECT reference_id,transaction_type FROM gateway_wallet_transactions WHERE reference_type='gateway_task' AND reference_id IN (SELECT id::text FROM gateway_tasks WHERE acceptance_run_id='$run_id'::uuid) GROUP BY reference_id,transaction_type HAVING count(*)>1) d;")
[[ $duplicate_remote == 0 && $duplicate_billing == 0 ]]
[[ $duplicate_remote == 0 ]] || fail_gate duplicate_upstream_submission "duplicate remote task IDs were detected"
[[ $duplicate_billing == 0 ]] || fail_gate duplicate_billing "duplicate billing transactions were detected"
kubectl --context "$context" -n "$namespace" exec deployment/easyai-acceptance-callback-collector -- \
wget -qO- http://127.0.0.1:8091/report |
jq -e '.duplicates == 0 and .invalid == 0' >/dev/null
jq -e '.duplicates == 0 and .invalid == 0' >/dev/null ||
fail_gate callback_validation_failed "callback collector detected duplicate or invalid callbacks"
}
apply_profile() {
@@ -218,7 +310,8 @@ apply_profile() {
AI_GATEWAY_ASYNC_WORKER_INSTANCE_HARD_LIMIT="$slots" \
AI_GATEWAY_DATABASE_MAX_CONNS="$pool" \
AI_GATEWAY_MEDIA_MATERIALIZATION_CONCURRENCY="$slots" \
AI_GATEWAY_MEDIA_REQUEST_CONCURRENCY="$slots" >/dev/null
AI_GATEWAY_MEDIA_REQUEST_CONCURRENCY="$slots" \
AI_GATEWAY_MEDIA_IMAGE_NORMALIZATION_CONCURRENCY=1 >/dev/null
kubectl --context "$context" -n "$namespace" rollout status deployment/"$deployment" --timeout=10m
done
for deployment in easyai-api-ningbo easyai-api-hongkong easyai-capacity-controller; do
@@ -254,7 +347,8 @@ restore_profile_best_effort() {
AI_GATEWAY_ASYNC_WORKER_INSTANCE_HARD_LIMIT="$slots" \
AI_GATEWAY_DATABASE_MAX_CONNS="$pool" \
AI_GATEWAY_MEDIA_MATERIALIZATION_CONCURRENCY="$slots" \
AI_GATEWAY_MEDIA_REQUEST_CONCURRENCY="$slots" >/dev/null 2>&1 || true
AI_GATEWAY_MEDIA_REQUEST_CONCURRENCY="$slots" \
AI_GATEWAY_MEDIA_IMAGE_NORMALIZATION_CONCURRENCY=1 >/dev/null 2>&1 || true
done
}
@@ -514,23 +608,29 @@ full() {
command=${1:-}
shift || true
require_local_cluster
load_environment
case $command in
quick)
[[ $# -eq 0 ]] || { usage >&2; exit 64; }
quick
;;
artifact-smoke)
artifact-smoke|full)
[[ ${1:-} == --release-manifest && $# -eq 2 ]] || { usage >&2; exit 64; }
artifact_smoke "$2"
;;
full)
[[ ${1:-} == --release-manifest && $# -eq 2 ]] || { usage >&2; exit 64; }
full "$2"
;;
*)
usage >&2
exit 64
;;
esac
"$script_dir/local-cluster.sh" new-run
require_local_cluster
load_environment
case $command in
quick)
quick
;;
artifact-smoke)
artifact_smoke "$2"
;;
full)
full "$2"
;;
esac
+16 -1
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
@@ -73,6 +73,19 @@ assert.equal(report.stages.amd64Artifact.status, 'passed');
assert.equal(report.promotion.ready, false);
assert.equal(readFileSync(output, 'utf8').includes('must-not-be-copied'), false);
execFileSync('node', [join(root, 'scripts/acceptance/report.mjs'), 'validate', '--input', output]);
const overwrite = spawnSync('node', [
join(root, 'scripts/acceptance/report.mjs'),
'build-local',
'--runtime', runtime,
'--snapshot', snapshot,
'--reports', reports,
'--artifact', artifact,
'--api-digest', digest,
'--output', output
], { encoding: 'utf8' });
assert.notEqual(overwrite.status, 0);
assert.match(`${overwrite.stdout}${overwrite.stderr}`, /EEXIST|file already exists/);
assert.equal(JSON.parse(readFileSync(output, 'utf8')).runId, runID);
execFileSync('node', [
join(root, 'scripts/acceptance/report.mjs'),
@@ -82,11 +95,13 @@ execFileSync('node', [
'--reports', reports,
'--certified-profile', 'P24',
'--failure-phase', 'capacity_p28_round_2',
'--failure-gate', 'local_control_plane_restarted',
'--output', partialOutput
]);
const partialReport = JSON.parse(readFileSync(partialOutput, 'utf8'));
assert.equal(partialReport.stages.localNative.status, 'failed');
assert.equal(partialReport.stages.localNative.failurePhase, 'capacity_p28_round_2');
assert.equal(partialReport.gates[0].id, 'local_control_plane_restarted');
assert.equal(partialReport.stages.amd64Artifact.status, 'not-run');
assert.equal(partialReport.promotion.ready, false);
assert.equal(readFileSync(partialOutput, 'utf8').includes('must-not-be-copied'), false);