build(deploy): add cloud stack containers

This commit is contained in:
2026-07-12 22:58:17 +08:00
parent 1f95d23beb
commit dbd70732e1
7 changed files with 172 additions and 1 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.venv
.pytest_cache
.ruff_cache
.env
__pycache__
*.py[cod]
*.sqlite3
tasks
console/node_modules
+19
View File
@@ -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
+1
View File
@@ -7,6 +7,7 @@
.trae
.venv
.env
.idea
__pycache__/
+15
View File
@@ -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"]
+72
View File
@@ -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:
@@ -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.
+54
View File
@@ -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"]