Files
easyai-ai-gateway/scripts/api-release-smoke.mjs
T
easyai dae5d16a58 refactor(release): 改为 Agent 双阶段人工发布
删除 Gitea Actions、Tag/Main 自动流水线和旧 Runner 配置,取消 Git 操作与发布授权的绑定。\n\n新增本地镜像发布、固定生产部署助手、digest manifest、迁移安全检查、simulation 冒烟及显式回滚流程。\n\n验证:pnpm lint、pnpm test、pnpm build、Go 全量测试、ShellCheck、Compose 配置、人工发布测试和 linux/amd64 完整栈冒烟。
2026-07-22 15:13:40 +08:00

146 lines
5.9 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
function fail(message) {
console.error(`api_release_smoke=FAIL ${message}`)
process.exit(1)
}
function assert(condition, message) {
if (!condition) fail(message)
}
const baseURL = (process.env.RELEASE_SMOKE_BASE_URL || '').replace(/\/+$/, '')
const webURL = (process.env.RELEASE_SMOKE_WEB_URL || '').replace(/\/+$/, '')
const composeProject = process.env.RELEASE_SMOKE_COMPOSE_PROJECT || ''
const composeFile = process.env.RELEASE_SMOKE_COMPOSE_FILE || 'docker-compose.yml'
const nonce = process.env.RELEASE_SMOKE_NONCE || Date.now().toString(36)
const databaseUser = process.env.RELEASE_SMOKE_DATABASE_USER || 'easyai'
const databaseName = process.env.RELEASE_SMOKE_DATABASE_NAME || 'easyai_ai_gateway'
if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(baseURL)) fail('invalid RELEASE_SMOKE_BASE_URL')
if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(webURL)) fail('invalid RELEASE_SMOKE_WEB_URL')
if (!/^[a-z0-9][a-z0-9_-]{2,62}$/.test(composeProject)) fail('invalid RELEASE_SMOKE_COMPOSE_PROJECT')
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(databaseUser)) fail('invalid RELEASE_SMOKE_DATABASE_USER')
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(databaseName)) fail('invalid RELEASE_SMOKE_DATABASE_NAME')
async function request(path, { method = 'GET', token = '', body } = {}) {
const response = await fetch(`${baseURL}${path}`, {
method,
signal: AbortSignal.timeout(15_000),
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
const text = await response.text()
let payload
try {
payload = text ? JSON.parse(text) : null
} catch {
fail(`${method} ${path} returned non-JSON HTTP ${response.status}`)
}
if (!response.ok) fail(`${method} ${path} returned HTTP ${response.status}`)
return { response, payload }
}
async function waitForAPI() {
let lastError = 'API did not respond'
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
const response = await fetch(`${baseURL}/api/v1/readyz`, {
signal: AbortSignal.timeout(2_000),
})
if (response.ok) return
lastError = `readiness returned HTTP ${response.status}`
} catch (error) {
lastError = error.message
}
await new Promise((resolve) => setTimeout(resolve, 1_000))
}
fail(`API readiness timed out: ${lastError}`)
}
await waitForAPI()
const health = await request('/api/v1/healthz')
assert(health.payload?.ok === true && health.payload?.service === 'easyai-ai-gateway', 'health payload mismatch')
const ready = await request('/api/v1/readyz')
assert(ready.payload?.ok === true, 'readiness payload mismatch')
const openapi = await request('/api/v1/openapi.json')
for (const path of [
'/api/v1/healthz',
'/api/v1/readyz',
'/api/v1/models',
'/api/v1/chat/completions',
'/api/v1/responses',
'/api/v1/images/generations',
]) {
assert(openapi.payload?.paths?.[path], `OpenAPI is missing ${path}`)
}
const username = `release_smoke_${nonce}`.slice(0, 60)
const password = `Release-Smoke-${nonce}-9x!`
const registration = await request('/api/v1/auth/register', {
method: 'POST',
body: { username, email: `${username}@example.invalid`, password },
})
assert(typeof registration.payload?.accessToken === 'string', 'registration did not return an access token')
const login = await request('/api/v1/auth/login', {
method: 'POST',
body: { account: username, password },
})
const jwt = login.payload?.accessToken
assert(typeof jwt === 'string' && jwt.length > 20, 'login did not return an access token')
try {
execFileSync('docker', [
'compose', '-p', composeProject, '-f', composeFile,
'exec', '-T', 'postgres', 'psql', '-v', 'ON_ERROR_STOP=1',
'-U', databaseUser, '-d', databaseName, '-c',
`UPDATE gateway_wallet_accounts SET balance = 100000 WHERE gateway_user_id = (SELECT id FROM gateway_users WHERE username = '${username.replaceAll("'", "''")}')`,
], { stdio: ['ignore', 'ignore', 'pipe'] })
} catch {
fail('could not fund the ephemeral smoke wallet')
}
const apiKeyResult = await request('/api/v1/api-keys', {
method: 'POST', token: jwt, body: { name: 'release smoke key' },
})
const apiKey = apiKeyResult.payload?.secret
assert(typeof apiKey === 'string' && apiKey.startsWith('sk-gw-'), 'API key creation failed')
const models = await request('/api/v1/models', { token: apiKey })
assert(Array.isArray(models.payload?.items) || Array.isArray(models.payload?.data), 'models response has no items')
const simulation = { runMode: 'simulation', simulation: true, simulationDurationMs: 5 }
const chat = await request('/api/v1/chat/completions', {
method: 'POST', token: apiKey,
body: { model: 'openai:gpt-4o-mini', messages: [{ role: 'user', content: 'release smoke' }], ...simulation },
})
assert(/^chatcmpl-/.test(chat.payload?.id || ''), 'chat simulation response mismatch')
const responses = await request('/api/v1/responses', {
method: 'POST', token: apiKey,
body: { model: 'openai:gpt-4o-mini', input: 'release smoke', ...simulation },
})
assert(/^resp/.test(responses.payload?.id || ''), 'responses simulation response mismatch')
const images = await request('/api/v1/images/generations', {
method: 'POST', token: apiKey,
body: { model: 'openai:gpt-image-1', prompt: 'release smoke', ...simulation },
})
assert(Array.isArray(images.payload?.data) && images.payload.data.length > 0, 'image simulation response mismatch')
const webHealth = await fetch(`${webURL}/api/v1/healthz`, { signal: AbortSignal.timeout(15_000) })
assert(webHealth.ok, `web API reverse proxy returned HTTP ${webHealth.status}`)
const webApp = await fetch(`${webURL}/`, { signal: AbortSignal.timeout(15_000) })
assert(webApp.ok && (await webApp.text()).includes('EasyAI AI Gateway'), 'web application smoke failed')
console.log('api_release_smoke=PASS health=ok readiness=ok openapi=ok auth=ok chat=ok responses=ok images=ok web=ok')