feat(acceptance): 建立同构验收与弹性容量体系

实现本地三节点 K3s 同构环境、脱敏生产快照、Gemini 图片和多参考图视频协议模拟、统一验收报告及故障注入。\n\n新增 Worker 容量控制器、资源与连接预算、任务恢复保护,并将生产验收拆分为 validation 执行和人工 CAS 放量。\n\n验证包括 Go 全量测试、PostgreSQL HTTP 集成测试、go vet、OpenAPI、ShellCheck、前端检查、迁移及发布脚本测试。
This commit is contained in:
2026-07-31 18:02:24 +08:00
parent 93d36d0e55
commit e05922b0f4
94 changed files with 12379 additions and 826 deletions
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
AI_GATEWAY_ACCEPTANCE_SNAPSHOT_DATABASE_URL='postgresql://...' \
scripts/acceptance/export-production-snapshot.sh \
--release-sha <full-sha> --output <snapshot.json>
This command is database read-only. The supplied role should have SELECT access
only. It never prints the database URL or candidate credentials.
EOF
}
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
repository_root=$(cd "$script_dir/../.." && pwd)
[[ ${1:-} == --release-sha && ${3:-} == --output && $# -eq 4 ]] || {
usage >&2
exit 64
}
release_sha=$2
output=$4
database_url=${AI_GATEWAY_ACCEPTANCE_SNAPSHOT_DATABASE_URL:-}
[[ $release_sha =~ ^[0-9a-f]{40}$ ]] || {
echo 'release SHA must be a full lowercase Git SHA' >&2
exit 64
}
[[ -n $database_url ]] || {
echo 'AI_GATEWAY_ACCEPTANCE_SNAPSHOT_DATABASE_URL is required' >&2
exit 64
}
umask 077
(
cd "$repository_root/apps/api"
AI_GATEWAY_DATABASE_URL="$database_url" \
go run ./cmd/acceptance-snapshot export \
--release-sha "$release_sha" \
--output "$output"
)
chmod 0600 "$output"
+556
View File
@@ -0,0 +1,556 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
repository_root=$(cd "$script_dir/../.." && pwd)
manifest_root="$repository_root/deploy/kubernetes/local-acceptance"
lock_file="$manifest_root/dependencies.lock"
private_root="$repository_root/.local-secrets/acceptance"
tools_root="$private_root/tools"
state_root="$private_root/state"
media_root="$private_root/media"
cluster_name=easyai-acceptance-local
context="k3d-$cluster_name"
namespace=easyai
usage() {
cat <<'EOF'
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 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.
EOF
}
load_lock() {
[[ -f $lock_file && ! -L $lock_file ]] || {
echo "missing dependency lock: $lock_file" >&2
exit 1
}
# shellcheck source=/dev/null
source "$lock_file"
: "${K3D_VERSION:?}" "${K3S_IMAGE:?}" "${CNPG_MANIFEST_URL:?}" "${CNPG_MANIFEST_SHA256:?}"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || {
echo "$1 is required" >&2
exit 1
}
}
private_file() {
local path=$1
[[ -f $path && ! -L $path ]] || return 1
local mode
if [[ $(uname -s) == Darwin ]]; then
mode=$(stat -f '%Lp' "$path")
else
mode=$(stat -c '%a' "$path")
fi
[[ $mode == 600 ]]
}
install_tools() {
load_lock
require_command curl
require_command shasum
install -d -m 0700 "$tools_root"
local os arch checksum url temporary
os=$(uname -s | tr '[:upper:]' '[:lower:]')
arch=$(uname -m)
[[ $os == darwin ]] || {
echo 'the pinned local acceptance installer currently supports macOS only' >&2
exit 1
}
case $arch in
arm64)
arch=arm64
checksum=$K3D_DARWIN_ARM64_SHA256
;;
x86_64)
arch=amd64
checksum=$K3D_DARWIN_AMD64_SHA256
;;
*)
echo "unsupported host architecture: $arch" >&2
exit 1
;;
esac
url="https://github.com/k3d-io/k3d/releases/download/$K3D_VERSION/k3d-darwin-$arch"
temporary="$tools_root/k3d.download"
curl -fsSL "$url" -o "$temporary"
[[ $(shasum -a 256 "$temporary" | awk '{print $1}') == "$checksum" ]] || {
echo 'k3d checksum mismatch' >&2
exit 1
}
chmod 0555 "$temporary"
mv "$temporary" "$tools_root/k3d"
echo "local_acceptance_tools=PASS k3d=$K3D_VERSION path=$tools_root/k3d"
}
k3d_binary() {
if [[ -x $tools_root/k3d ]]; then
printf '%s\n' "$tools_root/k3d"
return
fi
command -v k3d
}
docker_memory_bytes() {
docker info --format '{{.MemTotal}}'
}
preflight() {
load_lock
require_command docker
require_command kubectl
require_command jq
require_command openssl
require_command sed
require_command shasum
require_command go
require_command node
require_command curl
local k3d memory_bytes architecture
k3d=$(k3d_binary) || {
echo 'k3d is missing; run install-tools' >&2
exit 1
}
"$k3d" version | grep -Fq "$K3D_VERSION" || {
echo "k3d must be pinned to $K3D_VERSION" >&2
exit 1
}
docker info >/dev/null
memory_bytes=$(docker_memory_bytes)
[[ $memory_bytes =~ ^[0-9]+$ && $memory_bytes -ge 25769803776 ]] || {
memory_gib=$(awk -v bytes="${memory_bytes:-0}" 'BEGIN {printf "%.1f", bytes/1024/1024/1024}')
echo "Docker Desktop memory is ${memory_gib} GiB; local acceptance requires at least 24 GiB" >&2
exit 1
}
architecture=$(docker info --format '{{.Architecture}}')
[[ $architecture == aarch64 || $architecture == arm64 || $architecture == x86_64 ]] || {
echo "unsupported Docker architecture: $architecture" >&2
exit 1
}
docker buildx version >/dev/null
echo "local_acceptance_preflight=PASS docker_memory_bytes=$memory_bytes architecture=$architecture k3d=$K3D_VERSION"
}
ensure_private_material() {
install -d -m 0700 "$private_root" "$state_root" "$media_root"
local env_file="$state_root/local.env"
if [[ ! -e $env_file ]]; then
umask 077
{
printf 'LOCAL_CLUSTER_ID=%s\n' "easyai-local-$(openssl rand -hex 12)"
printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 32)"
printf 'CONFIG_JWT_SECRET=%s\n' "$(openssl rand -hex 32)"
printf 'SERVER_MAIN_INTERNAL_TOKEN=%s\n' "$(openssl rand -hex 32)"
printf 'SERVER_MAIN_INTERNAL_KEY=local-acceptance\n'
printf 'SERVER_MAIN_INTERNAL_SECRET=%s\n' "$(openssl rand -hex 32)"
} >"$env_file"
chmod 0600 "$env_file"
fi
private_file "$env_file" || {
echo "local acceptance state must be a 0600 regular file: $env_file" >&2
exit 1
}
}
generate_tls() {
local ca_key="$state_root/ca.key" ca_crt="$state_root/ca.crt"
local tls_key="$state_root/tls.key" tls_csr="$state_root/tls.csr"
local tls_crt="$state_root/tls.crt" extensions="$state_root/tls.ext"
if [[ ! -e $ca_key ]]; then
umask 077
openssl genrsa -out "$ca_key" 3072 >/dev/null 2>&1
openssl req -x509 -new -key "$ca_key" -sha256 -days 30 \
-subj '/CN=EasyAI local acceptance CA' -out "$ca_crt" >/dev/null 2>&1
openssl genrsa -out "$tls_key" 2048 >/dev/null 2>&1
openssl req -new -key "$tls_key" -subj '/CN=gateway.easyai.local' \
-out "$tls_csr" >/dev/null 2>&1
printf 'subjectAltName=DNS:gateway.easyai.local\nextendedKeyUsage=serverAuth\n' >"$extensions"
openssl x509 -req -in "$tls_csr" -CA "$ca_crt" -CAkey "$ca_key" \
-CAcreateserial -out "$tls_crt" -days 30 -sha256 -extfile "$extensions" \
>/dev/null 2>&1
chmod 0600 "$ca_key" "$ca_crt" "$tls_key" "$tls_csr" "$tls_crt" "$extensions"
fi
private_file "$ca_crt" && private_file "$tls_key" && private_file "$tls_crt"
}
render_k3d_config() {
local output=$1
[[ $media_root != *'|'* ]]
sed "s|EASYAI_ACCEPTANCE_MEDIA_DIR|$media_root|g" "$manifest_root/k3d.yaml" >"$output"
}
wait_rollout() {
kubectl --context "$context" -n "$namespace" rollout status "$1" --timeout="${2:-10m}"
}
apply_runtime_secrets() {
# shellcheck source=/dev/null
source "$state_root/local.env"
local database_ningbo database_hongkong database_direct
database_ningbo="postgresql://easyai:${POSTGRES_PASSWORD}@easyai-postgres-proxy-ningbo.easyai.svc.cluster.local:5432/easyai_ai_gateway?sslmode=require"
database_hongkong="postgresql://easyai:${POSTGRES_PASSWORD}@easyai-postgres-proxy-hongkong.easyai.svc.cluster.local:5432/easyai_ai_gateway?sslmode=require"
database_direct="postgresql://easyai:${POSTGRES_PASSWORD}@easyai-postgres-rw.easyai.svc.cluster.local:5432/easyai_ai_gateway?sslmode=require"
kubectl --context "$context" create namespace "$namespace" --dry-run=client -o yaml |
kubectl --context "$context" apply -f - >/dev/null
kubectl --context "$context" -n "$namespace" create secret generic easyai-postgres-app \
--type=kubernetes.io/basic-auth \
--from-literal=username=easyai --from-literal=password="$POSTGRES_PASSWORD" \
--dry-run=client -o yaml | kubectl --context "$context" apply -f - >/dev/null
kubectl --context "$context" -n "$namespace" create secret generic easyai-ai-gateway-runtime \
--from-literal=AI_GATEWAY_DATABASE_URL="$database_direct" \
--from-literal=CONFIG_JWT_SECRET="$CONFIG_JWT_SECRET" \
--from-literal=SERVER_MAIN_INTERNAL_TOKEN="$SERVER_MAIN_INTERNAL_TOKEN" \
--from-literal=SERVER_MAIN_INTERNAL_KEY="$SERVER_MAIN_INTERNAL_KEY" \
--from-literal=SERVER_MAIN_INTERNAL_SECRET="$SERVER_MAIN_INTERNAL_SECRET" \
--dry-run=client -o yaml | kubectl --context "$context" apply -f - >/dev/null
kubectl --context "$context" -n "$namespace" create secret generic easyai-database-ningbo \
--from-literal=AI_GATEWAY_DATABASE_URL="$database_ningbo" \
--dry-run=client -o yaml | kubectl --context "$context" apply -f - >/dev/null
kubectl --context "$context" -n "$namespace" create secret generic easyai-database-hongkong \
--from-literal=AI_GATEWAY_DATABASE_URL="$database_hongkong" \
--dry-run=client -o yaml | kubectl --context "$context" apply -f - >/dev/null
kubectl --context "$context" -n "$namespace" create secret generic easyai-oss-backup \
--from-literal=ACCESS_KEY_ID=local-disabled \
--from-literal=ACCESS_SECRET_KEY=local-disabled \
--dry-run=client -o yaml | kubectl --context "$context" apply -f - >/dev/null
kubectl --context "$context" -n "$namespace" create secret tls easyai-acceptance-tls \
--cert="$state_root/tls.crt" --key="$state_root/tls.key" \
--dry-run=client -o yaml | kubectl --context "$context" apply -f - >/dev/null
}
run_migrations() {
local api_image=$1
kubectl --context "$context" -n "$namespace" delete job easyai-local-migrate \
--ignore-not-found --wait=true >/dev/null
cat <<EOF | kubectl --context "$context" apply -f - >/dev/null
apiVersion: batch/v1
kind: Job
metadata:
name: easyai-local-migrate
namespace: easyai
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: $api_image
command: ["/bin/sh", "-ec", "cd /app && exec /app/easyai-ai-gateway-migrate"]
envFrom:
- secretRef:
name: easyai-ai-gateway-runtime
EOF
kubectl --context "$context" -n "$namespace" wait \
--for=condition=complete job/easyai-local-migrate --timeout=10m >/dev/null
}
mark_local_database() {
# shellcheck source=/dev/null
source "$state_root/local.env"
local primary
primary=$(kubectl --context "$context" -n "$namespace" get cluster easyai-postgres \
-o 'jsonpath={.status.currentPrimary}')
kubectl --context "$context" -n "$namespace" exec "$primary" -c postgres -- \
psql -X -v ON_ERROR_STOP=1 -U postgres -d easyai_ai_gateway \
-v cluster_id="$LOCAL_CLUSTER_ID" -c \
"INSERT INTO system_settings(setting_key,value)
VALUES('acceptance_local_cluster_id',jsonb_build_object('clusterId', :'cluster_id'))
ON CONFLICT(setting_key) DO UPDATE SET value=excluded.value,updated_at=now();" \
>/dev/null
}
import_snapshot() {
local api_image=$1 snapshot=$2
# shellcheck source=/dev/null
source "$state_root/local.env"
kubectl --context "$context" -n "$namespace" create configmap easyai-acceptance-snapshot \
--from-file=snapshot.json="$snapshot" --dry-run=client -o yaml |
kubectl --context "$context" apply -f - >/dev/null
kubectl --context "$context" -n "$namespace" delete job easyai-local-snapshot-import \
--ignore-not-found --wait=true >/dev/null
cat <<EOF | kubectl --context "$context" apply -f - >/dev/null
apiVersion: batch/v1
kind: Job
metadata:
name: easyai-local-snapshot-import
namespace: easyai
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: import
image: $api_image
command:
- /app/easyai-ai-gateway-acceptance-snapshot
- import
- --input
- /snapshot/snapshot.json
- --local-cluster-id
- $LOCAL_CLUSTER_ID
envFrom:
- secretRef:
name: easyai-ai-gateway-runtime
volumeMounts:
- name: snapshot
mountPath: /snapshot
readOnly: true
volumes:
- name: snapshot
configMap:
name: easyai-acceptance-snapshot
EOF
kubectl --context "$context" -n "$namespace" wait \
--for=condition=complete job/easyai-local-snapshot-import --timeout=5m >/dev/null
}
render_and_apply_application() {
local api_image=$1 web_image=$2 rendered=$state_root/application.rendered.yaml
sed \
-e "s|image: easyai-api|image: $api_image|g" \
-e "s|image: easyai-web|image: $web_image|g" \
-e 's/nodePort: 31088/nodePort: 32088/g' \
-e 's/nodePort: 31089/nodePort: 32089/g' \
"$repository_root/deploy/kubernetes/production/application.yaml" >"$rendered"
chmod 0600 "$rendered"
kubectl --context "$context" -n "$namespace" apply \
-f "$repository_root/deploy/kubernetes/production/service-account-rbac.yaml" >/dev/null
kubectl --context "$context" -n "$namespace" apply -f "$manifest_root/local-config.yaml" >/dev/null
kubectl --context "$context" -n "$namespace" apply -f "$rendered" >/dev/null
for workload in easyai-api-ningbo easyai-worker-ningbo; do
kubectl --context "$context" -n "$namespace" set env deployment/"$workload" \
--from=secret/easyai-database-ningbo >/dev/null
done
for workload in easyai-api-hongkong easyai-worker-hongkong; do
kubectl --context "$context" -n "$namespace" set env deployment/"$workload" \
--from=secret/easyai-database-hongkong >/dev/null
done
for workload in easyai-api-ningbo easyai-api-hongkong easyai-worker-ningbo easyai-worker-hongkong; do
kubectl --context "$context" -n "$namespace" patch deployment "$workload" --type=json \
-p='[{"op":"replace","path":"/spec/template/spec/volumes/0","value":{"name":"application-data","hostPath":{"path":"/var/lib/easyai-acceptance/media","type":"DirectoryOrCreate"}}}]' \
>/dev/null
done
kubectl --context "$context" -n "$namespace" scale \
deployment/easyai-web-ningbo deployment/easyai-web-hongkong --replicas=0 >/dev/null
}
bootstrap_runtime() {
local api_image=$1 api_digest=$2 snapshot=$3
# shellcheck source=/dev/null
source "$state_root/local.env"
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"
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
apiVersion: v1
kind: Pod
metadata:
name: easyai-local-bootstrap
namespace: easyai
labels:
easyai.io/environment: local-acceptance
spec:
restartPolicy: Never
containers:
- name: bootstrap
image: $api_image
command:
- /app/easyai-ai-gateway-acceptance-bootstrap
- --local-cluster-id
- $LOCAL_CLUSTER_ID
- --release-sha
- $release_sha
- --api-image-digest
- $api_digest
- --worker-image-digest
- $api_digest
- --emulator-base-url
- http://easyai-acceptance-upstream-proxy.easyai.svc.cluster.local:8090
- --callback-url
- http://easyai-acceptance-callback-collector.easyai.svc.cluster.local:8091/callbacks
- --identity-shards
- "32"
- --output
- /tmp/runtime.json
envFrom:
- secretRef:
name: easyai-ai-gateway-runtime
EOF
kubectl --context "$context" -n "$namespace" wait \
--for=jsonpath='{.status.phase}'=Succeeded pod/easyai-local-bootstrap --timeout=5m >/dev/null
umask 077
kubectl --context "$context" -n "$namespace" cp \
easyai-local-bootstrap:/tmp/runtime.json "$runtime_file" >/dev/null
chmod 0600 "$runtime_file"
kubectl --context "$context" -n "$namespace" delete pod easyai-local-bootstrap \
--wait=true >/dev/null
printf '%s\n' "$runtime_file" >"$state_root/current-runtime-path"
chmod 0600 "$state_root/current-runtime-path"
}
up_cluster() {
[[ ${1:-} == --snapshot && $# -eq 2 ]] || {
usage >&2
exit 64
}
local snapshot=$2
[[ -f $snapshot && ! -L $snapshot ]] || {
echo 'snapshot must be a regular non-symlink file' >&2
exit 1
}
[[ -z $(git -C "$repository_root" status --short) ]] || {
echo 'local acceptance requires a clean source tree so report and image source are reproducible' >&2
exit 1
}
preflight
ensure_private_material
generate_tls
(
cd "$repository_root/apps/api"
go run ./cmd/acceptance-snapshot validate --input "$snapshot"
)
cp "$snapshot" "$state_root/snapshot.json"
chmod 0600 "$state_root/snapshot.json"
local k3d config native_arch release_sha api_image web_image netem_image api_digest
k3d=$(k3d_binary)
if "$k3d" cluster list --no-headers | awk '{print $1}' | grep -Fxq "$cluster_name"; then
echo "cluster $cluster_name already exists; use status or explicit down --confirm" >&2
exit 1
fi
native_arch=$(docker info --format '{{.Architecture}}')
case $native_arch in
aarch64|arm64) native_arch=arm64 ;;
x86_64) native_arch=amd64 ;;
esac
release_sha=$(git -C "$repository_root" rev-parse HEAD)
api_image="easyai-acceptance-api:$release_sha"
web_image="easyai-acceptance-web:$release_sha"
netem_image="easyai-acceptance-netem:$release_sha"
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 \
-t "$web_image" "$repository_root"
docker buildx build --load --platform "linux/$native_arch" --target acceptance-netem \
-t "$netem_image" "$repository_root"
api_digest=$(docker image inspect "$api_image" --format '{{.Id}}')
[[ $api_digest =~ ^sha256:[0-9a-f]{64}$ ]]
config="$state_root/k3d.rendered.yaml"
render_k3d_config "$config"
EASYAI_ACCEPTANCE_MEDIA_DIR="$media_root" "$k3d" cluster create --config "$config"
docker update --cpus 4 --memory 8g "${cluster_name}-server-0" >/dev/null
docker update --cpus 4 --memory 8g "${cluster_name}-server-1" >/dev/null
docker update --cpus 2 --memory 4g "${cluster_name}-server-2" >/dev/null
"$k3d" image import -c "$cluster_name" "$api_image" "$web_image" "$netem_image"
local cnpg_manifest="$state_root/cnpg.yaml"
curl -fsSL "$CNPG_MANIFEST_URL" -o "$cnpg_manifest"
[[ $(shasum -a 256 "$cnpg_manifest" | awk '{print $1}') == "$CNPG_MANIFEST_SHA256" ]] || {
echo 'CNPG manifest checksum mismatch' >&2
exit 1
}
chmod 0600 "$cnpg_manifest"
kubectl --context "$context" apply --server-side --force-conflicts -f "$cnpg_manifest" >/dev/null
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
kubectl --context "$context" -n "$namespace" wait --for=condition=Ready \
cluster/easyai-postgres --timeout=15m >/dev/null
run_migrations "$api_image"
mark_local_database
import_snapshot "$api_image" "$snapshot"
sed "s|image: easyai-api|image: $api_image|g; s|image: easyai-acceptance-netem|image: $netem_image|g" \
"$manifest_root/support-services.yaml" |
kubectl --context "$context" -n "$namespace" apply -f - >/dev/null
wait_rollout deployment/easyai-acceptance-emulator
wait_rollout deployment/easyai-acceptance-callback-collector
wait_rollout deployment/easyai-acceptance-upstream-proxy
wait_rollout deployment/easyai-postgres-proxy-ningbo
wait_rollout deployment/easyai-postgres-proxy-hongkong
render_and_apply_application "$api_image" "$web_image"
sed "s|image: easyai-web|image: $web_image|g" "$manifest_root/tls-edges.yaml" |
kubectl --context "$context" -n "$namespace" apply -f - >/dev/null
wait_rollout deployment/easyai-api-ningbo
wait_rollout deployment/easyai-api-hongkong
wait_rollout deployment/easyai-worker-ningbo
wait_rollout deployment/easyai-worker-hongkong
wait_rollout deployment/easyai-capacity-controller
wait_rollout deployment/easyai-acceptance-edge-ningbo
wait_rollout deployment/easyai-acceptance-edge-hongkong
bootstrap_runtime "$api_image" "$api_digest" "$snapshot"
"$script_dir/network-fault.sh" baseline
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"
}
status_cluster() {
local k3d
k3d=$(k3d_binary) || {
echo 'k3d is unavailable' >&2
exit 1
}
"$k3d" cluster list --no-headers | awk '{print $1}' | grep -Fxq "$cluster_name" || {
echo "cluster $cluster_name does not exist" >&2
exit 1
}
kubectl --context "$context" get nodes -o wide
kubectl --context "$context" -n "$namespace" get cluster,pod,deployment
echo "local_acceptance_status=PASS context=$context"
}
down_cluster() {
[[ ${1:-} == --confirm && $# -eq 1 ]] || {
echo 'down requires the literal --confirm flag' >&2
exit 64
}
local k3d
k3d=$(k3d_binary)
"$k3d" cluster delete "$cluster_name"
echo "local_acceptance_down=PASS preserved_private_root=$private_root"
}
case ${1:-} in
install-tools)
[[ $# -eq 1 ]] || { usage >&2; exit 64; }
install_tools
;;
preflight)
[[ $# -eq 1 ]] || { usage >&2; exit 64; }
preflight
;;
up)
shift
up_cluster "$@"
;;
status)
[[ $# -eq 1 ]] || { usage >&2; exit 64; }
status_cluster
;;
down)
shift
down_cluster "$@"
;;
*)
usage >&2
exit 64
;;
esac
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
listen_port=${NETEM_LISTEN_PORT:-15432}
target_host=${NETEM_TARGET_HOST:-}
target_port=${NETEM_TARGET_PORT:-}
case "$listen_port:$target_port" in
*[!0-9:]*|:*|*:) echo "NETEM_LISTEN_PORT and NETEM_TARGET_PORT must be numeric" >&2; exit 64 ;;
esac
case "$target_host" in
""|*[!A-Za-z0-9.-]*) echo "NETEM_TARGET_HOST must be a DNS name or address" >&2; exit 64 ;;
esac
exec socat \
"TCP-LISTEN:${listen_port},fork,reuseaddr,keepalive" \
"TCP:${target_host}:${target_port},connect-timeout=10"
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/acceptance/network-fault.sh baseline
scripts/acceptance/network-fault.sh weak-link
scripts/acceptance/network-fault.sh upstream-outage
scripts/acceptance/network-fault.sh database-outage <ningbo|hongkong>
scripts/acceptance/network-fault.sh reset
Only operates on Pods carrying easyai.io/environment=local-acceptance in the
easyai-acceptance-local k3d context.
EOF
}
profile=${1:-}
site=${2:-}
namespace=${AI_GATEWAY_LOCAL_ACCEPTANCE_NAMESPACE:-easyai}
context=${AI_GATEWAY_LOCAL_ACCEPTANCE_CONTEXT:-k3d-easyai-acceptance-local}
command -v kubectl >/dev/null 2>&1 || {
echo 'kubectl is required' >&2
exit 1
}
[[ $(kubectl config get-contexts "$context" -o name) == "$context" ]] || {
echo "refusing netem because context $context does not exist" >&2
exit 1
}
pod_for() {
local name=$1
kubectl --context "$context" -n "$namespace" get pod \
-l "app.kubernetes.io/name=$name,easyai.io/environment=local-acceptance" \
-o 'jsonpath={.items[0].metadata.name}'
}
apply_qdisc() {
local pod=$1
shift
[[ -n $pod ]] || {
echo 'local acceptance proxy Pod was not found' >&2
exit 1
}
kubectl --context "$context" -n "$namespace" exec "$pod" -- \
tc qdisc replace dev eth0 root netem "$@"
}
reset_qdisc() {
local pod=$1
[[ -z $pod ]] || kubectl --context "$context" -n "$namespace" exec "$pod" -- \
tc qdisc del dev eth0 root >/dev/null 2>&1 || true
}
upstream_pod=$(pod_for easyai-acceptance-upstream-proxy)
ningbo_db_pod=$(kubectl --context "$context" -n "$namespace" get pod \
-l 'app.kubernetes.io/name=easyai-postgres-proxy,easyai.io/site=ningbo,easyai.io/environment=local-acceptance' \
-o 'jsonpath={.items[0].metadata.name}')
hongkong_db_pod=$(kubectl --context "$context" -n "$namespace" get pod \
-l 'app.kubernetes.io/name=easyai-postgres-proxy,easyai.io/site=hongkong,easyai.io/environment=local-acceptance' \
-o 'jsonpath={.items[0].metadata.name}')
case $profile in
baseline)
apply_qdisc "$upstream_pod" delay 20ms
apply_qdisc "$ningbo_db_pod" delay 20ms
apply_qdisc "$hongkong_db_pod" delay 20ms
;;
weak-link)
apply_qdisc "$upstream_pod" delay 20ms 10ms distribution normal loss 0.5%
apply_qdisc "$ningbo_db_pod" delay 20ms 10ms distribution normal loss 0.5%
apply_qdisc "$hongkong_db_pod" delay 20ms 10ms distribution normal loss 0.5%
;;
upstream-outage)
apply_qdisc "$upstream_pod" loss 100%
;;
database-outage)
case $site in
ningbo) apply_qdisc "$ningbo_db_pod" loss 100% ;;
hongkong) apply_qdisc "$hongkong_db_pod" loss 100% ;;
*) usage >&2; exit 64 ;;
esac
;;
reset)
reset_qdisc "$upstream_pod"
reset_qdisc "$ningbo_db_pod"
reset_qdisc "$hongkong_db_pod"
;;
*)
usage >&2
exit 64
;;
esac
echo "local_acceptance_netem=PASS profile=$profile${site:+ site=$site}"
+316
View File
@@ -0,0 +1,316 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
const shaPattern = /^[0-9a-f]{40}$/;
const digestPattern = /^sha256:[0-9a-f]{64}$/;
const hashPattern = /^[0-9a-f]{64}$/;
const unsafeKeyPattern = /(password|secret|token|credential|authorization|api.?key|access.?key|private.?key|connection.?string|database.?url|proxy)/i;
function parseArgs(argv) {
const command = argv.shift();
const values = {};
while (argv.length > 0) {
const key = argv.shift();
if (!key?.startsWith('--') || argv.length === 0) throw new Error('invalid arguments');
values[key.slice(2)] = argv.shift();
}
return { command, values };
}
async function regularJSON(path) {
const absolute = resolve(path);
const info = await lstat(absolute);
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${path} must be a regular non-symlink file`);
return JSON.parse(await readFile(absolute, 'utf8'));
}
function assertSecretSafe(value, path = '$') {
if (Array.isArray(value)) {
value.forEach((item, index) => assertSecretSafe(item, `${path}[${index}]`));
return;
}
if (value && typeof value === 'object') {
for (const [key, nested] of Object.entries(value)) {
if (key !== 'secretSafe' && unsafeKeyPattern.test(key)) {
throw new Error(`unsafe report key at ${path}.${key}`);
}
assertSecretSafe(nested, `${path}.${key}`);
}
return;
}
if (typeof value === 'string' && /(?:postgres(?:ql)?|https?):\/\/[^\s]+/i.test(value)) {
throw new Error(`URL-like value is forbidden at ${path}`);
}
}
function validate(report) {
if (report.schemaVersion !== 'acceptance-report/v1' || report.secretSafe !== true) {
throw new Error('unsupported or non-secret-safe acceptance report');
}
if (!report.runId || !shaPattern.test(report.release?.sourceSha ?? '') ||
!digestPattern.test(report.release?.apiImageDigest ?? '') ||
!digestPattern.test(report.release?.workerImageDigest ?? '') ||
!shaPattern.test(report.snapshot?.sourceReleaseSha ?? '') ||
!hashPattern.test(report.snapshot?.configHash ?? '') ||
!hashPattern.test(report.snapshot?.sha256 ?? '')) {
throw new Error('acceptance report identity fields are invalid');
}
if (!report.stages || !Array.isArray(report.gates) ||
report.promotion?.requiresManualConfirmation !== true ||
!['validation', 'live', 'not-applicable'].includes(report.promotion?.trafficMode)) {
throw new Error('acceptance report stages, gates, or promotion are invalid');
}
for (const [name, stage] of Object.entries(report.stages)) {
if (!['passed', 'failed', 'not-run'].includes(stage?.status)) {
throw new Error(`invalid stage status for ${name}`);
}
}
assertSecretSafe(report);
}
async function loadReports(directory) {
const entries = (await readdir(resolve(directory), { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.map((entry) => entry.name)
.sort();
const reports = [];
for (const name of entries) {
const report = await regularJSON(resolve(directory, name));
if (report.schemaVersion === 'acceptance-load-report/v1') {
if (report.secretSafe !== true) throw new Error(`load report ${name} is not secret-safe`);
reports.push({
file: name,
profile: report.profile,
passed: report.passed,
phases: report.phases,
startedAt: report.startedAt,
finishedAt: report.finishedAt
});
}
}
return reports;
}
async function buildLocal(values) {
for (const key of ['runtime', 'snapshot', 'reports', 'artifact', 'output']) {
if (!values[key]) throw new Error(`--${key} is required`);
}
const runtime = await regularJSON(values.runtime);
const snapshot = await regularJSON(values.snapshot);
const artifact = await regularJSON(values.artifact);
const loads = await loadReports(values.reports);
if (runtime.schemaVersion !== 'acceptance-runtime/v1' ||
snapshot.schemaVersion !== 'acceptance-snapshot/v1' ||
artifact.schemaVersion !== 'acceptance-artifact-smoke/v1') {
throw new Error('local report inputs have incompatible schema versions');
}
if (runtime.releaseSha !== artifact.releaseSha ||
artifact.apiImageDigest !== values['api-digest'] ||
runtime.snapshotConfigHash !== snapshot.source.configHash ||
runtime.snapshotSha256 !== snapshot.snapshotSha256) {
throw new Error('local report CAS identity mismatch');
}
const localPassed = loads.length > 0 && loads.every((item) => item.passed === true);
const gates = [
{ id: 'local_load_reports', passed: localPassed, detail: `${loads.length} load reports` },
{ id: 'amd64_artifact_smoke', passed: artifact.passed === true }
];
const report = {
schemaVersion: 'acceptance-report/v1',
runId: runtime.runId,
release: {
sourceSha: artifact.releaseSha,
apiImageDigest: artifact.apiImageDigest,
workerImageDigest: artifact.apiImageDigest
},
snapshot: {
sourceReleaseSha: snapshot.source.releaseSha,
configHash: snapshot.source.configHash,
sha256: snapshot.snapshotSha256
},
stages: {
localNative: { status: localPassed ? 'passed' : 'failed', loadReports: loads },
amd64Artifact: { status: artifact.passed ? 'passed' : 'failed', ...artifact },
onlineSimulation: { status: 'not-run' },
realCanary: { status: 'not-run' }
},
certifiedProfile: values['certified-profile'] ? {
name: values['certified-profile'],
source: 'local-candidate-only'
} : null,
gates,
promotion: {
ready: false,
requiresManualConfirmation: true,
trafficMode: 'not-applicable'
},
createdAt: new Date().toISOString(),
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 });
process.stdout.write(`acceptance_report_local=PASS sha256=${createHash('sha256').update(JSON.stringify(report)).digest('hex')}\n`);
}
async function buildLocalPartial(values) {
for (const key of ['runtime', 'snapshot', 'reports', 'output', 'failure-phase']) {
if (!values[key]) throw new Error(`--${key} is required`);
}
const runtime = await regularJSON(values.runtime);
const snapshot = await regularJSON(values.snapshot);
const loads = await loadReports(values.reports);
if (runtime.schemaVersion !== 'acceptance-runtime/v1' ||
snapshot.schemaVersion !== 'acceptance-snapshot/v1' ||
!shaPattern.test(runtime.releaseSha ?? '') ||
!digestPattern.test(runtime.apiImageDigest ?? '') ||
!digestPattern.test(runtime.workerImageDigest ?? '') ||
runtime.snapshotConfigHash !== snapshot.source.configHash ||
runtime.snapshotSha256 !== snapshot.snapshotSha256) {
throw new Error('partial local report inputs have incompatible CAS identity');
}
const completedLoadsPassed = loads.every((item) => item.passed === true);
const report = {
schemaVersion: 'acceptance-report/v1',
runId: runtime.runId,
release: {
sourceSha: runtime.releaseSha,
apiImageDigest: runtime.apiImageDigest,
workerImageDigest: runtime.workerImageDigest
},
snapshot: {
sourceReleaseSha: snapshot.source.releaseSha,
configHash: snapshot.source.configHash,
sha256: snapshot.snapshotSha256
},
stages: {
localNative: {
status: 'failed',
failurePhase: values['failure-phase'],
completedLoadReportsPassed: completedLoadsPassed,
loadReports: loads
},
amd64Artifact: { status: 'not-run' },
onlineSimulation: { status: 'not-run' },
realCanary: { status: 'not-run' }
},
certifiedProfile: values['certified-profile'] ? {
name: values['certified-profile'],
source: 'last-local-stable-candidate'
} : null,
gates: [
{
id: 'local_execution_complete',
passed: false,
detail: `stopped during ${values['failure-phase']}`
},
{
id: 'completed_load_reports',
passed: completedLoadsPassed,
detail: `${loads.length} load reports`
}
],
promotion: {
ready: false,
requiresManualConfirmation: true,
trafficMode: 'not-applicable'
},
createdAt: new Date().toISOString(),
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 });
process.stdout.write('acceptance_report_local_partial=PASS\n');
}
async function mergeProduction(values) {
for (const key of ['local-report', 'production-summary', 'output']) {
if (!values[key]) throw new Error(`--${key} is required`);
}
const local = await regularJSON(values['local-report']);
const production = await regularJSON(values['production-summary']);
validate(local);
if (local.release.sourceSha !== production.releaseSha ||
local.release.apiImageDigest !== production.apiImageDigest ||
local.snapshot.configHash !== production.snapshotConfigHash ||
local.snapshot.sha256 !== production.snapshotSha256) {
throw new Error('production summary does not match local acceptance CAS fields');
}
local.stages.onlineSimulation = {
status: production.passed === true ? 'passed' : 'failed',
stableCapacityProfile: production.stableCapacityProfile,
tasks: production.tasks,
geminiTasks: production.geminiTasks,
videoTasks: production.videoTasks
};
local.stages.realCanary = {
status: production.realCanaryPassed === true ? 'passed' : 'failed'
};
local.certifiedProfile = production.certifiedProfile ?? local.certifiedProfile;
local.gates.push(...(production.gates ?? []));
local.promotion = {
ready: production.passed === true && production.realCanaryPassed === true &&
local.gates.every((gate) => gate.passed === true),
requiresManualConfirmation: true,
trafficMode: 'validation'
};
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 });
process.stdout.write(`acceptance_report_production=PASS promotion_ready=${local.promotion.ready}\n`);
}
const { command, values } = parseArgs(process.argv.slice(2));
if (command === 'validate') {
const report = await regularJSON(values.input ?? '');
validate(report);
process.stdout.write('acceptance_report_validate=PASS\n');
} else if (command === 'build-local') {
await buildLocal(values);
} else if (command === 'build-local-partial') {
await buildLocalPartial(values);
} else if (command === 'merge-production') {
await mergeProduction(values);
} else if (command === 'mark-promoted') {
const input = values.input ?? '';
const output = values.output ?? input;
const report = await regularJSON(input);
validate(report);
if (report.promotion.ready !== true || report.promotion.trafficMode !== 'validation') {
throw new Error('only a ready validation report can be marked promoted');
}
report.promotion.trafficMode = 'live';
report.promotion.promotedAt = new Date().toISOString();
await writeFile(resolve(output), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
process.stdout.write('acceptance_report_promoted=PASS\n');
} else if (command === 'attach-monitor') {
const input = values.input ?? '';
const output = values.output ?? input;
const report = await regularJSON(input);
const monitor = await regularJSON(values.monitor ?? '');
validate(report);
if (monitor.schemaVersion !== 'acceptance-monitor-report/v1' ||
monitor.runId !== report.runId || monitor.releaseSha !== report.release.sourceSha ||
monitor.secretSafe !== true) {
throw new Error('monitor report does not match overall acceptance report');
}
report.stages.postPromotionMonitor = {
status: monitor.passed === true ? 'passed' : 'failed',
startedAt: monitor.startedAt,
finishedAt: monitor.finishedAt,
samples: monitor.samples,
failureGateId: monitor.failureGateId
};
if (monitor.passed !== true) {
report.promotion.ready = false;
report.promotion.trafficMode = 'validation';
}
await writeFile(resolve(output), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
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]');
}
+536
View File
@@ -0,0 +1,536 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
repository_root=$(cd "$script_dir/../.." && pwd)
private_root="$repository_root/.local-secrets/acceptance"
state_root="$private_root/state"
context=k3d-easyai-acceptance-local
namespace=easyai
runtime_path_file="$state_root/current-runtime-path"
snapshot="$state_root/snapshot.json"
load_binary="$state_root/easyai-ai-gateway-acceptance-load"
active_load_pid=
netem_active=false
current_phase=initialization
stable_profile=
usage() {
cat <<'EOF'
Usage:
scripts/acceptance/run-local-acceptance.sh quick
scripts/acceptance/run-local-acceptance.sh full --release-manifest dist/releases/<SHA>.json
scripts/acceptance/run-local-acceptance.sh artifact-smoke --release-manifest dist/releases/<SHA>.json
`full` executes P24/P28/P32 three times, the fault matrix, autoscaling/drain,
80% soak, 120% overload, and exact linux/amd64 artifact smoke. The load process
runs outside K3s and splits requests 50/50 across both TLS entrances.
EOF
}
private_file() {
local path=$1 mode
[[ -f $path && ! -L $path ]] || return 1
if [[ $(uname -s) == Darwin ]]; then
mode=$(stat -f '%Lp' "$path")
else
mode=$(stat -c '%a' "$path")
fi
[[ $mode == 600 ]]
}
cleanup() {
local status=$?
trap - EXIT
if [[ -n $active_load_pid ]]; then
kill "$active_load_pid" >/dev/null 2>&1 || true
wait "$active_load_pid" >/dev/null 2>&1 || true
fi
if [[ $netem_active == true ]]; then
"$script_dir/network-fault.sh" reset >/dev/null 2>&1 || true
fi
if [[ $status -ne 0 && -n ${runtime:-} && -n ${report_root:-} && -f ${runtime:-} && -f $snapshot ]]; then
restore_profile_best_effort "${stable_profile:-P24}"
local partial_args=(
"$script_dir/report.mjs" build-local-partial
--runtime "$runtime" \
--snapshot "$snapshot" \
--reports "$report_root" \
--failure-phase "$current_phase" \
--output "$report_root/acceptance-report.partial.json"
)
if [[ -n $stable_profile ]]; then
partial_args+=(--certified-profile "$stable_profile")
fi
if node "${partial_args[@]}" >/dev/null 2>&1; then
echo "local_acceptance=FAILED phase=$current_phase partial_report=$report_root/acceptance-report.partial.json" >&2
else
echo "local_acceptance=FAILED phase=$current_phase partial_report=unavailable" >&2
fi
fi
exit "$status"
}
trap cleanup EXIT
trap 'current_phase=signal_interrupted; exit 130' HUP INT TERM
require_local_cluster() {
command -v kubectl >/dev/null 2>&1
command -v jq >/dev/null 2>&1
command -v node >/dev/null 2>&1
[[ $(kubectl config get-contexts "$context" -o name) == "$context" ]]
private_file "$runtime_path_file" && private_file "$snapshot"
runtime=$(<"$runtime_path_file")
private_file "$runtime" || {
echo 'local acceptance runtime file is missing or not mode 0600' >&2
exit 1
}
jq -e '.schemaVersion == "acceptance-runtime/v1"' "$runtime" >/dev/null
run_id=$(jq -r '.runId' "$runtime")
release_sha=$(jq -r '.releaseSha' "$runtime")
report_root="$repository_root/dist/acceptance/local/$run_id"
install -d -m 0700 "$report_root"
(
cd "$repository_root/apps/api"
env -u AI_GATEWAY_TEST_DATABASE_URL go build -trimpath \
-o "$load_binary" ./cmd/acceptance-load
)
}
database_query() {
local sql=$1 primary
primary=$(kubectl --context "$context" -n "$namespace" get cluster easyai-postgres \
-o 'jsonpath={.status.currentPrimary}')
kubectl --context "$context" -n "$namespace" exec "$primary" -c postgres -- \
psql -X -v ON_ERROR_STOP=1 -U postgres -d easyai_ai_gateway -At -c "$sql"
}
load_environment() {
acceptance_api_keys=$(jq -r '.apiKeys | join(",")' "$runtime")
acceptance_run_token=$(jq -r '.runToken' "$runtime")
acceptance_gemini_model=$(jq -r '.geminiModel' "$runtime")
acceptance_video_model=$(jq -r '.videoModel' "$runtime")
acceptance_emulator_url=$(jq -r '.emulatorBaseUrl' "$runtime")
}
run_load() {
local profile=$1 report_path=$2
shift 2
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" \
AI_GATEWAY_ACCEPTANCE_EMULATOR_URL="$acceptance_emulator_url" \
AI_GATEWAY_ACCEPTANCE_API_KEYS="$acceptance_api_keys" \
AI_GATEWAY_ACCEPTANCE_RUN_ID="$run_id" \
AI_GATEWAY_ACCEPTANCE_RUN_TOKEN="$acceptance_run_token" \
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"
jq -e '.schemaVersion == "acceptance-load-report/v1" and .secretSafe == true and .passed == true' \
"$report_path" >/dev/null
}
sample_resources() {
local output=$1 stop_file=$2
printf 'timestamp,scope,name,cpu,memory\n' >"$output"
while [[ ! -e $stop_file ]]; do
kubectl --context "$context" top nodes --no-headers 2>/dev/null |
awk -v timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
'{print timestamp ",node," $1 "," $3 "," $5}' >>"$output" || true
kubectl --context "$context" -n "$namespace" top pods --no-headers 2>/dev/null |
awk -v timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
'{print timestamp ",pod," $1 "," $2 "," $3}' >>"$output" || true
sleep 5
done
}
verify_hard_gates() {
local unhealthy ready sync_state connections max_connections queue duplicate_remote duplicate_billing
ready=$(kubectl --context "$context" get nodes -o json |
jq '[.items[] | select(any(.status.conditions[]; .type=="Ready" and .status=="True"))] | length')
[[ $ready == 3 ]]
[[ $(kubectl --context "$context" get nodes -o json |
jq '[.items[] | select(any(.status.conditions[]; .type=="MemoryPressure" and .status=="True"))] | length') == 0 ]]
[[ $(kubectl --context "$context" -n "$namespace" get cluster easyai-postgres \
-o 'jsonpath={.status.readyInstances}') == 2 ]]
sync_state=$(database_query "SELECT COALESCE(string_agg(sync_state,','),'') FROM pg_stat_replication;")
[[ ",$sync_state," == *,sync,* || ",$sync_state," == *,quorum,* ]]
unhealthy=$(kubectl --context "$context" -n "$namespace" get pods -o json |
jq '[.items[]
| select(.metadata.labels["app.kubernetes.io/name"] == "easyai-api" or
.metadata.labels["app.kubernetes.io/name"] == "easyai-worker")
| .status.containerStatuses[]?
| select(.restartCount > 0 or .lastState.terminated.reason == "OOMKilled" or
.state.terminated.reason == "OOMKilled")] | length')
[[ $unhealthy == 0 ]]
while read -r _node _cpu _cpu_percent _memory memory_percent; do
memory_percent=${memory_percent%\%}
[[ $memory_percent =~ ^[0-9]+$ ]]
(( memory_percent < 80 ))
done < <(kubectl --context "$context" top nodes --no-headers)
while read -r _pod _cpu memory; do
case $memory in
*Gi) memory=$(awk -v value="${memory%Gi}" 'BEGIN {printf "%.0f", value*1024}') ;;
*Mi) memory=${memory%Mi} ;;
*Ki) memory=$(awk -v value="${memory%Ki}" 'BEGIN {printf "%.0f", value/1024}') ;;
*) return 1 ;;
esac
(( memory < 1536 ))
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 ))
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 ]]
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 ]]
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
}
apply_profile() {
local profile=$1 slots pool
case $profile in
P24) slots=24; pool=32 ;;
P28) slots=28; pool=36 ;;
P32) slots=32; pool=40 ;;
*) return 64 ;;
esac
local global=$((slots * 2))
kubectl --context "$context" -n "$namespace" patch configmap easyai-ai-gateway-config \
--type=merge -p "$(jq -cn \
--arg slots "$slots" --arg global "$global" \
'{data:{
AI_GATEWAY_WORKER_AUTOSCALING_ENABLED:"false",
AI_GATEWAY_ASYNC_WORKER_INSTANCE_HARD_LIMIT:$slots,
AI_GATEWAY_ASYNC_WORKER_HARD_LIMIT:$slots,
AI_GATEWAY_ASYNC_WORKER_GLOBAL_HARD_LIMIT:$global,
AI_GATEWAY_WORKER_TARGET_OUTSTANDING_PER_REPLICA:$global
}}')" >/dev/null
kubectl --context "$context" -n "$namespace" scale \
deployment/easyai-worker-ningbo deployment/easyai-worker-hongkong --replicas=1 >/dev/null
for deployment in easyai-worker-ningbo easyai-worker-hongkong; do
kubectl --context "$context" -n "$namespace" set env deployment/"$deployment" \
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
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
kubectl --context "$context" -n "$namespace" rollout restart deployment/"$deployment" >/dev/null
kubectl --context "$context" -n "$namespace" rollout status deployment/"$deployment" --timeout=10m
done
}
restore_profile_best_effort() {
local profile=$1 slots pool global
case $profile in
P24) slots=24; pool=32 ;;
P28) slots=28; pool=36 ;;
P32) slots=32; pool=40 ;;
*) return ;;
esac
global=$((slots * 2))
kubectl --context "$context" -n "$namespace" patch configmap easyai-ai-gateway-config \
--type=merge -p "$(jq -cn \
--arg slots "$slots" --arg global "$global" \
'{data:{
AI_GATEWAY_WORKER_AUTOSCALING_ENABLED:"false",
AI_GATEWAY_ASYNC_WORKER_INSTANCE_HARD_LIMIT:$slots,
AI_GATEWAY_ASYNC_WORKER_HARD_LIMIT:$slots,
AI_GATEWAY_ASYNC_WORKER_GLOBAL_HARD_LIMIT:$global,
AI_GATEWAY_WORKER_TARGET_OUTSTANDING_PER_REPLICA:$global
}}')" >/dev/null 2>&1 || true
kubectl --context "$context" -n "$namespace" scale \
deployment/easyai-worker-ningbo deployment/easyai-worker-hongkong \
--replicas=1 >/dev/null 2>&1 || true
for deployment in easyai-worker-ningbo easyai-worker-hongkong; do
kubectl --context "$context" -n "$namespace" set env deployment/"$deployment" \
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
done
}
run_profile_round() {
local profile=$1 repetition=$2 prefix
prefix="$report_root/${profile,,}-$repetition"
local stop_file="$prefix.resources.stop"
sample_resources "$prefix.resources.csv" "$stop_file" &
local sampler=$!
run_load gemini-baseline "$prefix-gemini-baseline.json"
run_load gemini-large "$prefix-gemini-large.json"
run_load gemini-peak "$prefix-gemini-peak.json"
run_load video-throughput "$prefix-video-throughput.json"
run_recovery "$prefix-video-recovery.json"
touch "$stop_file"
wait "$sampler"
verify_hard_gates
}
wait_for_recovery_owner() {
local deadline=$((SECONDS + 120)) owner
while (( SECONDS < deadline )); do
owner=$(database_query "
SELECT task.id::text||','||worker.pod_name||','||worker.site
FROM gateway_tasks task
JOIN gateway_worker_instances worker ON worker.instance_id=task.locked_by
WHERE task.acceptance_run_id='$run_id'::uuid
AND task.status='running'
AND task.request::text LIKE '%acceptance-long-recovery%'
AND worker.status='active'
ORDER BY task.updated_at
LIMIT 1;")
[[ -z $owner ]] || { printf '%s\n' "$owner"; return; }
sleep 1
done
return 1
}
run_recovery() {
local report_path=$1 owner task_id pod site
run_load video-recovery "$report_path" &
active_load_pid=$!
owner=$(wait_for_recovery_owner)
IFS=',' read -r task_id pod site <<<"$owner"
[[ $task_id =~ ^[0-9a-f-]{36}$ && $pod == easyai-worker-"$site"-* ]]
kubectl --context "$context" -n "$namespace" delete pod "$pod" \
--force --grace-period=0 --wait=false >/dev/null
kubectl --context "$context" -n "$namespace" rollout status \
deployment/easyai-worker-"$site" --timeout=10m
wait "$active_load_pid"
active_load_pid=
}
run_fault_matrix() {
local duration=${AI_GATEWAY_LOCAL_WEAK_LINK_DURATION:-5m}
"$script_dir/network-fault.sh" weak-link
netem_active=true
run_load mixed-soak "$report_root/fault-weak-link.json" \
-duration "$duration" -image-rate 1 -video-rate 1
"$script_dir/network-fault.sh" reset
netem_active=false
run_load video-throughput "$report_root/fault-upstream-outage.json" &
active_load_pid=$!
sleep 5
"$script_dir/network-fault.sh" upstream-outage
netem_active=true
sleep 10
"$script_dir/network-fault.sh" reset
netem_active=false
wait "$active_load_pid"
active_load_pid=
run_load video-recovery "$report_root/fault-database-outage.json" &
active_load_pid=$!
sleep 10
"$script_dir/network-fault.sh" database-outage hongkong
netem_active=true
sleep 30
"$script_dir/network-fault.sh" reset
netem_active=false
wait "$active_load_pid"
active_load_pid=
local leader
leader=$(kubectl --context "$context" -n "$namespace" get pods \
-l app.kubernetes.io/name=easyai-capacity-controller -o name |
while read -r pod; do
status=$(kubectl --context "$context" -n "$namespace" exec "$pod" -- \
wget -qO- http://127.0.0.1:8088/status)
[[ $(jq -r '.leader' <<<"$status") == true ]] && { printf '%s\n' "${pod#pod/}"; break; }
done)
[[ -n $leader ]]
kubectl --context "$context" -n "$namespace" delete pod "$leader" --wait=false >/dev/null
kubectl --context "$context" -n "$namespace" rollout status \
deployment/easyai-capacity-controller --timeout=5m
verify_hard_gates
}
run_autoscaling() {
local profile=$1 slots
slots=${profile#P}
kubectl --context "$context" -n "$namespace" patch configmap easyai-ai-gateway-config \
--type=merge -p "$(jq -cn --arg global "$((slots * 4))" \
'{data:{
AI_GATEWAY_WORKER_AUTOSCALING_ENABLED:"true",
AI_GATEWAY_WORKER_MIN_REPLICAS_NINGBO:"1",
AI_GATEWAY_WORKER_MIN_REPLICAS_HONGKONG:"1",
AI_GATEWAY_WORKER_MAX_REPLICAS_NINGBO:"2",
AI_GATEWAY_WORKER_MAX_REPLICAS_HONGKONG:"2",
AI_GATEWAY_ASYNC_WORKER_GLOBAL_HARD_LIMIT:$global
}}')" >/dev/null
kubectl --context "$context" -n "$namespace" rollout restart \
deployment/easyai-capacity-controller >/dev/null
kubectl --context "$context" -n "$namespace" rollout status \
deployment/easyai-capacity-controller --timeout=5m
run_load video-throughput "$report_root/autoscaling-load.json" &
active_load_pid=$!
local deadline=$((SECONDS + 420)) total=2
while (( SECONDS < deadline )); do
total=$(kubectl --context "$context" -n "$namespace" get deployment \
easyai-worker-ningbo easyai-worker-hongkong \
-o json | jq '[.items[].spec.replicas] | add')
(( total >= 3 )) && break
sleep 5
done
(( total >= 3 ))
wait "$active_load_pid"
active_load_pid=
deadline=$((SECONDS + 780))
while (( SECONDS < deadline )); do
total=$(kubectl --context "$context" -n "$namespace" get deployment \
easyai-worker-ningbo easyai-worker-hongkong \
-o json | jq '[.items[].spec.replicas] | add')
(( total == 2 )) && break
sleep 10
done
(( total == 2 ))
verify_hard_gates
}
artifact_smoke() {
local manifest=$1
node "$repository_root/scripts/release-manifest.mjs" validate "$manifest" >/dev/null
local manifest_sha api_image web_image api_digest artifact_report
manifest_sha=$(node "$repository_root/scripts/release-manifest.mjs" get "$manifest" sourceSha)
api_image=$(node "$repository_root/scripts/release-manifest.mjs" get "$manifest" images.api)
web_image=$(node "$repository_root/scripts/release-manifest.mjs" get "$manifest" images.web)
api_digest=${api_image##*@}
[[ $manifest_sha == "$release_sha" && $api_digest =~ ^sha256:[0-9a-f]{64}$ ]]
docker pull --platform linux/amd64 "$api_image" >/dev/null
docker pull --platform linux/amd64 "$web_image" >/dev/null
"$private_root/tools/k3d" image import -c easyai-acceptance-local "$api_image" "$web_image"
for deployment in easyai-api-ningbo easyai-api-hongkong easyai-worker-ningbo \
easyai-worker-hongkong easyai-capacity-controller easyai-acceptance-emulator \
easyai-acceptance-callback-collector; do
kubectl --context "$context" -n "$namespace" set image deployment/"$deployment" \
"*=$api_image" >/dev/null
kubectl --context "$context" -n "$namespace" rollout status deployment/"$deployment" --timeout=15m
done
for deployment in easyai-acceptance-edge-ningbo easyai-acceptance-edge-hongkong; do
kubectl --context "$context" -n "$namespace" set image deployment/"$deployment" \
"*=$web_image" >/dev/null
kubectl --context "$context" -n "$namespace" rollout status deployment/"$deployment" --timeout=10m
done
kubectl --context "$context" -n "$namespace" delete job easyai-artifact-migrate \
--ignore-not-found --wait=true >/dev/null
cat <<EOF | kubectl --context "$context" apply -f - >/dev/null
apiVersion: batch/v1
kind: Job
metadata:
name: easyai-artifact-migrate
namespace: easyai
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: $api_image
command: ["/bin/sh", "-ec", "cd /app && exec /app/easyai-ai-gateway-migrate"]
envFrom:
- secretRef:
name: easyai-ai-gateway-runtime
EOF
kubectl --context "$context" -n "$namespace" wait \
--for=condition=complete job/easyai-artifact-migrate --timeout=10m >/dev/null
run_load simulated-smoke "$report_root/artifact-simulated-smoke.json"
verify_hard_gates
artifact_report="$report_root/artifact-smoke.json"
jq -n \
--arg releaseSha "$manifest_sha" \
--arg apiImageDigest "$api_digest" \
--arg webImageDigest "${web_image##*@}" \
'{
schemaVersion:"acceptance-artifact-smoke/v1",
releaseSha:$releaseSha,
apiImageDigest:$apiImageDigest,
webImageDigest:$webImageDigest,
architecture:"linux/amd64",
migrationSmoke:true,
startupSmoke:true,
mediaSmoke:true,
passed:true,
secretSafe:true
}' >"$artifact_report"
chmod 0600 "$artifact_report"
}
quick() {
current_phase=quick
run_load simulated-smoke "$report_root/quick.json"
verify_hard_gates
echo "local_acceptance_quick=PASS run_id=$run_id report=$report_root/quick.json"
}
full() {
local manifest=$1 profile repetition
quick
for profile in P24 P28 P32; do
current_phase="capacity_${profile,,}_apply"
apply_profile "$profile"
for repetition in 1 2 3; do
current_phase="capacity_${profile,,}_round_$repetition"
run_profile_round "$profile" "$repetition"
done
stable_profile=$profile
done
current_phase=fault_matrix
run_fault_matrix
current_phase=autoscaling_and_drain
run_autoscaling "$stable_profile"
current_phase=certified_soak
run_load mixed-soak "$report_root/mixed-soak.json" \
-duration "${AI_GATEWAY_LOCAL_SOAK_DURATION:-2h}" \
-image-rate "${AI_GATEWAY_LOCAL_CERTIFIED_IMAGE_RATE:-1}" \
-video-rate "${AI_GATEWAY_LOCAL_CERTIFIED_VIDEO_RATE:-1}"
current_phase=overload_shedding
run_load mixed-overload "$report_root/mixed-overload.json" \
-duration "${AI_GATEWAY_LOCAL_OVERLOAD_DURATION:-10m}" \
-image-rate "${AI_GATEWAY_LOCAL_OVERLOAD_IMAGE_RATE:-2}" \
-video-rate "${AI_GATEWAY_LOCAL_OVERLOAD_VIDEO_RATE:-2}"
current_phase=amd64_artifact_smoke
artifact_smoke "$manifest"
current_phase=final_report
node "$script_dir/report.mjs" build-local \
--runtime "$runtime" \
--snapshot "$snapshot" \
--reports "$report_root" \
--artifact "$report_root/artifact-smoke.json" \
--api-digest "$(node "$repository_root/scripts/release-manifest.mjs" get "$manifest" images.api | sed 's/.*@//')" \
--certified-profile "$stable_profile" \
--output "$report_root/acceptance-report.json"
echo "local_acceptance_full=PASS run_id=$run_id certified_profile=$stable_profile report=$report_root/acceptance-report.json"
}
command=${1:-}
shift || true
require_local_cluster
load_environment
case $command in
quick)
[[ $# -eq 0 ]] || { usage >&2; exit 64; }
quick
;;
artifact-smoke)
[[ ${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
+8 -1
View File
@@ -7,13 +7,19 @@ source "$script_dir/common.sh"
load_cluster_env
cluster_ssh "$CLUSTER_NINGBO_HOST" \
'install -d -m 0755 /usr/local/lib/easyai-ai-gateway-release /usr/local/share/easyai-ai-gateway-release'
'install -d -m 0755 /usr/local/lib/easyai-ai-gateway-release /usr/local/share/easyai-ai-gateway-release/production'
cluster_scp "$cluster_root/deploy/kubernetes/easyai-ai-gateway-cluster-release" \
"$CLUSTER_NINGBO_HOST:/usr/local/sbin/easyai-ai-gateway-cluster-release"
cluster_scp "$cluster_root/deploy/kubernetes/easyai-ai-gateway-cluster-release.conf.example" \
"$CLUSTER_NINGBO_HOST:/usr/local/share/easyai-ai-gateway-release/easyai-ai-gateway-cluster-release.conf.example"
cluster_scp "$cluster_root/scripts/release-manifest.mjs" \
"$CLUSTER_NINGBO_HOST:/usr/local/lib/easyai-ai-gateway-release/release-manifest.mjs"
for desired_state_file in \
namespace.yaml service-account-rbac.yaml application-config.yaml application.yaml \
database.yaml network-policy.yaml kustomization.yaml; do
cluster_scp "$cluster_root/deploy/kubernetes/production/$desired_state_file" \
"$CLUSTER_NINGBO_HOST:/usr/local/share/easyai-ai-gateway-release/production/$desired_state_file"
done
cluster_ssh "$CLUSTER_NINGBO_HOST" 'bash -s' <<'REMOTE'
set -euo pipefail
if [[ ! -e /etc/easyai-ai-gateway-cluster-release.conf ]]; then
@@ -24,5 +30,6 @@ fi
chmod 0750 /usr/local/sbin/easyai-ai-gateway-cluster-release
chmod 0640 /etc/easyai-ai-gateway-cluster-release.conf
chmod 0755 /usr/local/lib/easyai-ai-gateway-release/release-manifest.mjs
find /usr/local/share/easyai-ai-gateway-release/production -type f -exec chmod 0644 {} +
REMOTE
echo 'cluster_release_helper_install=PASS'
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=scripts/cluster/common.sh
source "$script_dir/common.sh"
cluster_root=${cluster_root:?}
usage() {
cat <<'EOF'
Usage:
scripts/cluster/monitor-production-release.sh \
--run-id <production-run-id> --release-manifest dist/releases/<SHA>.json
Runs a finite 24-hour post-promotion monitor: 10-second samples for the first
2 hours, then 60-second samples. Test-only duration overrides are available via
AI_GATEWAY_RELEASE_MONITOR_DENSE_SECONDS and
AI_GATEWAY_RELEASE_MONITOR_TOTAL_SECONDS.
EOF
}
[[ ${1:-} == --run-id && ${3:-} == --release-manifest && $# -eq 4 ]] || {
usage >&2
exit 64
}
run_id=$2
release_manifest=$4
[[ $run_id =~ ^[0-9a-f-]{36}$ && -f $release_manifest && ! -L $release_manifest ]] || {
echo 'invalid monitor Run ID or release manifest' >&2
exit 64
}
load_cluster_env
require_commands curl jq node openssl
: "${AI_GATEWAY_ACCEPTANCE_ADMIN_TOKEN:?}"
: "${AI_GATEWAY_RELEASE_MONITOR_DENSE_SECONDS:=7200}"
: "${AI_GATEWAY_RELEASE_MONITOR_TOTAL_SECONDS:=86400}"
: "${AI_GATEWAY_RELEASE_MONITOR_DENSE_INTERVAL_SECONDS:=10}"
: "${AI_GATEWAY_RELEASE_MONITOR_SPARSE_INTERVAL_SECONDS:=60}"
[[ $AI_GATEWAY_RELEASE_MONITOR_DENSE_SECONDS =~ ^[0-9]+$ &&
$AI_GATEWAY_RELEASE_MONITOR_TOTAL_SECONDS =~ ^[1-9][0-9]*$ &&
$AI_GATEWAY_RELEASE_MONITOR_DENSE_INTERVAL_SECONDS =~ ^[1-9][0-9]*$ &&
$AI_GATEWAY_RELEASE_MONITOR_SPARSE_INTERVAL_SECONDS =~ ^[1-9][0-9]*$ &&
$AI_GATEWAY_RELEASE_MONITOR_DENSE_SECONDS -le $AI_GATEWAY_RELEASE_MONITOR_TOTAL_SECONDS ]] || {
echo 'invalid monitor duration configuration' >&2
exit 64
}
node "$cluster_root/scripts/release-manifest.mjs" validate "$release_manifest" >/dev/null
release_sha=$(node "$cluster_root/scripts/release-manifest.mjs" get "$release_manifest" sourceSha)
api_image=$(node "$cluster_root/scripts/release-manifest.mjs" get "$release_manifest" images.api)
api_digest=${api_image##*@}
namespace=${AI_GATEWAY_K3S_NAMESPACE:-easyai}
report_root=${AI_GATEWAY_ACCEPTANCE_REPORT_DIR:-"$cluster_root/dist/acceptance/$run_id"}
overall_report=$report_root/acceptance-report.json
monitor_report=$report_root/post-promotion-monitor.json
observations=$report_root/post-promotion-observations.csv
capacity_snapshot=$report_root/pre-acceptance-capacity.json
[[ -f $overall_report && ! -L $overall_report &&
-f $capacity_snapshot && ! -L $capacity_snapshot ]] || {
echo 'monitor requires the overall report and pre-acceptance capacity snapshot' >&2
exit 1
}
temporary_root=$(mktemp -d)
chmod 0700 "$temporary_root"
trap '[[ $temporary_root == /tmp/* || $temporary_root == /var/folders/* ]] && rm -rf -- "$temporary_root"' EXIT
remote_kubectl() {
local command_text
printf -v command_text '%q ' "$@"
cluster_ssh "$CLUSTER_NINGBO_HOST" "k3s kubectl $command_text"
}
database_query() {
local sql=$1 primary
primary=$(remote_kubectl get cluster easyai-postgres -n "$namespace" \
-o 'jsonpath={.status.currentPrimary}')
remote_kubectl exec -n "$namespace" "$primary" -c postgres -- \
psql -X -v ON_ERROR_STOP=1 -U postgres -d easyai_ai_gateway -At -c "$sql"
}
admin_request() {
local method=$1 path=$2 body=${3:-} output=$4
local service_ip service_port token_encoded body_encoded response status payload script command
service_ip=$(remote_kubectl get service api-ningbo-edge -n "$namespace" -o 'jsonpath={.spec.clusterIP}')
service_port=$(remote_kubectl get service api-ningbo-edge -n "$namespace" -o 'jsonpath={.spec.ports[0].port}')
token_encoded=$(printf '%s' "$AI_GATEWAY_ACCEPTANCE_ADMIN_TOKEN" | openssl base64 -A)
body_encoded=$(printf '%s' "$body" | openssl base64 -A)
# shellcheck disable=SC2016
script='
set -euo pipefail
token=$(printf "%s" "$4" | base64 -d)
body=$(printf "%s" "$5" | base64 -d)
args=(-sS --max-time 30 -w "\n%{http_code}" -X "$1" -H "Authorization: Bearer $token")
if [[ -n $body ]]; then args+=(-H "Content-Type: application/json" --data-binary "$body"); fi
curl "${args[@]}" "http://$2:$3$6"
'
printf -v command 'bash -c %q -- %q %q %q %q %q %q' \
"$script" "$method" "$service_ip" "$service_port" "$token_encoded" "$body_encoded" "$path"
response=$(cluster_ssh "$CLUSTER_NINGBO_HOST" "$command")
status=${response##*$'\n'}
payload=${response%$'\n'*}
printf '%s' "$payload" >"$output"
[[ $status =~ ^2[0-9][0-9]$ ]]
}
metric_sum() {
local pattern=$1 sum=0 pod value metrics
while read -r pod; do
[[ -n $pod ]] || continue
metrics=$(remote_kubectl exec -n "$namespace" "$pod" -- \
wget -qO- http://127.0.0.1:8088/metrics)
value=$(awk -v pattern="$pattern" '$0 ~ pattern {sum += $NF} END {print sum+0}' <<<"$metrics")
sum=$(awk -v left="$sum" -v right="$value" 'BEGIN {print left+right}')
done < <(remote_kubectl get pods -n "$namespace" \
-l app.kubernetes.io/name=easyai-worker \
-o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}')
printf '%s\n' "$sum"
}
pool_saturated_pods() {
local pod metrics max acquired idle saturated=0
while read -r pod; do
[[ -n $pod ]] || continue
metrics=$(remote_kubectl exec -n "$namespace" "$pod" -- \
wget -qO- http://127.0.0.1:8088/metrics)
max=$(awk '$1=="easyai_gateway_postgres_pool_max_connections" {print int($2)}' <<<"$metrics")
acquired=$(awk '$1=="easyai_gateway_postgres_pool_acquired_connections" {print int($2)}' <<<"$metrics")
idle=$(awk '$1=="easyai_gateway_postgres_pool_idle_connections" {print int($2)}' <<<"$metrics")
[[ -z $max || -z $acquired || -z $idle ]] || {
(( acquired == max && idle == 0 )) && saturated=$((saturated + 1))
}
done < <(remote_kubectl get pods -n "$namespace" \
-l 'app.kubernetes.io/part-of=easyai-ai-gateway' \
-o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}')
printf '%s\n' "$saturated"
}
check_wireguard() {
local -a hosts=("$CLUSTER_NINGBO_HOST" "$CLUSTER_HONGKONG_HOST" "$CLUSTER_LOS_ANGELES_HOST")
local -a ips=(10.77.0.1 10.77.0.2 10.77.0.3)
local source target output loss average handshakes
for source in 0 1 2; do
handshakes=$(cluster_ssh "${hosts[$source]}" \
"wg show wg0 latest-handshakes | awk '\$2 > 0 && systime()-\$2 < 180 {count++} END {print count+0}'")
(( handshakes == 2 )) || return 1
for target in 0 1 2; do
(( source == target )) && continue
output=$(cluster_ssh "${hosts[$source]}" "ping -q -c 10 -W 2 ${ips[$target]}") || return 1
loss=$(awk -F',' '/packet loss/ {gsub(/[^0-9.]/,"",$3); print $3}' <<<"$output")
average=$(awk -F'=' '/min\\/avg\\/max/ {split($2,v,"/"); gsub(/ /,"",v[2]); print v[2]}' <<<"$output")
if (( source == 2 || target == 2 )); then
awk -v loss="$loss" -v average="$average" 'BEGIN {exit !(loss < 1 && average < 300)}' || return 1
else
awk -v loss="$loss" -v average="$average" 'BEGIN {exit !(loss < 1 && average < 80)}' || return 1
fi
done
done
}
restore_previous_capacity() {
local remote_file="/root/easyai-pre-acceptance-$run_id.json"
cluster_scp "$capacity_snapshot" "$CLUSTER_NINGBO_HOST:$remote_file" >/dev/null
cluster_ssh "$CLUSTER_NINGBO_HOST" \
"k3s kubectl apply -f '$remote_file' >/dev/null && unlink '$remote_file'"
for deployment in easyai-api-ningbo easyai-api-hongkong easyai-worker-ningbo \
easyai-worker-hongkong easyai-capacity-controller; do
remote_kubectl rollout status deployment/"$deployment" -n "$namespace" --timeout=600s
done
}
pause_and_restore() {
local gate_id=$1 mode_file=$temporary_root/mode.json pause_file=$temporary_root/pause.json body
admin_request GET /api/admin/system/acceptance/traffic-mode '' "$mode_file"
body=$(jq -cn \
--argjson revision "$(jq -r '.revision' "$mode_file")" \
--arg releaseSha "$release_sha" \
--arg apiDigest "$api_digest" \
--arg reason "$gate_id" \
'{
revision:$revision,
releaseSha:$releaseSha,
apiImageDigest:$apiDigest,
workerImageDigest:$apiDigest,
reason:$reason
}')
admin_request POST /api/admin/system/acceptance/traffic-mode/pause "$body" "$pause_file"
[[ $(jq -r '.mode' "$pause_file") == validation ]]
restore_previous_capacity
}
started_epoch=$(date +%s)
started_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
monitor_since=$(date -u '+%Y-%m-%d %H:%M:%S+00')
baseline_lease_failure=$(metric_sum 'outcome="failure"')
baseline_lease_lost=$(metric_sum 'outcome="lost"')
samples=0
queue_growth_streak=0
pool_saturation_streak=0
previous_queue=0
failure_gate_id=
last_wireguard_epoch=0
printf 'timestamp,queue_depth,oldest_wait_seconds,connections,max_connections,saturated_pods,node_max_memory_percent,lease_failure,lease_lost\n' >"$observations"
while (( $(date +%s) - started_epoch < AI_GATEWAY_RELEASE_MONITOR_TOTAL_SECONDS )); do
now_epoch=$(date +%s)
elapsed=$((now_epoch - started_epoch))
interval=$AI_GATEWAY_RELEASE_MONITOR_SPARSE_INTERVAL_SECONDS
(( elapsed < AI_GATEWAY_RELEASE_MONITOR_DENSE_SECONDS )) &&
interval=$AI_GATEWAY_RELEASE_MONITOR_DENSE_INTERVAL_SECONDS
samples=$((samples + 1))
if ! curl -fsS --max-time 10 https://ai.51easyai.com/api/v1/healthz >/dev/null ||
! curl -fsS --max-time 10 https://ai.51easyai.com/api/v1/readyz >/dev/null; then
failure_gate_id=public_health
fi
ready_nodes=$(remote_kubectl get nodes -o json |
jq '[.items[] | select(any(.status.conditions[]; .type=="Ready" and .status=="True"))] | length')
pressure_nodes=$(remote_kubectl get nodes -o json |
jq '[.items[] | select(any(.status.conditions[]; .type=="MemoryPressure" and .status=="True"))] | length')
[[ $ready_nodes == 3 && $pressure_nodes == 0 ]] || failure_gate_id=${failure_gate_id:-node_health}
node_max_memory=$(remote_kubectl top nodes --no-headers |
awk '{gsub(/%/,"",$5); if ($5>max) max=$5} END {print max+0}')
(( node_max_memory < 85 )) || failure_gate_id=${failure_gate_id:-node_memory_hard}
unhealthy=$(remote_kubectl get pods -n "$namespace" -o json |
jq '[.items[]
| select(.metadata.labels["app.kubernetes.io/name"] == "easyai-api" or
.metadata.labels["app.kubernetes.io/name"] == "easyai-worker")
| .status.containerStatuses[]?
| select(.restartCount > 0 or .lastState.terminated.reason == "OOMKilled" or
.state.terminated.reason == "OOMKilled")] | length')
[[ $unhealthy == 0 ]] || failure_gate_id=${failure_gate_id:-pod_restart_or_oom}
pod_max_memory=$(remote_kubectl top pods -n "$namespace" \
-l 'app.kubernetes.io/part-of=easyai-ai-gateway' --no-headers |
awk '{
value=$3
if (value ~ /Gi$/) {sub(/Gi$/,"",value); value*=1024}
else if (value ~ /Mi$/) {sub(/Mi$/,"",value)}
else if (value ~ /Ki$/) {sub(/Ki$/,"",value); value/=1024}
if (value>max) max=value
} END {printf "%.0f",max}')
(( pod_max_memory < 1536 )) || failure_gate_id=${failure_gate_id:-pod_memory_hard}
[[ $(remote_kubectl get cluster easyai-postgres -n "$namespace" \
-o 'jsonpath={.status.readyInstances}') == 2 ]] || failure_gate_id=${failure_gate_id:-postgres_ready}
sync_state=$(database_query "SELECT COALESCE(string_agg(sync_state,','),'') FROM pg_stat_replication;")
[[ ",$sync_state," == *,sync,* || ",$sync_state," == *,quorum,* ]] ||
failure_gate_id=${failure_gate_id:-postgres_replication}
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 )) ||
failure_gate_id=${failure_gate_id:-postgres_connections}
queue_state=$(database_query "
SELECT count(*) FILTER (WHERE status='queued')||':'||
COALESCE(EXTRACT(EPOCH FROM now()-MIN(created_at)) FILTER (WHERE status='queued'),0)::bigint
FROM gateway_tasks
WHERE run_mode='production';")
IFS=: read -r queue_depth oldest_wait <<<"$queue_state"
if (( queue_depth > previous_queue && queue_depth > 0 )); then
queue_growth_streak=$((queue_growth_streak + 1))
else
queue_growth_streak=0
fi
previous_queue=$queue_depth
(( oldest_wait <= 900 && queue_growth_streak < 4 )) ||
failure_gate_id=${failure_gate_id:-queue_growth_or_oldest_wait}
saturated=$(pool_saturated_pods)
if (( saturated > 0 )); then
pool_saturation_streak=$((pool_saturation_streak + 1))
else
pool_saturation_streak=0
fi
(( pool_saturation_streak * interval < 30 )) ||
failure_gate_id=${failure_gate_id:-postgres_pool_saturation}
lease_failure=$(metric_sum 'outcome="failure"')
lease_lost=$(metric_sum 'outcome="lost"')
awk -v current="$lease_failure" -v baseline="$baseline_lease_failure" 'BEGIN {exit !(current<=baseline)}' ||
failure_gate_id=${failure_gate_id:-lease_renewal_failure}
awk -v current="$lease_lost" -v baseline="$baseline_lease_lost" 'BEGIN {exit !(current<=baseline)}' ||
failure_gate_id=${failure_gate_id:-lease_lost}
duplicates=$(database_query "
SELECT
(SELECT count(*) FROM (
SELECT remote_task_id
FROM gateway_tasks
WHERE run_mode='production' AND created_at >= '$monitor_since'::timestamptz
AND remote_task_id IS NOT NULL
GROUP BY remote_task_id HAVING count(*)>1
) d)
+
(SELECT count(*) FROM (
SELECT reference_id,transaction_type
FROM gateway_wallet_transactions
WHERE created_at >= '$monitor_since'::timestamptz
AND reference_type='gateway_task'
GROUP BY reference_id,transaction_type HAVING count(*)>1
) d);")
[[ $duplicates == 0 ]] || failure_gate_id=${failure_gate_id:-duplicate_execution_or_billing}
if (( now_epoch - last_wireguard_epoch >= 300 )); then
check_wireguard || failure_gate_id=${failure_gate_id:-wireguard}
last_wireguard_epoch=$now_epoch
fi
printf '%s,%s,%s,%s,%s,%s,%s,%s,%s\n' \
"$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$queue_depth" "$oldest_wait" \
"$connections" "$max_connections" "$saturated" "$node_max_memory" \
"$lease_failure" "$lease_lost" >>"$observations"
[[ -z $failure_gate_id ]] || break
sleep "$interval"
done
finished_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
passed=true
traffic_mode=live
restored=false
if [[ -n $failure_gate_id ]]; then
passed=false
pause_and_restore "$failure_gate_id"
traffic_mode=validation
restored=true
fi
jq -n \
--arg runId "$run_id" \
--arg releaseSha "$release_sha" \
--arg startedAt "$started_at" \
--arg finishedAt "$finished_at" \
--arg failureGateId "$failure_gate_id" \
--arg trafficMode "$traffic_mode" \
--argjson samples "$samples" \
--argjson passed "$passed" \
--argjson restored "$restored" \
'{
schemaVersion:"acceptance-monitor-report/v1",
runId:$runId,
releaseSha:$releaseSha,
startedAt:$startedAt,
finishedAt:$finishedAt,
samples:$samples,
passed:$passed,
failureGateId:($failureGateId | if length>0 then . else null end),
trafficMode:$trafficMode,
previousCapacityRestored:$restored,
secretSafe:true
}' >"$monitor_report"
chmod 0600 "$monitor_report" "$observations"
node "$cluster_root/scripts/acceptance/report.mjs" attach-monitor \
--input "$overall_report" --monitor "$monitor_report" --output "$overall_report" >/dev/null
if [[ $passed == true ]]; then
echo "production_release_monitor=PASS run_id=$run_id samples=$samples duration_seconds=$AI_GATEWAY_RELEASE_MONITOR_TOTAL_SECONDS traffic_mode=live"
else
echo "production_release_monitor=FAIL run_id=$run_id gate_id=$failure_gate_id traffic_mode=validation previous_capacity_restored=true" >&2
exit 1
fi
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=scripts/cluster/common.sh
source "$script_dir/common.sh"
cluster_root=${cluster_root:?}
usage() {
cat <<'EOF'
Usage:
scripts/cluster/run-production-acceptance.sh \
--promote dist/releases/<SHA>.json --run-id <production-run-id>
This is the only acceptance action that switches production traffic from
validation to live. It requires an already-passed overall acceptance report and
rechecks the release, digest, config, Run, and traffic-mode CAS fields.
EOF
}
[[ ${1:-} == --promote && ${3:-} == --run-id && $# -eq 4 ]] || {
usage >&2
exit 64
}
release_manifest=$2
run_id=$4
[[ -f $release_manifest && ! -L $release_manifest ]] || {
echo 'release manifest must be a regular non-symlink file' >&2
exit 1
}
[[ $run_id =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || {
echo 'run ID must be a lowercase UUID' >&2
exit 64
}
load_cluster_env
require_commands curl git jq node openssl
: "${AI_GATEWAY_ACCEPTANCE_ADMIN_TOKEN:?}"
node "$cluster_root/scripts/release-manifest.mjs" validate "$release_manifest" >/dev/null
release_sha=$(node "$cluster_root/scripts/release-manifest.mjs" get "$release_manifest" sourceSha)
api_image=$(node "$cluster_root/scripts/release-manifest.mjs" get "$release_manifest" images.api)
api_digest=${api_image##*@}
[[ $(git -C "$cluster_root" rev-parse HEAD) == "$release_sha" &&
-z $(git -C "$cluster_root" status --short) ]] || {
echo 'manual promotion requires the clean release source at HEAD' >&2
exit 1
}
report=${AI_GATEWAY_ACCEPTANCE_REPORT_DIR:-"$cluster_root/dist/acceptance/$run_id"}/acceptance-report.json
[[ -f $report && ! -L $report ]] || {
echo "passed overall acceptance report is missing: $report" >&2
exit 1
}
node "$cluster_root/scripts/acceptance/report.mjs" validate --input "$report" >/dev/null
[[ $(jq -r '.runId' "$report") == "$run_id" &&
$(jq -r '.release.sourceSha' "$report") == "$release_sha" &&
$(jq -r '.release.apiImageDigest' "$report") == "$api_digest" &&
$(jq -r '.stages.onlineSimulation.status' "$report") == passed &&
$(jq -r '.stages.realCanary.status' "$report") == passed &&
$(jq -r '.promotion.ready' "$report") == true &&
$(jq -r '.promotion.trafficMode' "$report") == validation ]] || {
echo 'overall acceptance report is not ready for this manual promotion' >&2
exit 1
}
namespace=${AI_GATEWAY_K3S_NAMESPACE:-easyai}
remote_release_helper=${AI_GATEWAY_REMOTE_RELEASE_HELPER:-/usr/local/sbin/easyai-ai-gateway-cluster-release}
temporary_root=$(mktemp -d)
chmod 0700 "$temporary_root"
trap '[[ $temporary_root == /tmp/* || $temporary_root == /var/folders/* ]] && rm -rf -- "$temporary_root"' EXIT
remote_kubectl() {
local command_text
printf -v command_text '%q ' "$@"
cluster_ssh "$CLUSTER_NINGBO_HOST" "k3s kubectl $command_text"
}
admin_request() {
local method=$1 path=$2 body=${3:-} output=$4
local service_ip service_port token_encoded body_encoded response status payload remote_script command
service_ip=$(remote_kubectl get service api-ningbo-edge -n "$namespace" \
-o 'jsonpath={.spec.clusterIP}')
service_port=$(remote_kubectl get service api-ningbo-edge -n "$namespace" \
-o 'jsonpath={.spec.ports[0].port}')
token_encoded=$(printf '%s' "$AI_GATEWAY_ACCEPTANCE_ADMIN_TOKEN" | openssl base64 -A)
body_encoded=$(printf '%s' "$body" | openssl base64 -A)
# shellcheck disable=SC2016
remote_script='
set -euo pipefail
token=$(printf "%s" "$4" | base64 -d)
body=$(printf "%s" "$5" | base64 -d)
args=(-sS --max-time 30 -w "\n%{http_code}" -X "$1" -H "Authorization: Bearer $token")
if [[ -n $body ]]; then
args+=(-H "Content-Type: application/json" --data-binary "$body")
fi
curl "${args[@]}" "http://$2:$3$6"
'
printf -v command 'bash -c %q -- %q %q %q %q %q %q' \
"$remote_script" "$method" "$service_ip" "$service_port" \
"$token_encoded" "$body_encoded" "$path"
response=$(cluster_ssh "$CLUSTER_NINGBO_HOST" "$command")
status=${response##*$'\n'}
payload=${response%$'\n'*}
printf '%s' "$payload" >"$output"
[[ $status =~ ^2[0-9][0-9]$ ]] || {
echo "acceptance control API failed: method=$method path=$path status=$status" >&2
return 1
}
}
current_manifest=$temporary_root/current.json
cluster_ssh "$CLUSTER_NINGBO_HOST" "$remote_release_helper status" >"$current_manifest"
node "$cluster_root/scripts/release-manifest.mjs" validate "$current_manifest" >/dev/null
[[ $(node "$cluster_root/scripts/release-manifest.mjs" get "$current_manifest" sourceSha) == "$release_sha" &&
$(node "$cluster_root/scripts/release-manifest.mjs" get "$current_manifest" images.api) == "$api_image" ]] || {
echo 'production release changed after acceptance' >&2
exit 1
}
for deployment in easyai-api-ningbo easyai-api-hongkong easyai-worker-ningbo easyai-worker-hongkong; do
[[ $(remote_kubectl get deployment "$deployment" -n "$namespace" \
-o 'jsonpath={.spec.template.spec.containers[0].image}') == "$api_image" ]]
done
mode_file=$temporary_root/mode.json
run_file=$temporary_root/run.json
promotion_file=$temporary_root/promotion.json
admin_request GET /api/admin/system/acceptance/traffic-mode '' "$mode_file"
admin_request GET "/api/admin/system/acceptance/runs/$run_id" '' "$run_file"
[[ $(jq -r '.mode' "$mode_file") == validation &&
$(jq -r '.runId' "$mode_file") == "$run_id" &&
$(jq -r '.releaseSha' "$mode_file") == "$release_sha" &&
$(jq -r '.apiImageDigest' "$mode_file") == "$api_digest" &&
$(jq -r '.status' "$run_file") == passed ]] || {
echo 'production acceptance Run or traffic-mode CAS changed before promotion' >&2
exit 1
}
config_hash=$(jq -r '.config.validationConfigHash' "$run_file")
profiles=$(jq -c '.config.validationCapacityProfiles' "$run_file")
[[ $config_hash =~ ^[0-9a-f]{64}$ && $(jq 'type == "array" and length > 0' <<<"$profiles") == true ]]
[[ $(remote_kubectl get configmap easyai-gateway-release -n "$namespace" \
-o 'jsonpath={.data.configHash}') == "$config_hash" ]] || {
echo 'production configuration changed after acceptance' >&2
exit 1
}
body=$(jq -cn \
--argjson revision "$(jq -r '.revision' "$mode_file")" \
--arg releaseSha "$release_sha" \
--arg apiDigest "$api_digest" \
--arg configHash "$config_hash" \
--argjson capacityProfiles "$profiles" \
'{
revision:$revision,
releaseSha:$releaseSha,
apiImageDigest:$apiDigest,
workerImageDigest:$apiDigest,
configHash:$configHash,
capacityProfiles:$capacityProfiles
}')
admin_request POST "/api/admin/system/acceptance/runs/$run_id/promote" "$body" "$promotion_file"
[[ $(jq -r '.mode' "$promotion_file") == live &&
$(jq -r '.runId' "$promotion_file") == "$run_id" ]] || {
echo 'live promotion CAS did not complete' >&2
exit 1
}
node "$cluster_root/scripts/acceptance/report.mjs" mark-promoted \
--input "$report" --output "$report" >/dev/null
echo "production_acceptance_promotion=PASS run_id=$run_id release=$release_sha traffic_mode=live"
exec "$script_dir/monitor-production-release.sh" \
--run-id "$run_id" --release-manifest "$release_manifest"
File diff suppressed because it is too large Load Diff
+3
View File
@@ -114,6 +114,9 @@ else
fi
scp "${scp_options[@]}" "$manifest" "$production_host:$remote_manifest"
if [[ $deploy_mode == kubernetes ]]; then
"$root/scripts/cluster/install-release-helper.sh"
fi
# shellcheck disable=SC2029 # remote_manifest is derived from a validated full SHA.
ssh "${ssh_options[@]}" "$production_host" "$remote_helper" deploy "$remote_manifest"
echo "production_deploy=PASS release=$source_sha"
+15
View File
@@ -15,6 +15,21 @@
"non-null column addition"
],
"reason": "The table stores ephemeral Worker heartbeats and currently has only two production rows. PostgreSQL adds the integer column with a constant default, then the migration backfills each live row from its existing allocation so a rolling deployment cannot assign unsafe failover capacity."
},
"apps/api/migrations/0095_oidc_session_context_identity.sql": {
"sha256": "3e6d17b5b564047c4a77ef3e4cf4ed88caba375a34a33bc7ecfbd7c59d6ab479",
"allowedViolations": [
"destructive DROP operation",
"non-null column addition"
],
"reason": "The migration adds only nullable session identity columns, backfills bound sessions, and replaces one CHECK constraint without dropping data. Production preflight on 2026-07-31 found zero gateway_oidc_sessions rows and a 48 KiB relation, so the constraint validation and metadata lock are bounded; the non-null finding comes from the new CHECK expression rather than a NOT NULL column addition."
},
"apps/api/migrations/0096_worker_capacity_controller.sql": {
"sha256": "52c2c5f31eaa61b96508ae9b2c9fa164e0f1544184c55a1ba85f64b9aca442cb",
"allowedViolations": [
"destructive DROP operation"
],
"reason": "The migration replaces only the existing Worker status CHECK constraint so a rolling deployment can mark lost instances stale. It does not drop data, columns, tables, or indexes; the old and new binaries both continue to read active and draining rows safely."
}
}
}
+6
View File
@@ -373,10 +373,16 @@ esac
mkdir -p dist/releases
manifest=$root/dist/releases/$source_sha.json
[[ ! -e $manifest ]] || fail "release manifest already exists: $manifest"
migration_file=$(git ls-files 'apps/api/migrations/*.sql' | LC_ALL=C sort | tail -n 1)
migration_version=${migration_file##*/}
migration_version=${migration_version%.sql}
[[ $migration_version =~ ^[0-9]{4}_[a-z0-9][a-z0-9_]*$ ]] ||
fail 'could not resolve the latest database migration version'
RELEASE_SOURCE_SHA=$source_sha \
RELEASE_BASE_SHA=$base_sha \
RELEASE_COMPONENTS=$component_list \
RELEASE_MIGRATIONS_CHANGED=$migrations_changed \
RELEASE_MIGRATION_VERSION=$migration_version \
RELEASE_API_IMAGE=$api_image \
RELEASE_WEB_IMAGE=$web_image \
RELEASE_BUILD_COMPLETED_AT=$build_completed_at \
+55
View File
@@ -32,6 +32,7 @@ function releaseIntegrity(manifest) {
const payload = structuredClone(manifest)
delete payload.integrity
delete payload.deployment
delete payload.certification
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
}
@@ -58,6 +59,8 @@ function validate(manifest) {
'migrationsChanged', 'images', 'smoke', 'integrity',
]
if (manifest.deployment !== undefined) rootKeys.push('deployment')
if (manifest.certification !== undefined) rootKeys.push('certification')
if (manifest.migrationVersion !== undefined) rootKeys.push('migrationVersion')
requireExactKeys(manifest, rootKeys, 'manifest')
if (manifest.schemaVersion !== 1) fail('schemaVersion must be 1')
if (!shaPattern.test(manifest.sourceSha || '')) fail('sourceSha must be a full lowercase Git SHA')
@@ -84,6 +87,10 @@ function validate(manifest) {
if (manifest.migrationsChanged && !manifest.components.includes('api')) {
fail('migration changes require the api component')
}
if (manifest.migrationVersion !== undefined &&
!/^\d{4}_[a-z0-9][a-z0-9_]*$/.test(manifest.migrationVersion)) {
fail('migrationVersion must identify the latest migration without .sql')
}
if (!manifest.images || typeof manifest.images !== 'object') fail('images must be an object')
requireExactKeys(manifest.images, ['api', 'web'], 'images')
for (const component of allowedComponents) {
@@ -126,6 +133,28 @@ function validate(manifest) {
fail('deployment record is invalid')
}
}
if (manifest.certification !== undefined) {
if (!manifest.certification || typeof manifest.certification !== 'object') {
fail('certification must be an object')
}
requireExactKeys(manifest.certification, [
'status', 'runId', 'certifiedAt', 'configHash', 'capacityProfile',
'workerInstanceSlots', 'workerMaxReplicasNingbo', 'workerMaxReplicasHongkong',
], 'certification')
if (manifest.certification.status !== 'passed' ||
!/^[0-9a-f-]{36}$/.test(manifest.certification.runId || '') ||
!Number.isFinite(Date.parse(manifest.certification.certifiedAt)) ||
!/^[0-9a-f]{64}$/.test(manifest.certification.configHash || '') ||
!['P24', 'P28', 'P32'].includes(manifest.certification.capacityProfile) ||
!Number.isInteger(manifest.certification.workerInstanceSlots) ||
manifest.certification.workerInstanceSlots < 1 ||
!Number.isInteger(manifest.certification.workerMaxReplicasNingbo) ||
manifest.certification.workerMaxReplicasNingbo < 1 ||
!Number.isInteger(manifest.certification.workerMaxReplicasHongkong) ||
manifest.certification.workerMaxReplicasHongkong < 1) {
fail('certification record is invalid')
}
}
const forbiddenKey = /(password|secret|token|credential|private.?key)/i
const visit = (value, path = '') => {
@@ -166,6 +195,7 @@ if (command === 'create') {
targetPlatform: 'linux/amd64',
components,
migrationsChanged,
migrationVersion: requireEnv('RELEASE_MIGRATION_VERSION', /^\d{4}_[a-z0-9][a-z0-9_]*$/),
images: {
api: requireEnv('RELEASE_API_IMAGE', digestImagePattern),
web: requireEnv('RELEASE_WEB_IMAGE', digestImagePattern),
@@ -206,6 +236,31 @@ if (command === 'record-deployment') {
process.exit(0)
}
if (command === 'record-certification') {
if (!field) fail('record-certification requires an output path')
const certifiedAt = requireEnv('RELEASE_CERTIFIED_AT')
const runId = requireEnv('RELEASE_ACCEPTANCE_RUN_ID', /^[0-9a-f-]{36}$/)
const configHash = requireEnv('RELEASE_CONFIG_HASH', /^[0-9a-f]{64}$/)
const capacityProfile = requireEnv('RELEASE_CAPACITY_PROFILE', /^P(?:24|28|32)$/)
const workerInstanceSlots = Number(process.env.RELEASE_WORKER_INSTANCE_SLOTS || '')
const workerMaxReplicasNingbo = Number(process.env.RELEASE_WORKER_MAX_REPLICAS_NINGBO || '')
const workerMaxReplicasHongkong = Number(process.env.RELEASE_WORKER_MAX_REPLICAS_HONGKONG || '')
manifest.certification = {
status: 'passed',
runId,
certifiedAt,
configHash,
capacityProfile,
workerInstanceSlots,
workerMaxReplicasNingbo,
workerMaxReplicasHongkong,
}
validate(manifest)
writeFileSync(field, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
console.log(`release_manifest=CERTIFIED release=${manifest.releaseId} run=${runId}`)
process.exit(0)
}
if (command === 'validate') {
const digest = createHash('sha256').update(readFileSync(path)).digest('hex')
console.log(`release_manifest=PASS release=${manifest.releaseId} sha256=${digest}`)