// 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 npmjs.org / // deb.debian.org / PyPI. Override or blank any of these to build against // the official upstreams. Base images (node, uv) are pinned in the // Dockerfile FROM lines and no longer overridable here. string(name: 'NPM_REGISTRY', defaultValue: 'https://registry.npmmirror.com', description: 'npm registry URL used by `npm ci`') 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.13-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 = [] ["NPM_REGISTRY", "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.' } } }