Files
easyai-ai-gateway/scripts/release-manifest.mjs
T

228 lines
9.1 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { readFileSync, writeFileSync } from 'node:fs'
const shaPattern = /^[0-9a-f]{40}$/
const digestImagePattern = /^[A-Za-z0-9.-]+(?::[0-9]+)?(?:\/[A-Za-z0-9._-]+)+@sha256:[0-9a-f]{64}$/
const allowedComponents = new Set(['api', 'web'])
const smokeChecks = [
'health',
'readiness',
'openapi',
'register',
'login',
'apiKey',
'models',
'chatCompletions',
'responses',
'images',
'web',
]
function requireExactKeys(value, keys, path) {
const actual = Object.keys(value).sort()
const expected = [...keys].sort()
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
fail(`${path} has unexpected or missing fields`)
}
}
function releaseIntegrity(manifest) {
const payload = structuredClone(manifest)
delete payload.integrity
delete payload.deployment
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
}
function fail(message) {
console.error(`release_manifest=FAIL ${message}`)
process.exit(1)
}
function load(path) {
try {
return JSON.parse(readFileSync(path, 'utf8'))
} catch (error) {
fail(`cannot read JSON manifest: ${error.message}`)
}
}
function validate(manifest) {
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
fail('manifest root must be an object')
}
const rootKeys = [
'schemaVersion', 'releaseId', 'sourceSha', 'baseReleaseSha', 'sourceRef',
'createdAt', 'buildCompletedAt', 'targetPlatform', 'components',
'migrationsChanged', 'images', 'smoke', 'integrity',
]
if (manifest.deployment !== undefined) rootKeys.push('deployment')
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')
if (manifest.releaseId !== manifest.sourceSha) fail('releaseId must equal sourceSha')
if (manifest.sourceRef !== 'refs/heads/main') fail('sourceRef must be refs/heads/main')
if (manifest.baseReleaseSha !== null && !shaPattern.test(manifest.baseReleaseSha || '')) {
fail('baseReleaseSha must be null or a full lowercase Git SHA')
}
if (manifest.baseReleaseSha === manifest.sourceSha) fail('baseReleaseSha must differ from sourceSha')
if (manifest.targetPlatform !== 'linux/amd64') fail('targetPlatform must be linux/amd64')
if (!Number.isFinite(Date.parse(manifest.createdAt))) fail('createdAt must be an ISO timestamp')
if (!Number.isFinite(Date.parse(manifest.buildCompletedAt))) {
fail('buildCompletedAt must be an ISO timestamp')
}
if (!Array.isArray(manifest.components) || manifest.components.length < 1 || manifest.components.length > 2) {
fail('components must contain api, web, or both')
}
const uniqueComponents = [...new Set(manifest.components)]
if (uniqueComponents.length !== manifest.components.length ||
uniqueComponents.some((component) => !allowedComponents.has(component))) {
fail('components contains an invalid or duplicate value')
}
if (typeof manifest.migrationsChanged !== 'boolean') fail('migrationsChanged must be boolean')
if (manifest.migrationsChanged && !manifest.components.includes('api')) {
fail('migration changes require the api component')
}
if (!manifest.images || typeof manifest.images !== 'object') fail('images must be an object')
requireExactKeys(manifest.images, ['api', 'web'], 'images')
for (const component of allowedComponents) {
if (!digestImagePattern.test(manifest.images[component] || '')) {
fail(`images.${component} must be a registry digest reference`)
}
}
if (!manifest.smoke || manifest.smoke.status !== 'passed' || manifest.smoke.mode !== 'simulation') {
fail('smoke must record a passed simulation check')
}
requireExactKeys(manifest.smoke, ['status', 'mode', 'completedAt', 'checks'], 'smoke')
if (!Number.isFinite(Date.parse(manifest.smoke.completedAt))) {
fail('smoke.completedAt must be an ISO timestamp')
}
if (!Array.isArray(manifest.smoke.checks) ||
manifest.smoke.checks.length !== smokeChecks.length ||
manifest.smoke.checks.some((check, index) => check !== smokeChecks[index])) {
fail('smoke.checks is incomplete or out of order')
}
if (!manifest.integrity || typeof manifest.integrity !== 'object') {
fail('integrity must be an object')
}
requireExactKeys(manifest.integrity, ['algorithm', 'value'], 'integrity')
if (manifest.integrity.algorithm !== 'sha256' || !/^[0-9a-f]{64}$/.test(manifest.integrity.value || '')) {
fail('integrity must contain a SHA-256 value')
}
if (releaseIntegrity(manifest) !== manifest.integrity.value) {
fail('manifest content integrity check failed')
}
if (manifest.deployment !== undefined) {
if (!manifest.deployment || typeof manifest.deployment !== 'object') {
fail('deployment must be an object')
}
requireExactKeys(manifest.deployment, ['status', 'action', 'deployedAt', 'durationSeconds'], 'deployment')
if (manifest.deployment.status !== 'passed' ||
!['deploy', 'rollback'].includes(manifest.deployment.action) ||
!Number.isFinite(Date.parse(manifest.deployment.deployedAt)) ||
!Number.isInteger(manifest.deployment.durationSeconds) ||
manifest.deployment.durationSeconds < 0) {
fail('deployment record is invalid')
}
}
const forbiddenKey = /(password|secret|token|credential|private.?key)/i
const visit = (value, path = '') => {
if (!value || typeof value !== 'object') return
for (const [key, child] of Object.entries(value)) {
const childPath = path ? `${path}.${key}` : key
if (forbiddenKey.test(key)) fail(`forbidden sensitive field: ${childPath}`)
visit(child, childPath)
}
}
visit(manifest)
return manifest
}
function requireEnv(name, pattern) {
const value = process.env[name] || ''
if (!value || (pattern && !pattern.test(value))) fail(`invalid or missing ${name}`)
return value
}
const [command = 'validate', path, field] = process.argv.slice(2)
if (command === 'create') {
if (!path) fail('create requires an output path')
const sourceSha = requireEnv('RELEASE_SOURCE_SHA', shaPattern)
const baseReleaseSha = process.env.RELEASE_BASE_SHA || null
if (baseReleaseSha !== null && !shaPattern.test(baseReleaseSha)) fail('invalid RELEASE_BASE_SHA')
const components = requireEnv('RELEASE_COMPONENTS').split(',').filter(Boolean)
const migrationsChanged = process.env.RELEASE_MIGRATIONS_CHANGED === 'true'
const manifest = {
schemaVersion: 1,
releaseId: sourceSha,
sourceSha,
baseReleaseSha,
sourceRef: 'refs/heads/main',
createdAt: new Date().toISOString(),
buildCompletedAt: requireEnv('RELEASE_BUILD_COMPLETED_AT'),
targetPlatform: 'linux/amd64',
components,
migrationsChanged,
images: {
api: requireEnv('RELEASE_API_IMAGE', digestImagePattern),
web: requireEnv('RELEASE_WEB_IMAGE', digestImagePattern),
},
smoke: {
status: 'passed',
mode: 'simulation',
completedAt: requireEnv('RELEASE_SMOKE_COMPLETED_AT'),
checks: smokeChecks,
},
integrity: { algorithm: 'sha256', value: '' },
}
manifest.integrity.value = releaseIntegrity(manifest)
validate(manifest)
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
const digest = createHash('sha256').update(readFileSync(path)).digest('hex')
console.log(`release_manifest=CREATED path=${path} sha256=${digest}`)
process.exit(0)
}
if (!path) fail(`${command} requires a manifest path`)
const manifest = validate(load(path))
if (command === 'record-deployment') {
if (!field) fail('record-deployment requires an output path')
const deployedAt = requireEnv('RELEASE_DEPLOYED_AT')
const durationSeconds = Number(process.env.RELEASE_DEPLOY_DURATION_SECONDS || '')
const action = process.env.RELEASE_DEPLOY_ACTION || 'deploy'
if (!Number.isFinite(Date.parse(deployedAt))) fail('invalid RELEASE_DEPLOYED_AT')
if (!Number.isInteger(durationSeconds) || durationSeconds < 0) {
fail('invalid RELEASE_DEPLOY_DURATION_SECONDS')
}
if (!['deploy', 'rollback'].includes(action)) fail('invalid RELEASE_DEPLOY_ACTION')
manifest.deployment = { status: 'passed', action, deployedAt, durationSeconds }
validate(manifest)
writeFileSync(field, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
console.log(`release_manifest=RECORDED release=${manifest.releaseId} action=${action}`)
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}`)
process.exit(0)
}
if (command === 'get') {
if (!field || !/^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/.test(field)) {
fail('get requires a safe dotted field path')
}
let value = manifest
for (const segment of field.split('.')) value = value?.[segment]
if (value === null || value === undefined) console.log('')
else if (typeof value === 'object') console.log(JSON.stringify(value))
else console.log(String(value))
process.exit(0)
}
fail(`unknown command: ${command}`)