feat(acceptance): 建立同构验收与弹性容量体系
实现本地三节点 K3s 同构环境、脱敏生产快照、Gemini 图片和多参考图视频协议模拟、统一验收报告及故障注入。\n\n新增 Worker 容量控制器、资源与连接预算、任务恢复保护,并将生产验收拆分为 validation 执行和人工 CAS 放量。\n\n验证包括 Go 全量测试、PostgreSQL HTTP 集成测试、go vet、OpenAPI、ShellCheck、前端检查、迁移及发布脚本测试。
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/acceptancesnapshot"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
fullSHAPattern = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
)
|
||||
|
||||
type options struct {
|
||||
clusterID string
|
||||
releaseSHA string
|
||||
apiImageDigest string
|
||||
workerDigest string
|
||||
emulatorBaseURL string
|
||||
callbackURL string
|
||||
output string
|
||||
identityShards int
|
||||
}
|
||||
|
||||
type runtimeFile struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
LocalClusterID string `json:"localClusterId"`
|
||||
RunID string `json:"runId"`
|
||||
RunToken string `json:"runToken"`
|
||||
APIKeys []string `json:"apiKeys"`
|
||||
Participants []runtimeParticipant `json:"participants"`
|
||||
GeminiModel string `json:"geminiModel"`
|
||||
VideoModel string `json:"videoModel"`
|
||||
EmulatorBaseURL string `json:"emulatorBaseUrl"`
|
||||
CallbackURL string `json:"callbackUrl"`
|
||||
ReleaseSHA string `json:"releaseSha"`
|
||||
APIImageDigest string `json:"apiImageDigest"`
|
||||
WorkerImageDigest string `json:"workerImageDigest"`
|
||||
SnapshotConfigHash string `json:"snapshotConfigHash"`
|
||||
SnapshotSHA256 string `json:"snapshotSha256"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type runtimeParticipant struct {
|
||||
APIKeyID string `json:"apiKeyId"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
opts, err := parseOptions()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "acceptance bootstrap:", err)
|
||||
os.Exit(64)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if err := run(ctx, opts); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "acceptance bootstrap:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func parseOptions() (options, error) {
|
||||
var opts options
|
||||
flag.StringVar(&opts.clusterID, "local-cluster-id", "", "required local cluster marker")
|
||||
flag.StringVar(&opts.releaseSHA, "release-sha", "", "full source Git SHA")
|
||||
flag.StringVar(&opts.apiImageDigest, "api-image-digest", "", "immutable API image digest")
|
||||
flag.StringVar(&opts.workerDigest, "worker-image-digest", "", "immutable Worker image digest")
|
||||
flag.StringVar(&opts.emulatorBaseURL, "emulator-base-url", "", "in-cluster protocol emulator URL")
|
||||
flag.StringVar(&opts.callbackURL, "callback-url", "", "in-cluster callback collector URL")
|
||||
flag.StringVar(&opts.output, "output", "", "private runtime output file")
|
||||
flag.IntVar(&opts.identityShards, "identity-shards", 32, "isolated acceptance identities")
|
||||
flag.Parse()
|
||||
opts.clusterID = strings.TrimSpace(opts.clusterID)
|
||||
opts.releaseSHA = strings.ToLower(strings.TrimSpace(opts.releaseSHA))
|
||||
opts.apiImageDigest = strings.ToLower(strings.TrimSpace(opts.apiImageDigest))
|
||||
opts.workerDigest = strings.ToLower(strings.TrimSpace(opts.workerDigest))
|
||||
opts.emulatorBaseURL = strings.TrimRight(strings.TrimSpace(opts.emulatorBaseURL), "/")
|
||||
opts.callbackURL = strings.TrimSpace(opts.callbackURL)
|
||||
if opts.clusterID == "" || !fullSHAPattern.MatchString(opts.releaseSHA) ||
|
||||
!digestPattern.MatchString(opts.apiImageDigest) || !digestPattern.MatchString(opts.workerDigest) {
|
||||
return options{}, errors.New("local cluster ID, full release SHA, and immutable image digests are required")
|
||||
}
|
||||
if opts.emulatorBaseURL == "" || opts.callbackURL == "" {
|
||||
return options{}, errors.New("emulator and callback URLs are required")
|
||||
}
|
||||
if opts.identityShards < 1 || opts.identityShards > 128 {
|
||||
return options{}, errors.New("identity shards must be between 1 and 128")
|
||||
}
|
||||
if strings.TrimSpace(opts.output) == "" {
|
||||
return options{}, errors.New("private runtime output path is required")
|
||||
}
|
||||
if flag.NArg() != 0 {
|
||||
return options{}, errors.New("unexpected positional arguments")
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context, opts options) error {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
return errors.New("AI_GATEWAY_DATABASE_URL is required")
|
||||
}
|
||||
database, err := store.ConnectWithMaxConns(ctx, databaseURL, 4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
if err := verifyLocalDatabase(ctx, database, opts.clusterID); err != nil {
|
||||
return err
|
||||
}
|
||||
snapshotConfigHash, snapshotSHA, err := importedSnapshot(ctx, database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupID, tenantID, err := ensureAcceptanceIdentityDomain(ctx, database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
participants := make([]runtimeParticipant, 0, opts.identityShards)
|
||||
apiKeys := make([]string, 0, opts.identityShards)
|
||||
for ordinal := 0; ordinal < opts.identityShards; ordinal++ {
|
||||
userID, err := ensureAcceptanceUser(ctx, database, groupID, tenantID, ordinal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyID, secret, err := ensureAcceptanceAPIKey(ctx, database, userID, groupID, tenantID, ordinal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := database.SetUserWalletBalance(ctx, store.WalletBalanceAdjustmentInput{
|
||||
GatewayUserID: userID,
|
||||
Currency: "resource",
|
||||
BalanceText: "1000000000",
|
||||
Reason: "local acceptance isolated wallet",
|
||||
}); err != nil && !errors.Is(err, store.ErrWalletBalanceUnchanged) {
|
||||
return err
|
||||
}
|
||||
participants = append(participants, runtimeParticipant{APIKeyID: keyID, UserID: userID})
|
||||
apiKeys = append(apiKeys, secret)
|
||||
}
|
||||
if err := ensureAcceptanceAccessRules(ctx, database, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
geminiModel, videoModel, err := selectedModels(ctx, database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runToken, err := randomToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
run, err := database.CreateAcceptanceRun(ctx, store.CreateAcceptanceRunInput{
|
||||
ReleaseSHA: opts.releaseSHA,
|
||||
APIImageDigest: opts.apiImageDigest,
|
||||
WorkerImageDigest: opts.workerDigest,
|
||||
APIKeyID: participants[0].APIKeyID,
|
||||
UserID: participants[0].UserID,
|
||||
Token: runToken,
|
||||
EmulatorBaseURL: opts.emulatorBaseURL,
|
||||
CallbackURL: opts.callbackURL,
|
||||
CapacityProfile: "P24",
|
||||
Config: map[string]any{
|
||||
"workloads": []any{"gemini_image_edit", "multi_reference_video"},
|
||||
"participants": participants,
|
||||
"localClusterId": opts.clusterID,
|
||||
"snapshotConfigHash": snapshotConfigHash,
|
||||
"snapshotSha256": snapshotSHA,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := database.ActivateAcceptanceRun(ctx, run.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
outputPath, err := privateOutputPath(opts.output)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := json.MarshalIndent(runtimeFile{
|
||||
SchemaVersion: "acceptance-runtime/v1",
|
||||
LocalClusterID: opts.clusterID,
|
||||
RunID: run.ID,
|
||||
RunToken: runToken,
|
||||
APIKeys: apiKeys,
|
||||
Participants: participants,
|
||||
GeminiModel: geminiModel,
|
||||
VideoModel: videoModel,
|
||||
EmulatorBaseURL: opts.emulatorBaseURL,
|
||||
CallbackURL: opts.callbackURL,
|
||||
ReleaseSHA: opts.releaseSHA,
|
||||
APIImageDigest: opts.apiImageDigest,
|
||||
WorkerImageDigest: opts.workerDigest,
|
||||
SnapshotConfigHash: snapshotConfigHash,
|
||||
SnapshotSHA256: snapshotSHA,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(outputPath, append(payload, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(
|
||||
"acceptance_bootstrap=PASS cluster_id=%s run_id=%s identities=%d config_hash=%s runtime_file=%s\n",
|
||||
opts.clusterID,
|
||||
run.ID,
|
||||
len(participants),
|
||||
snapshotConfigHash,
|
||||
outputPath,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyLocalDatabase(ctx context.Context, database *store.Store, clusterID string) error {
|
||||
var marker string
|
||||
err := database.Pool().QueryRow(ctx, `
|
||||
SELECT COALESCE(value->>'clusterId', '')
|
||||
FROM system_settings
|
||||
WHERE setting_key = $1`, acceptancesnapshot.LocalClusterSettingKey).Scan(&marker)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read local acceptance cluster marker: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func importedSnapshot(ctx context.Context, database *store.Store) (string, string, error) {
|
||||
var configHash, snapshotSHA string
|
||||
err := database.Pool().QueryRow(ctx, `
|
||||
SELECT COALESCE(value->>'configHash', ''),
|
||||
COALESCE(value->>'snapshotSha256', '')
|
||||
FROM system_settings
|
||||
WHERE setting_key = 'acceptance_snapshot'`).Scan(&configHash, &snapshotSHA)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read imported acceptance snapshot: %w", err)
|
||||
}
|
||||
if len(configHash) != 64 || len(snapshotSHA) != 64 {
|
||||
return "", "", errors.New("imported acceptance snapshot hashes are invalid")
|
||||
}
|
||||
return configHash, snapshotSHA, nil
|
||||
}
|
||||
|
||||
func ensureAcceptanceIdentityDomain(ctx context.Context, database *store.Store) (string, string, error) {
|
||||
var groupID string
|
||||
err := database.Pool().QueryRow(ctx, `
|
||||
INSERT INTO gateway_user_groups (
|
||||
group_key, name, description, source, priority,
|
||||
recharge_discount_policy, billing_discount_policy,
|
||||
rate_limit_policy, quota_policy, metadata, status
|
||||
)
|
||||
VALUES (
|
||||
'local-acceptance', 'Local Acceptance', 'Isolated local homologous acceptance identities',
|
||||
'gateway', 1, '{"discountFactor":1}'::jsonb, '{"discountFactor":1}'::jsonb,
|
||||
'{"rules":[]}'::jsonb, '{}'::jsonb,
|
||||
'{"purpose":"local_acceptance","isolated":true}'::jsonb, 'active'
|
||||
)
|
||||
ON CONFLICT (group_key) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
priority = EXCLUDED.priority,
|
||||
rate_limit_policy = EXCLUDED.rate_limit_policy,
|
||||
quota_policy = EXCLUDED.quota_policy,
|
||||
metadata = EXCLUDED.metadata,
|
||||
status = 'active',
|
||||
updated_at = now()
|
||||
RETURNING id::text`).Scan(&groupID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var tenantID string
|
||||
err = database.Pool().QueryRow(ctx, `
|
||||
INSERT INTO gateway_tenants (
|
||||
tenant_key, source, external_tenant_id, name, default_user_group_id,
|
||||
billing_profile, rate_limit_policy, auth_policy, metadata, status
|
||||
)
|
||||
VALUES (
|
||||
'local-acceptance', 'gateway', 'local-acceptance', 'Local Acceptance',
|
||||
$1::uuid, '{}'::jsonb, '{"rules":[]}'::jsonb, '{}'::jsonb,
|
||||
'{"purpose":"local_acceptance","isolated":true}'::jsonb, 'active'
|
||||
)
|
||||
ON CONFLICT (tenant_key) DO UPDATE
|
||||
SET default_user_group_id = EXCLUDED.default_user_group_id,
|
||||
metadata = EXCLUDED.metadata,
|
||||
status = 'active',
|
||||
updated_at = now()
|
||||
RETURNING id::text`, groupID).Scan(&tenantID)
|
||||
return groupID, tenantID, err
|
||||
}
|
||||
|
||||
func ensureAcceptanceUser(
|
||||
ctx context.Context,
|
||||
database *store.Store,
|
||||
groupID string,
|
||||
tenantID string,
|
||||
ordinal int,
|
||||
) (string, error) {
|
||||
userKey := fmt.Sprintf("local-acceptance-%03d", ordinal)
|
||||
roles := `["user"]`
|
||||
if ordinal == 0 {
|
||||
roles = `["manager"]`
|
||||
}
|
||||
var userID string
|
||||
err := database.Pool().QueryRow(ctx, `
|
||||
INSERT INTO gateway_users (
|
||||
user_key, source, external_user_id, username, display_name,
|
||||
gateway_tenant_id, tenant_id, tenant_key, default_user_group_id,
|
||||
roles, auth_profile, metadata, status
|
||||
)
|
||||
VALUES (
|
||||
$1, 'gateway', $1, $1, $2,
|
||||
$3::uuid, 'local-acceptance', 'local-acceptance', $4::uuid,
|
||||
$5::jsonb, '{}'::jsonb,
|
||||
jsonb_build_object('purpose','local_acceptance','ordinal',$6,'isolated',true),
|
||||
'active'
|
||||
)
|
||||
ON CONFLICT (user_key) DO UPDATE
|
||||
SET gateway_tenant_id = EXCLUDED.gateway_tenant_id,
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
tenant_key = EXCLUDED.tenant_key,
|
||||
default_user_group_id = EXCLUDED.default_user_group_id,
|
||||
roles = EXCLUDED.roles,
|
||||
metadata = EXCLUDED.metadata,
|
||||
status = 'active',
|
||||
deleted_at = NULL,
|
||||
updated_at = now()
|
||||
RETURNING id::text`,
|
||||
userKey,
|
||||
fmt.Sprintf("Local Acceptance %03d", ordinal),
|
||||
tenantID,
|
||||
groupID,
|
||||
roles,
|
||||
ordinal,
|
||||
).Scan(&userID)
|
||||
return userID, err
|
||||
}
|
||||
|
||||
func ensureAcceptanceAPIKey(
|
||||
ctx context.Context,
|
||||
database *store.Store,
|
||||
userID string,
|
||||
groupID string,
|
||||
tenantID string,
|
||||
ordinal int,
|
||||
) (string, string, error) {
|
||||
name := fmt.Sprintf("Local Acceptance %03d", ordinal)
|
||||
var keyID, secret string
|
||||
err := database.Pool().QueryRow(ctx, `
|
||||
SELECT id::text, COALESCE(key_secret, '')
|
||||
FROM gateway_api_keys
|
||||
WHERE gateway_user_id = $1::uuid
|
||||
AND name = $2
|
||||
AND status = 'active'
|
||||
AND deleted_at IS NULL
|
||||
AND COALESCE(key_secret, '') <> ''
|
||||
ORDER BY created_at
|
||||
LIMIT 1`, userID, name).Scan(&keyID, &secret)
|
||||
if err == nil {
|
||||
return keyID, secret, nil
|
||||
}
|
||||
if !store.IsNotFound(err) {
|
||||
return "", "", err
|
||||
}
|
||||
created, err := database.CreateAPIKey(ctx, store.CreateAPIKeyInput{
|
||||
Name: name,
|
||||
Scopes: []string{"image", "video"},
|
||||
}, &auth.User{
|
||||
ID: userID,
|
||||
Username: fmt.Sprintf("local-acceptance-%03d", ordinal),
|
||||
GatewayUserID: userID,
|
||||
GatewayTenantID: tenantID,
|
||||
TenantID: "local-acceptance",
|
||||
TenantKey: "local-acceptance",
|
||||
UserGroupID: groupID,
|
||||
Source: "gateway",
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := database.Pool().Exec(ctx, `
|
||||
UPDATE gateway_api_keys
|
||||
SET user_group_id = $2::uuid,
|
||||
rate_limit_policy = '{"rules":[]}'::jsonb,
|
||||
quota_policy = '{}'::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid`, created.APIKey.ID, groupID); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return created.APIKey.ID, created.Secret, nil
|
||||
}
|
||||
|
||||
func ensureAcceptanceAccessRules(ctx context.Context, database *store.Store, groupID string) error {
|
||||
_, err := database.Pool().Exec(ctx, `
|
||||
WITH selected AS (
|
||||
SELECT 'platform'::text AS resource_type, platform.id AS resource_id
|
||||
FROM integration_platforms platform
|
||||
WHERE COALESCE((platform.config->>'acceptanceSnapshot')::boolean, false)
|
||||
UNION
|
||||
SELECT 'platform_model', model.id
|
||||
FROM platform_models model
|
||||
JOIN integration_platforms platform ON platform.id = model.platform_id
|
||||
WHERE COALESCE((platform.config->>'acceptanceSnapshot')::boolean, false)
|
||||
UNION
|
||||
SELECT 'base_model', model.base_model_id
|
||||
FROM platform_models model
|
||||
JOIN integration_platforms platform ON platform.id = model.platform_id
|
||||
WHERE COALESCE((platform.config->>'acceptanceSnapshot')::boolean, false)
|
||||
AND model.base_model_id IS NOT NULL
|
||||
)
|
||||
INSERT INTO gateway_access_rules (
|
||||
subject_type, subject_id, resource_type, resource_id, effect,
|
||||
priority, min_permission_level, conditions, metadata, status
|
||||
)
|
||||
SELECT 'user_group', $1::uuid, selected.resource_type, selected.resource_id,
|
||||
'allow', 1, 0, '{}'::jsonb,
|
||||
'{"purpose":"local_acceptance"}'::jsonb, 'active'
|
||||
FROM selected
|
||||
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, effect)
|
||||
DO UPDATE SET status = 'active', priority = 1, updated_at = now()`, groupID)
|
||||
return err
|
||||
}
|
||||
|
||||
func selectedModels(ctx context.Context, database *store.Store) (string, string, error) {
|
||||
var gemini, video string
|
||||
err := database.Pool().QueryRow(ctx, `
|
||||
SELECT base_model.invocation_name
|
||||
FROM platform_models model
|
||||
JOIN integration_platforms platform ON platform.id = model.platform_id
|
||||
JOIN base_model_catalog base_model ON base_model.id = model.base_model_id
|
||||
WHERE platform.status = 'enabled'
|
||||
AND COALESCE((platform.config->>'acceptanceSnapshot')::boolean, false)
|
||||
AND model.enabled = true
|
||||
AND model.model_type @> '["image_edit"]'::jsonb
|
||||
ORDER BY platform.priority, model.created_at
|
||||
LIMIT 1`).Scan(&gemini)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
err = database.Pool().QueryRow(ctx, `
|
||||
SELECT base_model.invocation_name
|
||||
FROM platform_models model
|
||||
JOIN integration_platforms platform ON platform.id = model.platform_id
|
||||
JOIN base_model_catalog base_model ON base_model.id = model.base_model_id
|
||||
WHERE platform.status = 'enabled'
|
||||
AND COALESCE((platform.config->>'acceptanceSnapshot')::boolean, false)
|
||||
AND model.enabled = true
|
||||
AND model.model_type @> '["omni_video"]'::jsonb
|
||||
ORDER BY platform.priority, model.created_at
|
||||
LIMIT 1`).Scan(&video)
|
||||
return gemini, video, err
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
payload := make([]byte, 32)
|
||||
if _, err := rand.Read(payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(payload), nil
|
||||
}
|
||||
|
||||
func privateOutputPath(path string) (string, error) {
|
||||
absolute, err := filepath.Abs(strings.TrimSpace(path))
|
||||
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")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absolute), 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return absolute, nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxCallbackBodyBytes = 1 << 20
|
||||
|
||||
type collector struct {
|
||||
mu sync.Mutex
|
||||
deliveries int64
|
||||
duplicates int64
|
||||
invalid int64
|
||||
seen map[string]int
|
||||
}
|
||||
|
||||
func main() {
|
||||
address := strings.TrimSpace(os.Getenv("HTTP_ADDR"))
|
||||
if address == "" {
|
||||
address = ":8091"
|
||||
}
|
||||
state := &collector{seen: map[string]int{}}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
})
|
||||
mux.HandleFunc("GET /report", state.report)
|
||||
mux.HandleFunc("POST /callbacks", state.callback)
|
||||
server := &http.Server{
|
||||
Addr: address,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
slog.Info("acceptance callback collector started", "address", address)
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("acceptance callback collector stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *collector) callback(w http.ResponseWriter, request *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(request.Body, maxCallbackBodyBytes+1))
|
||||
if err != nil || len(body) > maxCallbackBodyBytes {
|
||||
c.recordInvalid()
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid callback body"})
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(body)))
|
||||
decoder.UseNumber()
|
||||
var payload map[string]any
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
c.recordInvalid()
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid callback JSON"})
|
||||
return
|
||||
}
|
||||
taskID := strings.TrimSpace(stringValue(payload["taskId"]))
|
||||
seq, _ := strconv.ParseInt(stringValue(payload["seq"]), 10, 64)
|
||||
idempotency := strings.TrimSpace(request.Header.Get("Idempotency-Key"))
|
||||
expected := taskID + ":" + strconv.FormatInt(seq, 10)
|
||||
if taskID == "" || seq <= 0 || idempotency != expected {
|
||||
c.recordInvalid()
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "callback identity is invalid"})
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.deliveries++
|
||||
c.seen[expected]++
|
||||
if c.seen[expected] > 1 {
|
||||
c.duplicates++
|
||||
}
|
||||
c.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (c *collector) report(w http.ResponseWriter, _ *http.Request) {
|
||||
c.mu.Lock()
|
||||
report := map[string]any{
|
||||
"schemaVersion": "acceptance-callback-report/v1",
|
||||
"deliveries": c.deliveries,
|
||||
"duplicates": c.duplicates,
|
||||
"invalid": c.invalid,
|
||||
"uniqueEvents": len(c.seen),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
}
|
||||
|
||||
func (c *collector) recordInvalid() {
|
||||
c.mu.Lock()
|
||||
c.invalid++
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
@@ -18,10 +19,13 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/acceptanceworkload"
|
||||
@@ -38,6 +42,7 @@ var reportURLPattern = regexp.MustCompile(`(?i)(?:https?|postgres(?:ql)?):\/\/[^
|
||||
type options struct {
|
||||
gateways []string
|
||||
gatewayTLSName string
|
||||
gatewayCAFile string
|
||||
emulatorURL string
|
||||
apiKeys []string
|
||||
runID string
|
||||
@@ -48,17 +53,21 @@ type options struct {
|
||||
reportPath string
|
||||
realImageURLs []string
|
||||
timeout time.Duration
|
||||
mixedDuration time.Duration
|
||||
imageRate float64
|
||||
videoRate float64
|
||||
}
|
||||
|
||||
type report struct {
|
||||
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"`
|
||||
SecretSafe bool `json:"secretSafe"`
|
||||
}
|
||||
|
||||
type phaseReport struct {
|
||||
@@ -76,6 +85,9 @@ type phaseReport struct {
|
||||
UniqueTaskIDs int `json:"uniqueTaskIds,omitempty"`
|
||||
UniqueImageCombos int `json:"uniqueImageCombinations,omitempty"`
|
||||
ForcedConversionTasks int `json:"forcedConversionTasks,omitempty"`
|
||||
Throttled int `json:"throttled,omitempty"`
|
||||
Unexpected5xx int `json:"unexpected5xx,omitempty"`
|
||||
OfferedRatePerSecond float64 `json:"offeredRatePerSecond,omitempty"`
|
||||
}
|
||||
|
||||
type phaseResult struct {
|
||||
@@ -84,16 +96,32 @@ type phaseResult struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type httpStatusError struct {
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *httpStatusError) Error() string {
|
||||
return fmt.Sprintf("HTTP %d: %s", e.Status, e.Body)
|
||||
}
|
||||
|
||||
func main() {
|
||||
opts, err := parseOptions()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "acceptance load configuration error:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opts.timeout)
|
||||
signalContext, stopSignals := signal.NotifyContext(
|
||||
context.Background(),
|
||||
os.Interrupt,
|
||||
syscall.SIGTERM,
|
||||
)
|
||||
defer stopSignals()
|
||||
ctx, cancel := context.WithTimeout(signalContext, opts.timeout)
|
||||
defer cancel()
|
||||
result := report{
|
||||
RunID: opts.runID, Profile: opts.profile, StartedAt: time.Now().UTC(), SecretSafe: true,
|
||||
SchemaVersion: "acceptance-load-report/v1",
|
||||
RunID: opts.runID, Profile: opts.profile, StartedAt: time.Now().UTC(), SecretSafe: true,
|
||||
}
|
||||
phaseResults, runErr := run(ctx, opts)
|
||||
for _, phase := range phaseResults {
|
||||
@@ -127,13 +155,20 @@ func parseOptions() (options, error) {
|
||||
var profile string
|
||||
var reportPath string
|
||||
var timeout time.Duration
|
||||
var mixedDuration time.Duration
|
||||
var imageRate float64
|
||||
var videoRate float64
|
||||
flag.StringVar(&profile, "profile", env("AI_GATEWAY_ACCEPTANCE_PROFILE", "simulated-all"), "simulated-all, Gemini profile, video profile, or real-canary")
|
||||
flag.StringVar(&reportPath, "report", env("AI_GATEWAY_ACCEPTANCE_REPORT", ""), "secret-safe JSON report path")
|
||||
flag.DurationVar(&timeout, "timeout", 45*time.Minute, "overall timeout")
|
||||
flag.DurationVar(&mixedDuration, "duration", envDuration("AI_GATEWAY_ACCEPTANCE_MIXED_DURATION", 10*time.Minute), "mixed workload duration")
|
||||
flag.Float64Var(&imageRate, "image-rate", envFloat("AI_GATEWAY_ACCEPTANCE_IMAGE_RATE", 0), "mixed image requests per second")
|
||||
flag.Float64Var(&videoRate, "video-rate", envFloat("AI_GATEWAY_ACCEPTANCE_VIDEO_RATE", 0), "mixed video requests per second")
|
||||
flag.Parse()
|
||||
opts := options{
|
||||
gateways: splitCSV(os.Getenv("AI_GATEWAY_ACCEPTANCE_GATEWAYS")),
|
||||
gatewayTLSName: strings.TrimSpace(os.Getenv("AI_GATEWAY_ACCEPTANCE_GATEWAY_TLS_SERVER_NAME")),
|
||||
gatewayCAFile: strings.TrimSpace(os.Getenv("AI_GATEWAY_ACCEPTANCE_GATEWAY_CA_FILE")),
|
||||
emulatorURL: strings.TrimRight(strings.TrimSpace(os.Getenv("AI_GATEWAY_ACCEPTANCE_EMULATOR_URL")), "/"),
|
||||
apiKeys: splitCSV(env("AI_GATEWAY_ACCEPTANCE_API_KEYS", os.Getenv("AI_GATEWAY_ACCEPTANCE_API_KEY"))),
|
||||
runID: strings.TrimSpace(os.Getenv("AI_GATEWAY_ACCEPTANCE_RUN_ID")),
|
||||
@@ -144,6 +179,9 @@ func parseOptions() (options, error) {
|
||||
reportPath: strings.TrimSpace(reportPath),
|
||||
realImageURLs: splitCSV(os.Getenv("AI_GATEWAY_ACCEPTANCE_REAL_IMAGE_URLS")),
|
||||
timeout: timeout,
|
||||
mixedDuration: mixedDuration,
|
||||
imageRate: imageRate,
|
||||
videoRate: videoRate,
|
||||
}
|
||||
if len(opts.gateways) != 2 {
|
||||
return options{}, errors.New("AI_GATEWAY_ACCEPTANCE_GATEWAYS must contain exactly two API base URLs")
|
||||
@@ -168,10 +206,16 @@ func parseOptions() (options, error) {
|
||||
return options{}, errors.New("timeout must be positive")
|
||||
}
|
||||
switch opts.profile {
|
||||
case "simulated-all", "gemini-baseline", "gemini-large", "gemini-peak", "video-throughput", "video-recovery":
|
||||
case "simulated-smoke", "simulated-all", "gemini-baseline", "gemini-large", "gemini-peak", "video-throughput", "video-recovery",
|
||||
"mixed-soak", "mixed-overload":
|
||||
if opts.emulatorURL == "" {
|
||||
return options{}, errors.New("AI_GATEWAY_ACCEPTANCE_EMULATOR_URL is required for simulated profiles")
|
||||
}
|
||||
if (opts.profile == "mixed-soak" || opts.profile == "mixed-overload") &&
|
||||
(opts.mixedDuration <= 0 || opts.imageRate < 0 || opts.videoRate < 0 ||
|
||||
opts.imageRate+opts.videoRate <= 0) {
|
||||
return options{}, errors.New("mixed profiles require a positive duration and offered rate")
|
||||
}
|
||||
case "real-canary":
|
||||
if len(opts.realImageURLs) < 3 {
|
||||
return options{}, errors.New("real-canary requires at least three public AI_GATEWAY_ACCEPTANCE_REAL_IMAGE_URLS")
|
||||
@@ -184,10 +228,15 @@ func parseOptions() (options, error) {
|
||||
|
||||
func run(ctx context.Context, opts options) ([]phaseResult, error) {
|
||||
var tlsConfig *tls.Config
|
||||
if opts.gatewayTLSName != "" {
|
||||
if opts.gatewayTLSName != "" || opts.gatewayCAFile != "" {
|
||||
rootCAs, err := acceptanceRootCAs(opts.gatewayCAFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConfig = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
ServerName: opts.gatewayTLSName,
|
||||
RootCAs: rootCAs,
|
||||
}
|
||||
}
|
||||
client := &http.Client{
|
||||
@@ -207,14 +256,20 @@ func run(ctx context.Context, opts options) ([]phaseResult, error) {
|
||||
result = runGemini(ctx, client, opts, name, profile.Requests, profile.InputBytes, profile.OutputBytes, false)
|
||||
} else {
|
||||
switch name {
|
||||
case "smoke-gemini":
|
||||
result = runGemini(ctx, client, opts, name, 4, 256<<10, 256<<10, false)
|
||||
case "smoke-video":
|
||||
result = runVideo(ctx, client, opts, name, 10, false, opts.emulatorFixtureURLs(), false, 0)
|
||||
case "video-throughput":
|
||||
result = runVideo(ctx, client, opts, name, 1200, false, opts.emulatorFixtureURLs(), false)
|
||||
result = runVideo(ctx, client, opts, name, 1200, false, opts.emulatorFixtureURLs(), false, 0)
|
||||
case "video-recovery":
|
||||
result = runVideo(ctx, client, opts, name, 96, true, opts.emulatorFixtureURLs(), false)
|
||||
result = runVideo(ctx, client, opts, name, 96, true, opts.emulatorFixtureURLs(), false, 0)
|
||||
case "mixed-soak", "mixed-overload":
|
||||
result = runMixed(ctx, client, opts, name == "mixed-overload")
|
||||
case "real-gemini-canary":
|
||||
result = runGemini(ctx, client, opts, name, 1, 256<<10, 0, true)
|
||||
case "real-video-canary":
|
||||
result = runVideo(ctx, client, opts, name, 1, false, opts.realImageURLs, true)
|
||||
result = runVideo(ctx, client, opts, name, 1, false, opts.realImageURLs, true, 0)
|
||||
default:
|
||||
return fmt.Errorf("unknown phase %q", name)
|
||||
}
|
||||
@@ -223,7 +278,9 @@ func run(ctx context.Context, opts options) ([]phaseResult, error) {
|
||||
return result.err
|
||||
}
|
||||
phases := []string{opts.profile}
|
||||
if opts.profile == "simulated-all" {
|
||||
if opts.profile == "simulated-smoke" {
|
||||
phases = []string{"smoke-gemini", "smoke-video"}
|
||||
} else if opts.profile == "simulated-all" {
|
||||
phases = []string{"gemini-baseline", "gemini-large", "gemini-peak", "video-throughput", "video-recovery"}
|
||||
} else if opts.profile == "real-canary" {
|
||||
phases = []string{"real-gemini-canary", "real-video-canary"}
|
||||
@@ -376,6 +433,7 @@ func runVideo(
|
||||
longRun bool,
|
||||
imageURLs []string,
|
||||
realUpstream bool,
|
||||
requestOffset int,
|
||||
) phaseResult {
|
||||
startedAt := time.Now()
|
||||
combinationCount := 128
|
||||
@@ -398,31 +456,32 @@ func runVideo(
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
logicalIndex := index + requestOffset
|
||||
requestStarted := time.Now()
|
||||
startedTasks[index] = requestStarted
|
||||
imageCount := acceptanceworkload.VideoImageCount(index)
|
||||
combo := append([]string(nil), combinations[index%len(combinations)]...)
|
||||
imageCount := acceptanceworkload.VideoImageCount(logicalIndex)
|
||||
combo := append([]string(nil), combinations[logicalIndex%len(combinations)]...)
|
||||
if len(combo) > imageCount {
|
||||
combo = combo[:imageCount]
|
||||
}
|
||||
if !realUpstream && index%4 == 0 {
|
||||
combo[0] = imageURLs[12+(index/4)%4]
|
||||
if !realUpstream && logicalIndex%4 == 0 {
|
||||
combo[0] = imageURLs[12+(logicalIndex/4)%4]
|
||||
}
|
||||
content := make([]any, 0, len(combo)+1)
|
||||
prompt := "多参考图生成连续运镜视频,保持人物、服装和场景一致"
|
||||
if longRun {
|
||||
prompt += " acceptance-long-recovery"
|
||||
}
|
||||
if !realUpstream && index%4 == 0 {
|
||||
if !realUpstream && logicalIndex%4 == 0 {
|
||||
prompt += " acceptance-force-conversion"
|
||||
}
|
||||
content = append(content, map[string]any{"type": "text", "text": prompt})
|
||||
for imageIndex, imageURL := range combo {
|
||||
role := "reference_image"
|
||||
if index%5 == 0 && imageIndex == 0 {
|
||||
if logicalIndex%5 == 0 && imageIndex == 0 {
|
||||
role = "first_frame"
|
||||
}
|
||||
if index%5 == 0 && len(combo) > 3 && imageIndex == len(combo)-1 {
|
||||
if logicalIndex%5 == 0 && len(combo) > 3 && imageIndex == len(combo)-1 {
|
||||
role = "last_frame"
|
||||
}
|
||||
content = append(content, map[string]any{
|
||||
@@ -431,13 +490,13 @@ func runVideo(
|
||||
})
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"model": opts.videoModel, "modelType": "omni_video", "content": content, "seed": index + 1,
|
||||
"model": opts.videoModel, "modelType": "omni_video", "content": content, "seed": logicalIndex + 1,
|
||||
"duration": 5, "ratio": "16:9", "resolution": "720p",
|
||||
})
|
||||
endpoint := opts.gateways[index%len(opts.gateways)] + "/api/v1/videos/generations"
|
||||
endpoint := opts.gateways[logicalIndex%len(opts.gateways)] + "/api/v1/videos/generations"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err == nil {
|
||||
opts.setHeaders(req, index, realUpstream)
|
||||
opts.setHeaders(req, logicalIndex, realUpstream)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Async", "true")
|
||||
var response *http.Response
|
||||
@@ -460,7 +519,7 @@ func runVideo(
|
||||
}
|
||||
mu.Lock()
|
||||
latencies[index] = time.Since(requestStarted)
|
||||
if !realUpstream && index%4 == 0 {
|
||||
if !realUpstream && logicalIndex%4 == 0 {
|
||||
forcedConversions++
|
||||
}
|
||||
if err != nil {
|
||||
@@ -492,6 +551,7 @@ func runVideo(
|
||||
continue
|
||||
}
|
||||
index, taskID := index, taskID
|
||||
logicalIndex := index + requestOffset
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -501,7 +561,7 @@ func runVideo(
|
||||
return
|
||||
}
|
||||
defer func() { <-pollSlots }()
|
||||
err := pollVideoTask(ctx, client, opts, taskID, index, realUpstream)
|
||||
err := pollVideoTask(ctx, client, opts, taskID, logicalIndex, realUpstream)
|
||||
mu.Lock()
|
||||
latencies[index] = time.Since(startedTasks[index])
|
||||
if err != nil {
|
||||
@@ -520,6 +580,9 @@ func runVideo(
|
||||
for _, combo := range combinations {
|
||||
comboSet[strings.Join(combo, "\x00")] = struct{}{}
|
||||
}
|
||||
if firstErr == nil && !realUpstream && len(comboSet) != 128 {
|
||||
firstErr = fmt.Errorf("video image combinations=%d, want 128", len(comboSet))
|
||||
}
|
||||
elapsed := time.Since(startedAt)
|
||||
result := phaseReport{
|
||||
Name: name, Requests: requestCount, Completed: completed, Failed: failures,
|
||||
@@ -533,6 +596,145 @@ func runVideo(
|
||||
return phaseResult{report: result, latencies: latencies, err: firstErr}
|
||||
}
|
||||
|
||||
func runMixed(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
opts options,
|
||||
expectThrottling bool,
|
||||
) phaseResult {
|
||||
runCtx, stopRun := context.WithCancel(ctx)
|
||||
defer stopRun()
|
||||
startedAt := time.Now()
|
||||
totalRate := opts.imageRate + opts.videoRate
|
||||
interval := time.Duration(float64(time.Second) / totalRate)
|
||||
if interval < time.Millisecond {
|
||||
interval = time.Millisecond
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
stop := time.NewTimer(opts.mixedDuration)
|
||||
defer stop.Stop()
|
||||
slots := make(chan struct{}, 4096)
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
latencies := make([]time.Duration, 0, int(totalRate*opts.mixedDuration.Seconds()))
|
||||
requests := 0
|
||||
completed := 0
|
||||
failed := 0
|
||||
throttled := 0
|
||||
unexpected5xx := 0
|
||||
var firstErr error
|
||||
imageAccumulator := float64(0)
|
||||
generating := true
|
||||
for generating {
|
||||
select {
|
||||
case <-runCtx.Done():
|
||||
generating = false
|
||||
if ctx.Err() != nil {
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = ctx.Err()
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
case <-stop.C:
|
||||
generating = false
|
||||
case <-ticker.C:
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
case <-runCtx.Done():
|
||||
generating = false
|
||||
continue
|
||||
}
|
||||
requestIndex := requests
|
||||
requests++
|
||||
imageAccumulator += opts.imageRate
|
||||
isImage := imageAccumulator >= totalRate
|
||||
if isImage {
|
||||
imageAccumulator -= totalRate
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-slots }()
|
||||
var result phaseResult
|
||||
if isImage {
|
||||
result = runGemini(runCtx, client, opts, "mixed-image", 1, 256<<10, 256<<10, false)
|
||||
} else {
|
||||
result = runVideo(
|
||||
runCtx,
|
||||
client,
|
||||
opts,
|
||||
"mixed-video",
|
||||
1,
|
||||
false,
|
||||
opts.emulatorFixtureURLs(),
|
||||
false,
|
||||
requestIndex,
|
||||
)
|
||||
}
|
||||
latency := time.Since(startedAt)
|
||||
if len(result.latencies) > 0 {
|
||||
latency = result.latencies[0]
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
latencies = append(latencies, latency)
|
||||
if result.err == nil {
|
||||
completed++
|
||||
return
|
||||
}
|
||||
var statusErr *httpStatusError
|
||||
if errors.As(result.err, &statusErr) && statusErr.Status == http.StatusTooManyRequests {
|
||||
throttled++
|
||||
if !expectThrottling && firstErr == nil {
|
||||
firstErr = result.err
|
||||
failed++
|
||||
stopRun()
|
||||
}
|
||||
return
|
||||
}
|
||||
if errors.As(result.err, &statusErr) && statusErr.Status >= 500 {
|
||||
unexpected5xx++
|
||||
}
|
||||
failed++
|
||||
if firstErr == nil {
|
||||
firstErr = result.err
|
||||
stopRun()
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
if firstErr == nil && expectThrottling && throttled == 0 {
|
||||
firstErr = errors.New("mixed overload did not receive any 429 response")
|
||||
}
|
||||
if firstErr == nil && completed+throttled != requests {
|
||||
firstErr = fmt.Errorf(
|
||||
"mixed workload accounted for %d of %d requests",
|
||||
completed+throttled,
|
||||
requests,
|
||||
)
|
||||
}
|
||||
if firstErr == nil && unexpected5xx > 0 {
|
||||
firstErr = fmt.Errorf("mixed workload received %d unexpected 5xx responses", unexpected5xx)
|
||||
}
|
||||
return phaseResult{
|
||||
report: phaseReport{
|
||||
Name: map[bool]string{false: "mixed-soak", true: "mixed-overload"}[expectThrottling],
|
||||
Requests: requests,
|
||||
Completed: completed,
|
||||
Failed: failed,
|
||||
DurationMS: time.Since(startedAt).Milliseconds(),
|
||||
Throttled: throttled,
|
||||
Unexpected5xx: unexpected5xx,
|
||||
OfferedRatePerSecond: totalRate,
|
||||
},
|
||||
latencies: latencies,
|
||||
err: firstErr,
|
||||
}
|
||||
}
|
||||
|
||||
func pollVideoTask(ctx context.Context, client *http.Client, opts options, taskID string, index int, realUpstream bool) error {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
@@ -558,7 +760,7 @@ func pollVideoTask(ctx context.Context, client *http.Client, opts options, taskI
|
||||
if mediaURL == "" {
|
||||
return fmt.Errorf("video task %s succeeded without a media URL", taskID)
|
||||
}
|
||||
return validateVideoAsset(ctx, http.DefaultClient, mediaURL)
|
||||
return validateVideoAsset(ctx, client, mediaURL)
|
||||
case "failed", "cancelled", "canceled":
|
||||
return fmt.Errorf("video task %s finished with status %s", taskID, status)
|
||||
}
|
||||
@@ -782,7 +984,7 @@ func scanUntil(reader *bufio.Reader, target []byte, limit int64) error {
|
||||
func responseStatusError(response *http.Response) error {
|
||||
defer response.Body.Close()
|
||||
payload, _ := io.ReadAll(io.LimitReader(response.Body, 512))
|
||||
return fmt.Errorf("HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
||||
return &httpStatusError{Status: response.StatusCode, Body: strings.TrimSpace(string(payload))}
|
||||
}
|
||||
|
||||
func percentileMilliseconds(values []time.Duration, quantile float64) float64 {
|
||||
@@ -837,6 +1039,31 @@ func redactError(message string, opts options) string {
|
||||
return reportURLPattern.ReplaceAllString(message, "[REDACTED_URL]")
|
||||
}
|
||||
|
||||
func acceptanceRootCAs(path string) (*x509.CertPool, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Gateway CA file: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, errors.New("Gateway CA file must be a regular non-symlink file")
|
||||
}
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Gateway CA file: %w", err)
|
||||
}
|
||||
roots, err := x509.SystemCertPool()
|
||||
if err != nil || roots == nil {
|
||||
roots = x509.NewCertPool()
|
||||
}
|
||||
if !roots.AppendCertsFromPEM(payload) {
|
||||
return nil, errors.New("Gateway CA file does not contain a PEM certificate")
|
||||
}
|
||||
return roots, nil
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
items := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(items))
|
||||
@@ -854,3 +1081,27 @@ func env(name string, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envDuration(name string, fallback time.Duration) time.Duration {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envFloat(name string, fallback float64) float64 {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
@@ -190,3 +192,18 @@ func TestAcceptanceGatewayTLSNameSetsHostHeader(t *testing.T) {
|
||||
t.Fatalf("Host=%q", request.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptanceRootCAsRejectsSymlink(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "ca.pem")
|
||||
if err := os.WriteFile(target, []byte("not a certificate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(root, "ca-link.pem")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := acceptanceRootCAs(link); err == nil {
|
||||
t.Fatal("symlink CA file was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/acceptancesnapshot"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(64)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
var err error
|
||||
switch os.Args[1] {
|
||||
case "export":
|
||||
err = runExport(ctx, os.Args[2:])
|
||||
case "validate":
|
||||
err = runValidate(os.Args[2:])
|
||||
case "import":
|
||||
err = runImport(ctx, os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
os.Exit(64)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "acceptance snapshot:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, `Usage:
|
||||
easyai-ai-gateway-acceptance-snapshot export --release-sha <SHA> --output <file>
|
||||
easyai-ai-gateway-acceptance-snapshot validate --input <file>
|
||||
easyai-ai-gateway-acceptance-snapshot import --input <file> --local-cluster-id <id>
|
||||
|
||||
export is read-only. import refuses to write unless system_settings contains a
|
||||
matching acceptance_local_cluster_id marker.`)
|
||||
}
|
||||
|
||||
func runExport(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("export", flag.ContinueOnError)
|
||||
releaseSHA := flags.String("release-sha", "", "full source release SHA")
|
||||
output := flags.String("output", "", "secret-safe snapshot output path")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("unexpected export arguments")
|
||||
}
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
return errors.New("AI_GATEWAY_DATABASE_URL is required")
|
||||
}
|
||||
outputPath, err := safeOutputPath(*output)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pool.Close()
|
||||
snapshot, err := acceptancesnapshot.Export(ctx, pool, acceptancesnapshot.ExportOptions{
|
||||
ReleaseSHA: *releaseSHA,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := acceptancesnapshot.Encode(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(outputPath, payload, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(
|
||||
"acceptance_snapshot_export=PASS schema=%s config_hash=%s snapshot_sha256=%s\n",
|
||||
snapshot.SchemaVersion,
|
||||
snapshot.Source.ConfigHash,
|
||||
snapshot.SnapshotSHA256,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runValidate(args []string) error {
|
||||
flags := flag.NewFlagSet("validate", flag.ContinueOnError)
|
||||
input := flags.String("input", "", "snapshot input path")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("unexpected validate arguments")
|
||||
}
|
||||
snapshot, err := readSnapshot(*input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(
|
||||
"acceptance_snapshot_validate=PASS schema=%s release=%s config_hash=%s snapshot_sha256=%s\n",
|
||||
snapshot.SchemaVersion,
|
||||
snapshot.Source.ReleaseSHA,
|
||||
snapshot.Source.ConfigHash,
|
||||
snapshot.SnapshotSHA256,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runImport(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("import", flag.ContinueOnError)
|
||||
input := flags.String("input", "", "snapshot input path")
|
||||
clusterID := flags.String("local-cluster-id", "", "required local cluster marker")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("unexpected import arguments")
|
||||
}
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
return errors.New("AI_GATEWAY_DATABASE_URL is required")
|
||||
}
|
||||
snapshot, err := readSnapshot(*input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := acceptancesnapshot.Import(ctx, pool, snapshot, *clusterID); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(
|
||||
"acceptance_snapshot_import=PASS cluster_id=%s config_hash=%s snapshot_sha256=%s\n",
|
||||
strings.TrimSpace(*clusterID),
|
||||
snapshot.Source.ConfigHash,
|
||||
snapshot.SnapshotSHA256,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSnapshot(path string) (acceptancesnapshot.Snapshot, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return acceptancesnapshot.Snapshot{}, errors.New("snapshot path is required")
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return acceptancesnapshot.Snapshot{}, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return acceptancesnapshot.Snapshot{}, errors.New("snapshot must be a regular non-symlink file")
|
||||
}
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return acceptancesnapshot.Snapshot{}, err
|
||||
}
|
||||
return acceptancesnapshot.Decode(payload)
|
||||
}
|
||||
|
||||
func safeOutputPath(path string) (string, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "", errors.New("output path is required")
|
||||
}
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parent := filepath.Dir(absolute)
|
||||
if err := os.MkdirAll(parent, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info, err := os.Lstat(absolute); err == nil {
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", errors.New("output must be a regular non-symlink file")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
return absolute, nil
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/capacitycontroller"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/httpapi"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
@@ -37,8 +38,15 @@ func main() {
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
executionMaxConns := cfg.DatabaseMaxConns
|
||||
if cfg.DatabaseCriticalMaxConns > 0 {
|
||||
executionMaxConns -= cfg.DatabaseCriticalMaxConns
|
||||
}
|
||||
if cfg.DatabaseRiverMaxConns > 0 {
|
||||
executionMaxConns -= cfg.DatabaseRiverMaxConns
|
||||
}
|
||||
db, err := store.ConnectWithPoolOptions(ctx, cfg.DatabaseURL, store.PostgresPoolOptions{
|
||||
MaxConns: cfg.DatabaseMaxConns,
|
||||
MaxConns: executionMaxConns,
|
||||
MinIdleConns: cfg.DatabaseMinIdleConns,
|
||||
MaxConnIdleTime: time.Duration(cfg.DatabaseMaxConnIdleSeconds) * time.Second,
|
||||
IdleInTransactionTimeout: time.Duration(cfg.DatabaseIdleInTransactionTimeoutSeconds) * time.Second,
|
||||
@@ -50,26 +58,78 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
coordinationDB := db
|
||||
if cfg.DatabaseCriticalMaxConns > 0 {
|
||||
coordinationDB, err = store.ConnectWithPoolOptions(ctx, cfg.DatabaseURL, store.PostgresPoolOptions{
|
||||
MaxConns: cfg.DatabaseCriticalMaxConns,
|
||||
MinIdleConns: min(cfg.DatabaseCriticalMaxConns, 1),
|
||||
MaxConnIdleTime: time.Duration(cfg.DatabaseMaxConnIdleSeconds) * time.Second,
|
||||
IdleInTransactionTimeout: time.Duration(cfg.DatabaseIdleInTransactionTimeoutSeconds) * time.Second,
|
||||
LockTimeout: time.Duration(cfg.DatabaseLockTimeoutSeconds) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
stop()
|
||||
logger.Error("connect critical postgres pool failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer coordinationDB.Close()
|
||||
}
|
||||
riverDB := db
|
||||
if cfg.DatabaseRiverMaxConns > 0 {
|
||||
riverDB, err = store.ConnectWithPoolOptions(ctx, cfg.DatabaseURL, store.PostgresPoolOptions{
|
||||
MaxConns: cfg.DatabaseRiverMaxConns,
|
||||
MinIdleConns: min(cfg.DatabaseRiverMaxConns, 1),
|
||||
MaxConnIdleTime: time.Duration(cfg.DatabaseMaxConnIdleSeconds) * time.Second,
|
||||
IdleInTransactionTimeout: time.Duration(cfg.DatabaseIdleInTransactionTimeoutSeconds) * time.Second,
|
||||
LockTimeout: time.Duration(cfg.DatabaseLockTimeoutSeconds) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
stop()
|
||||
logger.Error("connect River postgres pool failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer riverDB.Close()
|
||||
}
|
||||
// Cancel background LISTEN and worker goroutines before closing the pool.
|
||||
// This also prevents a startup panic from waiting forever in pgxpool.Close.
|
||||
defer stop()
|
||||
if recovery, err := db.RecoverInterruptedRuntimeState(ctx); err != nil {
|
||||
logger.Error("recover interrupted runtime state failed", "error", err)
|
||||
os.Exit(1)
|
||||
} else if recovery.ReleasedConcurrencyLeases > 0 || recovery.ReleasedRateReservations > 0 || recovery.FailedAttempts > 0 || recovery.FailedTasks > 0 || recovery.RequeuedAsyncTasks > 0 || recovery.CleanedTaskAdmissions > 0 {
|
||||
logger.Warn("interrupted runtime state recovered",
|
||||
"releasedConcurrencyLeases", recovery.ReleasedConcurrencyLeases,
|
||||
"releasedRateReservations", recovery.ReleasedRateReservations,
|
||||
"failedAttempts", recovery.FailedAttempts,
|
||||
"failedTasks", recovery.FailedTasks,
|
||||
"requeuedAsyncTasks", recovery.RequeuedAsyncTasks,
|
||||
"cleanedTaskAdmissions", recovery.CleanedTaskAdmissions,
|
||||
)
|
||||
if cfg.RunsBackgroundWorkers() {
|
||||
if recovery, recoveryErr := coordinationDB.RecoverInterruptedRuntimeState(ctx); recoveryErr != nil {
|
||||
logger.Error("recover interrupted runtime state failed", "error", recoveryErr)
|
||||
os.Exit(1)
|
||||
} else if recovery.ReleasedConcurrencyLeases > 0 || recovery.ReleasedRateReservations > 0 || recovery.FailedAttempts > 0 || recovery.FailedTasks > 0 || recovery.RequeuedAsyncTasks > 0 || recovery.CleanedTaskAdmissions > 0 {
|
||||
logger.Warn("interrupted runtime state recovered",
|
||||
"releasedConcurrencyLeases", recovery.ReleasedConcurrencyLeases,
|
||||
"releasedRateReservations", recovery.ReleasedRateReservations,
|
||||
"failedAttempts", recovery.FailedAttempts,
|
||||
"failedTasks", recovery.FailedTasks,
|
||||
"requeuedAsyncTasks", recovery.RequeuedAsyncTasks,
|
||||
"cleanedTaskAdmissions", recovery.CleanedTaskAdmissions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var handler http.Handler
|
||||
if cfg.RunsCapacityController() {
|
||||
kubernetes, kubernetesErr := capacitycontroller.NewKubernetesClient(capacitycontroller.KubernetesConfig{
|
||||
Namespace: cfg.CapacityControllerNamespace,
|
||||
APIServer: cfg.CapacityControllerAPIServer,
|
||||
TokenFile: cfg.CapacityControllerTokenFile,
|
||||
CAFile: cfg.CapacityControllerCAFile,
|
||||
})
|
||||
if kubernetesErr != nil {
|
||||
logger.Error("initialize capacity controller Kubernetes client failed", "error", kubernetesErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
controller := capacitycontroller.New(cfg, coordinationDB, kubernetes, logger)
|
||||
go controller.Run(ctx)
|
||||
handler = controller.Handler()
|
||||
} else {
|
||||
handler = httpapi.NewServerWithStores(ctx, cfg, db, coordinationDB, riverDB, logger)
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: httpapi.NewServerWithContext(ctx, cfg, db, logger),
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user