diff --git a/.env.example b/.env.example index 907464d988..d238ba5fc3 100644 --- a/.env.example +++ b/.env.example @@ -575,12 +575,22 @@ SSH_HOST= # 3. Set DATABASE_URL (uncomment) and restart the backend. # Leave DATABASE_URL unset for the default SQLite behavior. # +# HOSTED installs (docker-compose.hosted.yml, `start.sh --hosted`, every +# one-click image) default to the bundled PostgreSQL instead (#3159): +# - POSTGRES_PASSWORD is REQUIRED there (the file does not render without it); +# start.sh --hosted generates it. +# - DATABASE_URL unset → the bundled server. `DATABASE_URL=` (empty) → SQLite, +# which start.sh --hosted writes for an install that already has trinity.db. +# Keep the DATABASE_URL line below commented: start.sh reads an uncommented +# one, even empty, as a choice already made. +# # DATABASE_URL=postgresql://trinity:your-postgres-password@postgres:5432/trinity # DB_POOL_SIZE=10 # PostgreSQL connection pool size (ignored for SQLite) # DB_MAX_OVERFLOW=20 # PostgreSQL pool overflow (ignored for SQLite) # -# Credentials for the optional bundled postgres service (compose --profile postgres). -# POSTGRES_PASSWORD is required only when that profile is enabled. +# Credentials for the bundled postgres service. Dev compose: only with +# --profile postgres. Hosted compose: always, with POSTGRES_DB/POSTGRES_USER +# fixed at `trinity`. # POSTGRES_DB=trinity # POSTGRES_USER=trinity # POSTGRES_PASSWORD= diff --git a/.github/workflows/container-security.yml b/.github/workflows/container-security.yml index 213ce5bd9c..f610051a7b 100644 --- a/.github/workflows/container-security.yml +++ b/.github/workflows/container-security.yml @@ -244,6 +244,7 @@ jobs: export ADMIN_PASSWORD="$(openssl rand -base64 24 | tr -d '/+=\n')Aa1!" export REDIS_PASSWORD="$(openssl rand -hex 24)" export REDIS_BACKEND_PASSWORD="$(openssl rand -hex 24)" + export POSTGRES_PASSWORD="$(openssl rand -hex 24)" # hosted (#3159) status=0 while IFS= read -r file_set; do [ -n "$file_set" ] || continue diff --git a/VERSION b/VERSION index b0bb878545..c709fa0280 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.5 +1.0.0-aws.1 diff --git a/docker-compose.hosted.yml b/docker-compose.hosted.yml index bda272509c..0898b2e701 100644 --- a/docker-compose.hosted.yml +++ b/docker-compose.hosted.yml @@ -3,7 +3,17 @@ # Identical to docker-compose.prod.yml in every respect except one: it carries # NO `build:` blocks. (Plus the ent#580 marketplace admin claim: this file # renders an explicitly blank ADMIN_PASSWORD and forwards ADMIN_PASSWORD_SOURCE; -# prod refuses a blank one. The parity test allowlists exactly those lines.) +# prod refuses a blank one. Plus the bundled PostgreSQL of #3159: a `postgres` +# service, its volume, and a DATABASE_URL that defaults to it. The parity test +# allowlists exactly those lines.) +# +# DATABASE: hosted installs run on the bundled PostgreSQL. POSTGRES_PASSWORD is +# REQUIRED; the file does not render without it. `start.sh --hosted` generates +# it. A compose-only install (a template or a managed host that runs this file +# directly) sets it in its environment, e.g. `openssl rand -hex 24`, and leaves +# DATABASE_URL unset. An install that already has /data/trinity.db sets +# `DATABASE_URL=` (empty) to stay on SQLite; moving it to PostgreSQL is +# docs/migrations/SQLITE_TO_POSTGRES.md. # Every service resolves a prebuilt image from GHCR, so a # fresh VM is serving in ~2 minutes instead of spending 5-10 of them compiling # the agent base image on first boot. That build time is what fails the @@ -418,12 +428,14 @@ services: - TEMPLATE_REGISTRY_URL=${TEMPLATE_REGISTRY_URL:-https://raw.githubusercontent.com/Abilityai/trinity-templates/main/registry.yaml} # Internal API shared secret (C-003) - for scheduler/agent communication - INTERNAL_API_SECRET=${INTERNAL_API_SECRET} - # Configurable database backend (#300, experimental) — mirrors - # docker-compose.yml; without these lines the .env lever is inert in prod - # (the #1039/#1056 packaging-gap class). Unset → SQLite at /data/trinity.db. - # Prod compose ships no postgres service: a postgresql:// URL must point at - # an operator-managed PostgreSQL instance (see docs/POSTGRESQL_SETUP.md). - - DATABASE_URL=${DATABASE_URL:-} + # Database backend (#3159, HOST-022). Hosted installs default to the + # bundled `postgres` service below. `-` with no colon is load-bearing: + # UNSET resolves to the bundled PostgreSQL; set-but-EMPTY (`DATABASE_URL=`, + # which start.sh --hosted writes for an install that already has + # /data/trinity.db) keeps SQLite, so an update never boots an empty + # PostgreSQL beside a populated SQLite file. Any other postgresql:// URL + # points at an operator-managed server (docs/POSTGRESQL_SETUP.md). + - DATABASE_URL=${DATABASE_URL-postgresql://trinity:${POSTGRES_PASSWORD}@postgres:5432/trinity} - DB_POOL_SIZE=${DB_POOL_SIZE:-10} - DB_MAX_OVERFLOW=${DB_MAX_OVERFLOW:-20} # Fire-and-forget dispatch (#1083). When true, eligible autonomous turns @@ -522,6 +534,8 @@ services: # + deletes archived originals. - trinity-logs:/data/logs depends_on: + postgres: + condition: service_healthy redis: condition: service_healthy vector: @@ -688,6 +702,39 @@ services: retries: 5 start_period: 5s + # PostgreSQL (#3159, HOST-022) — the hosted database. SQLite reached + # end-of-support on 2026-09-01 (#1278), so every one-click channel starts on + # this service. It is hosted-only: docker-compose.prod.yml leaves the database + # to the operator, and the parity test allowlists exactly this service, its + # volume and the two depends_on edges. An install that keeps SQLite (an + # existing /data/trinity.db) still starts it, idle, so one file renders for + # both. Platform network only: agents NEVER reach it (Issue #589). + postgres: + image: postgres:16-alpine + container_name: trinity-postgres + logging: *default-logging + restart: unless-stopped + expose: + - "5432" + environment: + - POSTGRES_DB=trinity + - POSTGRES_USER=trinity + - "POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env (generate openssl rand -hex 24; start.sh --hosted does this)}" + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - trinity-platform # Issue #589: PostgreSQL is NOT on agent network + security_opt: + - no-new-privileges:true + # TCP probe: the image's first-boot init runs a socket-only temporary + # server, so a socket probe can report ready before the real server listens. + healthcheck: + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U trinity -d trinity"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + # Scheduler - dedicated service for cron-based agent task execution # Prevents duplicate executions that occurred with multiple backend workers scheduler: @@ -697,9 +744,9 @@ services: restart: unless-stopped environment: - DATABASE_PATH=/data/trinity.db - # #300: when set to a postgresql:// URL the scheduler reads/writes Postgres - # (the same DB the backend uses); unset → shared SQLite at DATABASE_PATH. - - DATABASE_URL=${DATABASE_URL:-} + # #300/#3159: the same database the backend uses — see the backend's + # DATABASE_URL comment for why the default uses `-` with no colon. + - DATABASE_URL=${DATABASE_URL-postgresql://trinity:${POSTGRES_PASSWORD}@postgres:5432/trinity} # Scheduler uses the same `backend` ACL user (identical access patterns). - "REDIS_URL=redis://backend:${REDIS_BACKEND_PASSWORD:?REDIS_BACKEND_PASSWORD must be set in .env}@redis:6379" - HEALTH_PORT=8001 @@ -716,6 +763,8 @@ services: volumes: - ${TRINITY_DATA_PATH:-./trinity-data}:/data depends_on: + postgres: + condition: service_healthy redis: condition: service_healthy backend: @@ -921,6 +970,7 @@ services: volumes: agent-configs: redis-data: + postgres-data: # PostgreSQL data (#3159) trinity-logs: # Vector log storage # trinity-data: using bind mount via TRINITY_DATA_PATH env var diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 630e76d9ab..75a7612dee 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -251,10 +251,13 @@ setting. The AMI is published in us-east-1 only. ## Configuration -> **Database backend:** Trinity uses **SQLite by default** (zero-config). To run -> a new instance on **PostgreSQL** instead, see -> [POSTGRESQL_SETUP.md](POSTGRESQL_SETUP.md) — it is opt-in via the -> `DATABASE_URL` env var and does not affect the SQLite default (#300). +> **Database backend:** a hosted install (`start.sh --hosted`, every one-click +> image) runs on the PostgreSQL service bundled in `docker-compose.hosted.yml`; +> `start.sh --hosted` generates `POSTGRES_PASSWORD` and `DATABASE_URL` on a new +> install and keeps an existing `trinity.db` install on SQLite (#3159, see +> [SQLITE_TO_POSTGRES.md](migrations/SQLITE_TO_POSTGRES.md#hosted-installs-one-click-images-and-startsh---hosted)). +> The dev and prod compose files default to SQLite; to run them on +> **PostgreSQL**, see [POSTGRESQL_SETUP.md](POSTGRESQL_SETUP.md) (`DATABASE_URL`, #300). ### Required Environment Variables diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 916c988d29..b3a286cdf2 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -121,7 +121,7 @@ Two Docker bridge networks, by design — agents physically cannot route to Redi | Network | Subnet | Members | |---------|--------|---------| -| `trinity-platform-network` | 172.29.0.0/16 | redis, scheduler, vector | +| `trinity-platform-network` | 172.29.0.0/16 | redis, scheduler, vector, postgres (hosted compose; dev `--profile postgres`, #3159) | | `trinity-agent-network` | 172.28.0.0/16 | agents, frontend | Bridges (members of **both** networks): `backend` (primary HTTP API — Redis on platform side, agents on agent side), `mcp-server` (agents reach `http://mcp-server:8080/mcp` via Docker DNS), `otel-collector` (agents push metrics), `cloudflared` (prod only — proxies to backend and public agents). diff --git a/docs/memory/requirements/infrastructure.md b/docs/memory/requirements/infrastructure.md index 485a05eb7f..b1ce60749f 100644 --- a/docs/memory/requirements/infrastructure.md +++ b/docs/memory/requirements/infrastructure.md @@ -149,9 +149,10 @@ - **HOST-019** (`quickstart.sh` is an alias, never a second installer — #2528): the root-level `quickstart.sh` `exec`s `scripts/deploy/start.sh`, translating its own `--defaults` to `--unattended` and passing every other flag through. It had been a forked copy — its own `.env` seeding (missing the #589 Redis passwords and `AGENT_AUTH_SECRET`), its own hand-rolled `.env` reader (the #2390 class), and a bare `docker compose up -d` that predated the HOST-015 guard — so on a production host it booted the dev file over the prod bind mount, seeded a demo workspace into an empty named volume, and reported healthy. HOST-006's "one install path" was documented but had an unlinked exception sitting in the repo root; the exception is gone. Pinned statically (no `docker compose`, no `openssl rand`, no `cut -d'='` in the file) and behaviourally (a stub `start.sh` receives the translated argv). - **HOST-020** (the standalone compose files say so, and every supported file set renders in CI — #2528): `docker-compose.prod.yml` and `docker-compose.hosted.yml` are complete files, NOT overlays on `docker-compose.yml` — their `security_opt`/`group_add`/`cap_drop` entries ARE the hardening, restated because nothing else supplies it. Stacked on the dev file, Compose merges list keys by concatenation: ≥ 2.24 rejects the exact-duplicate items (`services.backend.group_add array items[0,1] must be unique`); older versions silently double the frontend `ports` (dev maps host→80 for Vite, prod host→8080 for unprivileged nginx) and a container with two host mappings for one port never joins its network. Both file headers now state the rule and name the wrong invocation; `docs/DEPLOYMENT.md` tabulates the six supported sets (dev, dev+override, prod, prod+enterprise, hosted, hosted+override) with where `/data` lives for each; and `container-security.yml` → `verify-compose-file-sets` renders each by its exact `-f` sequence, `--env-file /dev/null` so a runner's stray `.env` cannot mask a render that depends on a local value. Base + prod is deliberately absent from the CI list — rendering it would legitimise it — and `tests/unit/test_2528_compose_file_sets.py` pins the set list against the workflow text so the two cannot drift. The "fix" of deleting the duplicated blocks from prod is explicitly wrong: it strips the production hardening from the file that runs alone. - **HOST-021** (the data-switch guard names prod, and speaks when both stores exist — #2528): the bind mount is what BOTH prod and hosted write, so the HOST-015 reverse refusal is reached from a source-built production host as readily as from a hosted one. It used to assert "installed with `--hosted`" and offer only `--hosted` as the remedy — on a prod box that would have pulled GHCR images onto a host that builds its own. It now names both installs and prints each one's invocation (`docker compose -f docker-compose.prod.yml up -d` / `start.sh --hosted`), with the never-stack rule inline. A third state is covered: **both** stores present (the real DB in the bind mount, a freshly migrated-and-seeded one in the named volume — exactly what a wrong-file start leaves behind). Neither refusal fires there, each being written for a clean crossing, so a repeat of the same mistake was silent. It is a **warning, not a refusal**, by necessity: the copy-across remedy both refusals print leaves both stores in place, so refusing would block the operator who did what they were told. The warning says which store the stack is about to use and which it will ignore. The two #2390-pinned refusal conditions are byte-identical; the guard is executed under `bash` with a `docker` shim in the test, not pattern-matched. + - **HOST-022** (hosted installs start on a bundled PostgreSQL — #3159): SQLite reached end-of-support on 2026-09-01 (#1278), and every one-click channel (DigitalOcean 1-Click, the AWS AMI, Vultr, compose-only templates) runs `docker-compose.hosted.yml`, so the database default lives there. The hosted file carries a `postgres` service (`postgres:16-alpine`, named volume `postgres-data`, `*default-logging`, `no-new-privileges`, `pg_isready` healthcheck, no published port) on `trinity-platform-network` **only** (agents cannot route to it, #589); backend and scheduler `depends_on` it `service_healthy`. `POSTGRES_PASSWORD` is required (`:?`), so a compose-only consumer cannot render the file without one. Backend and scheduler read `DATABASE_URL=${DATABASE_URL-postgresql://trinity:${POSTGRES_PASSWORD}@postgres:5432/trinity}`: **unset** resolves to the bundled server, **set-but-empty** keeps SQLite. `start.sh --hosted` (`ensure_hosted_database`, run after the HOST-015 data-switch guard and before `compose pull`) generates `POSTGRES_PASSWORD` (`openssl rand -hex 24`) when `.env` has none, and refuses when the `postgres-data` volume already exists without one; then, only when neither the shell nor `.env` has a `DATABASE_URL` line, writes the bundled URL on a fresh install (no `/trinity.db`) or `DATABASE_URL=` when `trinity.db` exists. It never rewrites an existing `DATABASE_URL`, and prints a pointer to `docs/migrations/SQLITE_TO_POSTGRES.md` on every run of an install still on SQLite. An existing SQLite install therefore never boots an empty PostgreSQL beside its `trinity.db`; its `postgres` container runs idle. A manual update that skips `start.sh` fails to render (no `POSTGRES_PASSWORD`) before it can flip the database. `.env.example` keeps `DATABASE_URL` commented, because `start.sh` copies it to `.env` on a new install. The service, its volume, the two `depends_on` edges and the two `DATABASE_URL` lines are the allowlisted hosted-only divergence in the HOST-005 parity test; `docker-compose.prod.yml` keeps the operator-managed database. The Packer provision step pre-pulls `postgres:16-alpine` (the AWS AMI reuses it). Nightly backups (#2216) use the backend image's `pg_dump` (`postgresql-client-17`, which dumps a 16 server); a test pins the client major at or above the hosted server's. - **Outstanding (AC4)**: the cloud-init user-data example (`trinity-ops-public/provision/cloud-init.sh`) taking admin password / domain / optional Cloudflare Tunnel token. Cross-repo, so it does not land here; #2280 stays open until it does. - **Explicit non-goals (flagged, not filed)**: arm64/multi-arch publishing (amd64 covers DigitalOcean Droplets and Vultr Cloud Compute; cross-building the ~1.9 GB agent base under QEMU is slow and flaky — gate it on Hetzner CAX / Umbrel actually needing it), image signing/attestation, and making the agent base image reference configurable (rejected: it would require widening the SEC-172 allowlist, and retagging achieves the same result with no security surface). -- **Tests**: `tests/unit/test_2280_hosted_compose_parity.py`, `tests/unit/test_2280_publish_workflow_and_stop.py`, `tests/unit/test_2390_start_sh_env_and_project_name.py`, `tests/unit/test_2528_compose_file_sets.py` (+ CI `container-security.yml` → `verify-compose-file-sets`) +- **Tests**: `tests/unit/test_2280_hosted_compose_parity.py`, `tests/unit/test_3159_hosted_postgres.py`, `tests/unit/test_2280_publish_workflow_and_stop.py`, `tests/unit/test_2390_start_sh_env_and_project_name.py`, `tests/unit/test_2528_compose_file_sets.py` (+ CI `container-security.yml` → `verify-compose-file-sets`) - **Docs**: `docs/DEPLOYMENT.md` (server install + TLS + the supported compose file sets), `docs/AGENT_INSTALL_GUIDE.md` (Step 1) - **Flow**: `docs/memory/feature-flows/hosted-install.md` diff --git a/docs/migrations/SQLITE_TO_POSTGRES.md b/docs/migrations/SQLITE_TO_POSTGRES.md index fccb16caf1..bbb4a8b4f2 100644 --- a/docs/migrations/SQLITE_TO_POSTGRES.md +++ b/docs/migrations/SQLITE_TO_POSTGRES.md @@ -38,6 +38,30 @@ Nothing in your running instance changes on the date itself — SQLite installs working — but they stop receiving schema migrations and fixes, so staying on SQLite past EOL means pinning an older Trinity release. +## Hosted installs (one-click images and `start.sh --hosted`) + +Hosted installs default to PostgreSQL (#3159). `docker-compose.hosted.yml` ships a +`postgres` service (`postgres:16-alpine`, volume `postgres-data`, platform network +only), and every one-click channel (DigitalOcean 1-Click, the AWS AMI, the Vultr +startup script, compose-only templates) runs that file. + +- **New install:** `start.sh --hosted` generates `POSTGRES_PASSWORD` and writes + `DATABASE_URL=postgresql://trinity:@postgres:5432/trinity` to `.env`. + The backend builds the schema via Alembic on first boot. No `trinity.db` is created. +- **Existing install with `trinity-data/trinity.db`:** `start.sh --hosted` writes + `DATABASE_URL=` (empty) to `.env`, which keeps SQLite, and prints a pointer to this + page on every run. It never points an install that has a `trinity.db` at an empty + PostgreSQL. The `postgres` container still starts, idle, because the compose file + needs `POSTGRES_PASSWORD` to render. Move the data with + [Migrating an existing deployment](#migrating-an-existing-deployment), then set + `DATABASE_URL` to the bundled server's URL. +- **Compose-only (no `start.sh`):** `POSTGRES_PASSWORD` is required; the file does + not render without it. Leave `DATABASE_URL` unset and the backend and scheduler + use the bundled server. Set `DATABASE_URL=` (empty) to keep an existing SQLite file. + +Nightly backups (#2216) on a hosted PostgreSQL install are `pg_dump -Fc` files +(`trinity-backup-YYYYMMDD.dump`) under `trinity-data/backups/`. + ## Switching to PostgreSQL (fresh deployment) For a new instance, PostgreSQL is a configuration change. The backend builds the diff --git a/docs/user-docs/guides/deploying/digitalocean.md b/docs/user-docs/guides/deploying/digitalocean.md index e6f3247fdd..4aa8303dfe 100644 --- a/docs/user-docs/guides/deploying/digitalocean.md +++ b/docs/user-docs/guides/deploying/digitalocean.md @@ -161,6 +161,7 @@ Deleting the Droplet also deletes everything on it, including your agents and Tr - **Creates an Ubuntu 24.04 Droplet** of size `s-4vcpu-8gb` in the region you chose. It attaches every SSH key already on your DigitalOcean account. - **Hands the Droplet a first-boot script.** The script clones the chosen release to `/opt/trinity` and runs `./scripts/deploy/start.sh --provision --cloud digitalocean --hosted --unattended`. That installs Docker, Caddy with a Let's Encrypt certificate for the IP address, and the host firewall, then installs Trinity from prebuilt images. The Marketplace 1-Click uses the same installer. - **Writes `/opt/trinity/.env`.** Your password goes in as `ADMIN_PASSWORD`, so the admin account exists at first boot. The file also gets `FRONTEND_PORT=8081` (Caddy owns ports 80 and 443 and forwards to the web UI), `FRONTEND_URL=https://` and `TRINITY_IMAGE_TAG=`. Finally, it records `TRINITY_INSTALL_SOURCE=do-script`, the marker that makes the **Secure this instance** step appear. +- **Runs Trinity on PostgreSQL.** The database is a bundled `trinity-postgres` container. `start.sh` generates its password into `.env` as `POSTGRES_PASSWORD`, next to the `DATABASE_URL` that points at it. - **Leaves Claude to the first sign-in.** It asks for no Claude credential. The **Connect Claude** step checks yours with Anthropic before saving it, which the installer could not do before the server existed. - **Waits for `https:///`** to answer with a valid certificate, for up to 15 minutes. The Droplet keeps its install log at `/var/log/trinity-install.log`. diff --git a/packer/aws/listing.md b/packer/aws/listing.md index 2433969bd2..d56b95fcef 100644 --- a/packer/aws/listing.md +++ b/packer/aws/listing.md @@ -72,7 +72,8 @@ Elastic IP keeps the address fixed. **Managing over SSH** (optional): `cd /opt/trinity`, then `docker compose -f docker-compose.hosted.yml ps` / `logs -f backend`, and -`sudo ./scripts/deploy/start.sh --hosted` to restart. Backups run nightly under +`sudo ./scripts/deploy/start.sh --hosted` to restart. The database is a bundled +PostgreSQL container (`trinity-postgres`). Backups run nightly under `/opt/trinity/trinity-data/backups/` on the same volume; take EBS snapshots as well. diff --git a/packer/digitalocean/listing.md b/packer/digitalocean/listing.md index e682421240..047d608aa7 100644 --- a/packer/digitalocean/listing.md +++ b/packer/digitalocean/listing.md @@ -145,6 +145,9 @@ Changing only one leaves the instance running one release's scripts against another's images, and it reports success either way. The version shown in the web interface reflects the images, so use it to confirm the upgrade landed. +The database is PostgreSQL, bundled in the Droplet as the `trinity-postgres` +container; its password is `POSTGRES_PASSWORD` in `/opt/trinity/.env`. + Database backups run nightly and before every migration, under `/opt/trinity/trinity-data/backups/`. They are on the same disk as the database, so they protect against corruption and mistakes, not against losing the Droplet diff --git a/packer/digitalocean/scripts/01-provision.sh b/packer/digitalocean/scripts/01-provision.sh index f8aaa8e36e..8a5d698251 100755 --- a/packer/digitalocean/scripts/01-provision.sh +++ b/packer/digitalocean/scripts/01-provision.sh @@ -100,10 +100,12 @@ done docker pull "ghcr.io/abilityai/trinity-agent-base:${TRINITY_IMAGE_TAG}" docker tag "ghcr.io/abilityai/trinity-agent-base:${TRINITY_IMAGE_TAG}" trinity-agent-base:latest -# Third-party images docker-compose.hosted.yml pulls that are not ours. +# Third-party images docker-compose.hosted.yml pulls that are not ours +# (postgres is the hosted database, #3159). docker pull redis:7-alpine docker pull timberio/vector:0.43.1-alpine docker pull alpine:3.20 +docker pull postgres:16-alpine # The OTel collector is NOT profile-gated in docker-compose.hosted.yml — unlike # cloudflared, which is correctly skippable under `profiles: [tunnel]` — so diff --git a/scripts/deploy/start.sh b/scripts/deploy/start.sh index 12357a05c8..01c8212a65 100755 --- a/scripts/deploy/start.sh +++ b/scripts/deploy/start.sh @@ -1333,6 +1333,53 @@ if [ "$_log_src" = "file" ]; then fi # ----------------------------------------------------------------------------- +# --- Hosted database: bundled PostgreSQL (#3159, HOST-022) ------------------ +# SQLite reached end-of-support on 2026-09-01 (#1278). A fresh hosted install +# gets the bundled `postgres` service; an install that already has +# /data/trinity.db stays on SQLite, pinned by an explicit empty DATABASE_URL +# (the hosted compose default treats unset and empty differently). An existing +# DATABASE_URL line, or one in the shell, is a decision already made and is +# never touched. POSTGRES_PASSWORD is generated either way because the hosted +# compose file does not render without it; on a SQLite install the postgres +# container runs idle. Called after the data-switch guard so a refused run +# writes nothing, and before `compose pull`, which renders the file. +ensure_hosted_database() { + [ "$HOSTED" = "1" ] || return 0 + local data_path + data_path="${TRINITY_DATA_PATH:-$(env_value TRINITY_DATA_PATH)}" + data_path="${data_path:-./trinity-data}" + + if [ -z "$(env_value POSTGRES_PASSWORD)" ]; then + # The postgres image applies the password only when it initialises an + # empty volume, so a new one would lock the backend out of its data. + if docker volume inspect "$(compose_project_name)_postgres-data" >/dev/null 2>&1; then + echo "ERROR: the PostgreSQL volume already exists but POSTGRES_PASSWORD is missing from .env." >&2 + echo " Restore the original password to .env; a new one cannot open the existing data." >&2 + return 1 + fi + set_env_key POSTGRES_PASSWORD "$(openssl rand -hex 24)" + echo "Auto-generated POSTGRES_PASSWORD" + fi + + if [ -z "${DATABASE_URL+x}" ] \ + && ! grep -qE '^[[:space:]]*(export[[:space:]]+)?DATABASE_URL=' .env 2>/dev/null; then + if [ -f "${data_path}/trinity.db" ]; then + set_env_key DATABASE_URL "" + else + set_env_key DATABASE_URL "postgresql://trinity:$(env_value POSTGRES_PASSWORD)@postgres:5432/trinity" + echo "Fresh install: database is the bundled PostgreSQL (DATABASE_URL written to .env)." + fi + fi + + if [ -z "${DATABASE_URL-$(env_value DATABASE_URL)}" ] && [ -f "${data_path}/trinity.db" ]; then + echo "⚠️ This install runs on SQLite (${data_path}/trinity.db), which reached end-of-support" + echo " on 2026-09-01. It stays on SQLite. To move to PostgreSQL, see" + echo " docs/migrations/SQLITE_TO_POSTGRES.md" + fi +} + +ensure_hosted_database + if [ "$HOSTED" = "1" ]; then echo "Pulling platform images (tag: ${TRINITY_IMAGE_TAG})..." # Tailored fatal, matching the agent-base pull one screen above. Bare, this diff --git a/scripts/deploy/trinity-do-create.sh b/scripts/deploy/trinity-do-create.sh index e652e2b140..88055ef5f6 100755 --- a/scripts/deploy/trinity-do-create.sh +++ b/scripts/deploy/trinity-do-create.sh @@ -26,7 +26,7 @@ SIZE='s-4vcpu-8gb' # 4 vCPU / 8 GB, $48/month — Trinity's recommended siz IMAGE='ubuntu-24-04-x64' DEFAULT_REGION='fra1' DEFAULT_NAME='trinity' -TRINITY_IMAGE_TAG="${TRINITY_IMAGE_TAG:-v0.9.5}" +TRINITY_IMAGE_TAG="${TRINITY_IMAGE_TAG:-v1.0.0-aws.1}" fail() { printf '\n%s\n\n' "$1" >&2; exit 1; } ask() { printf '%s' "$1" >&2; } diff --git a/tests/unit/test_2280_hosted_compose_parity.py b/tests/unit/test_2280_hosted_compose_parity.py index 5762cd14a8..78c8b15d4b 100644 --- a/tests/unit/test_2280_hosted_compose_parity.py +++ b/tests/unit/test_2280_hosted_compose_parity.py @@ -19,7 +19,10 @@ hosted == prod, MINUS every `build:` block, PLUS a GHCR `image:` for the four images Trinity builds. Nothing else may differ — except the three - ent#580 environment lines in ``_HOSTED_ONLY_ENV``, exact strings. + ent#580 environment lines and the #3159 DATABASE_URL lines in + ``_HOSTED_ONLY_ENV`` (exact strings), and the #3159 bundled PostgreSQL: + the ``postgres`` service, its volume and the backend/scheduler + ``depends_on`` edges to it (``_HOSTED_ONLY_SERVICES`` and friends). Deliberately compares the RAW yaml rather than ``docker compose config`` output: the raw form still contains the unexpanded ``${VAR:-default}`` strings, so a @@ -68,14 +71,22 @@ # hole for source builds with nothing gained. Exact strings, per service: # (entries only prod carries, entries only hosted carries). Each must still be # present, so a stale allowlist fails rather than silently widening. +_PROD_DB_URL = "DATABASE_URL=${DATABASE_URL:-}" +_HOSTED_DB_URL = "DATABASE_URL=${DATABASE_URL-postgresql://trinity:${POSTGRES_PASSWORD}@postgres:5432/trinity}" + _HOSTED_ONLY_ENV = { "backend": ( - ["ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set in .env (admin login + MCP auth)}"], + [ + "ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set in .env (admin login + MCP auth)}", + _PROD_DB_URL, + ], [ "ADMIN_PASSWORD=${ADMIN_PASSWORD?ADMIN_PASSWORD must be set in .env (admin login + MCP auth)}", "ADMIN_PASSWORD_SOURCE=${ADMIN_PASSWORD_SOURCE:-unset}", + _HOSTED_DB_URL, ], ), + "scheduler": ([_PROD_DB_URL], [_HOSTED_DB_URL]), "mcp-server": ( ["TRINITY_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set in .env}"], ["TRINITY_PASSWORD=${ADMIN_PASSWORD?ADMIN_PASSWORD must be set in .env}"], @@ -83,6 +94,14 @@ } +# #3159: the bundled PostgreSQL is hosted-only. prod leaves the database to the +# operator; every one-click channel runs the hosted file. Allowlisted by name so +# anything else added to one file alone still fails. +_HOSTED_ONLY_SERVICES = {"postgres"} +_HOSTED_ONLY_VOLUMES = {"postgres-data"} +_HOSTED_ONLY_DEPENDS = {"backend": "postgres", "scheduler": "postgres"} + + def _without(env: list, entries: list, where: str) -> list: missing = [e for e in entries if e not in env] assert not missing, ( @@ -123,7 +142,8 @@ def test_hosted_has_no_build_blocks(hosted: dict) -> None: def test_same_service_set(prod: dict, hosted: dict) -> None: - assert set(hosted["services"]) == set(prod["services"]), ( + assert _HOSTED_ONLY_SERVICES <= set(hosted["services"]), "stale _HOSTED_ONLY_SERVICES" + assert set(hosted["services"]) - _HOSTED_ONLY_SERVICES == set(prod["services"]), ( "hosted and prod describe different service sets — a service added to " "one file and not the other is the #1039/#1707 packaging gap." ) @@ -183,6 +203,12 @@ def test_service_parity_wholesale(prod: dict, hosted: dict) -> None: prod_only, hosted_only = _HOSTED_ONLY_ENV[name] prod_cmp["environment"] = _without(prod_cmp.get("environment") or [], prod_only, f"prod {name}") hosted_cmp["environment"] = _without(hosted_cmp.get("environment") or [], hosted_only, f"hosted {name}") + if name in _HOSTED_ONLY_DEPENDS: + dep = _HOSTED_ONLY_DEPENDS[name] + hosted_deps = dict(hosted_cmp.get("depends_on") or {}) + assert dep in hosted_deps, f"hosted {name}: allowlisted depends_on '{dep}' not found" + hosted_deps.pop(dep) + hosted_cmp["depends_on"] = hosted_deps if prod_cmp != hosted_cmp: differing = sorted( set(prod_cmp) ^ set(hosted_cmp) @@ -197,7 +223,12 @@ def test_service_parity_wholesale(prod: dict, hosted: dict) -> None: def test_top_level_parity(prod: dict, hosted: dict) -> None: - for key in ("volumes", "networks"): + hosted_volumes = dict(hosted.get("volumes") or {}) + assert _HOSTED_ONLY_VOLUMES <= set(hosted_volumes), "stale _HOSTED_ONLY_VOLUMES" + for v in _HOSTED_ONLY_VOLUMES: + hosted_volumes.pop(v) + assert hosted_volumes == prod.get("volumes"), "top-level volumes differ between prod and hosted" + for key in ("networks",): assert hosted.get(key) == prod.get(key), ( f"top-level '{key}' differs between prod and hosted — named volumes " f"and the two-network isolation (#589) must be identical or a hosted " diff --git a/tests/unit/test_2528_compose_file_sets.py b/tests/unit/test_2528_compose_file_sets.py index ea9a8e9d58..bb5a95663a 100644 --- a/tests/unit/test_2528_compose_file_sets.py +++ b/tests/unit/test_2528_compose_file_sets.py @@ -315,6 +315,7 @@ def test_supported_file_set_renders(file_set: tuple[str, ...]) -> None: "ADMIN_PASSWORD": secrets.token_urlsafe(18) + "Aa1!", "REDIS_PASSWORD": secrets.token_hex(24), "REDIS_BACKEND_PASSWORD": secrets.token_hex(24), + "POSTGRES_PASSWORD": secrets.token_hex(24), # hosted (#3159) }, ) assert proc.returncode == 0, f"{_flags(file_set)} does not render:\n{proc.stderr}" diff --git a/tests/unit/test_3159_hosted_postgres.py b/tests/unit/test_3159_hosted_postgres.py new file mode 100644 index 0000000000..dc11743edf --- /dev/null +++ b/tests/unit/test_3159_hosted_postgres.py @@ -0,0 +1,228 @@ +"""Hosted installs start on a bundled PostgreSQL (#3159, HOST-022). + +SQLite reached end-of-support on 2026-09-01 (#1278). Every one-click channel +(DigitalOcean, AWS, Vultr, compose-only templates) converges on +``docker-compose.hosted.yml`` + ``start.sh --hosted``, so the fix and its guard +live there. + +The start.sh cases execute the shipped ``ensure_hosted_database`` function +under bash with ``docker`` stubbed; the compose cases read the raw YAML so the +unexpanded ``${VAR...}`` forms are what is checked. +""" +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml") + +_ROOT = Path(__file__).resolve().parents[2] +_HOSTED = _ROOT / "docker-compose.hosted.yml" +_START = _ROOT / "scripts" / "deploy" / "start.sh" +_ENV_FILE_SH = _ROOT / "scripts" / "deploy" / "env-file.sh" +_PROVISION = _ROOT / "packer" / "digitalocean" / "scripts" / "01-provision.sh" + +_PG_URL = "postgresql://trinity:${POSTGRES_PASSWORD}@postgres:5432/trinity" +_DB_URL_ENTRY = "DATABASE_URL=${DATABASE_URL-" + _PG_URL + "}" + + +@pytest.fixture(scope="module") +def hosted() -> dict: + return yaml.safe_load(_HOSTED.read_text(encoding="utf-8")) + + +# --- compose ---------------------------------------------------------------- + +def test_postgres_service_shape(hosted: dict) -> None: + pg = hosted["services"]["postgres"] + assert pg["image"] == "postgres:16-alpine" + assert pg["networks"] == ["trinity-platform"], ( + "postgres must sit on trinity-platform-network only — agents never reach it (#589)" + ) + assert "pg_isready" in str(pg["healthcheck"]["test"]) + assert pg["logging"] == hosted["services"]["redis"]["logging"], "use *default-logging (#1871)" + assert "postgres-data:/var/lib/postgresql/data" in pg["volumes"] + assert "postgres-data" in hosted["volumes"] + assert "no-new-privileges:true" in pg["security_opt"] + assert "ports" not in pg, "postgres must not publish a host port" + + +def test_postgres_password_is_required(hosted: dict) -> None: + env = hosted["services"]["postgres"]["environment"] + pw = env["POSTGRES_PASSWORD"] if isinstance(env, dict) else next( + e.split("=", 1)[1] for e in env if e.startswith("POSTGRES_PASSWORD=") + ) + assert pw.startswith("${POSTGRES_PASSWORD:?"), ( + "compose-only consumers (#2283) must fail to render without POSTGRES_PASSWORD" + ) + + +@pytest.mark.parametrize("service", ["backend", "scheduler"]) +def test_waits_for_postgres_healthy(hosted: dict, service: str) -> None: + dep = hosted["services"][service]["depends_on"]["postgres"] + assert dep == {"condition": "service_healthy"} + + +@pytest.mark.parametrize("service", ["backend", "scheduler"]) +def test_database_url_defaults_to_bundled_postgres(hosted: dict, service: str) -> None: + """`-` (no colon): unset → bundled Postgres, set-but-empty → SQLite. + + start.sh writes `DATABASE_URL=` for an existing SQLite install, so a `:-` + default would flip that install onto an empty database.""" + env = hosted["services"][service]["environment"] + assert _DB_URL_ENTRY in env, f"{service} DATABASE_URL must be {_DB_URL_ENTRY!r}" + + +# --- packer ----------------------------------------------------------------- + +def test_snapshot_prepulls_postgres(hosted: dict) -> None: + image = hosted["services"]["postgres"]["image"] + assert re.search(rf"^docker pull {re.escape(image)}$", _PROVISION.read_text(), re.MULTILINE), ( + f"01-provision.sh must pre-pull {image} so first boot pulls nothing" + ) + + +# --- start.sh --------------------------------------------------------------- + +def _extract(func: str) -> str: + m = re.search(rf"^{func}\(\) \{{\n.*?^\}}$", _START.read_text(), re.MULTILINE | re.DOTALL) + assert m, f"start.sh no longer defines a top-level `{func}()`" + return m.group(0) + + +def _run(tmp_path: Path, *, hosted: str = "1", db_file: bool = False, pg_volume: bool = False, + env_lines: str = "", extra_env: dict | None = None) -> subprocess.CompletedProcess: + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + docker = bin_dir / "docker" + docker.write_text( + "#!/bin/bash\n" + 'if [ "$1" = "volume" ] && [ "$2" = "inspect" ]; then exit "${SHIM_VOLUME_RC:-1}"; fi\n' + "exit 1\n" + ) + docker.chmod(0o755) + data = tmp_path / "trinity-data" + data.mkdir(exist_ok=True) + if db_file: + (data / "trinity.db").write_bytes(b"SQLite format 3\x00") + env_file = tmp_path / ".env" + if not env_file.exists(): + env_file.write_text(env_lines) + script = "\n".join([ + "set -e", + f". '{_ENV_FILE_SH}'", + _extract("env_value"), + _extract("compose_project_name"), + _extract("ensure_hosted_database"), + f"HOSTED={hosted}", + "ensure_hosted_database", + ]) + env = { + "PATH": f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin", + "SHIM_VOLUME_RC": "0" if pg_volume else "1", + } + env.update(extra_env or {}) + return subprocess.run(["bash", "-c", script], cwd=tmp_path, capture_output=True, text=True, env=env) + + +def _env(tmp_path: Path) -> dict: + out = {} + for line in (tmp_path / ".env").read_text().splitlines(): + if "=" in line and not line.startswith("#"): + k, v = line.split("=", 1) + out[k] = v + return out + + +_BASH = pytest.mark.skipif(shutil.which("bash") is None, reason="bash required") + + +@_BASH +def test_fresh_install_gets_postgres(tmp_path: Path) -> None: + proc = _run(tmp_path) + assert proc.returncode == 0, proc.stderr + env = _env(tmp_path) + assert re.fullmatch(r"[0-9a-f]{48}", env["POSTGRES_PASSWORD"]) + assert env["DATABASE_URL"] == f"postgresql://trinity:{env['POSTGRES_PASSWORD']}@postgres:5432/trinity" + + +@_BASH +def test_rerun_is_idempotent(tmp_path: Path) -> None: + assert _run(tmp_path).returncode == 0 + first = (tmp_path / ".env").read_text() + proc = _run(tmp_path) + assert proc.returncode == 0, proc.stderr + assert (tmp_path / ".env").read_text() == first, "a rerun must not regenerate the password or URL" + + +@_BASH +def test_existing_sqlite_install_stays_on_sqlite(tmp_path: Path) -> None: + proc = _run(tmp_path, db_file=True, env_lines="REDIS_PASSWORD=x\n") + assert proc.returncode == 0, proc.stderr + env = _env(tmp_path) + assert env["DATABASE_URL"] == "", "an existing trinity.db must pin SQLite with an explicit empty DATABASE_URL" + assert env["POSTGRES_PASSWORD"], "the compose file still needs a password to render" + assert "docs/migrations/SQLITE_TO_POSTGRES.md" in proc.stdout + # and stays there on every later update + proc = _run(tmp_path, db_file=True) + assert proc.returncode == 0, proc.stderr + assert _env(tmp_path)["DATABASE_URL"] == "" + assert "docs/migrations/SQLITE_TO_POSTGRES.md" in proc.stdout + + +@_BASH +def test_operator_database_url_is_kept(tmp_path: Path) -> None: + url = "postgresql://u:p@db.example.com:5432/trinity" + proc = _run(tmp_path, db_file=True, env_lines=f"DATABASE_URL={url}\n") + assert proc.returncode == 0, proc.stderr + assert _env(tmp_path)["DATABASE_URL"] == url + assert "SQLITE_TO_POSTGRES" not in proc.stdout + + +@_BASH +def test_shell_database_url_counts_as_decided(tmp_path: Path) -> None: + proc = _run(tmp_path, extra_env={"DATABASE_URL": ""}) + assert proc.returncode == 0, proc.stderr + assert "DATABASE_URL" not in _env(tmp_path) + + +@_BASH +def test_populated_volume_without_password_refuses(tmp_path: Path) -> None: + proc = _run(tmp_path, pg_volume=True) + assert proc.returncode != 0 + assert "POSTGRES_PASSWORD" in proc.stderr + assert "POSTGRES_PASSWORD" not in _env(tmp_path) + + +@_BASH +def test_dev_mode_is_untouched(tmp_path: Path) -> None: + proc = _run(tmp_path, hosted="0") + assert proc.returncode == 0, proc.stderr + assert _env(tmp_path) == {} + + +def test_called_after_the_data_switch_guard_and_before_pull() -> None: + """A refused run must write nothing, and `compose pull` renders the file.""" + text = _START.read_text() + call = text.index("\nensure_hosted_database\n") + assert text.index("# --- Refuse a silent data switch") < call + assert call < text.index('docker compose "${COMPOSE_FILES[@]}" pull') + + +def test_backend_pg_dump_can_dump_the_hosted_server(hosted: dict) -> None: + """Nightly backups (#2216) run pg_dump in the backend image; pg_dump dumps + servers up to its own major version only.""" + server = int(re.search(r"postgres:(\d+)", hosted["services"]["postgres"]["image"]).group(1)) + m = re.search(r"postgresql-client-(\d+)", (_ROOT / "docker" / "backend" / "Dockerfile").read_text()) + assert m and int(m.group(1)) >= server, "backend pg_dump is older than the hosted PostgreSQL server" + + +def test_env_example_keeps_database_url_commented() -> None: + """start.sh copies .env.example to .env on a new install; an uncommented + DATABASE_URL line there would read as a decision and skip PostgreSQL.""" + text = (_ROOT / ".env.example").read_text() + assert not re.search(r"^\s*(export\s+)?DATABASE_URL=", text, re.MULTILINE) diff --git a/tests/unit/test_ent580_marketplace_admin_claim.py b/tests/unit/test_ent580_marketplace_admin_claim.py index 856b6ba4ac..ef89fa3810 100644 --- a/tests/unit/test_ent580_marketplace_admin_claim.py +++ b/tests/unit/test_ent580_marketplace_admin_claim.py @@ -291,7 +291,7 @@ def _render(compose: str, tmp_path: Path, dotenv_text: str): redis = secrets.token_hex(24) base_env = {k: v for k, v in os.environ.items() if k in ("PATH", "HOME", "DOCKER_HOST", "DOCKER_CONFIG")} dotenv = tmp_path / "claim.env" - dotenv.write_text(dotenv_text + f"REDIS_PASSWORD={redis}\nREDIS_BACKEND_PASSWORD={redis}\n") + dotenv.write_text(dotenv_text + f"REDIS_PASSWORD={redis}\nREDIS_BACKEND_PASSWORD={redis}\nPOSTGRES_PASSWORD={redis}\n") return subprocess.run( ["docker", "compose", "--env-file", str(dotenv), "-f", compose, "config"], cwd=_ROOT, capture_output=True, text=True, timeout=120, env=base_env,