From dbd70732e1c44492f348a30d86fc2789421bfe0b Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Sun, 12 Jul 2026 22:58:17 +0800 Subject: [PATCH] build(deploy): add cloud stack containers --- .dockerignore | 10 +++ .env.example | 19 +++++ .gitignore | 1 + Dockerfile | 15 ++++ compose.yaml | 72 +++++++++++++++++++ .../cloud-control-plane-integration/tasks.md | 2 +- tests/test_deployment_config.py | 54 ++++++++++++++ 7 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 tests/test_deployment_config.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3dd16a1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.venv +.pytest_cache +.ruff_cache +.env +__pycache__ +*.py[cod] +*.sqlite3 +tasks +console/node_modules diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9b9346d --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +POSTGRES_DB=device_cloud +POSTGRES_USER=device_cloud +POSTGRES_PASSWORD=change-me-database-password + +CLOUD_API_PORT=8001 +CLOUD_PUBLIC_CREDENTIALS_JSON=[{"principal_id":"local-sdk","token":"change-me-public-token","scopes":["tasks:submit","tasks:read","pool:read","plugins:read","plugins:admin"]}] +CLOUD_HOST_CREDENTIALS_JSON=[{"principal_id":"local-host-agent","token":"change-me-host-token","scopes":[],"host_id":"host-local"}] +CLOUD_SCHEDULER_INTERVAL_SECONDS=1 +CLOUD_LEASE_REAPER_INTERVAL_SECONDS=5 +CLOUD_LEASE_DURATION_SECONDS=60 +CLOUD_MAX_TASK_ATTEMPTS=3 + +HOST_AGENT_HOST_ID=host-local +HOST_AGENT_TOKEN=change-me-host-token +HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS=30 +HOST_AGENT_POLL_TIMEOUT_SECONDS=20 +HOST_AGENT_RETRY_BACKOFF_SECONDS=1 +HOST_AGENT_MAX_RETRY_BACKOFF_SECONDS=30 +HOST_AGENT_MAX_RETRY_ATTEMPTS=5 diff --git a/.gitignore b/.gitignore index 87a33aa..4fa252d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .trae .venv +.env .idea __pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1bf10a4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy + +WORKDIR /app + +COPY . . +RUN uv sync --locked --all-packages --no-dev + +ENV PATH="/app/.venv/bin:$PATH" + +CMD ["device-cloud-api", "--host", "0.0.0.0", "--port", "8001"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..a22df38 --- /dev/null +++ b/compose.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: + build: + context: . + 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 + - python + - -c + - >- + import urllib.request; + urllib.request.urlopen('http://127.0.0.1:8001/health/ready', timeout=2) + interval: 5s + timeout: 3s + retries: 12 + restart: unless-stopped + + host-agent: + build: + context: . + 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} + depends_on: + cloud-api: + condition: service_healthy + restart: unless-stopped + +volumes: + postgres-data: diff --git a/openspec/changes/cloud-control-plane-integration/tasks.md b/openspec/changes/cloud-control-plane-integration/tasks.md index e2c125b..5b5fb48 100644 --- a/openspec/changes/cloud-control-plane-integration/tasks.md +++ b/openspec/changes/cloud-control-plane-integration/tasks.md @@ -65,7 +65,7 @@ - [x] 8.1 Extend public task status models/routes with attempt count, lease expiry metadata, and terminal failure details without exposing lease credentials. - [x] 8.2 Add bearer authentication and typed authorization errors to `CloudClient` while preserving injectable HTTP clients for tests. -- [ ] 8.3 Add container definitions and example environment configuration for the cloud API, PostgreSQL, and Host Agent without committing secrets. +- [x] 8.3 Add container definitions and example environment configuration for the cloud API, PostgreSQL, and Host Agent without committing secrets. - [ ] 8.4 Document local SQLite startup, deployed PostgreSQL migration/startup, credential/scopes setup, Runtime AI Planner configuration, and shutdown/rollback procedures. - [ ] 8.5 Document the single scheduler-enabled control-plane limitation and the at-least-once device-side-effect trade-off. diff --git a/tests/test_deployment_config.py b/tests/test_deployment_config.py new file mode 100644 index 0000000..043f201 --- /dev/null +++ b/tests/test_deployment_config.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_compose_defines_database_control_plane_and_outbound_host_agent() -> None: + compose = yaml.safe_load((ROOT / "compose.yaml").read_text(encoding="utf-8")) + services = compose["services"] + + assert set(services) == {"postgres", "cloud-api", "host-agent"} + assert services["postgres"]["image"].startswith("postgres:18") + assert services["cloud-api"]["depends_on"]["postgres"]["condition"] == ( + "service_healthy" + ) + assert services["host-agent"]["depends_on"]["cloud-api"]["condition"] == ( + "service_healthy" + ) + assert "ports" not in services["host-agent"] + assert services["host-agent"]["environment"][ + "HOST_AGENT_CONTROL_PLANE_URL" + ] == "http://cloud-api:8001" + + +def test_container_uses_locked_workspace_install_and_migrations() -> None: + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + compose = yaml.safe_load((ROOT / "compose.yaml").read_text(encoding="utf-8")) + cloud_command = compose["services"]["cloud-api"]["command"][-1] + + assert "uv sync --locked --all-packages --no-dev" in dockerfile + assert "alembic" in cloud_command + assert "upgrade head" in cloud_command + assert "device-cloud-api --host 0.0.0.0" in cloud_command + + +def test_example_environment_contains_only_placeholder_credentials() -> None: + values = {} + for line in (ROOT / ".env.example").read_text(encoding="utf-8").splitlines(): + if line and not line.startswith("#"): + name, value = line.split("=", 1) + values[name] = value + + public_credentials = json.loads(values["CLOUD_PUBLIC_CREDENTIALS_JSON"]) + host_credentials = json.loads(values["CLOUD_HOST_CREDENTIALS_JSON"]) + + assert public_credentials[0]["token"].startswith("change-me-") + assert host_credentials[0]["token"] == values["HOST_AGENT_TOKEN"] + assert host_credentials[0]["token"].startswith("change-me-") + assert host_credentials[0]["host_id"] == values["HOST_AGENT_HOST_ID"]