From 52dae806ef48a403a336a44913e5a854862b104c Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 13 Jul 2026 10:40:27 +0800 Subject: [PATCH] =?UTF-8?q?Jenkins=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 +++ Dockerfile | 3 + Jenkinsfile | 126 +++++++++++++++++++++++++++++++++++++++ compose.deploy.yaml | 72 ++++++++++++++++++++++ compose.yaml | 8 +-- docs/CLOUD_DEPLOYMENT.md | 20 +++++++ 6 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 Jenkinsfile create mode 100644 compose.deploy.yaml diff --git a/.env.example b/.env.example index fe6061f..d272993 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,10 @@ +# Only used by compose.deploy.yaml (image: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}). +# Jenkins publishes IMAGE_TAG as "-"; match the +# tag of the build you intend to deploy. +REGISTRY=git.jerryyan.net +IMAGE_NAME=q792602257/agentic-mobile-control +IMAGE_TAG=latest + POSTGRES_DB=device_cloud POSTGRES_USER=device_cloud POSTGRES_PASSWORD=change-me-database-password diff --git a/Dockerfile b/Dockerfile index 1bf10a4..733be98 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + COPY . . RUN uv sync --locked --all-packages --no-dev diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..df3240f --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,126 @@ +// 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') + } + + 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 } + } + + // 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 { + def img = docker.build("${FULL_IMAGE}:${IMAGE_TAG}", '.') + 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.' } + } +} diff --git a/compose.deploy.yaml b/compose.deploy.yaml new file mode 100644 index 0000000..ebbb1de --- /dev/null +++ b/compose.deploy.yaml @@ -0,0 +1,72 @@ +services: + postgres: + image: postgres:18 + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 12 + restart: unless-stopped + + cloud-api: + image: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG:-latest} + command: + - sh + - -c + - >- + alembic -c packages/cloud-platform/cloud/migrations/alembic.ini upgrade head + && exec device-cloud-api --host 0.0.0.0 --port 8001 + environment: + CLOUD_ENVIRONMENT: production + CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON} + CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON} + CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1} + CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5} + CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60} + CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3} + ports: + - "${CLOUD_API_PORT:-8001}:8001" + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8001/health/ready"] + interval: 5s + timeout: 3s + retries: 12 + restart: unless-stopped + + host-agent: + image: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG:-latest} + command: ["device-host-agent"] + environment: + HOST_AGENT_CONTROL_PLANE_URL: http://cloud-api:8001 + HOST_AGENT_HOST_ID: ${HOST_AGENT_HOST_ID} + HOST_AGENT_TOKEN: ${HOST_AGENT_TOKEN} + HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS: ${HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS:-30} + HOST_AGENT_POLL_TIMEOUT_SECONDS: ${HOST_AGENT_POLL_TIMEOUT_SECONDS:-20} + HOST_AGENT_RETRY_BACKOFF_SECONDS: ${HOST_AGENT_RETRY_BACKOFF_SECONDS:-1} + HOST_AGENT_MAX_RETRY_BACKOFF_SECONDS: ${HOST_AGENT_MAX_RETRY_BACKOFF_SECONDS:-30} + HOST_AGENT_MAX_RETRY_ATTEMPTS: ${HOST_AGENT_MAX_RETRY_ATTEMPTS:-5} + AI_PLANNER_ENABLED: ${AI_PLANNER_ENABLED:-false} + AI_PLANNER_PROVIDER: ${AI_PLANNER_PROVIDER:-anthropic} + AI_PLANNER_MODEL: ${AI_PLANNER_MODEL:-} + AI_PLANNER_TIMEOUT_SECONDS: ${AI_PLANNER_TIMEOUT_SECONDS:-30} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + volumes: + - ${HOST_AGENT_TASKS_PATH:-./tasks}:/app/tasks + depends_on: + cloud-api: + condition: service_healthy + restart: unless-stopped + +volumes: + postgres-data: diff --git a/compose.yaml b/compose.yaml index 63e76ee..0c52c2b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -38,13 +38,7 @@ services: postgres: condition: service_healthy healthcheck: - test: - - CMD - - python - - -c - - >- - import urllib.request; - urllib.request.urlopen('http://127.0.0.1:8001/health/ready', timeout=2) + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8001/health/ready"] interval: 5s timeout: 3s retries: 12 diff --git a/docs/CLOUD_DEPLOYMENT.md b/docs/CLOUD_DEPLOYMENT.md index 3e909f9..53e9e38 100644 --- a/docs/CLOUD_DEPLOYMENT.md +++ b/docs/CLOUD_DEPLOYMENT.md @@ -59,6 +59,26 @@ migrations, then starts the API on port `8001`. Readiness is available at: GET http://127.0.0.1:8001/health/ready ``` +## Deploying A Jenkins-Built Image + +`docker compose up --build` above builds the image from source on the target +host. For environments that pull a pre-built image instead, `Jenkinsfile` runs +the pytest suite (`-m "not integration"`), builds the image from the same +`Dockerfile`, smoke-tests both console scripts (`device-cloud-api --help`, +`device-host-agent --help`), and pushes `/:-` +plus `:latest` to the configured registry. + +`compose.deploy.yaml` is the same three-service stack as `compose.yaml` +except `cloud-api` and `host-agent` reference `image:` instead of `build:`. +Set `REGISTRY`, `IMAGE_NAME`, and `IMAGE_TAG` (see `.env.example`) to the tag +Jenkins published, then deploy without a local build step: + +```bash +docker compose -f compose.deploy.yaml pull +docker compose -f compose.deploy.yaml up -d +docker compose -f compose.deploy.yaml ps +``` + For a deployment that manages processes outside Compose, apply migrations before starting the new Cloud API version: