diff --git a/cloud/deploy/.dockerignore b/cloud/deploy/.dockerignore new file mode 100644 index 0000000..d96e442 --- /dev/null +++ b/cloud/deploy/.dockerignore @@ -0,0 +1,14 @@ +# Keep the build context small — the images sync from pyproject + uv.lock, not these. +.git +.venv +**/__pycache__ +**/*.pyc +**/node_modules +apps/web/.next +apps/vscode/dist +website +**/.pytest_cache +**/.mypy_cache +**/.ruff_cache +*.db +.volo diff --git a/cloud/deploy/Dockerfile.api b/cloud/deploy/Dockerfile.api new file mode 100644 index 0000000..11b538a --- /dev/null +++ b/cloud/deploy/Dockerfile.api @@ -0,0 +1,26 @@ +# Volo Cloud — API image (the FastAPI control plane). +# Build context is the REPO ROOT (the app is a uv-workspace member): +# docker build -f cloud/deploy/Dockerfile.api -t volo-cloud-api . +# +# Syncs the whole workspace so transitive workspace deps (volo-core, volo-api for the auth seam, +# volo-runner, …) and the `serve` extra (uvicorn) are present. Optimizing to a slim, cloud-only +# image is a follow-up. +FROM python:3.12-slim + +# uv provides fast, reproducible installs from the committed uv.lock. +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + PYTHONUNBUFFERED=1 \ + # deny anonymous callers in a hosted deployment (M30 auth gate) + VOLO_REQUIRE_AUTH=true + +WORKDIR /app +COPY . . +RUN uv sync --frozen --all-packages --all-extras + +EXPOSE 8080 +# uvicorn calls the app factory create_cloud_app() +CMD ["uv", "run", "uvicorn", "volo_cloud.app:create_cloud_app", \ + "--factory", "--host", "0.0.0.0", "--port", "8080"] diff --git a/cloud/deploy/Dockerfile.worker b/cloud/deploy/Dockerfile.worker new file mode 100644 index 0000000..a20371c --- /dev/null +++ b/cloud/deploy/Dockerfile.worker @@ -0,0 +1,22 @@ +# Volo Cloud — sim-minutes worker image (M27). +# Build context is the REPO ROOT: +# docker build -f cloud/deploy/Dockerfile.worker -t volo-cloud-worker . +# +# Runs the queue drain loop: claim a queued SimJob, run the reliability suite, meter wall-clock as +# sim-minutes, charge the workspace quota, store the report. Point it at the same VOLO_DB_URL as the +# API. A job's agent is only executed if it is in VOLO_SIM_AGENT_ALLOWLIST (safe-by-default, +# ADR-0033) — production should additionally sandbox this container per job. +FROM python:3.12-slim + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + PYTHONUNBUFFERED=1 + +WORKDIR /app +COPY . . +RUN uv sync --frozen --all-packages --all-extras + +# --poll: seconds between queue polls when idle. Override with --once to drain and exit. +CMD ["uv", "run", "volo-cloud-worker", "--poll", "2"] diff --git a/cloud/deploy/README.md b/cloud/deploy/README.md new file mode 100644 index 0000000..635e7d3 --- /dev/null +++ b/cloud/deploy/README.md @@ -0,0 +1,50 @@ +# Deploying Volo Cloud (hosted sim-minutes) + +The commercial control plane (M26/M30) + the sim-minutes worker (M27) run locally on SQLite with +zero infra. This directory packages them for a hosted deployment: two container images (API + +worker) and a Fly.io manifest. **Nothing here changes application behavior** — it's packaging. + +## Images + +Both build from the **repo root** (the cloud app is a uv-workspace member): + +```bash +docker build -f cloud/deploy/Dockerfile.api -t volo-cloud-api . +docker build -f cloud/deploy/Dockerfile.worker -t volo-cloud-worker . +``` + +- **API** — `uvicorn volo_cloud.app:create_cloud_app --factory` on port 8080. +- **Worker** — `volo-cloud-worker --poll 2`, the queue drain loop. + +## Configuration (env / secrets) + +| Variable | Purpose | +|---|---| +| `VOLO_DB_URL` | **Postgres** DSN shared by API + worker (e.g. `postgresql://…`). Defaults to local SQLite if unset. | +| `VOLO_REQUIRE_AUTH` | `true` in a hosted deployment — denies anonymous callers (set in the image/manifest). | +| `VOLO_JWT_JWKS` *or* `VOLO_JWT_SECRET` | SSO: an IdP's JWKS (RS256) or a shared secret (HS256). `VOLO_JWT_ISS` / `VOLO_JWT_AUD` optional. | +| `VOLO_SIM_AGENT_ALLOWLIST` | Comma-separated agents the **worker** may execute (safe-by-default, ADR-0033). | + +> **Security:** the worker executes a job's agent code only if it's allowlisted. In production also +> sandbox the worker container per job — treat it as an untrusted-code boundary. + +## Fly.io + +`fly.toml` runs one image with two processes (`app`, `worker`). A first deploy: + +```bash +fly launch --copy-config --no-deploy # uses cloud/deploy/fly.toml +fly postgres create && fly postgres attach … # sets VOLO_DB_URL (DATABASE_URL) as a secret +fly secrets set VOLO_JWT_JWKS='{"keys":[…]}' VOLO_SIM_AGENT_ALLOWLIST='my_pkg.agent:run' +fly deploy +``` + +Scale the worker independently of the API: + +```bash +fly scale count app=1 worker=2 +``` + +The same images run on any container host (Cloud Run, ECS, a plain VM) — set the same env and run +the two commands. A slimmer cloud-only image (syncing just `volo-cloud` instead of the whole +workspace) is a reasonable follow-up optimization. diff --git a/cloud/deploy/fly.toml b/cloud/deploy/fly.toml new file mode 100644 index 0000000..3ed0a7d --- /dev/null +++ b/cloud/deploy/fly.toml @@ -0,0 +1,29 @@ +# Fly.io manifest for the Volo Cloud control plane (API + sim-minutes worker). +# One image, two processes. Set secrets (DB, auth, allowlist) with `fly secrets set` — see README. +app = "volo-cloud" +primary_region = "iad" + +[build] + dockerfile = "cloud/deploy/Dockerfile.api" + +[env] + # Deny anonymous callers; a real IdP is wired via VOLO_JWT_JWKS / VOLO_JWT_SECRET (secrets). + VOLO_REQUIRE_AUTH = "true" + +# The API serves HTTP; the worker drains the sim-minutes queue. Both run from the same image; +# the worker process overrides the command. +[processes] + app = "uv run uvicorn volo_cloud.app:create_cloud_app --factory --host 0.0.0.0 --port 8080" + worker = "uv run volo-cloud-worker --poll 2" + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb"