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: 14 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.git
.github
**/node_modules
**/.next
**/.turbo
**/dist
**/__pycache__
**/.pytest_cache
services/olas-adapter/.venv
playwright-report
test-results
.env
.env.*
!.env.example
90 changes: 90 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: Deploy

on:
workflow_dispatch:
inputs:
environment:
description: Deployment target
type: choice
options: [preview, production]
default: preview
required: true

permissions:
contents: read
packages: write

jobs:
verify:
name: Verify release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.34.5
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm format:check
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test

publish:
name: Publish container images
needs: verify
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
if: ${{ inputs.environment == 'preview' || github.ref == 'refs/heads/main' }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}/synesis
tags: type=sha
- uses: docker/build-push-action@v6
with:
context: .
file: deploy/Dockerfile.node
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- uses: docker/build-push-action@v6
with:
context: .
file: deploy/Dockerfile.olas
push: true
tags: ghcr.io/${{ github.repository }}/synesis-olas:${{ github.sha }}

deploy-web:
name: Deploy web (optional Vercel)
needs: [verify, publish]
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- name: Deploy when Vercel secrets are configured
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
run: |
if [ -z "$VERCEL_TOKEN" ] || [ -z "$VERCEL_ORG_ID" ] || [ -z "$VERCEL_PROJECT_ID" ]; then
echo "Vercel secrets are not configured; container images were published successfully."
exit 0
fi
corepack enable
corepack prepare pnpm@10.34.5 --activate
pnpm install --frozen-lockfile
pnpm dlx vercel@latest pull --yes --environment=${{ inputs.environment }} --token="$VERCEL_TOKEN"
pnpm dlx vercel@latest build --token="$VERCEL_TOKEN"
pnpm dlx vercel@latest deploy --prebuilt --token="$VERCEL_TOKEN"
12 changes: 12 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Safe local defaults. Live mode requires rotated secrets and the explicit acknowledgement.
SYNESIS_MODE=demo
SYNESIS_LIVE_ACKNOWLEDGED=
POSTGRES_USER=synesis
POSTGRES_PASSWORD=synesis
POSTGRES_DB=synesis
SYNESIS_WEB_PORT=3000
SYNESIS_API_PORT=4000
SYNESIS_WEB_ORIGINS=http://localhost:3000
NEXT_PUBLIC_API_ORIGIN=http://localhost:4000
SYNESIS_SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
OLAS_ADAPTER_INTERNAL_TOKEN=synesis-demo-internal-token-change-me
24 changes: 24 additions & 0 deletions deploy/Dockerfile.node
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
FROM node:24-bookworm-slim AS build

WORKDIR /workspace
RUN corepack enable

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.json ./
COPY apps ./apps
COPY packages ./packages
RUN pnpm install --frozen-lockfile
RUN pnpm build

FROM node:24-bookworm-slim AS runtime

WORKDIR /workspace
RUN corepack enable
COPY --from=build /workspace/package.json /workspace/pnpm-lock.yaml /workspace/pnpm-workspace.yaml /workspace/turbo.json ./
COPY --from=build /workspace/node_modules ./node_modules
COPY --from=build /workspace/apps ./apps
COPY --from=build /workspace/packages ./packages
COPY deploy/entrypoint.sh /usr/local/bin/synesis-entrypoint
RUN chmod +x /usr/local/bin/synesis-entrypoint

ENV NODE_ENV=production
ENTRYPOINT ["/usr/local/bin/synesis-entrypoint"]
13 changes: 13 additions & 0 deletions deploy/Dockerfile.olas
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM ghcr.io/astral-sh/uv:0.8.17-python3.11-bookworm-slim

WORKDIR /service
COPY services/olas-adapter/pyproject.toml services/olas-adapter/uv.lock ./
RUN uv sync --locked --no-dev
COPY services/olas-adapter/src ./src
COPY deploy/entrypoint.sh /usr/local/bin/synesis-entrypoint
RUN chmod +x /usr/local/bin/synesis-entrypoint

ENV PATH="/service/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
ENTRYPOINT ["/usr/local/bin/synesis-entrypoint"]
CMD ["uvicorn", "synesis_olas.main:app", "--host", "0.0.0.0", "--port", "8100"]
45 changes: 39 additions & 6 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,42 @@
# Deployment

Synesis will be deployed as separate web, API, worker, and Olas adapter services,
with managed PostgreSQL and Redis. Container images, Compose orchestration, and
production manifests are tracked by issue #28 so this scaffold does not create a
false deployment path before the service contracts exist.
Synesis ships as separate web, API, worker, and Olas adapter containers with
PostgreSQL and Redis on a private network. The Compose file is the canonical
local and preview topology; production should use managed PostgreSQL/Redis and
an encrypted secret store with the same service contracts.

The workspace can currently run without PostgreSQL or Redis: all service health
checks and quality gates are deterministic and side-effect free.
## Local stack

```powershell
Copy-Item deploy/.env.example .env
docker compose -f deploy/compose.yaml up --build
```

Open `http://localhost:3000`. The API health endpoint is available at
`http://localhost:4000/health`. Database migrations run as a one-shot service
before the API and worker become healthy. Stop the stack with
`docker compose -f deploy/compose.yaml down`; add `-v` only when intentionally
removing the local Postgres and Redis volumes.

The default `SYNESIS_MODE=demo` cannot broadcast value movement. Every service
uses `deploy/entrypoint.sh`; changing to `SYNESIS_MODE=live` fails closed unless
`SYNESIS_LIVE_ACKNOWLEDGED=I_UNDERSTAND_LIVE_VALUE_MOVEMENT` is explicitly set.
Live mode additionally requires rotated adapter tokens, a real organization
wallet, and a private Base RPC as validated by the adapter configuration.

