删除 Gitea Actions、Tag/Main 自动流水线和旧 Runner 配置,取消 Git 操作与发布授权的绑定。\n\n新增本地镜像发布、固定生产部署助手、digest manifest、迁移安全检查、simulation 冒烟及显式回滚流程。\n\n验证:pnpm lint、pnpm test、pnpm build、Go 全量测试、ShellCheck、Compose 配置、人工发布测试和 linux/amd64 完整栈冒烟。
146 lines
5.9 KiB
JavaScript
Executable File
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')
|