Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/container-security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.9.5
1.0.0-aws.1
70 changes: 60 additions & 10 deletions docker-compose.hosted.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -522,6 +534,8 @@ services:
# + deletes archived originals.
- trinity-logs:/data/logs
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
vector:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -716,6 +763,8 @@ services:
volumes:
- ${TRINITY_DATA_PATH:-./trinity-data}:/data
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
backend:
Expand Down Expand Up @@ -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

Expand Down
11 changes: 7 additions & 4 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/memory/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion docs/memory/requirements/infrastructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<data path>/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`

Expand Down
24 changes: 24 additions & 0 deletions docs/migrations/SQLITE_TO_POSTGRES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<password>@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
Expand Down
Loading
Loading