## Hosted environments

- Preview uses isolated databases, Redis, adapter tokens, and KeeperHub
credentials. It must never receive production secrets or a production wallet.
- Production runs the API, worker, and adapter on private service networking;
expose only web and the API health/read endpoints through the edge. Configure
encrypted secrets, TLS, automated Postgres backups/PITR, Redis persistence,
health checks, and image rollback to the previous immutable digest.
- The `Deploy` workflow is manual. Its `production` environment is intentionally
protected by GitHub environment reviewers; configure the required approval
rule before adding a Vercel or container-host token. CI must pass before a
production dispatch.

For a rollback, redeploy the previous image digest, run the matching migration
rollback only when the migration is backward-compatible, and verify `/health`,
queue lag, and proof verification before reopening traffic.
165 changes: 165 additions & 0 deletions deploy/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
name: synesis

services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-synesis}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-synesis}
POSTGRES_DB: ${POSTGRES_DB:-synesis}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 12
restart: unless-stopped

redis:
image: redis:7.4-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 12
restart: unless-stopped

migrate:
build:
context: ..
dockerfile: deploy/Dockerfile.node
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-synesis}:${POSTGRES_PASSWORD:-synesis}@postgres:5432/${POSTGRES_DB:-synesis}
SYNESIS_MODE: ${SYNESIS_MODE:-demo}
SYNESIS_LIVE_ACKNOWLEDGED: ${SYNESIS_LIVE_ACKNOWLEDGED:-}
command: ["pnpm", "--filter", "@synesis/database", "db:migrate"]
depends_on:
postgres:
condition: service_healthy
restart: "no"

api:
build:
context: ..
dockerfile: deploy/Dockerfile.node
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-synesis}:${POSTGRES_PASSWORD:-synesis}@postgres:5432/${POSTGRES_DB:-synesis}
REDIS_URL: redis://redis:6379
SYNESIS_API_PORT: 4000
SYNESIS_MODE: ${SYNESIS_MODE:-demo}
SYNESIS_LIVE_ACKNOWLEDGED: ${SYNESIS_LIVE_ACKNOWLEDGED:-}
SYNESIS_WEB_ORIGINS: ${SYNESIS_WEB_ORIGINS:-http://localhost:3000}
SYNESIS_SECRET_ENCRYPTION_KEY: ${SYNESIS_SECRET_ENCRYPTION_KEY:-0000000000000000000000000000000000000000000000000000000000000000}
OLAS_ADAPTER_URL: http://olas-adapter:8100
OLAS_ADAPTER_INTERNAL_TOKEN: ${OLAS_ADAPTER_INTERNAL_TOKEN:-synesis-demo-internal-token-change-me}
command: ["pnpm", "--filter", "@synesis/api", "start"]
ports:
- "${SYNESIS_API_PORT:-4000}:4000"
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:4000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 12
restart: unless-stopped

worker:
build:
context: ..
dockerfile: deploy/Dockerfile.node
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-synesis}:${POSTGRES_PASSWORD:-synesis}@postgres:5432/${POSTGRES_DB:-synesis}
REDIS_URL: redis://redis:6379
SYNESIS_MODE: ${SYNESIS_MODE:-demo}
SYNESIS_LIVE_ACKNOWLEDGED: ${SYNESIS_LIVE_ACKNOWLEDGED:-}
command: ["pnpm", "--filter", "@synesis/worker", "start"]
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
restart: unless-stopped

olas-adapter:
build:
context: ..
dockerfile: deploy/Dockerfile.olas
environment:
SYNESIS_MODE: ${SYNESIS_MODE:-demo}
SYNESIS_LIVE_ACKNOWLEDGED: ${SYNESIS_LIVE_ACKNOWLEDGED:-}
OLAS_ADAPTER_INTERNAL_TOKEN: ${OLAS_ADAPTER_INTERNAL_TOKEN:-synesis-demo-internal-token-change-me}
KEEPERHUB_GATEWAY_URL: http://api:4000/internal/v1/keeperhub/submit-call
expose:
- "8100"
depends_on:
api:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8100/health')",
]
interval: 10s
timeout: 5s
retries: 12
restart: unless-stopped

web:
build:
context: ..
dockerfile: deploy/Dockerfile.node
environment:
SYNESIS_MODE: ${SYNESIS_MODE:-demo}
SYNESIS_LIVE_ACKNOWLEDGED: ${SYNESIS_LIVE_ACKNOWLEDGED:-}
NEXT_PUBLIC_API_ORIGIN: ${NEXT_PUBLIC_API_ORIGIN:-http://localhost:4000}
command:
[
"pnpm",
"--filter",
"@synesis/web",
"exec",
"next",
"start",
"--hostname",
"0.0.0.0",
"--port",
"3000",
]
ports:
- "${SYNESIS_WEB_PORT:-3000}:3000"
depends_on:
api:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/app').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 12
restart: unless-stopped

volumes:
postgres-data:
redis-data:
10 changes: 10 additions & 0 deletions deploy/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/bin/sh
set -eu

if [ "${SYNESIS_MODE:-demo}" = "live" ] \
&& [ "${SYNESIS_LIVE_ACKNOWLEDGED:-}" != "I_UNDERSTAND_LIVE_VALUE_MOVEMENT" ]; then
echo "Refusing live start: set SYNESIS_LIVE_ACKNOWLEDGED=I_UNDERSTAND_LIVE_VALUE_MOVEMENT" >&2
exit 78
fi

exec "$@"
Loading
Loading