1081 lines
59 KiB
TypeScript
1081 lines
59 KiB
TypeScript
/** @jest-environment node */
|
||
|
||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
||
import { existsSync, readFileSync } from 'fs'
|
||
import { spawnSync } from 'child_process'
|
||
import os from 'os'
|
||
import path from 'path'
|
||
|
||
function extractBashFunction(source: string, name: string): string {
|
||
const match = source.match(new RegExp(`(?:^|\\n)${name}\\(\\) \\{[\\s\\S]*?\\n\\}`, 'm'))
|
||
if (!match) {
|
||
throw new Error(`Bash function not found: ${name}`)
|
||
}
|
||
return match[0].trimStart()
|
||
}
|
||
|
||
function findBash(): string | null {
|
||
const candidates = process.platform === 'win32'
|
||
? [
|
||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
||
]
|
||
: ['/bin/bash', '/usr/bin/bash']
|
||
return candidates.find(existsSync) || null
|
||
}
|
||
|
||
function toBashPath(value: string): string {
|
||
if (process.platform !== 'win32') return value
|
||
const match = value.match(/^([A-Za-z]):[\\/](.*)$/)
|
||
if (!match) return value.replace(/\\/g, '/')
|
||
return `/${match[1].toLowerCase()}/${match[2].replace(/\\/g, '/')}`
|
||
}
|
||
|
||
function runBashGate(
|
||
bash: string,
|
||
functions: string,
|
||
invocation: string,
|
||
variables: Record<string, string>,
|
||
) {
|
||
return spawnSync(bash, ['-c', `set -u\n${functions}\n${invocation}`], {
|
||
cwd: process.cwd(),
|
||
encoding: 'utf8',
|
||
env: {
|
||
...process.env,
|
||
MSYS2_ARG_CONV_EXCL: '*',
|
||
MSYS_NO_PATHCONV: '1',
|
||
...variables,
|
||
},
|
||
})
|
||
}
|
||
|
||
describe('production video transcode drain gate', () => {
|
||
let script: string
|
||
|
||
beforeAll(async () => {
|
||
script = (await readFile(path.join(process.cwd(), 'scripts/git-incremental-deploy.ps1'), 'utf8'))
|
||
.replace(/\r\n/g, '\n')
|
||
})
|
||
|
||
test('先设置旧容器两类后台 worker drain 并确认 inactive,再启动候选容器', () => {
|
||
const drain = script.indexOf('request_video_transcode_drain "$TRANSCODE_DRAIN_SOURCE"')
|
||
const taskDetailDrain = script.indexOf('request_task_detail_upload_drain "$TRANSCODE_DRAIN_SOURCE"', drain)
|
||
const candidate = script.indexOf('log "Starting candidate container $NEXT_CONTAINER"')
|
||
|
||
expect(drain).toBeGreaterThan(0)
|
||
expect(taskDetailDrain).toBeGreaterThan(drain)
|
||
expect(candidate).toBeGreaterThan(taskDetailDrain)
|
||
expect(script).toContain('wait_for_video_transcode_drain "$name"')
|
||
expect(script).toContain('wait_for_task_detail_upload_drain "$name"')
|
||
})
|
||
|
||
test('蓝绿入口存在时仍逐个排空意外运行的直连容器,且绝不强制删除', () => {
|
||
const discoverCurrent = script.indexOf('if ! CURRENT_CONTAINER="$(discover_current_container)"; then')
|
||
const collectDirect = script.indexOf('if [ -n "$LEGACY_IMAGE" ] && container_running "$CONTAINER_NAME"; then', discoverCurrent)
|
||
const drainLoop = script.indexOf('for TRANSCODE_DRAIN_SOURCE in $TRANSCODE_DRAIN_SOURCES; do', collectDirect)
|
||
const verifyLease = script.indexOf('verify_transcode_lease_schema "$TRANSCODE_DRAIN_SOURCE"', drainLoop)
|
||
const requestDrain = script.indexOf('request_video_transcode_drain "$TRANSCODE_DRAIN_SOURCE"', verifyLease)
|
||
const candidate = script.indexOf('log "Starting candidate container $NEXT_CONTAINER"', requestDrain)
|
||
const finalDirectRemoval = script.lastIndexOf('remove_video_container_safely "$CONTAINER_NAME"')
|
||
|
||
expect(collectDirect).toBeGreaterThan(discoverCurrent)
|
||
expect(drainLoop).toBeGreaterThan(collectDirect)
|
||
expect(verifyLease).toBeGreaterThan(drainLoop)
|
||
expect(requestDrain).toBeGreaterThan(verifyLease)
|
||
expect(candidate).toBeGreaterThan(requestDrain)
|
||
expect(finalDirectRemoval).toBeGreaterThan(candidate)
|
||
expect(script).toContain('TRANSCODE_DRAIN_SOURCES="${TRANSCODE_DRAIN_SOURCES} ${CONTAINER_NAME}"')
|
||
expect(script).toContain('Legacy direct container could not be proven inactive and was preserved')
|
||
expect(script).not.toContain('docker rm -f "$CONTAINER_NAME"')
|
||
})
|
||
|
||
test('正式发布发现任何运行中的影子容器时,在触碰生产入口前 fail-closed', () => {
|
||
const shadowDiscovery = script.indexOf("RUNNING_CONTAINER_METADATA=\"$(docker ps --format")
|
||
const shadowFailure = script.indexOf('Running shadow container(s) detected; production deployment is blocked', shadowDiscovery)
|
||
const directTopologyGate = script.indexOf('LEGACY_IMAGE="$(docker inspect', shadowFailure)
|
||
const proxyMutation = script.indexOf('mkdir -p "$PROXY_ROOT"', directTopologyGate)
|
||
const currentDiscovery = script.indexOf('CURRENT_CONTAINER="$(discover_current_container)"', proxyMutation)
|
||
|
||
expect(shadowDiscovery).toBeGreaterThan(0)
|
||
expect(shadowFailure).toBeGreaterThan(shadowDiscovery)
|
||
expect(directTopologyGate).toBeGreaterThan(shadowFailure)
|
||
expect(proxyMutation).toBeGreaterThan(directTopologyGate)
|
||
expect(currentDiscovery).toBeGreaterThan(proxyMutation)
|
||
expect(script).toContain('if [ "$TARGET" = "production" ]; then')
|
||
expect(script).toContain('$1 == "youyidian-3d-material-shadow"')
|
||
expect(script).toContain('$1 ~ /^youyidian-3d-shadow($|[-_])/')
|
||
expect(script).toContain('{{.Label "com.docker.compose.service"}}')
|
||
expect(script).toContain('$2 == "app-shadow"')
|
||
expect(script).toContain('Cannot inspect running containers for shadow topology')
|
||
expect(script).toContain('until they are stopped and verified')
|
||
})
|
||
|
||
test('uploads 路径必须显式落入受控 allowlist,默认锁定当前正式 ZFS 数据集', () => {
|
||
expect(script).toContain("$defaultUploadsMount = '/mnt/PoolOne/youyidian-uploads'")
|
||
expect(script).toContain("'/mnt/PoolOne/apps/youyidian-3d/uploads'")
|
||
expect(script).toContain('[string]$UploadsMount = $env:GONGXIANGP2_UPLOADS_MOUNT')
|
||
expect(script).toContain('$trustedUploadsMounts -cnotcontains $UploadsMount')
|
||
expect(script).toContain("'__UPLOADS_MOUNT__' = ConvertTo-ShellSingleQuoted $UploadsMount")
|
||
expect(script).not.toContain("UPLOADS_MOUNT='/mnt/PoolOne/apps/youyidian-3d/uploads'")
|
||
})
|
||
|
||
test('findmnt 模拟拒绝根盘目录壳、多行或未受信 tuple,并接受两条显式路径', () => {
|
||
const bash = findBash()
|
||
expect(bash).not.toBeNull()
|
||
if (!bash) return
|
||
|
||
const helper = extractBashFunction(script, 'uploads_findmnt_metadata_is_trusted')
|
||
const invoke = 'uploads_findmnt_metadata_is_trusted "$CASE_PATH" "$CASE_MOUNT" "$CASE_ROOT"'
|
||
const run = (casePath: string, mount: string, root = '/ /dev/sda2 ext4') => runBashGate(
|
||
bash,
|
||
helper,
|
||
invoke,
|
||
{ CASE_PATH: casePath, CASE_MOUNT: mount, CASE_ROOT: root },
|
||
)
|
||
|
||
expect(run(
|
||
'/mnt/PoolOne/youyidian-uploads',
|
||
'/mnt/PoolOne/youyidian-uploads PoolOne/youyidian-uploads zfs',
|
||
).status).toBe(0)
|
||
expect(run(
|
||
'/mnt/PoolOne/apps/youyidian-3d/uploads',
|
||
'/mnt/PoolOne PoolOne zfs',
|
||
).status).toBe(0)
|
||
expect(run(
|
||
'/mnt/PoolOne/youyidian-uploads',
|
||
'/ /dev/sda2 ext4',
|
||
).status).not.toBe(0)
|
||
expect(run(
|
||
'/mnt/PoolOne/youyidian-uploads',
|
||
'/mnt/PoolOne/youyidian-uploads PoolOne/youyidian-uploads ext4',
|
||
).status).not.toBe(0)
|
||
expect(run(
|
||
'/mnt/PoolOne/youyidian-uploads',
|
||
'/mnt/PoolOne/youyidian-uploads PoolOne/youyidian-uploads zfs\n/ /dev/sda2 ext4',
|
||
).status).not.toBe(0)
|
||
})
|
||
|
||
test('Docker Mounts 模拟拒绝 volume、错 source、只读和重复 /app/uploads;旧容器仅可用已解析到同一数据盘的别名', () => {
|
||
const bash = findBash()
|
||
expect(bash).not.toBeNull()
|
||
if (!bash) return
|
||
|
||
const helper = extractBashFunction(script, 'uploads_container_mount_metadata_is_trusted')
|
||
const invoke = 'uploads_container_mount_metadata_is_trusted "$EXPECTED_SOURCE" "$CASE_METADATA"'
|
||
const expectedSource = '/mnt/PoolOne/youyidian-uploads'
|
||
const run = (metadata: string) => runBashGate(
|
||
bash,
|
||
helper,
|
||
invoke,
|
||
{ EXPECTED_SOURCE: expectedSource, CASE_METADATA: metadata },
|
||
)
|
||
|
||
expect(run(`/app/uploads\tbind\t${expectedSource}\ttrue`).status).toBe(0)
|
||
expect(run('/app/uploads\tvolume\tuploads-volume\ttrue').status).not.toBe(0)
|
||
expect(run('/app/uploads\tbind\t/mnt/PoolOne/apps/youyidian-3d/uploads\ttrue').status).not.toBe(0)
|
||
expect(run(`/app/uploads\tbind\t${expectedSource}\tfalse`).status).not.toBe(0)
|
||
expect(run(
|
||
`/app/uploads\tbind\t${expectedSource}\ttrue\n/app/uploads\tbind\t${expectedSource}\ttrue`,
|
||
).status).not.toBe(0)
|
||
|
||
const legacySource = '/mnt/PoolOne/apps/youyidian-3d/uploads'
|
||
const aliasHelper = `
|
||
readlink() {
|
||
if [ "$3" = "${legacySource}" ]; then
|
||
printf '%s\\n' "${expectedSource}"
|
||
else
|
||
printf '%s\\n' "$3"
|
||
fi
|
||
}
|
||
${helper}`
|
||
const runWithResolvedAlias = (allowAlias: string) => runBashGate(
|
||
bash,
|
||
aliasHelper,
|
||
'uploads_container_mount_metadata_is_trusted "$EXPECTED_SOURCE" "$CASE_METADATA" "$ALLOW_ALIAS"',
|
||
{
|
||
EXPECTED_SOURCE: expectedSource,
|
||
CASE_METADATA: `/app/uploads\tbind\t${legacySource}\ttrue`,
|
||
ALLOW_ALIAS: allowAlias,
|
||
},
|
||
)
|
||
expect(runWithResolvedAlias('0').status).not.toBe(0)
|
||
expect(runWithResolvedAlias('1').status).toBe(0)
|
||
})
|
||
|
||
test('statfs 模拟必须拒绝宿主根盘签名和宿主/容器不一致', () => {
|
||
const bash = findBash()
|
||
expect(bash).not.toBeNull()
|
||
if (!bash) return
|
||
|
||
const helpers = [
|
||
extractBashFunction(script, 'host_statfs_signature_is_trusted'),
|
||
extractBashFunction(script, 'statfs_signatures_match'),
|
||
].join('\n')
|
||
const runHost = (uploads: string, root: string) => runBashGate(
|
||
bash,
|
||
helpers,
|
||
'host_statfs_signature_is_trusted "$UPLOADS_SIGNATURE" "$ROOT_SIGNATURE"',
|
||
{ UPLOADS_SIGNATURE: uploads, ROOT_SIGNATURE: root },
|
||
)
|
||
const runContainer = (host: string, container: string) => runBashGate(
|
||
bash,
|
||
helpers,
|
||
'statfs_signatures_match "$HOST_SIGNATURE" "$CONTAINER_SIGNATURE"',
|
||
{ HOST_SIGNATURE: host, CONTAINER_SIGNATURE: container },
|
||
)
|
||
|
||
expect(runHost('a1b2:2fc12fc1', '9abc:ef53').status).toBe(0)
|
||
expect(runHost('9abc:ef53', '9abc:ef53').status).not.toBe(0)
|
||
expect(runContainer('a1b2:2fc12fc1', 'a1b2:2fc12fc1').status).toBe(0)
|
||
expect(runContainer('a1b2:2fc12fc1', 'ffff:2fc12fc1').status).not.toBe(0)
|
||
})
|
||
|
||
test('宿主门禁先于所有容器/流量动作,候选严格 create→inspect→UPLOAD_DIR→start→statfs→health→复验→切流', () => {
|
||
const hostGate = script.indexOf('if ! verify_host_uploads_mount; then')
|
||
const firstContainerDiscovery = script.indexOf('RUNNING_CONTAINER_METADATA="$(docker ps --format', hostGate)
|
||
const firstLegacyInspect = script.indexOf('LEGACY_IMAGE="$(docker inspect', hostGate)
|
||
const drainLoop = script.indexOf('for TRANSCODE_DRAIN_SOURCE in $TRANSCODE_DRAIN_SOURCES; do', firstLegacyInspect)
|
||
const existingStorage = script.indexOf('verify_container_uploads_storage "$TRANSCODE_DRAIN_SOURCE"', drainLoop)
|
||
const existingDrain = script.indexOf('request_video_transcode_drain "$TRANSCODE_DRAIN_SOURCE"', existingStorage)
|
||
const create = script.indexOf('create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT"')
|
||
const mountInspect = script.indexOf('verify_container_uploads_mount_metadata "$NEXT_CONTAINER"', create)
|
||
const uploadDirInspect = script.indexOf('verify_container_upload_dir_config "$NEXT_CONTAINER" 1', mountInspect)
|
||
const start = script.indexOf('docker start "$NEXT_CONTAINER"', uploadDirInspect)
|
||
const statfs = script.indexOf('verify_container_uploads_statfs "$NEXT_CONTAINER"', start)
|
||
const uploadDirRuntime = script.indexOf('verify_container_upload_dir_runtime "$NEXT_CONTAINER" 1', statfs)
|
||
const health = script.indexOf('wait_for_container_health "$NEXT_CONTAINER"', uploadDirRuntime)
|
||
const writeProxy = script.indexOf('write_proxy_config "${NEXT_IP}:3000" "$NEXT_CONTAINER"', health)
|
||
const finalStorage = script.indexOf('verify_container_uploads_storage "$NEXT_CONTAINER"', writeProxy)
|
||
const switchTraffic = script.indexOf('if ! switch_proxy; then', finalStorage)
|
||
|
||
expect(hostGate).toBeGreaterThan(0)
|
||
expect(firstContainerDiscovery).toBeGreaterThan(hostGate)
|
||
expect(firstLegacyInspect).toBeGreaterThan(hostGate)
|
||
expect(existingStorage).toBeGreaterThan(drainLoop)
|
||
expect(existingDrain).toBeGreaterThan(existingStorage)
|
||
expect(create).toBeGreaterThan(existingDrain)
|
||
expect(mountInspect).toBeGreaterThan(create)
|
||
expect(uploadDirInspect).toBeGreaterThan(mountInspect)
|
||
expect(start).toBeGreaterThan(uploadDirInspect)
|
||
expect(statfs).toBeGreaterThan(start)
|
||
expect(uploadDirRuntime).toBeGreaterThan(statfs)
|
||
expect(health).toBeGreaterThan(uploadDirRuntime)
|
||
expect(finalStorage).toBeGreaterThan(writeProxy)
|
||
expect(switchTraffic).toBeGreaterThan(finalStorage)
|
||
|
||
for (const name of [
|
||
'uploads_findmnt_metadata_is_trusted',
|
||
'host_statfs_signature_is_trusted',
|
||
'statfs_signatures_match',
|
||
'uploads_container_mount_metadata_is_trusted',
|
||
'verify_host_uploads_mount',
|
||
'verify_container_uploads_mount_metadata',
|
||
'verify_container_uploads_statfs',
|
||
'verify_container_upload_dir_config',
|
||
'verify_container_upload_dir_runtime',
|
||
'restore_unreloaded_proxy_config',
|
||
]) {
|
||
expect(extractBashFunction(script, name)).not.toContain('|| true')
|
||
}
|
||
const rejectCandidate = extractBashFunction(script, 'fail_candidate_before_traffic')
|
||
expect(rejectCandidate).not.toContain('|| true')
|
||
expect(rejectCandidate.indexOf('restore_unreloaded_proxy_config')).toBeLessThan(
|
||
rejectCandidate.indexOf('remove_video_container_safely "$NEXT_CONTAINER"'),
|
||
)
|
||
})
|
||
|
||
test('应用上传根目录必须由容器参数钉死到已验证的 /app/uploads', () => {
|
||
const createContainer = extractBashFunction(script, 'create_container')
|
||
const verifyConfig = extractBashFunction(script, 'verify_container_upload_dir_config')
|
||
const verifyRuntime = extractBashFunction(script, 'verify_container_upload_dir_runtime')
|
||
|
||
expect(createContainer.indexOf('--env-file "$ENV_FILE_PATH"')).toBeLessThan(
|
||
createContainer.indexOf('-e "UPLOAD_DIR=/app/uploads"'),
|
||
)
|
||
expect(verifyConfig).toContain('Container ${name} must declare exactly UPLOAD_DIR=/app/uploads')
|
||
expect(verifyConfig).toContain('Existing container ${name} has an unsafe UPLOAD_DIR override')
|
||
expect(verifyRuntime).toContain('if (resolved !== "/app/uploads") process.exit(1)')
|
||
expect(script).toContain('verify_container_uploads_storage "$NEXT_CONTAINER" 1')
|
||
})
|
||
|
||
test('上传路径关闭请求缓冲,并按 128MiB 上传与 20MiB 分片设置独立余量', () => {
|
||
const exactStart = script.indexOf('location = /api/materials/upload {')
|
||
const chunkStart = script.indexOf('location = /api/materials/upload/chunk {', exactStart)
|
||
const uploadPrefixStart = script.indexOf('location ^~ /api/materials/upload/ {', chunkStart)
|
||
const fallbackStart = script.indexOf('location / {', uploadPrefixStart)
|
||
const exactLocation = script.slice(exactStart, chunkStart)
|
||
const chunkLocation = script.slice(chunkStart, uploadPrefixStart)
|
||
const uploadPrefixLocation = script.slice(uploadPrefixStart, fallbackStart)
|
||
const globalLimitMatch = script.match(/http \{[\s\S]*?client_max_body_size (\d+)m;/)
|
||
const exactLimitMatch = exactLocation.match(/client_max_body_size (\d+)m;/)
|
||
const chunkLimitMatch = chunkLocation.match(/client_max_body_size (\d+)m;/)
|
||
const uploadPrefixLimitMatch = uploadPrefixLocation.match(/client_max_body_size (\d+)m;/)
|
||
|
||
expect(exactStart).toBeGreaterThan(0)
|
||
expect(chunkStart).toBeGreaterThan(exactStart)
|
||
expect(uploadPrefixStart).toBeGreaterThan(chunkStart)
|
||
expect(fallbackStart).toBeGreaterThan(uploadPrefixStart)
|
||
expect(exactLocation).toContain('proxy_request_buffering off;')
|
||
expect(chunkLocation).toContain('proxy_request_buffering off;')
|
||
expect(uploadPrefixLocation).toContain('proxy_request_buffering off;')
|
||
expect(Number(exactLimitMatch?.[1])).toBe(129)
|
||
expect(Number(chunkLimitMatch?.[1])).toBe(21)
|
||
expect(Number(uploadPrefixLimitMatch?.[1])).toBe(2)
|
||
expect(Number(globalLimitMatch?.[1])).toBe(16)
|
||
})
|
||
|
||
test('公开钉钉免登入口在代理层限制为 64KiB', () => {
|
||
const loginStart = script.indexOf('location = /api/dingtalk/h5-login {')
|
||
const nextLocation = script.indexOf('location = /api/materials/upload {', loginStart)
|
||
const loginLocation = script.slice(loginStart, nextLocation)
|
||
|
||
expect(loginStart).toBeGreaterThan(0)
|
||
expect(nextLocation).toBeGreaterThan(loginStart)
|
||
expect(loginLocation).toContain('client_max_body_size 64k;')
|
||
expect(loginLocation).toContain('proxy_request_buffering off;')
|
||
})
|
||
|
||
test('候选切流前必须同时验证健康依赖和精确部署提交', () => {
|
||
const readiness = script.indexOf('health_payload_is_ready() {')
|
||
const candidateHealth = script.indexOf('wait_for_container_health "$NEXT_CONTAINER" "$COMMIT_SHA"')
|
||
const firstSwitch = script.indexOf('switch_proxy; then', candidateHealth)
|
||
|
||
expect(readiness).toBeGreaterThan(0)
|
||
expect(candidateHealth).toBeGreaterThan(readiness)
|
||
expect(firstSwitch).toBeGreaterThan(candidateHealth)
|
||
expect(script).toContain("grep -Fq -- '\"status\":\"healthy\"'")
|
||
expect(script).toContain("grep -Fq -- '\"database\":{\"status\":\"ok\"'")
|
||
expect(script).toContain("grep -Fq -- '\"redis\":{\"status\":\"ok\"'")
|
||
expect(script).toContain('"\\"deploymentCommit\\":\\"${expected_commit}\\""')
|
||
expect(script).toContain('verify_proxy_target "$NEXT_CONTAINER" "$COMMIT_SHA"')
|
||
expect(script).toContain('container_healthy "$NEXT_CONTAINER" "$COMMIT_SHA"')
|
||
expect(script).toContain('listen [::]:${PORT} ipv6only=on;')
|
||
expect(script).toContain('runtime_proxy_container_at_url "http://[::1]:${PORT}/api/health"')
|
||
expect(script).toContain("curl --noproxy '*' -g -fsS --max-time 8 \"http://[::1]:${PORT}/api/health\"")
|
||
})
|
||
|
||
test('公网验收必须证明候选、精确提交、健康依赖、S3 与两张 Logo', () => {
|
||
const publicProbe = script.indexOf('Checking public FRP, authenticated S3, and Logo decode acceptance under the deployment lock')
|
||
const committedTarget = script.indexOf('COMMITTED_PUBLIC_TARGET="$(tr -d', publicProbe)
|
||
const payloadGate = script.indexOf('health_payload_is_ready /tmp/git-deploy-public-health.json "$COMMIT_SHA"', committedTarget)
|
||
const targetGate = script.indexOf('[ "$public_target_count" = "1" ] && [ "$public_target" = "$COMMITTED_PUBLIC_TARGET" ]', payloadGate)
|
||
const s3Gate = script.indexOf('https://s3.jiansun.vip/minio/health/live', targetGate)
|
||
const settings = script.indexOf('$PUBLIC_APP_URL/api/system-settings', s3Gate)
|
||
const configuredLogoValidation = script.indexOf("settings.headerLogoUrl, settings.loginLogoUrl", settings)
|
||
const configuredLogoFetch = script.indexOf('done </tmp/git-deploy-public-logo-urls.txt', configuredLogoValidation)
|
||
const fallbackLogos = script.indexOf('for fallback_logo in header-logo.png login-logo.png', configuredLogoFetch)
|
||
|
||
expect(publicProbe).toBeGreaterThan(0)
|
||
expect(committedTarget).toBeGreaterThan(publicProbe)
|
||
expect(payloadGate).toBeGreaterThan(committedTarget)
|
||
expect(targetGate).toBeGreaterThan(payloadGate)
|
||
expect(s3Gate).toBeGreaterThan(targetGate)
|
||
expect(settings).toBeGreaterThan(s3Gate)
|
||
expect(configuredLogoValidation).toBeGreaterThan(settings)
|
||
expect(configuredLogoFetch).toBeGreaterThan(configuredLogoValidation)
|
||
expect(fallbackLogos).toBeGreaterThan(configuredLogoFetch)
|
||
expect(script).toContain('Public application endpoint does not prove the expected commit and healthy database/Redis')
|
||
expect(script).toContain('abort_after_uncertain_switch "Public S3 endpoint is unhealthy after cutover"')
|
||
expect(script).toContain('did not return an image Content-Type')
|
||
expect(script).not.toContain('[ "$public_target" = "$NEXT_CONTAINER" ]')
|
||
expect(script).not.toContain('Public S3 endpoint is unhealthy; application deployment succeeded')
|
||
})
|
||
|
||
test('候选必须在切流前验证数据库身份、S3 认证及全部品牌图解码', () => {
|
||
const candidateHealth = script.indexOf('wait_for_container_health "$NEXT_CONTAINER" "$COMMIT_SHA"')
|
||
const databaseIdentity = script.indexOf('verify_candidate_database_identity "$NEXT_CONTAINER"', candidateHealth)
|
||
const brandAndStorage = script.indexOf('verify_candidate_brand_and_storage_dependencies "$NEXT_CONTAINER" "$NEXT_IP"', databaseIdentity)
|
||
const capability = script.indexOf('verify_video_transcode_capability "$NEXT_CONTAINER"', brandAndStorage)
|
||
const switchTraffic = script.indexOf('if ! switch_proxy; then', capability)
|
||
|
||
expect(candidateHealth).toBeGreaterThan(0)
|
||
expect(databaseIdentity).toBeGreaterThan(candidateHealth)
|
||
expect(brandAndStorage).toBeGreaterThan(databaseIdentity)
|
||
expect(capability).toBeGreaterThan(brandAndStorage)
|
||
expect(switchTraffic).toBeGreaterThan(capability)
|
||
expect(script).toContain('new HeadBucketCommand({ Bucket: process.env.S3_BUCKET })')
|
||
expect(script).toContain('verify_candidate_baked_brand_assets "$name"')
|
||
expect(script).toContain('verify_candidate_configured_brand_assets "$name" "$candidate_ip"')
|
||
})
|
||
|
||
test('构建机在任何拉代码或发布动作前必须证明 Node 可用', () => {
|
||
const preflight = script.indexOf('start_remote_step "Preflight"')
|
||
const requireNode = script.indexOf('require_cmd node', preflight)
|
||
const gitSync = script.indexOf('start_remote_step "Git Sync"', requireNode)
|
||
|
||
expect(preflight).toBeGreaterThan(0)
|
||
expect(requireNode).toBeGreaterThan(preflight)
|
||
expect(gitSync).toBeGreaterThan(requireNode)
|
||
})
|
||
|
||
test('生产镜像来源绑定 exact SHA,运行时环境变量不能冒充新代码', () => {
|
||
const dockerfile = readFileSync(path.join(process.cwd(), 'Dockerfile'), 'utf8')
|
||
const buildArg = script.indexOf('--build-arg "VCS_REF=$COMMIT_SHA"')
|
||
const productionLabel = script.indexOf('Production image revision label matches $COMMIT_SHA')
|
||
const candidateLabel = script.indexOf('verify_image_revision_label "$REGISTRY_COMMIT" "$COMMIT_SHA"')
|
||
const candidateFile = script.indexOf('verify_container_baked_revision "$NEXT_CONTAINER" "$COMMIT_SHA"')
|
||
const candidateHealth = script.indexOf('wait_for_container_health "$NEXT_CONTAINER" "$COMMIT_SHA"')
|
||
|
||
expect(script).toContain('Production deployment requires a freshly built and pushed exact-SHA image')
|
||
expect(buildArg).toBeGreaterThan(0)
|
||
expect(productionLabel).toBeGreaterThan(buildArg)
|
||
expect(candidateLabel).toBeGreaterThan(productionLabel)
|
||
expect(candidateFile).toBeGreaterThan(candidateLabel)
|
||
expect(candidateHealth).toBeGreaterThan(candidateFile)
|
||
expect(dockerfile).toContain('LABEL org.opencontainers.image.revision=$VCS_REF')
|
||
expect(dockerfile).toContain('/app/DEPLOYMENT_COMMIT')
|
||
expect(dockerfile).toContain('chmod 0444 /app/DEPLOYMENT_COMMIT')
|
||
})
|
||
|
||
test('HTTP Gitea 只允许固定仓库和精确提交,SSH 通道禁止明文凭据与盲信主机', () => {
|
||
expect(script).toContain("[string]$RepoUrl = 'http://git.1080p.hk/WuYongJie/gongxiangp2.git'")
|
||
expect(script).toContain('Inline Git credentials are forbidden')
|
||
expect(script).toContain("$trustedRepoUrl = 'http://git.1080p.hk/WuYongJie/gongxiangp2.git'")
|
||
expect(script).toContain("if (-not $DryRun -and $Commit -cnotmatch '^[0-9a-f]{40}$')")
|
||
expect(script).toContain('HTTP Git transport requires -Commit with the exact 40-character lowercase SHA')
|
||
expect(script).toContain("[ \"$REPO_URL\" = 'http://git.1080p.hk/WuYongJie/gongxiangp2.git' ]")
|
||
expect(script).not.toContain('https://git.1080p.hk')
|
||
expect(script).toContain("-c 'http.lowSpeedLimit=1'")
|
||
expect(script).toContain("-c 'http.lowSpeedTime=30'")
|
||
expect(script).toContain('-c http.lowSpeedLimit=1')
|
||
expect(script).toContain('-c http.lowSpeedTime=30')
|
||
expect(script).toContain('StrictHostKeyChecking=yes')
|
||
expect(script).toContain('-hostkey $BuildServerHostKey')
|
||
expect(script).toContain("awk '!/^#/ { print $2 \" \" $3 }' | ssh-keygen -lf - -E sha256")
|
||
expect(script).toContain('Production host key fingerprint does not match the pinned value')
|
||
expect(script).not.toContain('StrictHostKeyChecking=no')
|
||
expect(script).not.toContain('http.extraHeader')
|
||
expect(script).not.toContain('__GIT_AUTH_HEADER__')
|
||
expect(script).toContain('mkdir -m 700')
|
||
expect(script).toContain("chmod 600 '$remoteTemp'")
|
||
expect(script).toContain("test ! -L '$remoteTemp'")
|
||
expect(script).toContain("test -O '$remoteTemp'")
|
||
})
|
||
|
||
test('远端构建目录只能使用已验证的专用仓库,绝不删除未知目录', () => {
|
||
expect(script).toContain("$trustedRemoteBuildDir = '/root/youyidian-3d-git-build'")
|
||
expect(script).toContain('[ "$REMOTE_BUILD_DIR" = \'/root/youyidian-3d-git-build\' ]')
|
||
expect(script).toContain('Pinned build directory is not the expected Git worktree; refusing destructive cleanup')
|
||
expect(script).toContain('[ "$(realpath -e "$REMOTE_BUILD_DIR")" = "$REMOTE_BUILD_DIR" ]')
|
||
expect(script).toContain('[ "$existing_origin" = "$REPO_URL" ]')
|
||
expect(script).not.toContain('rm -rf "$REMOTE_BUILD_DIR"')
|
||
expect(script).not.toContain('git remote set-url origin "$REPO_URL"')
|
||
})
|
||
|
||
test('生产按不可变 manifest 拉取并用平台 config digest 跨 Docker 版本核对镜像', () => {
|
||
const captureDigest = script.indexOf('EXPECTED_MANIFEST_DIGEST="$(curl -fsSI')
|
||
const capturePlatform = script.indexOf('EXPECTED_PLATFORM_MANIFEST_DIGEST="$(printf', captureDigest)
|
||
const captureId = script.indexOf('EXPECTED_IMAGE_ID="$(printf', capturePlatform)
|
||
const immutableReference = script.indexOf('REGISTRY_COMMIT="${BUILD_SERVER_IP}:${REGISTRY_PORT}/${IMAGE_NAME}@${EXPECTED_MANIFEST_DIGEST}"', captureId)
|
||
const remoteCompare = script.indexOf('ACTUAL_PROD_IMAGE_ID="$(prod_ssh', immutableReference)
|
||
const payloadCompare = script.indexOf('ACTUAL_IMAGE_ID="$(docker image inspect', remoteCompare)
|
||
const createCandidate = script.indexOf('create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT"', payloadCompare)
|
||
|
||
expect(captureDigest).toBeGreaterThan(0)
|
||
expect(capturePlatform).toBeGreaterThan(captureDigest)
|
||
expect(captureId).toBeGreaterThan(capturePlatform)
|
||
expect(script).toContain('application/vnd.oci.image.index.v1+json')
|
||
expect(script).toContain('tolower($1) == "docker-content-digest:" { print $2 }')
|
||
expect(script).not.toContain("awk -F ': *' 'tolower($1) == \"docker-content-digest\"")
|
||
expect(script).toContain('entry.platform.os === "linux"')
|
||
expect(script).toContain('entry.platform.architecture === "amd64"')
|
||
expect(script).toContain('document.config && document.config.digest')
|
||
expect(immutableReference).toBeGreaterThan(captureDigest)
|
||
expect(remoteCompare).toBeGreaterThan(immutableReference)
|
||
expect(payloadCompare).toBeGreaterThan(remoteCompare)
|
||
expect(createCandidate).toBeGreaterThan(payloadCompare)
|
||
expect(script).toContain('Production image ID differs from the image built on the pinned build server')
|
||
expect(script).toContain('[ "$image_id" = "$EXPECTED_IMAGE_ID" ]')
|
||
})
|
||
|
||
test('Docker 构建保留 Sharp/SWC optional 平台包且迁移 SQL 位于最终 context allowlist', () => {
|
||
const dockerfile = readFileSync(path.join(process.cwd(), 'Dockerfile'), 'utf8')
|
||
const dockerignore = readFileSync(path.join(process.cwd(), '.dockerignore'), 'utf8')
|
||
const finalSqlIgnore = dockerignore.lastIndexOf('*.sql')
|
||
const finalMigrationInclude = dockerignore.lastIndexOf('!prisma/migrations/**')
|
||
|
||
expect(dockerfile).toContain('npm ci --prefer-offline')
|
||
expect(dockerfile).toContain(
|
||
'npm_config_build_from_source=canvas npm_config_nodedir=/usr/local npm ci',
|
||
)
|
||
expect(dockerfile).not.toContain('--omit=optional')
|
||
expect(dockerfile).toContain("grep -Eq '^[0-9a-f]{40}$'")
|
||
expect(dockerfile).toContain("const sharp=require('sharp')")
|
||
expect(dockerfile).toContain('runner brand image decode smoke passed')
|
||
expect(dockerfile).toContain("['header-logo.png','login-logo.png','favicon.png']")
|
||
expect(finalMigrationInclude).toBeGreaterThan(finalSqlIgnore)
|
||
})
|
||
|
||
;(process.platform === 'win32' ? test : test.skip)(
|
||
'Target=shadow 在任何 Git 扫描或远程操作前 fail-closed',
|
||
() => {
|
||
const deployScript = path.join(process.cwd(), 'scripts', 'git-incremental-deploy.ps1')
|
||
const powershell = path.join(
|
||
process.env.SystemRoot || 'C:\\Windows',
|
||
'System32',
|
||
'WindowsPowerShell',
|
||
'v1.0',
|
||
'powershell.exe',
|
||
)
|
||
const deploy = spawnSync(powershell, [
|
||
'-NoProfile',
|
||
'-ExecutionPolicy', 'Bypass',
|
||
'-File', deployScript,
|
||
'-Target', 'shadow',
|
||
'-DryRun',
|
||
], { cwd: process.cwd(), encoding: 'utf8', timeout: 30_000 })
|
||
const output = `${deploy.stdout}\n${deploy.stderr}`
|
||
|
||
expect(deploy.status).not.toBe(0)
|
||
expect(output).toContain('Target=shadow is disabled fail-closed')
|
||
expect(output).toContain('uploads/runtime/Redis isolation')
|
||
expect(output).not.toContain('Resolving and scanning the exact Git tree')
|
||
expect(output).not.toContain('Connecting to build server')
|
||
},
|
||
35_000,
|
||
)
|
||
|
||
;(process.platform === 'win32' ? test : test.skip)(
|
||
'HTTP Gitea 正式发布未提供完整提交 SHA 时在任何远程操作前 fail-closed',
|
||
() => {
|
||
const deployScript = path.join(process.cwd(), 'scripts', 'git-incremental-deploy.ps1')
|
||
const powershell = path.join(
|
||
process.env.SystemRoot || 'C:\\Windows',
|
||
'System32',
|
||
'WindowsPowerShell',
|
||
'v1.0',
|
||
'powershell.exe',
|
||
)
|
||
const deploy = spawnSync(powershell, [
|
||
'-NoProfile',
|
||
'-ExecutionPolicy', 'Bypass',
|
||
'-File', deployScript,
|
||
], { cwd: process.cwd(), encoding: 'utf8', timeout: 30_000 })
|
||
const output = `${deploy.stdout}\n${deploy.stderr}`
|
||
|
||
expect(deploy.status).not.toBe(0)
|
||
expect(output).toContain('HTTP Git transport requires -Commit with the exact 40-character lowercase SHA')
|
||
expect(output).not.toContain('Resolving and scanning the exact Git tree')
|
||
expect(output).not.toContain('Connecting to build server')
|
||
},
|
||
35_000,
|
||
)
|
||
|
||
test('旧镜像不能证明协议能力时 fail-closed,候选容器不会启动', () => {
|
||
expect(script).toContain("grep -Fqx -- 'VIDEO_TRANSCODE_DRAIN_PROTOCOL_V1'")
|
||
expect(script).toContain('-e "VIDEO_TRANSCODE_INSTANCE_ID=$transcode_instance_id"')
|
||
expect(script).toContain('grep -Fqx -- "instanceId=$instance_id"')
|
||
expect(script).toContain('if ! verify_video_transcode_capability "$NEXT_CONTAINER"; then')
|
||
expect(script).toContain('candidate was not started (first rollout fails closed)')
|
||
expect(script).toContain('apply the additive Prisma migration before deployment')
|
||
})
|
||
|
||
test('即使没有旧容器,候选容器也必须独立验证租约字段后才能切流', () => {
|
||
const candidateSchema = script.indexOf('verify_transcode_lease_schema "$NEXT_CONTAINER"')
|
||
const switchTraffic = script.indexOf('write_proxy_config "${NEXT_IP}:3000" "$NEXT_CONTAINER"')
|
||
|
||
expect(candidateSchema).toBeGreaterThan(0)
|
||
expect(switchTraffic).toBeGreaterThan(candidateSchema)
|
||
expect(script).toContain('materials_transcode_lease_idx')
|
||
expect(script).toContain('20260811_add_video_transcode_lease')
|
||
expect(script).toContain('finished_at IS NOT NULL AND rolled_back_at IS NULL')
|
||
expect(script).toContain('transcode_lease_token");')
|
||
expect(script).toContain('token.data_type === "text" && token.is_nullable === "YES"')
|
||
expect(script).toContain('expires.data_type === "timestamp without time zone" && expires.is_nullable === "YES"')
|
||
expect(script).toContain('String(migrations[0].checksum || "") === sourceChecksum')
|
||
expect(script).toContain('readFileSync(migrationPath, "utf8").replace(/\\r\\n/g, "\\n")')
|
||
expect(script).toContain('!/\\bUNIQUE\\b|\\bWHERE\\b\/i.test(indexDefinition)')
|
||
expect(script).toContain('!columnsOk || !indexOk || !migrationOk')
|
||
expect(script).toContain('traffic was not switched')
|
||
})
|
||
|
||
test('旧域名扫描绑定精确目标 SHA,并将同一 SHA 固定给远程构建', () => {
|
||
const resolveRemote = script.indexOf("'ls-remote', '--heads', $RepositoryUrl, \"refs/heads/$TargetBranch\"")
|
||
const localObjectReuse = script.indexOf("'clone', '--no-checkout', '--shared', $localRepository, $targetCheckout", resolveRemote)
|
||
const fetchFallback = script.indexOf('"+refs/heads/${TargetBranch}:refs/preflight/branch"', localObjectReuse)
|
||
const checkoutFallback = script.indexOf("'worktree', 'add', '--detach', $targetCheckout, $targetCommit", fetchFallback)
|
||
const scanTarget = script.indexOf('& $nodeCommand.Source $scannerPath', checkoutFallback)
|
||
const preflight = script.indexOf("Write-Info 'Resolving and scanning the exact Git tree that will be deployed...'")
|
||
const bindCommit = script.indexOf("$tokens['__COMMIT__'] = ConvertTo-ShellSingleQuoted $resolvedTargetCommit")
|
||
const renderRemote = script.indexOf('$remoteScript = $remoteTemplate')
|
||
const remoteDeploy = script.indexOf('$exitCode = Invoke-BuildServerScript -ScriptText $remoteScript')
|
||
const remoteTemplateStart = script.indexOf("$remoteTemplate = @'")
|
||
const remoteTemplateEnd = script.indexOf("\n'@", remoteTemplateStart)
|
||
const remoteTemplate = script.slice(remoteTemplateStart, remoteTemplateEnd)
|
||
|
||
expect(script).toContain("Join-Path $PSScriptRoot 'check-legacy-storage-boundaries.mjs'")
|
||
expect(script).toContain('Get-Command node -ErrorAction SilentlyContinue')
|
||
expect(script).toContain('Legacy storage boundary preflight failed for target $targetCommit with exit code')
|
||
expect(resolveRemote).toBeGreaterThan(0)
|
||
expect(localObjectReuse).toBeGreaterThan(resolveRemote)
|
||
expect(fetchFallback).toBeGreaterThan(localObjectReuse)
|
||
expect(checkoutFallback).toBeGreaterThan(fetchFallback)
|
||
expect(scanTarget).toBeGreaterThan(checkoutFallback)
|
||
expect(preflight).toBeGreaterThan(0)
|
||
expect(bindCommit).toBeGreaterThan(preflight)
|
||
expect(renderRemote).toBeGreaterThan(bindCommit)
|
||
expect(remoteDeploy).toBeGreaterThan(renderRemote)
|
||
expect(remoteTemplate).not.toContain('Invoke-LegacyStorageBoundaryPreflight')
|
||
expect(script.indexOf('function Invoke-LegacyStorageBoundaryPreflight')).toBeLessThan(remoteTemplateStart)
|
||
expect(remoteTemplate).toContain('TARGET_REF="$COMMIT_INPUT"')
|
||
expect(remoteTemplate).toContain('[ "$COMMIT_SHA" = "$COMMIT_INPUT" ]')
|
||
})
|
||
|
||
;(process.platform === 'win32' ? test : test.skip)(
|
||
'分支最新树通过时,-Commit 指向含旧域名的旧提交仍会在远程操作前拒绝',
|
||
async () => {
|
||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'video-deploy-target-test-'))
|
||
const repository = path.join(tempRoot, 'repository')
|
||
const badSource = path.join(repository, 'app', 'deploy-target.ts')
|
||
const scanner = path.join(process.cwd(), 'scripts', 'check-legacy-storage-boundaries.mjs')
|
||
const deployScript = path.join(process.cwd(), 'scripts', 'git-incremental-deploy.ps1')
|
||
const powershell = path.join(
|
||
process.env.SystemRoot || 'C:\\Windows',
|
||
'System32',
|
||
'WindowsPowerShell',
|
||
'v1.0',
|
||
'powershell.exe',
|
||
)
|
||
const runGit = (...args: string[]) => {
|
||
const result = spawnSync('git', args, { cwd: repository, encoding: 'utf8' })
|
||
if (result.status !== 0) {
|
||
throw new Error(`git ${args.join(' ')} failed: ${result.stdout}\n${result.stderr}`)
|
||
}
|
||
return result.stdout.trim()
|
||
}
|
||
|
||
try {
|
||
await mkdir(path.dirname(badSource), { recursive: true })
|
||
runGit('init')
|
||
runGit('config', 'user.name', 'Codex Test')
|
||
runGit('config', 'user.email', 'codex-test@example.invalid')
|
||
await writeFile(badSource, "export const unsafeLogo = 'https://s3.1080p.hk/bucket/logo.png'\n", 'utf8')
|
||
runGit('add', '.')
|
||
runGit('-c', 'commit.gpgsign=false', 'commit', '-m', 'unsafe old target')
|
||
const oldCommit = runGit('rev-parse', 'HEAD')
|
||
|
||
await writeFile(badSource, "export const safeLogo = '/brand/header-logo.png'\n", 'utf8')
|
||
runGit('add', '.')
|
||
runGit('-c', 'commit.gpgsign=false', 'commit', '-m', 'safe branch head')
|
||
runGit('branch', '-M', 'main')
|
||
|
||
const latestScan = spawnSync(process.execPath, [scanner], { cwd: repository, encoding: 'utf8' })
|
||
expect(latestScan.status).toBe(0)
|
||
|
||
const deploy = spawnSync(powershell, [
|
||
'-NoProfile',
|
||
'-ExecutionPolicy', 'Bypass',
|
||
'-File', deployScript,
|
||
'-RepoUrl', repository,
|
||
'-Branch', 'main',
|
||
'-Commit', oldCommit,
|
||
'-DryRun',
|
||
'-BuildServerPassword', 'local-test-only',
|
||
'-BuildServerProxyCommand', 'must-not-run',
|
||
], { cwd: process.cwd(), encoding: 'utf8', timeout: 60_000 })
|
||
const output = `${deploy.stdout}\n${deploy.stderr}`
|
||
|
||
expect(deploy.status).not.toBe(0)
|
||
expect(output).toContain('app/deploy-target.ts')
|
||
expect(output).toContain('Legacy storage boundary preflight failed for target')
|
||
expect(output).not.toContain('Cannot find plink.exe')
|
||
}
|
||
finally {
|
||
await rm(tempRoot, { recursive: true, force: true })
|
||
}
|
||
},
|
||
70_000,
|
||
)
|
||
|
||
test('候选容器切流前必须验证品牌与上传幂等数据库门禁', () => {
|
||
const dockerfile = readFileSync(path.join(process.cwd(), 'Dockerfile'), 'utf8')
|
||
const leaseSchema = script.indexOf('verify_transcode_lease_schema "$NEXT_CONTAINER"')
|
||
const brandDefaults = script.indexOf('verify_brand_logo_defaults "$NEXT_CONTAINER"')
|
||
const uploadIdempotency = script.indexOf('verify_material_upload_idempotency_schema "$NEXT_CONTAINER"')
|
||
const switchTraffic = script.indexOf('write_proxy_config "${NEXT_IP}:3000" "$NEXT_CONTAINER"')
|
||
|
||
expect(brandDefaults).toBeGreaterThan(leaseSchema)
|
||
expect(uploadIdempotency).toBeGreaterThan(brandDefaults)
|
||
expect(switchTraffic).toBeGreaterThan(uploadIdempotency)
|
||
expect(script).toContain('normalize(defaults.get("headerLogoUrl")) === "/brand/header-logo.png"')
|
||
expect(script).toContain('normalize(defaults.get("loginLogoUrl")) === "/brand/login-logo.png"')
|
||
expect(script).toContain('!defaults.has("logoUrl") || normalize(defaults.get("logoUrl")) === "/brand/header-logo.png"')
|
||
expect(script).toContain('SELECT id, \\"headerLogoUrl\\", \\"loginLogoUrl\\" FROM \\"system_settings\\" ORDER BY id')
|
||
expect(script).toContain('parsed.hostname === "s3.jiansun.vip"')
|
||
expect(script).toContain('parsed.pathname.startsWith("/gongxiangpan/")')
|
||
expect(script).toContain('const rowsOk = brandRows.every')
|
||
expect(script).toContain('!migrationOk || !rowsOk')
|
||
expect(script).toContain('20260811_update_brand_logo_defaults')
|
||
expect(script).toContain('20251118000000_add_upload_id_to_materials')
|
||
expect(script).toContain('materials_uploadId_key')
|
||
expect(script).toContain('column.data_type === "text"')
|
||
expect(script).toContain('column.is_nullable === "YES"')
|
||
expect(script).toContain('index.access_method === "btree"')
|
||
expect(script).toContain('Number(index.indnkeyatts) === 1')
|
||
expect(script).toContain('keyColumns.length === 1 && keyColumns[0] === "uploadId"')
|
||
expect(script).toContain('normalizedPredicate === "uploadidisnotnullanddeletedatisnull"')
|
||
expect(script).toContain('migrationByName.get(historicalMigrationName) === historicalSourceChecksum')
|
||
expect(script).toContain('migrationByName.get(normalizationMigrationName) === normalizationSourceChecksum')
|
||
expect(script).toContain('20260812_align_material_upload_idempotency_index')
|
||
expect(script).toContain('20260813_add_material_custody_department')
|
||
expect(script).toContain('20260813_add_organization_units_and_employment_state')
|
||
expect(script).toContain('20260813_add_user_employee_binding')
|
||
expect(dockerfile).toContain('/app/prisma/migrations/20251118000000_add_upload_id_to_materials/migration.sql')
|
||
expect(dockerfile).toContain('COPY --from=builder --chown=root:root /app/prisma/migrations/20251118000000_add_upload_id_to_materials')
|
||
expect(script).toContain('finished_at IS NOT NULL AND rolled_back_at IS NULL')
|
||
expect(script).toContain('traffic was not switched')
|
||
})
|
||
|
||
test('候选容器带预置 drain 启动,全部门禁和稳定切流后才恢复 worker', () => {
|
||
const preDrain = script.indexOf(': > "$(transcode_drain_file "$NEXT_CONTAINER")"')
|
||
const preTaskDetailDrain = script.indexOf(': > "$(task_detail_drain_file "$NEXT_CONTAINER")"', preDrain)
|
||
const createCandidate = script.indexOf('create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT"')
|
||
const inspectCandidate = script.indexOf('verify_container_uploads_mount_metadata "$NEXT_CONTAINER"', createCandidate)
|
||
const startCandidate = script.indexOf('docker start "$NEXT_CONTAINER"', inspectCandidate)
|
||
const health = script.indexOf('wait_for_container_health "$NEXT_CONTAINER"', startCandidate)
|
||
const lease = script.indexOf('verify_transcode_lease_schema "$NEXT_CONTAINER"', health)
|
||
const taskDetailLease = script.indexOf('verify_task_detail_upload_lease_schema "$NEXT_CONTAINER"', lease)
|
||
const brand = script.indexOf('verify_brand_logo_defaults "$NEXT_CONTAINER"', taskDetailLease)
|
||
const uploadIdempotency = script.indexOf('verify_material_upload_idempotency_schema "$NEXT_CONTAINER"', brand)
|
||
const capability = script.indexOf('verify_video_transcode_capability "$NEXT_CONTAINER"', uploadIdempotency)
|
||
const taskDetailCapability = script.indexOf('verify_task_detail_upload_capability "$NEXT_CONTAINER"', capability)
|
||
const taskDetailInactive = script.indexOf('wait_for_task_detail_upload_drain "$NEXT_CONTAINER" 2', taskDetailCapability)
|
||
const proxyConfig = script.indexOf('write_proxy_config "${NEXT_IP}:3000" "$NEXT_CONTAINER"', taskDetailInactive)
|
||
const resumeCandidate = script.indexOf('resume_video_transcode "$NEXT_CONTAINER"', proxyConfig)
|
||
const resumeTaskDetail = script.indexOf('resume_task_detail_upload "$NEXT_CONTAINER"', resumeCandidate)
|
||
const workerCommit = script.indexOf('BACKGROUND_WORKERS_COMMITTED=1', resumeTaskDetail)
|
||
const oldStop = script.indexOf('docker stop --time 30 "$CURRENT_CONTAINER"', workerCommit)
|
||
|
||
expect(preDrain).toBeGreaterThan(0)
|
||
expect(preTaskDetailDrain).toBeGreaterThan(preDrain)
|
||
expect(createCandidate).toBeGreaterThan(preTaskDetailDrain)
|
||
expect(inspectCandidate).toBeGreaterThan(createCandidate)
|
||
expect(startCandidate).toBeGreaterThan(inspectCandidate)
|
||
expect(health).toBeGreaterThan(startCandidate)
|
||
expect(lease).toBeGreaterThan(health)
|
||
expect(taskDetailLease).toBeGreaterThan(lease)
|
||
expect(brand).toBeGreaterThan(taskDetailLease)
|
||
expect(uploadIdempotency).toBeGreaterThan(brand)
|
||
expect(capability).toBeGreaterThan(uploadIdempotency)
|
||
expect(taskDetailCapability).toBeGreaterThan(capability)
|
||
expect(taskDetailInactive).toBeGreaterThan(taskDetailCapability)
|
||
expect(proxyConfig).toBeGreaterThan(taskDetailInactive)
|
||
expect(resumeCandidate).toBeGreaterThan(proxyConfig)
|
||
expect(resumeTaskDetail).toBeGreaterThan(resumeCandidate)
|
||
expect(workerCommit).toBeGreaterThan(resumeTaskDetail)
|
||
expect(oldStop).toBeGreaterThan(workerCommit)
|
||
expect(script).toContain('remove_video_container_safely "$NEXT_CONTAINER"')
|
||
expect(script).not.toContain('docker rm -f "$NEXT_CONTAINER"')
|
||
})
|
||
|
||
test('worker 提交后任何晚期失败都不会解除旧槽 drain', () => {
|
||
const trap = script.slice(
|
||
script.indexOf('resume_transcode_after_failed_deploy() {'),
|
||
script.indexOf('trap resume_transcode_after_failed_deploy EXIT'),
|
||
)
|
||
|
||
expect(trap).toContain('[ "$BACKGROUND_WORKERS_COMMITTED" != "1" ]')
|
||
expect(trap).toContain('for target in $TRANSCODE_DRAIN_TARGETS')
|
||
expect(trap).toContain('for target in $TASK_DETAIL_DRAIN_TARGETS')
|
||
expect(trap).toContain('previous background-worker drain markers remain enabled')
|
||
expect(script.indexOf('BACKGROUND_WORKERS_COMMITTED=1')).toBeLessThan(
|
||
script.indexOf('systemctl start "$PROXY_RECONCILE_SERVICE"'),
|
||
)
|
||
const switchBlock = script.indexOf('SWITCH_REPORTED_FAILURE=0')
|
||
const trafficMaySwitch = script.indexOf('BACKGROUND_WORKERS_COMMITTED=1', switchBlock)
|
||
const switchTraffic = script.indexOf('if ! switch_proxy; then', trafficMaySwitch)
|
||
expect(trafficMaySwitch).toBeGreaterThan(switchBlock)
|
||
expect(switchTraffic).toBeGreaterThan(trafficMaySwitch)
|
||
const abortStart = script.indexOf('abort_after_uncertain_switch() {')
|
||
const abortEnd = script.indexOf('# The historical shadow topology', abortStart)
|
||
const abort = script.slice(abortStart, abortEnd)
|
||
expect(abort).toContain('if [ "${PROXY_LOCK_HELD:-0}" != "1" ]')
|
||
expect(abort).toContain('flock -w 30 8')
|
||
expect(script).toContain('abort_after_uncertain_switch "Proxy reconciler failed its post-deployment verification"')
|
||
})
|
||
|
||
test('普通发布与首次 bootstrap 共用全局锁并只消费当前未消费 handoff', () => {
|
||
const lock = script.indexOf('flock -w 30 9')
|
||
const handoffGate = script.indexOf('verify_bootstrap_handoff_if_present')
|
||
const candidate = script.indexOf('create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT"', handoffGate)
|
||
const consume = script.indexOf('mv -- "$READY_BOOTSTRAP_HANDOFF/handoff.ready" "$READY_BOOTSTRAP_HANDOFF/consumed"', candidate)
|
||
|
||
expect(lock).toBeGreaterThan(0)
|
||
expect(handoffGate).toBeGreaterThan(lock)
|
||
expect(candidate).toBeGreaterThan(handoffGate)
|
||
expect(consume).toBeGreaterThan(candidate)
|
||
expect(script).toContain('[ ! -e "$handoff/consumed" ] || continue')
|
||
expect(script).toContain('The maintenance entry requires exactly one unconsumed bootstrap handoff')
|
||
expect(script).toContain('A normal blue-green entry cannot consume a stale bootstrap handoff')
|
||
expect(script).toContain('verify_ready_bootstrap_handoff_evidence "$READY_BOOTSTRAP_HANDOFF"')
|
||
expect(script).toContain('maintenance_entry_is_ready_after_restore || fail "Bootstrap maintenance entry is not an exact dual-stack 503 handoff"')
|
||
expect(script).toContain('Preserved legacy container is not the exact stopped handoff identity')
|
||
expect(script).toContain('uploads_container_mount_metadata_is_trusted "$UPLOADS_MOUNT" "$mount_metadata"')
|
||
})
|
||
|
||
test('maintenance 缺 handoff 会拒绝,已消费的旧 SHA 不阻塞后续蓝绿发布', async () => {
|
||
const bash = findBash()
|
||
if (!bash) return
|
||
const root = await mkdtemp(path.join(os.tmpdir(), 'deploy-handoff-test-'))
|
||
const attempt = path.join(root, 'attempt-1')
|
||
const exactSha = 'a'.repeat(40)
|
||
const nextSha = 'b'.repeat(40)
|
||
const legacyId = '1'.repeat(64)
|
||
const imageId = `sha256:${'2'.repeat(64)}`
|
||
const repoDigest = `registry.example/app@sha256:${'3'.repeat(64)}`
|
||
const preserved = `youyidian-3d-material-legacy-${exactSha.slice(0, 12)}-${legacyId.slice(0, 12)}`
|
||
const uploadsSignature = 'abcd:1234'
|
||
const support = `
|
||
fail() { printf '%s\\n' "$*" >&2; exit 91; }
|
||
runtime_proxy_container() { printf '%s\\n' "\${MOCK_RUNTIME_TARGET:-}"; }
|
||
is_proxy_running() { [ "\${MOCK_PROXY_RUNNING:-0}" = "1" ]; }
|
||
maintenance_entry_is_ready_after_restore() { [ "\${MOCK_MAINTENANCE_READY:-0}" = "1" ]; }
|
||
stat() {
|
||
if [ "$1" = "-c" ] && [ "$2" = "%a" ]; then
|
||
if [ "$3" = "\${MOCK_HANDOFF_DIR}" ]; then printf '700\\n'; else printf '600\\n'; fi
|
||
return 0
|
||
fi
|
||
command stat "$@"
|
||
}
|
||
docker() {
|
||
if [ "$1" = "inspect" ] && [ "$2" = "--format" ]; then
|
||
case "$3" in
|
||
*Mounts*) printf '/app/uploads\\tbind\\t%s\\ttrue\\n' "\${MOCK_MOUNT_SOURCE}" ;;
|
||
*) printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "\${MOCK_CONTAINER_ID}" "\${MOCK_IMAGE_ID}" "\${MOCK_STATUS}" "\${MOCK_RUNNING}" "\${MOCK_PAUSED}" ;;
|
||
esac
|
||
return 0
|
||
fi
|
||
if [ "$1" = "image" ] && [ "$2" = "inspect" ]; then
|
||
printf '%s\\n' "\${MOCK_REPO_DIGEST}"
|
||
return 0
|
||
fi
|
||
if [ "$1" = "port" ]; then
|
||
printf '0.0.0.0:%s\\n[::]:%s\\n' "\${PORT}" "\${PORT}"
|
||
return 0
|
||
fi
|
||
return 97
|
||
}
|
||
`
|
||
const functions = [
|
||
support,
|
||
extractBashFunction(script, 'container_uploads_mount_metadata'),
|
||
extractBashFunction(script, 'uploads_container_mount_metadata_is_trusted'),
|
||
extractBashFunction(script, 'verify_ready_bootstrap_handoff_evidence'),
|
||
extractBashFunction(script, 'verify_bootstrap_handoff_if_present'),
|
||
].join('\n')
|
||
const baseVariables = {
|
||
TARGET: 'production', BOOTSTRAP_ROOT: toBashPath(root), COMMIT_SHA: exactSha,
|
||
READY_BOOTSTRAP_HANDOFF: '', MOCK_PROXY_RUNNING: '1', MOCK_RUNTIME_TARGET: '',
|
||
MOCK_MAINTENANCE_READY: '1', MOCK_CONTAINER_ID: legacyId, MOCK_IMAGE_ID: imageId,
|
||
MOCK_STATUS: 'exited', MOCK_RUNNING: 'false', MOCK_PAUSED: 'false',
|
||
MOCK_REPO_DIGEST: repoDigest, MOCK_MOUNT_SOURCE: '/mnt/PoolOne/youyidian-uploads',
|
||
CONTAINER_NAME: 'youyidian-3d-material', HOST_UPLOADS_STATFS_SIGNATURE: uploadsSignature,
|
||
UPLOADS_MOUNT: '/mnt/PoolOne/youyidian-uploads', PORT: '4320',
|
||
MOCK_HANDOFF_DIR: toBashPath(attempt),
|
||
}
|
||
|
||
try {
|
||
const missing = runBashGate(bash, functions, 'verify_bootstrap_handoff_if_present', {
|
||
...baseVariables, BOOTSTRAP_ROOT: toBashPath(path.join(root, 'missing')),
|
||
})
|
||
expect(missing.status).not.toBe(0)
|
||
|
||
await mkdir(attempt, { recursive: true })
|
||
const evidence = {
|
||
'target-commit': exactSha,
|
||
'legacy-container-id': legacyId,
|
||
'legacy-image-id': imageId,
|
||
'legacy-image-repo-digest': repoDigest,
|
||
'legacy-upstream': '172.18.0.4:3000',
|
||
'uploads-statfs-signature': uploadsSignature,
|
||
'preserved-container': preserved,
|
||
'handoff.ready': 'ready',
|
||
}
|
||
for (const [name, value] of Object.entries(evidence)) {
|
||
await writeFile(path.join(attempt, name), `${value}\n`, 'utf8')
|
||
}
|
||
const ready = runBashGate(bash, functions, 'verify_bootstrap_handoff_if_present; printf "%s" "$READY_BOOTSTRAP_HANDOFF"', {
|
||
...baseVariables,
|
||
})
|
||
expect(ready.status).toBe(0)
|
||
expect(ready.stdout).toContain('attempt-1')
|
||
|
||
for (const overrides of [
|
||
{ MOCK_MAINTENANCE_READY: '0' },
|
||
{ MOCK_STATUS: 'running', MOCK_RUNNING: 'true' },
|
||
{ MOCK_PAUSED: 'true' },
|
||
{ MOCK_CONTAINER_ID: '4'.repeat(64) },
|
||
{ MOCK_IMAGE_ID: `sha256:${'5'.repeat(64)}` },
|
||
{ MOCK_REPO_DIGEST: `registry.example/app@sha256:${'6'.repeat(64)}` },
|
||
{ MOCK_MOUNT_SOURCE: '/wrong/uploads' },
|
||
]) {
|
||
const rejected = runBashGate(bash, functions, 'verify_bootstrap_handoff_if_present', {
|
||
...baseVariables, ...overrides,
|
||
})
|
||
expect(rejected.status).not.toBe(0)
|
||
}
|
||
|
||
await rm(path.join(attempt, 'legacy-upstream'))
|
||
const incomplete = runBashGate(bash, functions, 'verify_bootstrap_handoff_if_present', baseVariables)
|
||
expect(incomplete.status).not.toBe(0)
|
||
await writeFile(path.join(attempt, 'legacy-upstream'), '172.18.0.4:3000\n', 'utf8')
|
||
|
||
await rm(path.join(attempt, 'handoff.ready'))
|
||
await writeFile(path.join(attempt, 'consumed'), 'ready\n', 'utf8')
|
||
const later = runBashGate(bash, functions, 'verify_bootstrap_handoff_if_present; printf "%s" "$READY_BOOTSTRAP_HANDOFF"', {
|
||
...baseVariables, COMMIT_SHA: nextSha, MOCK_RUNTIME_TARGET: 'youyidian-3d-material-blue',
|
||
})
|
||
expect(later.status).toBe(0)
|
||
expect(later.stdout).toBe('')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
}
|
||
}, 30_000)
|
||
|
||
test('迁移审计仅在显式迁移批次启用,并在候选创建前绑定数据库实际身份', () => {
|
||
const audit = script.indexOf('verify_migration_audit_evidence')
|
||
const optIn = script.indexOf('[switch]$RequireMigrationAudit')
|
||
const rootParser = script.indexOf('--read-only --user 0:0 --cap-drop ALL --security-opt no-new-privileges', audit)
|
||
const identity = script.indexOf('verify_candidate_database_identity "$NEXT_CONTAINER"', rootParser)
|
||
const switchTraffic = script.indexOf('if ! switch_proxy; then', identity)
|
||
|
||
expect(audit).toBeGreaterThan(0)
|
||
expect(optIn).toBeGreaterThan(0)
|
||
expect(rootParser).toBeGreaterThan(audit)
|
||
expect(identity).toBeGreaterThan(rootParser)
|
||
expect(switchTraffic).toBeGreaterThan(identity)
|
||
expect(script).toContain('for await (const chunk of process.stdin)')
|
||
expect(script).not.toContain('process.stdin.on("end", async () => {')
|
||
})
|
||
|
||
test('只有显式迁移批次才校验审计来源提交与 Prisma 定义', () => {
|
||
const gitSync = script.indexOf('COMMIT_SHA="$(git rev-parse HEAD)"')
|
||
const ancestor = script.indexOf('git merge-base --is-ancestor "$MIGRATION_AUDIT_SOURCE_COMMIT" "$COMMIT_SHA"', gitSync)
|
||
const schemaDiff = script.indexOf('git diff --quiet "$MIGRATION_AUDIT_SOURCE_COMMIT" "$COMMIT_SHA" -- prisma/schema.prisma prisma/migrations', ancestor)
|
||
const build = script.indexOf('start_remote_step "Docker Build"', schemaDiff)
|
||
|
||
expect(gitSync).toBeGreaterThan(0)
|
||
expect(ancestor).toBeGreaterThan(gitSync)
|
||
expect(schemaDiff).toBeGreaterThan(ancestor)
|
||
expect(build).toBeGreaterThan(schemaDiff)
|
||
expect(script).toContain('if [ "$REQUIRE_MIGRATION_AUDIT" = "1" ] && [ "$SKIP_DEPLOY" != "1" ]; then')
|
||
expect(script).toContain('Database schema or migrations changed after the approved audit; create a fresh verified migration audit')
|
||
})
|
||
|
||
test('日常应用发布默认不消费迁移审计,也不会因此进入迁移流程', () => {
|
||
expect(script).toContain("'__REQUIRE_MIGRATION_AUDIT__'")
|
||
expect(script).toContain('REQUIRE_MIGRATION_AUDIT=__REQUIRE_MIGRATION_AUDIT__')
|
||
expect(script).toContain('if [ "$REQUIRE_MIGRATION_AUDIT" != "1" ]; then')
|
||
expect(script).toContain('Migration audit is not required for this normal application release')
|
||
expect(script).toContain('[ "$REQUIRE_MIGRATION_AUDIT" = "1" ] || return 0')
|
||
})
|
||
|
||
test('迁移凭证仅在显式迁移批次绑定审计源提交', () => {
|
||
expect(script).toContain('if ($RequireMigrationAudit -and [string]::IsNullOrWhiteSpace($MigrationAuditSourceCommit))')
|
||
expect(script).toContain('-e MIGRATION_AUDIT_SOURCE_COMMIT="$MIGRATION_AUDIT_SOURCE_COMMIT"')
|
||
expect(script).toContain('audit.sourceCommit !== process.env.MIGRATION_AUDIT_SOURCE_COMMIT')
|
||
expect(script).not.toContain('audit.sourceCommit !== process.env.COMMIT_SHA')
|
||
})
|
||
|
||
test('候选数据库身份 Node 门禁能消费有效 stdin,坏 JSON 必须失败', () => {
|
||
const functionStart = script.indexOf('verify_candidate_database_identity() {')
|
||
const nodeStartMarker = "node -e '\n"
|
||
const nodeStart = script.indexOf(nodeStartMarker, functionStart) + nodeStartMarker.length
|
||
const nodeEnd = script.indexOf("\n' < \"$MIGRATION_AUDIT_FILE\"", nodeStart)
|
||
const nodeSource = script.slice(nodeStart, nodeEnd)
|
||
const prismaMock = `
|
||
const Module = require('module');
|
||
const originalLoad = Module._load;
|
||
Module._load = function(request, parent, isMain) {
|
||
if (request === '@prisma/client') return { PrismaClient: class {
|
||
async $queryRawUnsafe() { return [{ database: 'prod', user: 'app', server_address: '10.0.0.8', server_port: '5432' }]; }
|
||
async $disconnect() {}
|
||
} };
|
||
return originalLoad.call(this, request, parent, isMain);
|
||
};
|
||
`
|
||
const audit = JSON.stringify({ database: {
|
||
configuredHost: 'db.internal', configuredPort: '5432', configuredDatabase: 'prod', configuredUser: 'app',
|
||
actualDatabase: 'prod', actualUser: 'app', serverAddress: '10.0.0.8', serverPort: '5432',
|
||
} })
|
||
const env = { ...process.env, DATABASE_URL: 'postgresql://app:secret@db.internal:5432/prod' }
|
||
const valid = spawnSync(process.execPath, ['-e', `${prismaMock}\n${nodeSource}`], { input: audit, encoding: 'utf8', env })
|
||
const invalid = spawnSync(process.execPath, ['-e', `${prismaMock}\n${nodeSource}`], { input: '{bad', encoding: 'utf8', env })
|
||
|
||
expect(functionStart).toBeGreaterThan(0)
|
||
expect(nodeEnd).toBeGreaterThan(nodeStart)
|
||
expect(valid.status).toBe(0)
|
||
expect(invalid.status).not.toBe(0)
|
||
})
|
||
|
||
test('Logo 解码 Node 门禁接受真实 PNG,损坏输入必须失败', () => {
|
||
const functionStart = script.indexOf('decode_image_stream_with_candidate() {')
|
||
const nodeStartMarker = "node -e '\n"
|
||
const nodeStart = script.indexOf(nodeStartMarker, functionStart) + nodeStartMarker.length
|
||
const nodeEnd = script.indexOf("\n'\n}", nodeStart)
|
||
const nodeSource = script.slice(nodeStart, nodeEnd)
|
||
const validPng = readFileSync(path.join(process.cwd(), 'public', 'brand', 'header-logo.png'))
|
||
const valid = spawnSync(process.execPath, ['-e', nodeSource], { input: validPng, encoding: 'buffer' })
|
||
const invalid = spawnSync(process.execPath, ['-e', nodeSource], { input: Buffer.from('not-an-image'), encoding: 'buffer' })
|
||
|
||
expect(functionStart).toBeGreaterThan(0)
|
||
expect(nodeEnd).toBeGreaterThan(nodeStart)
|
||
expect(valid.status).toBe(0)
|
||
expect(invalid.status).not.toBe(0)
|
||
})
|
||
|
||
test('部署失败会恢复旧容器 drain,停止旧容器前再次核对 active 标记', () => {
|
||
expect(script).toContain('trap resume_transcode_after_failed_deploy EXIT')
|
||
expect(script).toContain('for target in $TRANSCODE_DRAIN_TARGETS')
|
||
expect(script).toContain('resume_video_transcode "$target"')
|
||
expect(script).toContain('for target in $TASK_DETAIL_DRAIN_TARGETS')
|
||
expect(script).toContain('resume_task_detail_upload "$target"')
|
||
const videoIdle = script.indexOf('wait_for_video_transcode_drain "$CURRENT_CONTAINER" 2 &&')
|
||
const taskDetailIdle = script.indexOf('wait_for_task_detail_upload_drain "$CURRENT_CONTAINER" 2 &&', videoIdle)
|
||
const stop = script.indexOf('docker stop --time 30 "$CURRENT_CONTAINER"', taskDetailIdle)
|
||
expect(videoIdle).toBeGreaterThan(0)
|
||
expect(taskDetailIdle).toBeGreaterThan(videoIdle)
|
||
expect(stop).toBeGreaterThan(taskDetailIdle)
|
||
})
|
||
|
||
test('stable proxy 缺失且存在 legacy direct 时,在任何容器或流量动作前 fail-closed', () => {
|
||
const topologyGate = script.indexOf('# This general deployment entry must never perform the one-time direct-to-proxy')
|
||
const inspectLegacy = script.indexOf('LEGACY_IMAGE="$(docker inspect', topologyGate)
|
||
const checkProxy = script.indexOf('if is_proxy_running; then', inspectLegacy)
|
||
const failClosed = script.indexOf('Use the controlled one-time direct-to-proxy migration SOP', checkProxy)
|
||
const proxyMutation = script.indexOf('mkdir -p "$PROXY_ROOT"', failClosed)
|
||
const drainExisting = script.indexOf('request_video_transcode_drain "$TRANSCODE_DRAIN_SOURCE"', proxyMutation)
|
||
const removeInactiveSlot = script.indexOf('remove_video_container_safely "$NEXT_CONTAINER"', drainExisting)
|
||
const startCandidate = script.indexOf('create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT"', removeInactiveSlot)
|
||
const switchTraffic = script.indexOf('if ! switch_proxy; then', startCandidate)
|
||
const stopPrevious = script.indexOf('docker stop --time 30 "$CURRENT_CONTAINER"', switchTraffic)
|
||
|
||
expect(topologyGate).toBeGreaterThan(0)
|
||
expect(inspectLegacy).toBeGreaterThan(topologyGate)
|
||
expect(checkProxy).toBeGreaterThan(inspectLegacy)
|
||
expect(failClosed).toBeGreaterThan(checkProxy)
|
||
expect(proxyMutation).toBeGreaterThan(failClosed)
|
||
expect(drainExisting).toBeGreaterThan(proxyMutation)
|
||
expect(removeInactiveSlot).toBeGreaterThan(drainExisting)
|
||
expect(startCandidate).toBeGreaterThan(removeInactiveSlot)
|
||
expect(switchTraffic).toBeGreaterThan(startCandidate)
|
||
expect(stopPrevious).toBeGreaterThan(switchTraffic)
|
||
expect(script).toContain('no container or traffic was changed')
|
||
})
|
||
|
||
test('通用脚本不包含 automatic direct→proxy migration 或 direct rollback', () => {
|
||
const rollbackStart = script.indexOf('rollback_to_previous_entry() {')
|
||
const rollbackEnd = script.indexOf('abort_after_uncertain_switch() {', rollbackStart)
|
||
const rollback = script.slice(rollbackStart, rollbackEnd)
|
||
const operationalStart = script.indexOf('# This general deployment entry must never perform the one-time direct-to-proxy')
|
||
const operational = script.slice(operationalStart)
|
||
|
||
expect(rollback).toContain('[ "$PROXY_WAS_RUNNING" = "1" ]')
|
||
expect(rollback).not.toContain('[ "$PROXY_WAS_RUNNING" != "1" ]')
|
||
expect(rollback).not.toContain('create_container "$CONTAINER_NAME"')
|
||
expect(rollback).not.toContain('stop_proxy')
|
||
expect(operational).not.toContain('migrating public entry from direct container to proxy')
|
||
expect(operational).not.toContain('proxy migration was not attempted')
|
||
})
|
||
})
|