build(deploy): add cloud stack containers
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.venv
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.env
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*.sqlite3
|
||||
tasks
|
||||
console/node_modules
|
||||
@@ -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
|
||||
@@ -7,6 +7,7 @@
|
||||
.trae
|
||||
|
||||
.venv
|
||||
.env
|
||||
.idea
|
||||
|
||||
__pycache__/
|
||||
|
||||
+15
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user