216 lines
11 KiB
JavaScript
216 lines
11 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { readFile } from 'node:fs/promises'
|
|
import test from 'node:test'
|
|
|
|
const pipeline = (await readFile(new URL('../Jenkinsfile.service', import.meta.url), 'utf8'))
|
|
.replaceAll('\r\n', '\n')
|
|
const coordinatorPipeline = (await readFile(new URL('../Jenkinsfile', import.meta.url), 'utf8'))
|
|
.replaceAll('\r\n', '\n')
|
|
const directDeployScript = (await readFile(new URL('../deploy/scripts/deploy-built-service.sh', import.meta.url), 'utf8'))
|
|
.replaceAll('\r\n', '\n')
|
|
const ciMavenSettings = (await readFile(new URL('../deploy/maven/settings-ci.xml', import.meta.url), 'utf8'))
|
|
.replaceAll('\r\n', '\n')
|
|
const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile', import.meta.url), 'utf8'))
|
|
.replaceAll('\r\n', '\n')
|
|
const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8'))
|
|
.replaceAll('\r\n', '\n')
|
|
|
|
test('both pipelines reject the placeholder registry from failed build 662 before Maven or Docker', () => {
|
|
for (const source of [coordinatorPipeline, pipeline]) {
|
|
assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: ''/)
|
|
const guard = source.match(/(?:requestedRepository|imageRepository)\.tokenize\('\/'\)\[0\] ==~ \/\(\?i\)([^\n]+)\//)
|
|
assert.ok(guard, 'registry host must reject reserved example domains')
|
|
const reservedHost = new RegExp(`^(?:${guard[1]})$`, 'i')
|
|
for (const repository of [
|
|
'registry.example.com/team/baiyue-firstleg',
|
|
'registry.example.com/your-group/baiyue-firstleg',
|
|
'EXAMPLE.COM:5000/team/image',
|
|
'registry.example.net/team/image',
|
|
'example.org/team/image'
|
|
]) assert.ok(reservedHost.test(repository.split('/')[0]), repository)
|
|
for (const host of ['registry.company.com:5000', '10.0.0.10:10082', 'example.company.com']) {
|
|
assert.equal(reservedHost.test(host), false, host)
|
|
}
|
|
const mavenStage = source.match(/stage\('[^']*Maven[^']*'\)/)
|
|
assert.ok(mavenStage)
|
|
assert.ok(source.indexOf(guard[0]) < mavenStage.index,
|
|
'invalid registry must fail before expensive Java builds')
|
|
}
|
|
})
|
|
|
|
test('service pipeline keeps boolean operators on parseable Groovy continuation lines', () => {
|
|
assert.doesNotMatch(pipeline, /^\s*(?:&&|\|\|)/m)
|
|
})
|
|
|
|
test('test compilation is preserved even when execution is explicitly skipped', () => {
|
|
assert.match(pipeline, /boolean skipTestExecution = params\.SKIP_TESTS/)
|
|
assert.match(pipeline, /skipTestExecution \? '-DskipTests' : ''/)
|
|
assert.match(pipeline, /-pl \$\{env\.RESOLVED_SERVICE\} -am clean package/)
|
|
assert.doesNotMatch(pipeline, /-Dmaven\.test\.skip=true/)
|
|
})
|
|
|
|
test('coordinator and service jobs enforce their own deployment environments', () => {
|
|
assert.match(coordinatorPipeline, /case 'backend-java':[\s\S]*?return 'test'/)
|
|
assert.match(coordinatorPipeline,/case 'backend-java-prod':[\s\S]*?return 'production'/)
|
|
assert.doesNotMatch(coordinatorPipeline,/return 'development'/)
|
|
assert.match(pipeline,/startsWith\('baiyue-firstleg-'\)/)
|
|
assert.match(pipeline,/!jobBaseName.startsWith\('baiyue-firstleg-dev-'\)/)
|
|
assert.match(pipeline,/!jobBaseName.startsWith\('baiyue-firstleg-prod-'\)/)
|
|
})
|
|
|
|
test('AUTO advances interrupted baselines and retries only their selected services', () => {
|
|
assert.match(coordinatorPipeline, /def parseAutoBuildState\(/)
|
|
assert.match(coordinatorPipeline, /def previousAutoBuildState\(/)
|
|
assert.match(coordinatorPipeline, /previousAutoBuildState\(currentBuild\.previousBuild, serviceModules\)/)
|
|
assert.match(coordinatorPipeline, /state\.startsWith\('PENDING'\)/)
|
|
assert.match(coordinatorPipeline, /failedServices\.addAll\(selected\.split\(','\)/)
|
|
assert.match(coordinatorPipeline, /git rev-parse HEAD\^/)
|
|
assert.match(coordinatorPipeline, /selected\.addAll\(retryServices\)/)
|
|
assert.match(coordinatorPipeline, /AUTO \$\{env\.IMAGE_TAG\} \| PENDING/)
|
|
assert.match(coordinatorPipeline, /AUTO \$\{env\.IMAGE_TAG\} \| RESULTS/)
|
|
assert.doesNotMatch(coordinatorPipeline, /GIT_PREVIOUS_SUCCESSFUL_COMMIT/)
|
|
assert.doesNotMatch(coordinatorPipeline, /commonChanged \? serviceModules/)
|
|
})
|
|
|
|
test('AUTO maps shared modules to their real consumers instead of rebuilding every service', () => {
|
|
const securityImpact = coordinatorPipeline.slice(
|
|
coordinatorPipeline.indexOf("'common-security/': ["),
|
|
coordinatorPipeline.indexOf("'common-openapi/': [")
|
|
)
|
|
assert.match(coordinatorPipeline, /def fullRebuildPaths = \[/)
|
|
assert.match(coordinatorPipeline, /'common-security\/': \[/)
|
|
assert.match(coordinatorPipeline, /'common-event-contract\/': \[/)
|
|
assert.match(coordinatorPipeline, /commonPathServices\.each \{ prefix, impactedServices ->/)
|
|
assert.match(coordinatorPipeline, /selected\.addAll\(impactedServices\)/)
|
|
assert.match(securityImpact, /'auth'.*'finance'.*'file'/s)
|
|
assert.doesNotMatch(securityImpact, /gateway|print|integration-service|event-bridge-service/)
|
|
})
|
|
|
|
test('TEST builds respect explicit test execution controls', () => {
|
|
assert.match(coordinatorPipeline,/case 'backend-java-prod':[\s\S]*?return 'production'/)
|
|
assert.match(coordinatorPipeline,/!params.SKIP_TESTS/)
|
|
assert.match(pipeline,/boolean skipTestExecution = params.SKIP_TESTS/)
|
|
})
|
|
|
|
test('Java builds use the shared builder pool and serialize incremental target deployments', () => {
|
|
assert.match(pipeline,/agent \{ label 'java-builder' \}/)
|
|
assert.match(pipeline,/node\('java-test'\)/)
|
|
assert.match(pipeline,/lock\(resource: "firstleg-test-\$\{env.RESOLVED_SERVICE\}-deploy"/)
|
|
assert.match(pipeline,/'TARGET_IS_LOCAL=true'/)
|
|
assert.match(directDeployScript,/up -d --no-deps --pull never/)
|
|
assert.doesNotMatch(directDeployScript,/--remove-orphans|docker save/)
|
|
})
|
|
|
|
test('downstream skips tests only by explicit flag and keeps test compilation',()=>{
|
|
assert.match(pipeline,/boolean skipTestExecution = params.SKIP_TESTS/)
|
|
assert.doesNotMatch(pipeline,/maven.test.skip/)
|
|
})
|
|
|
|
test('TEST deployment lock is acquired before target executor',()=>{
|
|
const stage=pipeline.slice(pipeline.indexOf("stage('部署 TEST')"))
|
|
assert.match(stage,/agent none/)
|
|
assert.ok(stage.indexOf('lock(resource:') < stage.indexOf("node('java-test')"))
|
|
})
|
|
|
|
test('only Gateway publishes to host loopback',()=>{
|
|
assert.equal([...serviceCompose.matchAll(/^ ports:/gm)].length,1)
|
|
assert.match(directDeployScript,/export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/)
|
|
})
|
|
|
|
test('Firstleg deployment passes terminal drayage SMTP settings into the container', () => {
|
|
const firstlegService = serviceCompose.slice(
|
|
serviceCompose.indexOf(' firstleg:\n'),
|
|
serviceCompose.indexOf(' finance:\n')
|
|
)
|
|
for (const key of [
|
|
'TERMINAL_DRAYAGE_MAIL_ENABLED',
|
|
'TERMINAL_DRAYAGE_MAIL_HOST',
|
|
'TERMINAL_DRAYAGE_MAIL_PORT',
|
|
'TERMINAL_DRAYAGE_MAIL_USERNAME',
|
|
'TERMINAL_DRAYAGE_MAIL_PASSWORD',
|
|
'TERMINAL_DRAYAGE_MAIL_SSL',
|
|
'TERMINAL_DRAYAGE_MAIL_SCAN_DELAY'
|
|
]) {
|
|
assert.match(firstlegService, new RegExp(`^ ${key}: ` + '\\$\\{' + key, 'm'))
|
|
}
|
|
assert.match(directDeployScript, /TERMINAL_DRAYAGE_MAIL_ENABLED/)
|
|
assert.match(directDeployScript, /TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD/)
|
|
})
|
|
|
|
test('parallel image publications isolate registry credentials per Jenkins job', () => {
|
|
const imageStage = pipeline.slice(
|
|
pipeline.indexOf("stage('构建并发布单服务镜像')"),
|
|
pipeline.indexOf("stage('部署 TEST')")
|
|
)
|
|
const loginIndex = imageStage.indexOf('docker login')
|
|
const buildIndex = imageStage.indexOf('docker build')
|
|
const pushIndex = imageStage.indexOf('docker push')
|
|
const logoutIndex = imageStage.indexOf('docker logout')
|
|
const cleanupIndex = imageStage.indexOf('rm -rf "$DOCKER_CONFIG"')
|
|
|
|
assert.match(imageStage, /String dockerConfigDir = "\$\{pwd\(tmp: true\)\}\/docker-auth"/)
|
|
assert.match(imageStage, /withEnv\(\["DOCKER_CONFIG=\$\{dockerConfigDir\}"\]\)/)
|
|
assert.ok(loginIndex >= 0 && loginIndex < buildIndex, 'registry login must happen before image build')
|
|
assert.ok(buildIndex < pushIndex, 'image build must happen before image push')
|
|
assert.ok(pushIndex < logoutIndex, 'registry logout must happen after image push')
|
|
assert.ok(logoutIndex < cleanupIndex, 'temporary Docker credentials must be removed last')
|
|
assert.doesNotMatch(imageStage, /\/home\/deploy\/\.docker\/config\.json/)
|
|
})
|
|
|
|
test('TEST deployments use explicit local env and Compose files', () => {
|
|
assert.match(directDeployScript,/TEST deploy must run on the target host/)
|
|
assert.match(directDeployScript,/--env-file "\$ENV_FILE"/)
|
|
assert.match(directDeployScript,/-f "\$COMPOSE_FILE"/)
|
|
assert.match(directDeployScript,/if \[ "\$DEPLOY_ENV" = "test" \] && \[ "\$\{TARGET_IS_LOCAL:-false\}" != "true" \]/)
|
|
})
|
|
|
|
test('shared builders use a reachable public Maven mirror instead of the retired private proxy', () => {
|
|
assert.match(pipeline, /MAVEN_SETTINGS = 'deploy\/maven\/settings-ci\.xml'/)
|
|
assert.match(ciMavenSettings, /https:\/\/maven\.aliyun\.com\/repository\/public/)
|
|
assert.doesNotMatch(ciMavenSettings, /:18081/)
|
|
})
|
|
|
|
test('Java service images use configurable Java 17 base images', () => {
|
|
const internalBaseImage = 'eclipse-temurin:17-jre-alpine'
|
|
|
|
assert.match(coordinatorPipeline, new RegExp(internalBaseImage.replaceAll('.', '\\.')))
|
|
assert.match(pipeline, new RegExp(internalBaseImage.replaceAll('.', '\\.')))
|
|
assert.match(serviceDockerfile, /ARG BASE_IMAGE/)
|
|
assert.doesNotMatch(coordinatorPipeline, /docker\.m\.daocloud\.io\/library\/eclipse-temurin/)
|
|
assert.doesNotMatch(pipeline, /docker\.m\.daocloud\.io\/library\/eclipse-temurin/)
|
|
assert.doesNotMatch(serviceDockerfile, /docker\.m\.daocloud\.io\/library\/eclipse-temurin/)
|
|
})
|
|
|
|
test('source workspaces are preserved for incremental Git fetches', () => {
|
|
const buildStage=pipeline.slice(0,pipeline.indexOf("stage('部署 TEST')"))
|
|
assert.match(buildStage,/checkout scm/)
|
|
assert.doesNotMatch(buildStage,/cleanWs\(|deleteDir\(/)
|
|
assert.match(pipeline.slice(pipeline.indexOf("stage('部署 TEST')")),/deleteDir\(\)/)
|
|
})
|
|
|
|
test('direct deployments wait for health and automatically restore the previous image', () => {
|
|
assert.match(directDeployScript, /HEALTH_TIMEOUT_SECONDS="\$\{DEPLOY_HEALTH_TIMEOUT_SECONDS:-240\}"/)
|
|
assert.match(directDeployScript, /previous_image="\$\(local_container_image \|\| true\)"/)
|
|
assert.match(directDeployScript, /if wait_for_local_health; then/)
|
|
assert.match(directDeployScript, /开始自动回滚到/)
|
|
assert.match(directDeployScript, /自动回滚成功/)
|
|
assert.match(directDeployScript, /自动回滚失败/)
|
|
assert.match(directDeployScript, /return 1/)
|
|
})
|
|
|
|
test('retired bridge jobs cannot be dispatched', () => {
|
|
assert.doesNotMatch(pipeline,/EVENT_BRIDGE|event-bridge-service|gateway-overseas/)
|
|
assert.doesNotMatch(coordinatorPipeline,/EVENT_BRIDGE|event-bridge-service|gateway-overseas/)
|
|
})
|
|
|
|
|
|
test('gray publication reuses verified SSH credentials and the unified health executor', () => {
|
|
for (const credential of ['firstleg-production-env', 'gitea-production-pull-token', 'firstleg-production-ssh', 'firstleg-production-host', 'firstleg-production-known-hosts']) assert.ok(pipeline.includes(credential))
|
|
assert.match(pipeline, /git merge-base --is-ancestor "\$RESOLVED_SOURCE_COMMIT" origin\/main/)
|
|
assert.match(directDeployScript, /StrictHostKeyChecking=yes/)
|
|
assert.match(directDeployScript, /trap cleanup_remote EXIT HUP INT TERM/)
|
|
assert.match(serviceCompose, /name: firstleg-\$\{DEPLOY_ENV:-test\}-network/)
|
|
assert.match(serviceCompose, /SPRING_PROFILES_ACTIVE: \$\{DEPLOY_ENV:-test\},cn/)
|
|
assert.doesNotMatch(directDeployScript, /deploy-blue-green|worker|StrictHostKeyChecking=no/)
|
|
})
|