chore(merge): 整合最新 main 与统一认证变更
ci / verify (pull_request) Successful in 12m18s

合并远端生产 CI、依赖与 API 文档改动,保留统一认证和 SSF 能力。由于远端生产基线已占用 0062,将尚未发布的身份迁移整理为 0063 至 0068 的最终增量 Schema,移除仅用于开发期草稿升级的破坏性迁移步骤。

验证:go test ./...。其余生产门禁在合并提交后继续执行。
This commit is contained in:
2026-07-17 19:06:37 +08:00
100 changed files with 9673 additions and 1799 deletions
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$root"
tag=${IMAGE_TAG:-}
registry=${AI_GATEWAY_IMAGE_REGISTRY:-registry.cn-shanghai.aliyuncs.com/easyaigc}
builder=${AI_GATEWAY_BUILDX_BUILDER:-easyai-gateway-ci}
cache_limit=${AI_GATEWAY_BUILDX_CACHE_LIMIT:-2gb}
platform=${AI_GATEWAY_PLATFORM:-linux/amd64}
keep_images=0
build_complete=0
readonly trivy_db_repository=ghcr.m.daocloud.io/aquasecurity/trivy-db:2
case ${1:-} in
'') ;;
--keep-images) keep_images=1 ;;
*)
echo 'usage: ci-build-images.sh [--keep-images]' >&2
exit 64
;;
esac
if [[ ! $tag =~ ^[0-9a-f]{40}$ ]]; then
echo 'IMAGE_TAG must be a full lowercase Git SHA' >&2
exit 1
fi
if [[ ! $registry =~ ^[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._/-]+)+$ ]]; then
echo 'AI_GATEWAY_IMAGE_REGISTRY has an invalid format' >&2
exit 1
fi
if [[ ! $cache_limit =~ ^[1-9][0-9]*(kb|mb|gb)$ ]]; then
echo 'AI_GATEWAY_BUILDX_CACHE_LIMIT has an invalid format' >&2
exit 1
fi
api_image="$registry/ai-gateway:$tag"
web_image="$registry/ai-gateway-web:$tag"
cleanup() {
original_status=$?
trap - EXIT HUP INT TERM
cleanup_status=0
docker buildx prune --builder "$builder" --force --max-used-space "$cache_limit" >/dev/null || {
cleanup_status=$?
echo 'failed to prune the CI BuildKit cache' >&2
}
if [[ $keep_images -ne 1 || $build_complete -ne 1 ]]; then
docker image rm "$api_image" "$web_image" >/dev/null 2>&1 || {
image_rm_status=$?
[[ $cleanup_status -ne 0 ]] || cleanup_status=$image_rm_status
echo 'failed to remove CI image tags' >&2
}
fi
if [[ $original_status -ne 0 ]]; then
exit "$original_status"
fi
exit "$cleanup_status"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
if ! docker buildx inspect "$builder" >/dev/null 2>&1; then
docker buildx create --name "$builder" --driver docker-container --use >/dev/null
fi
docker buildx inspect "$builder" --bootstrap >/dev/null
common_args=(
--builder "$builder"
--platform "$platform"
--file Dockerfile
--pull
--load
)
docker buildx build --builder "$builder" \
"${common_args[@]:2}" \
--target api \
--build-arg "GO_BUILD_IMAGE=${AI_GATEWAY_GO_BUILD_IMAGE:-docker.m.daocloud.io/library/golang:1.26.5-alpine}" \
--build-arg "API_RUNTIME_IMAGE=${AI_GATEWAY_API_RUNTIME_IMAGE:-docker.m.daocloud.io/library/alpine:3.22}" \
--tag "$api_image" .
docker buildx build --builder "$builder" \
"${common_args[@]:2}" \
--target web \
--build-arg "NODE_BUILD_IMAGE=${AI_GATEWAY_NODE_BUILD_IMAGE:-docker.m.daocloud.io/library/node:24-alpine}" \
--build-arg "WEB_RUNTIME_IMAGE=${AI_GATEWAY_WEB_RUNTIME_IMAGE:-docker.m.daocloud.io/library/nginx:1.27-alpine}" \
--tag "$web_image" .
for image in "$api_image" "$web_image"; do
trivy image --db-repository "$trivy_db_repository" \
--scanners vuln,secret --severity HIGH,CRITICAL --ignore-unfixed \
--exit-code 1 --timeout 15m "$image"
done
build_complete=1
printf 'gateway_images=PASS git_sha=%s api=%s web=%s\n' "$tag" "$api_image" "$web_image"
+435
View File
@@ -0,0 +1,435 @@
#!/usr/bin/env node
import { execFileSync, spawnSync } from 'node:child_process'
import { lstatSync, readFileSync } from 'node:fs'
import { basename } from 'node:path'
const migrationDirectory = 'apps/api/migrations/'
const baselineFile = 'deploy/ci/production-migration-base'
const base = process.argv[2] || process.env.PRODUCTION_MIGRATION_BASE_SHA || ''
const immutableRef =
process.argv[3] || process.env.MIGRATION_IMMUTABILITY_REF || ''
function fail(message) {
console.error(`migration_safety=FAIL ${message}`)
process.exit(1)
}
function git(args, options = {}) {
try {
return execFileSync('git', args, {
encoding: options.encoding ?? 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
} catch (error) {
const stderr = error.stderr?.toString().trim()
fail(stderr ? `git_error=${JSON.stringify(stderr)}` : 'git command failed')
}
}
if (!/^[0-9a-f]{40}$/.test(base)) {
fail('PRODUCTION_MIGRATION_BASE_SHA must be a full lowercase commit SHA')
}
const root = git(['rev-parse', '--show-toplevel']).trim()
process.chdir(root)
const resolvedBase = git([
'rev-parse',
'--verify',
'--end-of-options',
`${base}^{commit}`,
]).trim()
if (resolvedBase !== base) {
fail('production migration baseline did not resolve exactly')
}
const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', base, 'HEAD'], {
stdio: 'ignore',
})
if (ancestry.status !== 0) {
fail('production migration baseline is not an ancestor of HEAD')
}
const head = git(['rev-parse', '--verify', 'HEAD^{commit}']).trim()
const baselineTreeEntry = git(['ls-tree', 'HEAD', '--', baselineFile]).trim()
let baselineState
try {
baselineState = lstatSync(baselineFile)
} catch {
fail('production migration baseline file is unavailable in the worktree')
}
if (
!/^100644 blob [0-9a-f]+\t/.test(baselineTreeEntry) ||
!baselineState.isFile() ||
baselineState.isSymbolicLink()
) {
fail('production migration baseline must be a mode 100644 regular file')
}
if (readFileSync(baselineFile, 'utf8').trim() !== base) {
fail('production migration baseline argument does not match the reviewed file')
}
function changedMigrations(from) {
const rawChanges = git(
[
'diff',
'--name-status',
'-z',
'--no-renames',
`${from}..HEAD`,
'--',
migrationDirectory,
],
{ encoding: null },
)
const fields = rawChanges.toString('utf8').split('\0')
if (fields.at(-1) === '') fields.pop()
if (fields.length % 2 !== 0) {
fail('could not parse changed migration paths')
}
return fields
}
const fields = changedMigrations(base)
function executableSql(sql, source = 'SQL input') {
let output = ''
for (let index = 0; index < sql.length; index += 1) {
const current = sql[index]
const next = sql[index + 1]
if (current === '-' && next === '-') {
index += 2
while (index < sql.length && sql[index] !== '\n') index += 1
output += '\n'
continue
}
if (current === '/' && next === '*') {
let depth = 1
index += 2
while (index < sql.length && depth > 0) {
if (sql[index] === '/' && sql[index + 1] === '*') {
depth += 1
index += 2
continue
}
if (sql[index] === '*' && sql[index + 1] === '/') {
depth -= 1
index += 2
continue
}
index += 1
}
if (depth !== 0) fail('unterminated SQL block comment')
index -= 1
output += ' '
continue
}
if (current === "'" || current === '"') {
const prefix = sql.slice(Math.max(0, index - 2), index).toUpperCase()
const previous = sql[index - 1]?.toUpperCase()
const beforePrevious = sql[index - 2] || ''
const beforeUnicodePrefix = sql[index - 3] || ''
const escapeString =
current === "'" &&
previous === 'E' &&
!/[A-Za-z0-9_$]/.test(beforePrevious)
const unicodeString =
prefix === 'U&' && !/[A-Za-z0-9_$]/.test(beforeUnicodePrefix)
if (escapeString || unicodeString) {
fail(`${source}: PostgreSQL escape/unicode strings are not allowed in migrations`)
}
const quote = current
index += 1
let closed = false
while (index < sql.length) {
if (sql[index] === quote && sql[index + 1] === quote) {
index += 2
continue
}
if (sql[index] === quote) {
closed = true
break
}
index += 1
}
if (!closed) fail('unterminated SQL quoted value')
output += ' '
continue
}
if (current === '$') {
const delimiter = sql.slice(index).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)?.[0]
if (delimiter) {
const bodyStart = index + delimiter.length
const bodyEnd = sql.indexOf(delimiter, bodyStart)
if (bodyEnd === -1) fail('unterminated SQL dollar-quoted body')
// PostgreSQL function bodies may contain executable DDL. Scan their
// contents recursively instead of treating them as inert strings.
const bodySql = executableSql(sql.slice(bodyStart, bodyEnd), source)
// A bare END terminates a PL/pgSQL block inside a dollar-quoted body,
// but at the top level PostgreSQL treats END as COMMIT. Mask only the
// block terminator here so the top-level transaction rule stays exact.
const bodyWithoutBlockEnd = bodySql
.split(';')
.map((statement) => (/^\s*END\s*$/i.test(statement) ? ' ' : statement))
.join(';')
output += ` ${bodyWithoutBlockEnd} `
index = bodyEnd + delimiter.length - 1
continue
}
}
output += current
}
return output
}
const destructiveRules = [
{
name: 'procedural SQL body',
pattern:
/^\s*(?:DO\b|CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE)\b)/i,
},
{
name: 'dynamic SQL execution',
pattern: /\bEXECUTE\b/i,
},
{
name: 'destructive DROP operation',
pattern: /\bDROP\b/i,
},
{ name: 'MERGE operation', pattern: /\bMERGE\b/i },
{
name: 'CREATE OR REPLACE operation',
pattern: /\bCREATE\s+OR\s+REPLACE\b/i,
},
{ name: 'TRUNCATE operation', pattern: /\bTRUNCATE(?:\s+TABLE)?\b/i },
{ name: 'DELETE FROM operation', pattern: /\bDELETE\s+FROM\b/i },
{ name: 'permission revocation', pattern: /\bREVOKE\b/i },
{
name: 'transaction control operation',
pattern:
/\b(?:COMMIT|ROLLBACK|ABORT|SAVEPOINT)\b|\bRELEASE\s+SAVEPOINT\b|\bPREPARE\s+TRANSACTION\b|^\s*END(?:\s+(?:WORK|TRANSACTION))?(?:\s+AND\s+(?:NO\s+)?CHAIN)?\s*$/i,
},
{
name: 'SQL string semantics override',
pattern:
/\bSET\s+(?:LOCAL\s+|SESSION\s+)?STANDARD_CONFORMING_STRINGS\b/i,
},
{
name: 'object or column rename',
pattern: /\bRENAME\b/i,
},
{ name: 'SET SCHEMA operation', pattern: /\bSET\s+SCHEMA\b/i },
{
name: 'column type change',
pattern:
/\bALTER\s+TABLE\b[\s\S]*\bALTER\s+(?:COLUMN\s+)?[\s\S]*\b(?:SET\s+DATA\s+)?TYPE\b/i,
},
{
name: 'SET NOT NULL operation',
pattern:
/\bALTER\s+TABLE\b[\s\S]*\bALTER\s+(?:COLUMN\s+)?[\s\S]*\bSET\s+NOT\s+NULL\b/i,
},
{
name: 'non-null column addition',
pattern:
/\bALTER\s+TABLE\b[\s\S]*\bADD\s+(?:COLUMN\s+)?[\s\S]*\bNOT\s+NULL\b/i,
},
{
name: 'DROP DEFAULT operation',
pattern:
/\bALTER\s+TABLE\b[\s\S]*\bALTER\s+(?:COLUMN\s+)?[\s\S]*\bDROP\s+DEFAULT\b/i,
},
]
const violations = []
let addedFiles = 0
let immutableBase = ''
const orderingCandidates = new Set()
if (immutableRef) {
if (
!/^[0-9a-f]{40}$/.test(immutableRef) &&
immutableRef !== 'refs/remotes/origin/main'
) {
fail('MIGRATION_IMMUTABILITY_REF must be a full commit SHA or refs/remotes/origin/main')
}
const immutableTip = git([
'rev-parse',
'--verify',
'--end-of-options',
`${immutableRef}^{commit}`,
]).trim()
const baseAtEvent = spawnSync(
'git',
['show', `${immutableTip}:${baselineFile}`],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
)
if (baseAtEvent.status === 0) {
const previousBaselineTreeEntry = git([
'ls-tree',
immutableTip,
'--',
baselineFile,
]).trim()
if (!/^100644 blob [0-9a-f]+\t/.test(previousBaselineTreeEntry)) {
fail('previous production migration baseline must be a mode 100644 regular file')
}
const previousProductionBase = baseAtEvent.stdout.trim()
if (!/^[0-9a-f]{40}$/.test(previousProductionBase)) {
fail('previous production migration baseline is invalid')
}
const baselineAdvance = spawnSync(
'git',
['merge-base', '--is-ancestor', previousProductionBase, base],
{ stdio: 'ignore' },
)
if (baselineAdvance.status !== 0) {
fail('production migration baseline must only move forward')
}
}
const baselineWasAlreadyReviewed = spawnSync(
'git',
['merge-base', '--is-ancestor', base, immutableTip],
{ stdio: 'ignore' },
)
if (baselineWasAlreadyReviewed.status !== 0) {
fail('production migration baseline must belong to the immutable event base history')
}
if (immutableTip === head) {
const parent = spawnSync(
'git',
['rev-parse', '--verify', `${immutableTip}^1^{commit}`],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
)
immutableBase = parent.status === 0 ? parent.stdout.trim() : ''
} else {
const immutableAncestry = spawnSync(
'git',
['merge-base', '--is-ancestor', immutableTip, head],
{ stdio: 'ignore' },
)
if (immutableAncestry.status !== 0) {
fail('migration immutability base is not an ancestor of HEAD')
}
immutableBase = immutableTip
}
if (immutableBase) {
const immutableChanges = changedMigrations(immutableBase)
for (let index = 0; index < immutableChanges.length; index += 2) {
const status = immutableChanges[index]
const file = immutableChanges[index + 1]
if (status === 'A') {
orderingCandidates.add(file)
} else {
violations.push(
`${file}: migration present on main is immutable (status ${status})`,
)
}
}
}
}
function migrationNamesAt(ref) {
const output = git(
['ls-tree', '-r', '-z', '--name-only', ref, '--', migrationDirectory],
{ encoding: null },
)
return output
.toString('utf8')
.split('\0')
.filter(Boolean)
.filter((file) => {
const relative = file.slice(migrationDirectory.length)
return file.startsWith(migrationDirectory) && !relative.includes('/')
})
.map((file) => basename(file))
.filter((name) => /^\d{4}_[a-z0-9][a-z0-9_]*\.sql$/.test(name))
.sort()
}
const orderingBase = immutableBase || base
const existingNames = migrationNamesAt(orderingBase)
const previousMaximumName = existingNames.at(-1) || ''
if (!immutableBase) {
for (let index = 0; index < fields.length; index += 2) {
if (fields[index] === 'A') orderingCandidates.add(fields[index + 1])
}
}
for (let index = 0; index < fields.length; index += 2) {
const status = fields[index]
const file = fields[index + 1]
if (status !== 'A') {
violations.push(`${file}: applied migrations are immutable (status ${status})`)
continue
}
addedFiles += 1
const filename = basename(file)
if (
file !== `${migrationDirectory}${filename}` ||
!/^\d{4}_[a-z0-9][a-z0-9_]*\.sql$/.test(filename)
) {
violations.push(`${file}: new migration must use NNNN_lowercase_name.sql`)
continue
}
if (
orderingCandidates.has(file) &&
previousMaximumName &&
filename <= previousMaximumName
) {
violations.push(
`${file}: new migration name must sort after ${previousMaximumName}`,
)
continue
}
const treeEntry = git(['ls-tree', 'HEAD', '--', file]).trim()
let fileState
try {
fileState = lstatSync(file)
} catch {
violations.push(`${file}: new migration is unavailable in the worktree`)
continue
}
if (
!/^100644 blob [0-9a-f]+\t/.test(treeEntry) ||
!fileState.isFile() ||
fileState.isSymbolicLink()
) {
violations.push(`${file}: new migration must be a mode 100644 regular file`)
continue
}
const statements = executableSql(readFileSync(file, 'utf8'), file).split(';')
for (const statement of statements) {
for (const rule of destructiveRules) {
if (rule.pattern.test(statement)) {
violations.push(`${file}: ${rule.name}`)
}
}
}
}
if (violations.length > 0) {
for (const violation of [...new Set(violations)]) {
console.error(`migration_safety_violation=${JSON.stringify(violation)}`)
}
fail(`base=${base} violations=${new Set(violations).size}`)
}
console.log(`migration_safety=PASS base=${base} added_files=${addedFiles}`)
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
tag=${1:-}
die() {
printf 'invalid release tag: %s\n' "$tag" >&2
exit 1
}
[[ $tag == v* ]] || die
version=${tag#v}
# Bash arrays discard trailing empty fields, so parsing first would accept
# values such as v1.2.3. and v1.2.3-alpha. Validate the complete string with
# the SemVer 2.0.0 grammar before doing anything else with the tag.
semver_pattern='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
LC_ALL=C
export LC_ALL
[[ $version =~ $semver_pattern ]] || die
printf 'release_tag=PASS tag=%s\n' "$tag"
+452
View File
@@ -0,0 +1,452 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
const baseURL = (process.env.GATEWAY_BASE_URL || 'http://127.0.0.1:8088').replace(/\/+$/, '');
const model = process.env.DEYUN_VIDEO_MODEL || 'deyun-seedance-2.0-canary';
const expectedPlatform = process.env.DEYUN_VIDEO_PLATFORM || '漫路(火山兼容)';
const expectedProviderModel = process.env.DEYUN_VIDEO_PROVIDER_MODEL || 'doubao-seedance-2-0';
const pollIntervalMs = positiveInteger(process.env.DEYUN_VIDEO_POLL_INTERVAL_MS, 5000);
const taskTimeoutMs = positiveInteger(process.env.DEYUN_VIDEO_TASK_TIMEOUT_MS, 20 * 60 * 1000);
const outputPath =
process.env.DEYUN_VIDEO_E2E_OUTPUT ||
`artifacts/deyun-video-e2e-${new Date().toISOString().replaceAll(/[:.]/g, '-')}.json`;
const database = {
container: process.env.GATEWAY_E2E_DB_CONTAINER || 'postgres',
name: process.env.GATEWAY_E2E_DB_NAME || 'easyai_ai_gateway',
user: process.env.GATEWAY_E2E_DB_USER || 'easyai',
};
const validationCases = [
{
name: '480p-4s-audio-off',
request: {
model,
prompt: 'A glossy red cube rotates slowly on a clean white studio table, locked camera, soft daylight.',
resolution: '480p',
ratio: '16:9',
duration: 4,
generate_audio: false,
seed: 41001,
watermark: false,
runMode: 'real',
},
expected: {
height: 480,
duration: 4,
audio: false,
ratio: 16 / 9,
},
},
{
name: '720p-6s-audio-on',
request: {
model,
prompt: 'A calm ocean wave rolls toward a sandy beach at sunrise, locked camera, natural ambient sound.',
resolution: '720p',
ratio: '16:9',
duration: 6,
generate_audio: true,
seed: 42002,
watermark: false,
runMode: 'real',
},
expected: {
height: 720,
duration: 6,
audio: true,
ratio: 16 / 9,
},
},
];
const requestedCases = new Set(
String(process.env.DEYUN_VIDEO_CASES || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
);
if (requestedCases.size > 0) {
const selectedCases = validationCases.filter((validationCase) => requestedCases.has(validationCase.name));
if (selectedCases.length !== requestedCases.size) {
const knownCases = validationCases.map((validationCase) => validationCase.name).join(', ');
throw new Error(`Unknown DEYUN_VIDEO_CASES value; known cases: ${knownCases}`);
}
validationCases.splice(0, validationCases.length, ...selectedCases);
}
const submittedTaskIds = [];
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function positiveInteger(value, fallback) {
const parsed = Number.parseInt(String(value || ''), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function nearlyEqual(left, right, tolerance = 1e-6) {
return Math.abs(Number(left) - Number(right)) <= tolerance;
}
function runPSQL(query) {
return execFileSync(
'docker',
['exec', database.container, 'psql', '-U', database.user, '-d', database.name, '-At', '-F', '\t', '-c', query],
{ encoding: 'utf8' },
).trim();
}
function resolveGatewayAPIKey() {
if (process.env.GATEWAY_E2E_API_KEY) {
assert(process.env.GATEWAY_E2E_API_KEY_ID, 'Set GATEWAY_E2E_API_KEY_ID when GATEWAY_E2E_API_KEY is provided');
const metadata = apiKeyMetadata(process.env.GATEWAY_E2E_API_KEY_ID);
return {
...metadata,
secret: process.env.GATEWAY_E2E_API_KEY,
source: 'environment',
};
}
const query = `
SELECT key.id::text,
key.key_secret,
key.gateway_user_id::text,
wallet.balance::float8,
wallet.frozen_balance::float8
FROM gateway_api_keys key
JOIN gateway_wallet_accounts wallet
ON wallet.gateway_user_id = key.gateway_user_id
AND wallet.currency = 'resource'
AND wallet.status = 'active'
WHERE key.deleted_at IS NULL
AND key.status = 'active'
AND COALESCE(key.key_secret, '') <> ''
AND (key.expires_at IS NULL OR key.expires_at > now())
AND (key.scopes ? 'video' OR key.scopes ? '*' OR key.scopes ? 'all')
AND (wallet.balance - wallet.frozen_balance) > 0
ORDER BY (wallet.balance - wallet.frozen_balance) DESC, key.created_at DESC
LIMIT 1`;
const fields = runPSQL(query).split('\t');
assert(fields.length === 5 && fields[0] && fields[1], 'No recoverable video-scoped Gateway API Key with positive balance was found');
return {
keyId: fields[0],
secret: fields[1],
userId: fields[2],
balance: Number(fields[3]),
frozenBalance: Number(fields[4]),
source: 'database',
};
}
function apiKeyMetadata(keyId) {
const escapedKeyId = String(keyId).replaceAll("'", "''");
const output = runPSQL(`
SELECT key.id::text,
key.gateway_user_id::text,
wallet.balance::float8,
wallet.frozen_balance::float8
FROM gateway_api_keys key
JOIN gateway_wallet_accounts wallet
ON wallet.gateway_user_id = key.gateway_user_id
AND wallet.currency = 'resource'
WHERE key.id = '${escapedKeyId}'::uuid
LIMIT 1`);
const fields = output.split('\t');
assert(fields.length === 4 && fields[0], `Gateway API Key ${keyId} was not found`);
return {
keyId: fields[0],
userId: fields[1],
balance: Number(fields[2]),
frozenBalance: Number(fields[3]),
};
}
function walletState(userId) {
const escapedUserId = String(userId).replaceAll("'", "''");
const fields = runPSQL(`
SELECT balance::float8, frozen_balance::float8
FROM gateway_wallet_accounts
WHERE gateway_user_id = '${escapedUserId}'::uuid
AND currency = 'resource'
LIMIT 1`).split('\t');
assert(fields.length === 2, `Wallet for Gateway user ${userId} was not found`);
return {
balance: Number(fields[0]),
frozenBalance: Number(fields[1]),
};
}
async function request(path, init = {}) {
const response = await fetch(`${baseURL}${path}`, {
...init,
headers: {
Authorization: `Bearer ${authentication.secret}`,
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...(init.headers || {}),
},
});
const text = await response.text();
if (!response.ok) {
throw new Error(`${init.method || 'GET'} ${path} failed ${response.status}: ${text}`);
}
return text ? JSON.parse(text) : {};
}
async function createVideoTask(body) {
const accepted = await request('/api/v1/videos/generations', {
method: 'POST',
headers: { 'X-Async': 'true' },
body: JSON.stringify(body),
});
const taskId = accepted.taskId || accepted.task?.id;
assert(taskId, `Video task acceptance is missing taskId: ${JSON.stringify(accepted)}`);
submittedTaskIds.push(taskId);
return taskId;
}
async function pollTask(taskId) {
const startedAt = Date.now();
while (Date.now() - startedAt < taskTimeoutMs) {
const task = await request(`/api/v1/tasks/${taskId}`);
if (task.status === 'succeeded') return task;
if (task.status === 'failed' || task.status === 'cancelled') {
throw new Error(`Task ${taskId} ended as ${task.status}: ${task.errorMessage || task.error || JSON.stringify(task.result)}`);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error(`Timed out after ${taskTimeoutMs}ms waiting for task ${taskId}`);
}
async function taskEvents(taskId) {
const response = await fetch(`${baseURL}/api/v1/tasks/${taskId}/events`, {
headers: { Authorization: `Bearer ${authentication.secret}` },
});
const text = await response.text();
if (!response.ok) throw new Error(`GET task events failed ${response.status}: ${text}`);
return text
.split(/\r?\n\r?\n/)
.flatMap((block) => {
const data = block
.split(/\r?\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trim())
.join('\n');
return data ? [JSON.parse(data)] : [];
});
}
async function taskPreprocessingLogs(taskId) {
return request(`/api/v1/tasks/${taskId}/param-preprocessing`);
}
function videoURLFromTask(task) {
const data = Array.isArray(task.result?.data) ? task.result.data : [];
return data.find((item) => item?.type === 'video')?.url || data.find((item) => item?.url)?.url || '';
}
async function downloadVideo(url, filePath) {
const response = await fetch(url);
assert(response.ok, `Download ${url} failed ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
assert(bytes.length > 0, `Downloaded video ${url} is empty`);
await writeFile(filePath, bytes);
return bytes.length;
}
function probeVideo(filePath) {
const output = execFileSync(
'ffprobe',
['-v', 'error', '-show_streams', '-show_format', '-of', 'json', filePath],
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 },
);
return JSON.parse(output);
}
function validateRequestSnapshot(task, expectedRequest) {
const snapshot = task.request || {};
for (const key of ['model', 'resolution', 'ratio', 'duration', 'generate_audio', 'seed']) {
assert(
snapshot[key] === expectedRequest[key],
`Task ${task.id} request.${key}=${JSON.stringify(snapshot[key])}, expected ${JSON.stringify(expectedRequest[key])}`,
);
}
}
function validateTaskAudit(task, events) {
const attempts = Array.isArray(task.attempts) ? task.attempts : [];
assert(attempts.length === 1, `Task ${task.id} has ${attempts.length} attempts, expected exactly one`);
const attempt = attempts[0];
assert(attempt.platformName === expectedPlatform, `Task ${task.id} used platform ${attempt.platformName}, expected ${expectedPlatform}`);
assert(attempt.provider === 'volces', `Task ${task.id} used provider ${attempt.provider}, expected volces`);
assert(
attempt.providerModelName === expectedProviderModel,
`Task ${task.id} used provider model ${attempt.providerModelName}, expected ${expectedProviderModel}`,
);
assert(task.remoteTaskId, `Task ${task.id} is missing remoteTaskId`);
assert(attempt.requestId, `Task ${task.id} attempt is missing requestId`);
const startedEvents = events.filter((event) => event.eventType === 'task.attempt.started');
assert(startedEvents.length === 1, `Task ${task.id} emitted ${startedEvents.length} task.attempt.started events`);
assert(Number(task.finalChargeAmount) > 0, `Task ${task.id} finalChargeAmount must be positive`);
return attempt;
}
function validateMedia(probe, expected, taskId) {
const streams = Array.isArray(probe.streams) ? probe.streams : [];
const video = streams.find((stream) => stream.codec_type === 'video');
const audioStreams = streams.filter((stream) => stream.codec_type === 'audio');
assert(video, `Task ${taskId} output has no video stream`);
const width = Number(video.width);
const height = Number(video.height);
const duration = Number(video.duration || probe.format?.duration);
assert(height === expected.height, `Task ${taskId} output height=${height}, expected ${expected.height}`);
const ratioError = Math.abs(width / height - expected.ratio) / expected.ratio;
assert(ratioError <= 0.02, `Task ${taskId} aspect ratio ${width}:${height} differs from 16:9 by ${(ratioError * 100).toFixed(2)}%`);
assert(Number.isFinite(duration), `Task ${taskId} output duration is unavailable`);
assert(Math.abs(duration - expected.duration) <= 1, `Task ${taskId} duration=${duration}s, expected ${expected.duration}s ±1s`);
if (expected.audio) {
assert(audioStreams.length > 0, `Task ${taskId} requested audio but output has no audio stream`);
const audioDuration = Number(audioStreams[0].duration || probe.format?.duration);
if (Number.isFinite(audioDuration)) {
assert(audioDuration >= duration - 1, `Task ${taskId} audio duration=${audioDuration}s is too short for video duration=${duration}s`);
}
} else {
assert(audioStreams.length === 0, `Task ${taskId} disabled audio but output has ${audioStreams.length} audio stream(s)`);
}
return {
width,
height,
duration,
ratio: width / height,
videoCodec: video.codec_name || null,
audioStreams: audioStreams.map((stream) => ({
codec: stream.codec_name || null,
channels: Number(stream.channels || 0),
sampleRate: Number(stream.sample_rate || 0),
duration: Number(stream.duration || probe.format?.duration || 0),
})),
};
}
const authentication = resolveGatewayAPIKey();
function sanitizedFailureMessage(error) {
return String(error instanceof Error ? error.message : error)
.replace(/Request id:\s*[A-Za-z0-9_-]+/gi, 'Request id: [redacted]')
.replace(/\b\d{8,}\b/g, '[redacted-id]');
}
async function writeFailureReport(error) {
const report = {
ok: false,
generatedAt: new Date().toISOString(),
baseURL,
model,
expectedPlatform,
expectedProviderModel,
authentication: {
type: 'gateway_api_key',
source: authentication.source,
keyId: authentication.keyId,
},
submittedTaskIds,
error: sanitizedFailureMessage(error),
};
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
return report;
}
async function main() {
const tempDirectory = await mkdtemp(join(tmpdir(), 'deyun-video-e2e-'));
const walletBefore = walletState(authentication.userId);
const results = [];
try {
for (const validationCase of validationCases) {
const taskId = await createVideoTask(validationCase.request);
const task = await pollTask(taskId);
const [events, preprocessing] = await Promise.all([
taskEvents(taskId),
taskPreprocessingLogs(taskId),
]);
validateRequestSnapshot(task, validationCase.request);
const attempt = validateTaskAudit(task, events);
assert(
Array.isArray(preprocessing.items) && preprocessing.items.length > 0,
`Task ${taskId} has no parameter preprocessing audit record`,
);
const videoURL = videoURLFromTask(task);
assert(videoURL, `Task ${taskId} result is missing a video URL`);
const videoPath = join(tempDirectory, `${validationCase.name}.mp4`);
const byteSize = await downloadVideo(videoURL, videoPath);
const probe = probeVideo(videoPath);
const observed = validateMedia(probe, validationCase.expected, taskId);
results.push({
name: validationCase.name,
taskId,
remoteTaskId: task.remoteTaskId,
requestId: attempt.requestId,
platform: attempt.platformName,
providerModelName: attempt.providerModelName,
requested: {
resolution: validationCase.request.resolution,
ratio: validationCase.request.ratio,
duration: validationCase.request.duration,
generateAudio: validationCase.request.generate_audio,
seed: validationCase.request.seed,
},
observed,
byteSize,
finalChargeAmount: Number(task.finalChargeAmount),
billingSummary: task.billingSummary || {},
eventTypes: events.map((event) => event.eventType),
preprocessingChangeCount: preprocessing.items.reduce((sum, item) => sum + Number(item.changeCount || 0), 0),
});
}
} finally {
await rm(tempDirectory, { recursive: true, force: true });
}
const walletAfter = walletState(authentication.userId);
const totalCharge = results.reduce((sum, result) => sum + result.finalChargeAmount, 0);
const walletDebit = walletBefore.balance - walletAfter.balance;
assert(nearlyEqual(walletDebit, totalCharge, 1e-6), `Wallet debit=${walletDebit}, expected total charge=${totalCharge}`);
assert(
nearlyEqual(walletAfter.frozenBalance, walletBefore.frozenBalance, 1e-6),
`Wallet frozen balance changed from ${walletBefore.frozenBalance} to ${walletAfter.frozenBalance}`,
);
const report = {
ok: true,
generatedAt: new Date().toISOString(),
baseURL,
model,
expectedPlatform,
expectedProviderModel,
authentication: {
type: 'gateway_api_key',
source: authentication.source,
keyId: authentication.keyId,
},
wallet: {
balanceBefore: walletBefore.balance,
balanceAfter: walletAfter.balance,
frozenBalanceBefore: walletBefore.frozenBalance,
frozenBalanceAfter: walletAfter.frozenBalance,
debit: walletDebit,
totalCharge,
},
results,
};
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
console.log(JSON.stringify({ ...report, outputPath }, null, 2));
}
main().catch(async (error) => {
const report = await writeFailureReport(error);
console.error(report.error);
console.error(`Failure report: ${outputPath}`);
process.exitCode = 1;
});
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
const baseURL = (process.env.GATEWAY_BASE_URL || 'http://127.0.0.1:8088').replace(/\/+$/, '');
const authentication = resolveGatewayAPIKey();
const timeoutMs = Number(process.env.GATEWAY_E2E_TIMEOUT_MS || 180_000);
const scenarios = [
{
name: '火山 128K Chat 未传上限',
platform: '火山引擎',
model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', value: 43690, injected: true },
},
{
name: '火山 32K Chat 未传上限',
platform: '火山引擎',
model: process.env.GATEWAY_E2E_VOLCES_32K_MODEL || 'volces-openai:doubao-seed-1-8-251228',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', value: 10922, injected: true },
},
{
name: '火山 Chat max_tokens=null',
platform: '火山引擎',
model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215',
path: '/v1/chat/completions',
body: chatBody({ max_tokens: null }),
expected: { parameter: 'max_tokens', value: 43690, injected: true },
},
{
name: '火山 Chat 显式 max_tokens',
platform: '火山引擎',
model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215',
path: '/v1/chat/completions',
body: chatBody({ max_tokens: 64 }),
expected: { parameter: 'max_tokens', value: 64, injected: false },
},
{
name: '火山 Chat 显式 max_completion_tokens',
platform: '火山引擎',
model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215',
path: '/v1/chat/completions',
body: chatBody({ max_completion_tokens: 64 }),
expected: { parameter: 'max_tokens', absent: true, injected: false, preserved: { max_completion_tokens: 64 } },
},
{
name: '火山 Responses 未传上限',
platform: '火山引擎',
model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215',
path: '/v1/responses',
body: responsesBody(),
expected: { parameter: 'max_output_tokens', value: 43690, injected: true },
},
{
name: '阿里百炼 Qwen Chat 未传上限',
platform: '阿里云百炼',
model: process.env.GATEWAY_E2E_QWEN_MODEL || 'aliyun-bailian-openai:qwen3.7-plus',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', absent: true, injected: false },
},
{
name: '阿里百炼 Qwen Responses 未传上限',
platform: '阿里云百炼',
model: process.env.GATEWAY_E2E_QWEN_MODEL || 'aliyun-bailian-openai:qwen3.7-plus',
path: '/v1/responses',
body: responsesBody(),
expected: { parameter: 'max_output_tokens', absent: true, injected: false },
},
{
name: 'DeepSeek 官网 Chat 未传上限',
platform: 'DeepSeek 官网',
model: process.env.GATEWAY_E2E_DEEPSEEK_MODEL || 'deepseek-openai:deepseek-v4-pro',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', absent: true, injected: false },
},
{
name: 'DeepSeek 官网 Responses 回退未传上限',
platform: 'DeepSeek 官网',
model: process.env.GATEWAY_E2E_DEEPSEEK_MODEL || 'deepseek-openai:deepseek-v4-pro',
path: '/v1/responses',
body: responsesBody(),
expected: { parameter: 'max_output_tokens', absent: true, injected: false },
},
{
name: '智谱 GLM-5.2 Chat 未传上限',
platform: '智谱AI',
model: process.env.GATEWAY_E2E_ZHIPU_MODEL || 'zhipu-openai:glm-5.2',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', absent: true, injected: false },
},
{
name: 'OpenAI GPT-4o Chat 未传上限',
platform: 'OpenAI',
model: process.env.GATEWAY_E2E_OPENAI_MODEL || 'openai:gpt-4o',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', absent: true, injected: false },
},
{
name: 'Google Gemini Chat 未传上限',
platform: 'Google Gemini',
model: process.env.GATEWAY_E2E_GEMINI_MODEL || 'gemini:gemini-2.5-pro',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', absent: true, injected: false },
},
];
function chatBody(extra = {}) {
return {
messages: [{ role: 'user', content: '这是输出上限链路验证。只回复 OK。' }],
...extra,
};
}
function responsesBody(extra = {}) {
return { input: '这是输出上限链路验证。只回复 OK。', ...extra };
}
function resolveGatewayAPIKey() {
if (process.env.GATEWAY_E2E_API_KEY) {
return { value: process.env.GATEWAY_E2E_API_KEY, source: 'environment', keyId: null };
}
const container = process.env.GATEWAY_E2E_DB_CONTAINER || 'postgres';
const database = process.env.GATEWAY_E2E_DB_NAME || 'easyai_ai_gateway';
const user = process.env.GATEWAY_E2E_DB_USER || 'easyai';
const query = `SELECT key.id::text || E'\\t' || key.key_secret FROM gateway_api_keys key JOIN gateway_wallet_accounts wallet ON wallet.gateway_user_id = key.gateway_user_id AND wallet.status = 'active' WHERE key.deleted_at IS NULL AND key.status = 'active' AND key.key_secret IS NOT NULL AND key.key_secret <> '' AND key.scopes ? 'chat' AND (key.expires_at IS NULL OR key.expires_at > now()) AND (wallet.balance - wallet.frozen_balance) > 0 ORDER BY (wallet.balance - wallet.frozen_balance) DESC, key.created_at DESC LIMIT 1`;
let output = '';
try {
output = execFileSync('docker', ['exec', container, 'psql', '-U', user, '-d', database, '-At', '-c', query], { encoding: 'utf8' }).trim();
} catch {
throw new Error('Unable to load an active chat-scoped Gateway API Key; set GATEWAY_E2E_API_KEY explicitly');
}
const separator = output.indexOf('\t');
if (separator <= 0 || separator === output.length - 1) {
throw new Error('No active chat-scoped Gateway API Key with a positive balance was found');
}
return { keyId: output.slice(0, separator), value: output.slice(separator + 1), source: 'database' };
}
async function fetchText(path, options = {}) {
const response = await fetch(`${baseURL}${path}`, {
...options,
signal: AbortSignal.timeout(timeoutMs),
headers: { Authorization: `Bearer ${authentication.value}`, ...(options.headers || {}) },
});
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = { raw: text.slice(0, 2_000) };
}
return { response, body };
}
async function getTask(taskId) {
const { response, body } = await fetchText(`/api/v1/tasks/${taskId}`);
if (!response.ok) throw new Error(`GET task ${taskId} failed ${response.status}: ${JSON.stringify(body)}`);
return body;
}
async function getPreprocessing(taskId) {
const { response, body } = await fetchText(`/api/v1/tasks/${taskId}/param-preprocessing`);
if (!response.ok) throw new Error(`GET preprocessing ${taskId} failed ${response.status}: ${JSON.stringify(body)}`);
return body?.items || [];
}
function successfulAttempt(task) {
const attempts = Array.isArray(task.attempts) ? task.attempts : [];
return attempts.findLast((attempt) => attempt.status === 'succeeded') || attempts.at(-1);
}
function hasOwn(value, key) {
return value != null && Object.prototype.hasOwnProperty.call(value, key);
}
function validateAudit(scenario, task, preprocessing) {
const attempt = successfulAttempt(task);
if (!attempt) throw new Error('task does not contain an upstream attempt');
if (task.simulated || attempt.simulated) throw new Error('request was served by a simulation candidate');
const platformName = String(attempt.metrics?.platformName || task.metrics?.platformName || '');
if (!platformName.includes(scenario.platform)) {
throw new Error(`unexpected platform ${platformName}; expected ${scenario.platform}`);
}
const snapshot = attempt.requestSnapshot || {};
const expected = scenario.expected;
if (expected.absent) {
if (hasOwn(snapshot, expected.parameter)) {
throw new Error(`${expected.parameter} was unexpectedly sent upstream as ${JSON.stringify(snapshot[expected.parameter])}`);
}
} else if (snapshot[expected.parameter] !== expected.value) {
throw new Error(`${expected.parameter}=${JSON.stringify(snapshot[expected.parameter])}; expected ${expected.value}`);
}
for (const [key, value] of Object.entries(expected.preserved || {})) {
if (snapshot[key] !== value) throw new Error(`${key} was not preserved in the upstream snapshot`);
}
const changes = preprocessing.flatMap((item) => item.changes || []);
const outputLimitChanges = changes.filter((change) => change.processor === 'OutputTokenLimitProcessor');
if (expected.injected) {
const matched = outputLimitChanges.some((change) => change.action === 'set' && change.path === expected.parameter && change.after === expected.value);
if (!matched) throw new Error(`missing OutputTokenLimitProcessor audit for ${expected.parameter}=${expected.value}`);
} else if (outputLimitChanges.length > 0) {
throw new Error(`unexpected OutputTokenLimitProcessor audit: ${JSON.stringify(outputLimitChanges)}`);
}
return {
taskId: task.id,
attemptId: attempt.id,
platform: platformName,
provider: attempt.metrics?.provider || task.metrics?.provider || null,
platformModelId: attempt.metrics?.platformModelId || task.metrics?.platformModelId || null,
upstreamProtocol: attempt.metrics?.upstreamProtocol || task.metrics?.upstreamProtocol || null,
upstreamEndpoint: attempt.metrics?.upstreamEndpoint || task.metrics?.upstreamEndpoint || null,
simulated: Boolean(task.simulated || attempt.simulated),
requestParameters: {
max_tokens: hasOwn(snapshot, 'max_tokens') ? snapshot.max_tokens : '(absent)',
max_completion_tokens: hasOwn(snapshot, 'max_completion_tokens') ? snapshot.max_completion_tokens : '(absent)',
max_output_tokens: hasOwn(snapshot, 'max_output_tokens') ? snapshot.max_output_tokens : '(absent)',
},
outputLimitAudit: outputLimitChanges,
usage: task.usage || attempt.usage || null,
};
}
const results = [];
for (const scenario of scenarios) {
const startedAt = Date.now();
let taskId = null;
try {
const requestBody = { model: scenario.model, ...scenario.body };
const { response, body } = await fetchText(scenario.path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
taskId = response.headers.get('x-gateway-task-id');
if (!response.ok) throw new Error(`POST ${scenario.path} failed ${response.status}: ${JSON.stringify(body)}`);
if (!taskId) throw new Error('response is missing X-Gateway-Task-Id');
const [task, preprocessing] = await Promise.all([getTask(taskId), getPreprocessing(taskId)]);
const audit = validateAudit(scenario, task, preprocessing);
results.push({ name: scenario.name, model: scenario.model, path: scenario.path, passed: true, durationMs: Date.now() - startedAt, audit });
process.stderr.write(`PASS ${scenario.name} (${Date.now() - startedAt} ms)\n`);
} catch (error) {
results.push({ name: scenario.name, model: scenario.model, path: scenario.path, passed: false, durationMs: Date.now() - startedAt, taskId, error: error instanceof Error ? error.message : String(error) });
process.stderr.write(`FAIL ${scenario.name}: ${error instanceof Error ? error.message : String(error)}\n`);
}
}
const report = {
ok: results.every((result) => result.passed),
generatedAt: new Date().toISOString(),
baseURL,
authentication: { type: 'gateway_api_key', source: authentication.source, keyId: authentication.keyId },
summary: { total: results.length, passed: results.filter((result) => result.passed).length, failed: results.filter((result) => !result.passed).length },
results,
};
const outputPath = process.env.GATEWAY_E2E_OUTPUT;
if (outputPath) {
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
}
console.log(JSON.stringify(report, null, 2));
if (!report.ok) process.exitCode = 1;
+401
View File
@@ -0,0 +1,401 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
PREFIX=/opt/easyai-gateway-ci
GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL:-https://git.51easyai.com}
RUNNER_NAME=${RUNNER_NAME:-easyai-gateway-ci-unprivileged-v2}
RUNNER_VOLUME=easyai-gateway-ci-v2-data
RUNNER_CONTAINER=easyai-gateway-ci-v2-runner
LEGACY_BUILDER=${AI_GATEWAY_BUILDX_BUILDER:-easyai-gateway-ci}
MIN_FREE_GIB=${CI_RUNNER_MIN_FREE_GIB:-8}
MIN_POST_INSTALL_FREE_GIB=${CI_RUNNER_MIN_POST_INSTALL_FREE_GIB:-4}
RUNNER_CPUS=2
RUNNER_MEMORY=2g
RUNNER_MEMORY_SWAP=3g
RUNNER_PIDS_LIMIT=1024
GITEA_RUNNER_VERSION=2.0.0
GO_VERSION=1.26.5
GO_SHA256=5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053
NODE_VERSION=24.16.0
NODE_SHA256=d804845d34eddc21dc1092b519d643ef40b1f58ec5dec5c22b1f4bd8fabde6c9
TRIVY_VERSION=0.70.0
TRIVY_SHA256=8b4376d5d6befe5c24d503f10ff136d9e0c49f9127a4279fd110b727929a5aa9
SHELLCHECK_VERSION=0.11.0
SHELLCHECK_SHA256=8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198
COMPOSE_VERSION=5.3.1
COMPOSE_SHA256=f9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959
PNPM_VERSION=10.18.1
RUNNER_IMAGE='docker.io/gitea/runner:2.0.0-dind-rootless@sha256:5b7b625ff773d0ee761788c47582503ec1b241fa5b81edebad48a57e663f4f3a'
JOB_IMAGE='docker.io/library/node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7'
RUNNER_IMAGE_SOURCE=${CI_RUNNER_IMAGE_SOURCE:-$RUNNER_IMAGE}
NESTED_DOCKER_REGISTRY_MIRROR=${CI_NESTED_DOCKER_REGISTRY_MIRROR:-}
RUNNER_LABEL="easyai-gateway-ci-unprivileged-v2:docker://$JOB_IMAGE"
fail() {
echo "$*" >&2
exit 1
}
case $MIN_FREE_GIB:$MIN_POST_INSTALL_FREE_GIB in
*[!0-9:]* | :* | *:)
fail "CI runner free-space limits must be whole GiB values"
;;
esac
[ "$MIN_FREE_GIB" -ge 8 ] || fail "CI_RUNNER_MIN_FREE_GIB cannot be lower than 8"
[ "$MIN_POST_INSTALL_FREE_GIB" -ge 4 ] || \
fail "CI_RUNNER_MIN_POST_INSTALL_FREE_GIB cannot be lower than 4"
[ "$(id -u)" -eq 0 ] || fail "run as root"
[ "$(uname -s)-$(uname -m)" = "Linux-x86_64" ] || fail "only Linux x86_64 is supported"
case $RUNNER_IMAGE in
*"/runner:${GITEA_RUNNER_VERSION}-dind-rootless@sha256:"*) ;;
*) fail "RUNNER_IMAGE does not match GITEA_RUNNER_VERSION" ;;
esac
runner_manifest_digest=${RUNNER_IMAGE##*@}
case $RUNNER_IMAGE_SOURCE in
*@"$runner_manifest_digest") ;;
*) fail "CI_RUNNER_IMAGE_SOURCE must use the pinned runner manifest digest" ;;
esac
if [ -n "$NESTED_DOCKER_REGISTRY_MIRROR" ] && \
! printf '%s\n' "$NESTED_DOCKER_REGISTRY_MIRROR" | \
grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?$'; then
fail "CI_NESTED_DOCKER_REGISTRY_MIRROR must be an HTTPS registry origin"
fi
command -v docker >/dev/null 2>&1 || fail "Docker Engine is required"
docker info >/dev/null 2>&1 || fail "Docker Engine is not reachable"
grep -Fq "\"$RUNNER_LABEL\"" "$ROOT/deploy/ci/act-runner-v2-config.yaml" || \
fail "runner label does not match the pinned job image"
# Fail closed before installing anything. Neither the legacy host runner nor an
# earlier v2 unit may poll for work while its replacement is being prepared.
systemctl disable --now easyai-gateway-act-runner.service >/dev/null 2>&1 || true
systemctl disable --now easyai-gateway-ci-v2-runner.service >/dev/null 2>&1 || true
docker rm --force "$RUNNER_CONTAINER" >/dev/null 2>&1 || true
rm -f /etc/systemd/system/easyai-gateway-act-runner.service
if id easyai-gateway-runner >/dev/null 2>&1; then
if getent group docker >/dev/null 2>&1; then
gpasswd -d easyai-gateway-runner docker >/dev/null 2>&1 || true
fi
if getent group sudo >/dev/null 2>&1; then
gpasswd -d easyai-gateway-runner sudo >/dev/null 2>&1 || true
fi
fi
if id easyai-gateway-ci-v2 >/dev/null 2>&1; then
if getent group docker >/dev/null 2>&1; then
gpasswd -d easyai-gateway-ci-v2 docker >/dev/null 2>&1 || true
fi
if getent group sudo >/dev/null 2>&1; then
gpasswd -d easyai-gateway-ci-v2 sudo >/dev/null 2>&1 || true
fi
fi
for account in easyai-gateway-runner easyai-gateway-ci-v2; do
if id "$account" >/dev/null 2>&1 && \
id -nG "$account" | tr ' ' '\n' | grep -Eq '^(docker|sudo)$'; then
fail "$account must not belong to a privileged group"
fi
done
if ! command -v make >/dev/null 2>&1 || ! command -v gcc >/dev/null 2>&1 || \
! command -v jq >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 || \
! command -v xz >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
build-essential ca-certificates curl git jq perl unzip util-linux xz-utils
fi
docker_root=$(docker info --format '{{.DockerRootDir}}')
[ -n "$docker_root" ] || fail "Docker root directory could not be determined"
require_free_space() {
minimum_gib=$1
phase=$2
available_kib=$(df -Pk "$docker_root" | awk 'NR == 2 { print $4 }')
case $available_kib in
'' | *[!0-9]*) fail "free space could not be determined for $docker_root" ;;
esac
minimum_kib=$((minimum_gib * 1024 * 1024))
if [ "$available_kib" -lt "$minimum_kib" ]; then
echo "$phase requires at least ${minimum_gib} GiB free on the Docker filesystem" >&2
return 1
fi
}
# This builder belonged only to the retired gateway image-building runner. Cap
# its unused cache before adding the nested daemon; never prune global images,
# volumes, or caches owned by another service on this shared production host.
if docker buildx version >/dev/null 2>&1 && \
docker buildx inspect "$LEGACY_BUILDER" >/dev/null 2>&1; then
docker buildx prune --builder "$LEGACY_BUILDER" --force --max-used-space 512mb
fi
require_free_space "$MIN_FREE_GIB" "CI runner installation"
download_verified() {
url=$1
output=$2
checksum=$3
if [ ! -f "$output" ] || ! printf '%s %s\n' "$checksum" "$output" | sha256sum -c - >/dev/null 2>&1; then
rm -f "$output"
curl -fL --retry 5 --retry-delay 2 "$url" -o "$output"
fi
printf '%s %s\n' "$checksum" "$output" | sha256sum -c -
}
install -d -m 0755 "$PREFIX/bin" "$PREFIX/downloads" "$PREFIX/toolchains"
download_verified \
"https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" \
"$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" \
"$SHELLCHECK_SHA256"
rm -rf "$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}"
tar -xJf "$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" \
-C "$PREFIX/downloads"
install -m 0755 "$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}/shellcheck" "$PREFIX/bin/shellcheck"
download_verified \
"https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64" \
"$PREFIX/downloads/docker-compose-${COMPOSE_VERSION}-linux-x86_64" \
"$COMPOSE_SHA256"
install -m 0755 "$PREFIX/downloads/docker-compose-${COMPOSE_VERSION}-linux-x86_64" "$PREFIX/bin/docker-compose"
download_verified \
"https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \
"$PREFIX/downloads/go${GO_VERSION}.linux-amd64.tar.gz" \
"$GO_SHA256"
rm -rf "$PREFIX/toolchains/go"
tar -xzf "$PREFIX/downloads/go${GO_VERSION}.linux-amd64.tar.gz" -C "$PREFIX/toolchains"
download_verified \
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
"$PREFIX/downloads/node-v${NODE_VERSION}-linux-x64.tar.xz" \
"$NODE_SHA256"
rm -rf "$PREFIX/toolchains/node"
mkdir -p "$PREFIX/toolchains/node"
tar -xJf "$PREFIX/downloads/node-v${NODE_VERSION}-linux-x64.tar.xz" \
-C "$PREFIX/toolchains/node" --strip-components=1
PATH="$PREFIX/toolchains/node/bin:$PATH" \
"$PREFIX/toolchains/node/bin/npm" install --global --prefix "$PREFIX/toolchains/node" \
--ignore-scripts "pnpm@${PNPM_VERSION}"
download_verified \
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
"$PREFIX/downloads/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
"$TRIVY_SHA256"
tar -xzf "$PREFIX/downloads/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" -C "$PREFIX/bin" trivy
chmod 0755 "$PREFIX/bin/trivy"
GOBIN="$PREFIX/bin" GOPROXY="${GO_MODULE_PROXY:-https://goproxy.cn,direct}" \
"$PREFIX/toolchains/go/bin/go" install golang.org/x/vuln/cmd/govulncheck@v1.6.0
chown -R root:root "$PREFIX"
chmod -R go-w "$PREFIX"
require_free_space "$MIN_FREE_GIB" "runner image pull"
docker pull "$RUNNER_IMAGE_SOURCE"
runner_runtime_image=$(docker image inspect --format '{{.Id}}' "$RUNNER_IMAGE_SOURCE")
printf '%s\n' "$runner_runtime_image" | grep -Eq '^sha256:[0-9a-f]{64}$' || \
fail "pinned runner image did not resolve to an immutable local image ID"
require_free_space "$MIN_POST_INSTALL_FREE_GIB" "completed runner image pull"
docker volume create "$RUNNER_VOLUME" >/dev/null
# The registered runner state and the nested rootless daemon cache share one
# dedicated volume. Persisting the pinned job image lets validation finish
# before registration and avoids racing the first queued workflow for layers.
docker run --rm --pull=never --user 0:0 \
--volume "$RUNNER_VOLUME:/data" \
--entrypoint /bin/sh "$runner_runtime_image" \
-ec 'chown 1000:1000 /data && chmod 0700 /data'
install -d -m 0755 /etc/easyai-gateway-ci-v2
if [ -n "$NESTED_DOCKER_REGISTRY_MIRROR" ]; then
printf '{"data-root":"/data/docker","registry-mirrors":["%s"]}\n' "$NESTED_DOCKER_REGISTRY_MIRROR" \
> /etc/easyai-gateway-ci-v2/daemon.json
else
printf '{"data-root":"/data/docker"}\n' > /etc/easyai-gateway-ci-v2/daemon.json
fi
chmod 0644 /etc/easyai-gateway-ci-v2/daemon.json
# Docker's official rootless DinD baseline is --privileged. Gitea's example
# additionally names an AppArmor profile, but Docker does not create that host
# profile. Enable it only when this daemon proves it can start the pinned image
# with the profile; otherwise leave the option empty.
RUNNER_SECURITY_OPTIONS=
if docker run --rm --pull=never --privileged \
--security-opt apparmor=rootlesskit \
--entrypoint /bin/true "$runner_runtime_image" >/dev/null 2>&1; then
RUNNER_SECURITY_OPTIONS="--security-opt=apparmor=rootlesskit"
elif [ -r /proc/sys/kernel/apparmor_restrict_unprivileged_userns ] && \
[ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" = "1" ]; then
fail "restricted AppArmor user namespaces require the rootlesskit profile (install it with the host Docker packages)"
fi
# Exercise the actual daemon startup before registration. This probe has no
# token, state volume, host bind mount, or socket mount.
PROBE_CONTAINER="easyai-gateway-ci-v2-probe-$$"
cleanup_probe() {
docker rm --force "$PROBE_CONTAINER" >/dev/null 2>&1 || true
}
trap 'cleanup_probe' EXIT
trap 'cleanup_probe; exit 129' HUP
trap 'cleanup_probe; exit 130' INT
trap 'cleanup_probe; exit 143' TERM
if [ -n "$RUNNER_SECURITY_OPTIONS" ]; then
set -- "$RUNNER_SECURITY_OPTIONS"
else
set --
fi
docker run --detach --rm --pull=never \
--name "$PROBE_CONTAINER" --privileged \
--cpus "$RUNNER_CPUS" --memory "$RUNNER_MEMORY" \
--memory-swap "$RUNNER_MEMORY_SWAP" --pids-limit "$RUNNER_PIDS_LIMIT" "$@" \
--volume "$RUNNER_VOLUME:/data" \
--volume /etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro \
--volume "$PREFIX:$PREFIX:ro" \
--env DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns \
--env DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520 \
--env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns \
--entrypoint dockerd-entrypoint.sh "$runner_runtime_image" >/dev/null
attempt=0
until docker exec "$PROBE_CONTAINER" docker info >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ] || \
[ "$(docker inspect --format '{{.State.Running}}' "$PROBE_CONTAINER" 2>/dev/null || true)" != "true" ]; then
docker logs "$PROBE_CONTAINER" 2>&1 || true
fail "pinned rootless Docker-in-Docker probe did not become ready"
fi
sleep 2
done
[ "$(docker exec "$PROBE_CONTAINER" id -u)" = "1000" ] || \
fail "rootless Docker-in-Docker probe is not uid 1000"
[ "$(docker inspect --format '{{.HostConfig.Memory}}:{{.HostConfig.MemorySwap}}:{{.HostConfig.NanoCpus}}:{{.HostConfig.PidsLimit}}' "$PROBE_CONTAINER")" = \
'2147483648:3221225472:2000000000:1024' ] || \
fail "rootless Docker-in-Docker probe does not have the required aggregate resource limits"
docker exec "$PROBE_CONTAINER" docker info --format '{{range .SecurityOptions}}{{println .}}{{end}}' | \
grep -Fq 'name=rootless' || fail "rootless Docker-in-Docker probe is not rootless"
[ "$(docker exec "$PROBE_CONTAINER" docker info --format '{{.DockerRootDir}}')" = /data/docker ] || \
fail "rootless Docker-in-Docker probe is not using its persistent dedicated data root"
# Complete every slow and capability-sensitive validation before registration;
# otherwise a queued workflow can race the installer for the same image layers.
docker exec "$PROBE_CONTAINER" docker pull "$JOB_IMAGE"
outer_sentinel_pid=$(docker exec "$PROBE_CONTAINER" /bin/sh -c \
'sleep 300 >/dev/null 2>&1 & printf "%s\n" "$!"')
case $outer_sentinel_pid in
'' | *[!0-9]*) fail "could not create the outer PID namespace sentinel" ;;
esac
if ! docker exec "$PROBE_CONTAINER" docker run --rm --pull=never \
--pid=host --entrypoint /bin/sh "$JOB_IMAGE" \
-ec 'test ! -e "/proc/$1"' sh "$outer_sentinel_pid"; then
docker exec "$PROBE_CONTAINER" kill "$outer_sentinel_pid" >/dev/null 2>&1 || true
fail "nested --pid=host can see the outer runner PID namespace"
fi
docker exec "$PROBE_CONTAINER" kill "$outer_sentinel_pid" >/dev/null 2>&1 || true
docker exec "$PROBE_CONTAINER" docker run --rm --pull=never \
--volume "$PREFIX:$PREFIX:ro" \
--entrypoint /bin/sh "$JOB_IMAGE" -ec '
export PATH=/opt/easyai-gateway-ci/bin:/opt/easyai-gateway-ci/toolchains/go/bin:/opt/easyai-gateway-ci/toolchains/node/bin:$PATH
go version
node --version
pnpm --version
docker-compose version
shellcheck --version
trivy --version
govulncheck -version
'
cleanup_probe
trap - EXIT HUP INT TERM
install -m 0644 "$ROOT/deploy/ci/act-runner-v2-config.yaml" /etc/easyai-gateway-ci-v2/config.yaml
umask 077
printf 'RUNNER_SECURITY_OPTIONS="%s"\nRUNNER_IMAGE_REF="%s"\n' \
"$RUNNER_SECURITY_OPTIONS" "$runner_runtime_image" \
> /etc/easyai-gateway-ci-v2/runner.env
install -m 0644 "$ROOT/deploy/ci/easyai-gateway-ci-v2-runner.service" \
/etc/systemd/system/easyai-gateway-ci-v2-runner.service
runner_is_registered() {
docker run --rm --pull=never \
--volume "$RUNNER_VOLUME:/data" \
--entrypoint /bin/sh "$runner_runtime_image" \
-ec 'test -s /data/.runner'
}
if ! runner_is_registered; then
[ -n "${RUNNER_REGISTRATION_TOKEN:-}" ] || \
fail "RUNNER_REGISTRATION_TOKEN is required for first registration"
docker rm --force easyai-gateway-ci-v2-register >/dev/null 2>&1 || true
printf '%s\n%s\n%s\n' "$GITEA_INSTANCE_URL" "$RUNNER_REGISTRATION_TOKEN" "$RUNNER_NAME" | \
docker run --rm --interactive --pull=never \
--name easyai-gateway-ci-v2-register \
--volume "$RUNNER_VOLUME:/data" \
--volume /etc/easyai-gateway-ci-v2/config.yaml:/config.yaml:ro \
--env HOME=/data \
--entrypoint /usr/local/bin/gitea-runner \
"$runner_runtime_image" --config /config.yaml register
fi
unset RUNNER_REGISTRATION_TOKEN
docker run --rm --pull=never \
--volume "$RUNNER_VOLUME:/data" \
--entrypoint /bin/sh "$runner_runtime_image" \
-ec 'test -s /data/.runner && chmod 0600 /data/.runner'
RUNNER_VALIDATED=0
cleanup_unvalidated_runner() {
if [ "$RUNNER_VALIDATED" -ne 1 ]; then
systemctl disable --now easyai-gateway-ci-v2-runner.service >/dev/null 2>&1 || true
docker rm --force "$RUNNER_CONTAINER" >/dev/null 2>&1 || true
fi
}
trap 'cleanup_unvalidated_runner' EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
systemctl daemon-reload
systemctl enable --now easyai-gateway-ci-v2-runner.service
attempt=0
until docker exec "$RUNNER_CONTAINER" docker info >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ]; then
systemctl --no-pager --full status easyai-gateway-ci-v2-runner.service || true
docker logs "$RUNNER_CONTAINER" 2>&1 || true
fail "rootless Docker daemon did not become ready"
fi
sleep 2
done
attempt=0
until docker logs "$RUNNER_CONTAINER" 2>&1 | grep -Fq 'declare successfully'; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ] || \
[ "$(docker inspect --format '{{.State.Running}}' "$RUNNER_CONTAINER" 2>/dev/null || true)" != "true" ]; then
docker logs "$RUNNER_CONTAINER" 2>&1 || true
fail "Gitea runner did not declare itself successfully"
fi
sleep 2
done
[ "$(docker inspect --format '{{.Config.User}}' "$RUNNER_CONTAINER")" = "rootless" ] || \
fail "outer runner container is not using the rootless image user"
[ "$(docker inspect --format '{{.HostConfig.Privileged}}' "$RUNNER_CONTAINER")" = "true" ] || \
fail "outer rootless DinD container is not privileged"
[ "$(docker inspect --format '{{.HostConfig.Memory}}:{{.HostConfig.MemorySwap}}:{{.HostConfig.NanoCpus}}:{{.HostConfig.PidsLimit}}' "$RUNNER_CONTAINER")" = \
'2147483648:3221225472:2000000000:1024' ] || \
fail "outer rootless DinD container does not have the required aggregate resource limits"
if docker inspect --format '{{range .Mounts}}{{println .Source}}{{end}}' "$RUNNER_CONTAINER" | \
grep -Eq '/var/run/docker\.sock|/run/docker\.sock'; then
fail "host Docker socket was mounted into the runner"
fi
docker exec "$RUNNER_CONTAINER" docker info --format '{{range .SecurityOptions}}{{println .}}{{end}}' | \
grep -Fq 'name=rootless' || fail "nested Docker daemon is not rootless"
[ "$(docker exec "$RUNNER_CONTAINER" docker info --format '{{.DockerRootDir}}')" = /data/docker ] || \
fail "nested Docker daemon is not using its persistent dedicated data root"
docker exec "$RUNNER_CONTAINER" docker image inspect "$JOB_IMAGE" >/dev/null || \
fail "validated pinned job image is unavailable after runner activation"
if ! require_free_space "$MIN_POST_INSTALL_FREE_GIB" "completed CI runner installation"; then
fail "CI runner was stopped because the post-install disk reserve was not met"
fi
systemctl --no-pager --full status easyai-gateway-ci-v2-runner.service
RUNNER_VALIDATED=1
trap - EXIT HUP INT TERM