Multi-stage Dockerfile: stage 1 (node:20-bookworm-slim) builds cloud-console with vite base "/console/"; stage 2 (uv) copies dist/ to /app/console-static. Cloud API mounts the SPA at /console via SpaStaticFiles (StaticFiles subclass that falls back to index.html for deep-link refreshes) when the new CLOUD_CONSOLE_STATIC_DIR env is set, and 307-redirects / to /console/. Static files bypass bearer auth (the SPA shell is public; tokens are still required for /v1/*). Compose enables the mount by default; local dev still uses npm run dev + CLOUD_CONSOLE_CORS_ORIGINS. Jenkinsfile passes mirror overrides (NODE_IMAGE, NPM_REGISTRY, UV_IMAGE, APT_MIRROR, UV_INDEX_URL) as --build-arg, defaulting to CN mirrors (registry.jerryyan.net, registry.npmmirror.com, registry-ghcr.jerryyan.top, mirrors.aliyun.com) so CN builds don't time out; Dockerfile ARGs default to official upstreams so `docker build .` still works anywhere. Backend suite: 443 passed (-m "not integration"); cloud-console typecheck and production build succeed with the new base path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
153 lines
6.4 KiB
Groovy
153 lines
6.4 KiB
Groovy
// Jenkins pipeline: test the Device Cloud Platform (Python / uv workspace),
|
|
// build the deployable Docker image (Cloud API + Host Agent share one image;
|
|
// the entrypoint is selected via the Compose `command`), smoke-test the
|
|
// container, and optionally push it to a registry.
|
|
//
|
|
// Requires the Docker Pipeline plugin and a Docker-capable agent.
|
|
pipeline {
|
|
agent any
|
|
|
|
options {
|
|
timestamps()
|
|
disableConcurrentBuilds()
|
|
timeout(time: 30, unit: 'MINUTES')
|
|
buildDiscarder(logRotator(numToKeepStr: '20'))
|
|
}
|
|
|
|
parameters {
|
|
string(name: 'IMAGE_NAME', defaultValue: 'q792602257/agentic-mobile-control', description: 'Image repository name')
|
|
string(name: 'REGISTRY', defaultValue: 'git.jerryyan.net', description: 'Registry host, e.g. registry.example.com. Empty = local build only.')
|
|
string(name: 'UV_INDEX_URL', defaultValue: 'https://mirrors.aliyun.com/pypi/simple', description: 'Optional PyPI mirror index URL for uv sync (empty = uv default)')
|
|
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: 'Skip the uv sync/pytest stage (faster builds when only packaging)')
|
|
booleanParam(name: 'PUSH', defaultValue: true, description: 'Push the image to REGISTRY after a successful build')
|
|
|
|
// Mirror overrides passed to `docker build` as --build-arg. Defaults target
|
|
// CN networks so Jenkins builds don't time out pulling from Docker Hub /
|
|
// ghcr.io / npmjs.org / deb.debian.org. Override or blank any of these to
|
|
// build against the official upstreams.
|
|
string(name: 'NODE_IMAGE', defaultValue: 'registry.jerryyan.net/library/node:20-bookworm-slim', description: 'Stage 1 base image (Docker Hub library/node proxy)')
|
|
string(name: 'NPM_REGISTRY', defaultValue: 'https://registry.npmmirror.com', description: 'npm registry URL used by `npm ci`')
|
|
string(name: 'UV_IMAGE', defaultValue: 'registry-ghcr.jerryyan.top/astral-sh/uv:python3.14-bookworm-slim', description: 'Stage 2 base image (ghcr.io/astral-sh/uv proxy)')
|
|
string(name: 'APT_MIRROR', defaultValue: 'mirrors.aliyun.com', description: 'Debian apt mirror host (e.g. mirrors.aliyun.com). Empty = deb.debian.org')
|
|
}
|
|
|
|
environment {
|
|
// Short commit for a traceable, immutable tag alongside :latest.
|
|
GIT_SHA = sh(script: 'git rev-parse --short HEAD 2>/dev/null || echo unknown', returnStdout: true).trim()
|
|
IMAGE_TAG = "${env.BUILD_NUMBER}-${GIT_SHA}"
|
|
FULL_IMAGE = "${params.REGISTRY?.trim() ? params.REGISTRY.trim() + '/' : ''}${params.IMAGE_NAME}"
|
|
}
|
|
|
|
stages {
|
|
stage('Checkout') {
|
|
steps {
|
|
checkout scm
|
|
// Pre-create the host-side uv cache dir as the Jenkins user *before* the
|
|
// 'Test' stage's docker agent bind-mounts it. If this dir doesn't exist
|
|
// yet, Docker auto-creates it as root when the container starts, which
|
|
// then blocks writes from the non-root user the container runs as.
|
|
sh 'mkdir -p "$HOME/.cache/uv"'
|
|
}
|
|
}
|
|
|
|
// Root, cloud-api, and host-agent suites, excluding tests that need real
|
|
// hardware/PostgreSQL/device services (see tests/ pytest marker "integration").
|
|
stage('Test') {
|
|
when { expression { return !params.SKIP_TESTS } }
|
|
agent {
|
|
docker {
|
|
image 'registry-ghcr.jerryyan.top/astral-sh/uv:python3.14-bookworm-slim'
|
|
reuseNode true
|
|
// The Docker Pipeline plugin runs the container as the Jenkins host
|
|
// user (non-root), so /root isn't writable. Cache under /tmp, which is
|
|
// world-writable, and mount the persistent host cache dir there.
|
|
args '-v $HOME/.cache/uv:/tmp/uv-cache'
|
|
}
|
|
}
|
|
environment {
|
|
// Pin HOME/cache explicitly for the same reason as the cache mount above.
|
|
HOME = '/tmp'
|
|
UV_CACHE_DIR = '/tmp/uv-cache'
|
|
UV_LINK_MODE = 'copy'
|
|
UV_INDEX_URL = "${params.UV_INDEX_URL}"
|
|
}
|
|
steps {
|
|
sh 'uv sync --locked --all-packages'
|
|
sh '''
|
|
uv run pytest tests apps/cloud-api/tests apps/device-host-agent/tests \
|
|
-m "not integration" -ra --junit-xml=test-results.xml
|
|
'''
|
|
}
|
|
post {
|
|
always {
|
|
junit testResults: 'test-results.xml', allowEmptyResults: true
|
|
}
|
|
}
|
|
}
|
|
|
|
stage('Build image') {
|
|
steps {
|
|
script {
|
|
// Pass every mirror override through as --build-arg. Empty values
|
|
// are skipped so the Dockerfile ARG default applies.
|
|
def buildArgs = []
|
|
["NODE_IMAGE", "NPM_REGISTRY", "UV_IMAGE", "APT_MIRROR", "UV_INDEX_URL"].each { name ->
|
|
def v = params[name]?.toString()?.trim()
|
|
if (v) {
|
|
buildArgs << "--build-arg ${name}=${v}"
|
|
}
|
|
}
|
|
def dockerArgs = (buildArgs.join(' ') + ' .').trim()
|
|
def img = docker.build("${FULL_IMAGE}:${IMAGE_TAG}", dockerArgs)
|
|
img.tag('latest')
|
|
env.BUILT_IMAGE = "${FULL_IMAGE}:${IMAGE_TAG}"
|
|
echo "Built ${env.BUILT_IMAGE} (+ :latest)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Confirm both packaged console scripts start up before shipping the image.
|
|
// A full boot needs PostgreSQL (cloud-api runs Alembic migrations on
|
|
// start), so this checks the entrypoints resolve and import cleanly
|
|
// instead of booting the real service.
|
|
stage('Smoke test') {
|
|
steps {
|
|
sh '''
|
|
set -eu
|
|
docker run --rm "${BUILT_IMAGE}" device-cloud-api --help >/dev/null
|
|
docker run --rm "${BUILT_IMAGE}" device-host-agent --help >/dev/null
|
|
echo "smoke test OK"
|
|
'''
|
|
}
|
|
}
|
|
|
|
stage('Push') {
|
|
when { expression { return params.PUSH } }
|
|
steps {
|
|
script {
|
|
def registryUrl = params.REGISTRY?.trim() ? "https://${params.REGISTRY.trim()}" : ''
|
|
docker.withRegistry(registryUrl, 'gitea-registry') {
|
|
def img = docker.image("${FULL_IMAGE}:${IMAGE_TAG}")
|
|
img.push()
|
|
img.push('latest')
|
|
}
|
|
echo "Pushed ${FULL_IMAGE}:${IMAGE_TAG} and :latest"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
post {
|
|
always {
|
|
// Free local disk: drop this build's tag and any dangling layers.
|
|
sh '''
|
|
[ -n "${BUILT_IMAGE:-}" ] && docker rmi "${BUILT_IMAGE}" >/dev/null 2>&1 || true
|
|
docker image prune -f >/dev/null 2>&1 || true
|
|
'''
|
|
cleanWs()
|
|
}
|
|
success { echo "OK: ${env.FULL_IMAGE}:${env.IMAGE_TAG}" }
|
|
failure { echo 'Build failed — see stage logs above.' }
|
|
}
|
|
}
|