diff --git a/.env.production.example b/.env.production.example index b782403..3723518 100644 --- a/.env.production.example +++ b/.env.production.example @@ -3,15 +3,23 @@ APP_ORIGIN=https://involute.example.com POSTGRES_DB=involute POSTGRES_USER=involute -POSTGRES_PASSWORD=replace-with-a-long-random-password +POSTGRES_PASSWORD=replace-with-a-long-url-safe-random-password +DATABASE_URL=postgresql://involute:replace-with-a-long-url-safe-random-password@db:5432/involute?schema=public +POSTGRES_VOLUME_NAME=involute_postgres-prod-data + +SERVER_BIND_ADDRESS=127.0.0.1 +WEB_BIND_ADDRESS=127.0.0.1 +INVOLUTE_IMAGE_NAMESPACE=fakechris +INVOLUTE_IMAGE_TAG=latest AUTH_TOKEN=replace-with-a-long-random-token VIEWER_ASSERTION_SECRET=replace-with-a-long-random-secret SESSION_TTL_SECONDS=2592000 +REQUIRE_GOOGLE_OAUTH=true ADMIN_EMAIL_ALLOWLIST=you@example.com SEED_DATABASE=false -GOOGLE_OAUTH_CLIENT_ID= -GOOGLE_OAUTH_CLIENT_SECRET= +GOOGLE_OAUTH_CLIENT_ID=replace-with-google-client-id +GOOGLE_OAUTH_CLIENT_SECRET=replace-with-google-client-secret GOOGLE_OAUTH_REDIRECT_URI=https://involute.example.com/auth/google/callback diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 65b1fa8..8bb63ff 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,6 +40,8 @@ jobs: INVOLUTE_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.INVOLUTE_GOOGLE_OAUTH_CLIENT_ID }} INVOLUTE_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.INVOLUTE_GOOGLE_OAUTH_CLIENT_SECRET }} INVOLUTE_GOOGLE_OAUTH_REDIRECT_URI: ${{ secrets.INVOLUTE_GOOGLE_OAUTH_REDIRECT_URI }} + INVOLUTE_IMAGE_TAG: ${{ vars.INVOLUTE_IMAGE_TAG || 'latest' }} + INVOLUTE_REQUIRE_GOOGLE_OAUTH: ${{ vars.INVOLUTE_REQUIRE_GOOGLE_OAUTH || 'true' }} steps: - name: Validate required deployment secrets run: | @@ -68,6 +70,14 @@ jobs: exit 1 fi done + if [ "$INVOLUTE_REQUIRE_GOOGLE_OAUTH" = "true" ]; then + for var in INVOLUTE_GOOGLE_OAUTH_CLIENT_ID INVOLUTE_GOOGLE_OAUTH_CLIENT_SECRET INVOLUTE_GOOGLE_OAUTH_REDIRECT_URI; do + if [ -z "$(printenv "$var")" ]; then + echo "Missing required secret: $var" >&2 + exit 1 + fi + done + fi fi - name: Checkout @@ -119,6 +129,8 @@ jobs: "involute_google_oauth_client_id": os.environ.get("INVOLUTE_GOOGLE_OAUTH_CLIENT_ID", ""), "involute_google_oauth_client_secret": os.environ.get("INVOLUTE_GOOGLE_OAUTH_CLIENT_SECRET", ""), "involute_google_oauth_redirect_uri": os.environ.get("INVOLUTE_GOOGLE_OAUTH_REDIRECT_URI", ""), + "involute_image_tag": os.environ.get("INVOLUTE_IMAGE_TAG", "latest"), + "involute_require_google_oauth": os.environ.get("INVOLUTE_REQUIRE_GOOGLE_OAUTH", "true"), } } } diff --git a/.gitignore b/.gitignore index a95a6b5..2af2885 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ dist .env .env.production ops/ansible/inventory/hosts.yml +ops/ansible/group_vars/**/*.yml +ops/ansible/vault-password.txt .venv-ansible/ .DS_Store !packages/server/prisma/.env diff --git a/README.md b/README.md index 40fecc3..df6cd7c 100644 --- a/README.md +++ b/README.md @@ -148,19 +148,21 @@ INVOLUTE_IMAGE_NAMESPACE=fakechris INVOLUTE_IMAGE_TAG=latest pnpm compose:pull:u ## VPS deployment (fresh install) -This is the recommended first production path: one VPS, Docker Compose, Postgres, the Node API, the static web container, and Caddy terminating HTTPS on a single domain. +This is the recommended first production path: one VPS, Docker Compose, Postgres, the Node API, and the static web container from published Docker Hub images. Terminate HTTPS with either the host reverse proxy or the optional compose Caddy profile, but keep one env file: `.env.production`. Status: - the deployment files and automation are in place -- the Tailscale-only path has already been exercised -- the remaining production work is to validate the same stack on a public domain with real Google OAuth callback and one backup/restore drill +- production should run through `.env.production` and `docker-compose.prod.images.yml` +- deployments should use Ansible Vault for secrets, not hand-edited env files +- every deploy runs smoke checks for `/health`, `/auth/session`, and `/auth/google/start` Files involved: -- [`docker-compose.prod.yml`](./docker-compose.prod.yml) +- [`docker-compose.prod.images.yml`](./docker-compose.prod.images.yml) - [`Caddyfile`](./Caddyfile) - [`.env.production.example`](./.env.production.example) +- [`scripts/prod-smoke.sh`](./scripts/prod-smoke.sh) - [`scripts/postgres-backup.sh`](./scripts/postgres-backup.sh) Assumptions: @@ -181,8 +183,10 @@ cp .env.production.example .env.production APP_DOMAIN=involute.example.com APP_ORIGIN=https://involute.example.com POSTGRES_PASSWORD=... +DATABASE_URL=postgresql://involute:@db:5432/involute?schema=public AUTH_TOKEN=... VIEWER_ASSERTION_SECRET=... +REQUIRE_GOOGLE_OAUTH=true ADMIN_EMAIL_ALLOWLIST=you@example.com GOOGLE_OAUTH_CLIENT_ID=... GOOGLE_OAUTH_CLIENT_SECRET=... @@ -198,22 +202,24 @@ pnpm compose:prod:up 4. Smoke check it: ```bash -docker compose --env-file .env.production -f docker-compose.prod.yml ps -curl -I https://involute.example.com -curl https://involute.example.com/health +docker compose --env-file .env.production -f docker-compose.prod.images.yml ps +pnpm smoke:prod https://involute.example.com ``` 5. If you need to re-assert the first admin explicitly: ```bash -docker compose --env-file .env.production -f docker-compose.prod.yml run --rm \ +docker compose --env-file .env.production -f docker-compose.prod.images.yml run --rm \ --entrypoint /bin/sh server -lc \ 'pnpm --filter @turnkeyai/involute-server run admin:bootstrap you@example.com' ``` Operational notes: -- production compose keeps Postgres internal; only Caddy exposes `80/443` +- production compose passes `DATABASE_URL` directly to the API and migration containers; do not rely on ad-hoc URL concatenation +- use a URL-safe Postgres password so `DATABASE_URL` and `POSTGRES_PASSWORD` stay identical +- by default, `docker-compose.prod.images.yml` exposes API/web on `SERVER_BIND_ADDRESS:4200` and `WEB_BIND_ADDRESS:4201` for a host reverse proxy +- to let compose Caddy own `80/443`, run with the `caddy` profile and ensure host nginx/apache is not bound to those ports - `server-init` runs `prisma migrate deploy` before the API starts - `SEED_DATABASE` defaults to `false` in production; turn it on only for a fresh demo seed - the web container is the static production build, not the Vite dev server @@ -265,7 +271,17 @@ For the current Tailscale-only test phase, use: - `involute_bind_address: ` - `involute_app_origin: http://:4201` -When the public domain and OAuth are ready, switch the inventory to `production` and use [`docker-compose.prod.yml`](./docker-compose.prod.yml). +When the public domain and OAuth are ready, switch the inventory to `production` and use [`docker-compose.prod.images.yml`](./docker-compose.prod.images.yml). + +For local Ansible deploys, keep secrets in an encrypted vault file: + +```bash +cp ops/ansible/group_vars/all/vault.yml.example ops/ansible/group_vars/all/vault.yml +ansible-vault encrypt ops/ansible/group_vars/all/vault.yml +ANSIBLE_VAULT_PASSWORD_FILE=ops/ansible/vault-password.txt pnpm deploy:prod +``` + +`ops/ansible/group_vars/all/vault.yml` and `ops/ansible/vault-password.txt` are ignored by git. GitHub Actions can run the same deployment path from [`.github/workflows/deploy.yml`](./.github/workflows/deploy.yml). Configure these repository secrets before enabling it: @@ -278,7 +294,8 @@ GitHub Actions can run the same deployment path from [`.github/workflows/deploy. - `INVOLUTE_VIEWER_ASSERTION_SECRET` - `INVOLUTE_BIND_ADDRESS` for `tailscale` - `INVOLUTE_APP_DOMAIN` and `INVOLUTE_POSTGRES_PASSWORD` for `production` -- optional: `INVOLUTE_ADMIN_EMAIL_ALLOWLIST`, `INVOLUTE_GOOGLE_OAUTH_CLIENT_ID`, `INVOLUTE_GOOGLE_OAUTH_CLIENT_SECRET`, `INVOLUTE_GOOGLE_OAUTH_REDIRECT_URI` +- `INVOLUTE_GOOGLE_OAUTH_CLIENT_ID`, `INVOLUTE_GOOGLE_OAUTH_CLIENT_SECRET`, `INVOLUTE_GOOGLE_OAUTH_REDIRECT_URI` when `REQUIRE_GOOGLE_OAUTH=true` +- optional: `INVOLUTE_ADMIN_EMAIL_ALLOWLIST`, `INVOLUTE_IMAGE_TAG` Recommended repository variables: diff --git a/docker-compose.prod.images.yml b/docker-compose.prod.images.yml index 32c7344..02854d6 100644 --- a/docker-compose.prod.images.yml +++ b/docker-compose.prod.images.yml @@ -36,7 +36,7 @@ services: environment: ADMIN_EMAIL_ALLOWLIST: ${ADMIN_EMAIL_ALLOWLIST:-} GOOGLE_OAUTH_ADMIN_EMAILS: ${ADMIN_EMAIL_ALLOWLIST:-${GOOGLE_OAUTH_ADMIN_EMAILS:-}} - DATABASE_URL: postgresql://${POSTGRES_USER:-involute}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-involute}?schema=public + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env.production} SEED_DATABASE: ${SEED_DATABASE:-false} SEED_DEFAULT_ADMIN: "false" @@ -54,11 +54,12 @@ services: ALLOW_ADMIN_FALLBACK: "false" APP_ORIGIN: ${APP_ORIGIN:?Set APP_ORIGIN in .env.production} AUTH_TOKEN: ${AUTH_TOKEN:?Set AUTH_TOKEN in .env.production} - DATABASE_URL: postgresql://${POSTGRES_USER:-involute}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-involute}?schema=public + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env.production} GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-} GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-} GOOGLE_OAUTH_REDIRECT_URI: ${GOOGLE_OAUTH_REDIRECT_URI:-} PORT: 4200 + REQUIRE_GOOGLE_OAUTH: ${REQUIRE_GOOGLE_OAUTH:-true} SESSION_TTL_SECONDS: ${SESSION_TTL_SECONDS:-2592000} VIEWER_ASSERTION_SECRET: ${VIEWER_ASSERTION_SECRET:?Set VIEWER_ASSERTION_SECRET in .env.production} healthcheck: @@ -73,6 +74,8 @@ services: timeout: 5s retries: 20 start_period: 10s + ports: + - "${SERVER_BIND_ADDRESS:-127.0.0.1}:4200:4200" web: image: ${INVOLUTE_IMAGE_REGISTRY:-docker.io}/${INVOLUTE_IMAGE_NAMESPACE:-fakechris}/involute-web:${INVOLUTE_IMAGE_TAG:-latest} @@ -83,9 +86,12 @@ services: depends_on: server: condition: service_healthy + ports: + - "${WEB_BIND_ADDRESS:-127.0.0.1}:4201:4201" caddy: image: caddy:2.10-alpine + profiles: ["caddy"] restart: unless-stopped depends_on: server: @@ -112,7 +118,7 @@ services: condition: service_healthy environment: AUTH_TOKEN: ${AUTH_TOKEN:?Set AUTH_TOKEN in .env.production} - DATABASE_URL: postgresql://${POSTGRES_USER:-involute}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-involute}?schema=public + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env.production} INVOLUTE_CONFIG_PATH: /tmp/involute-config.json VIEWER_ASSERTION_SECRET: ${VIEWER_ASSERTION_SECRET:?Set VIEWER_ASSERTION_SECRET in .env.production} volumes: @@ -120,5 +126,8 @@ services: volumes: postgres-prod-data: + name: ${POSTGRES_VOLUME_NAME:-involute_postgres-prod-data} caddy-data: + name: ${CADDY_DATA_VOLUME_NAME:-involute_caddy-data} caddy-config: + name: ${CADDY_CONFIG_VOLUME_NAME:-involute_caddy-config} diff --git a/ops/ansible/group_vars/all/.gitkeep b/ops/ansible/group_vars/all/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ops/ansible/group_vars/all/.gitkeep @@ -0,0 +1 @@ + diff --git a/ops/ansible/group_vars/all/vault.yml.example b/ops/ansible/group_vars/all/vault.yml.example new file mode 100644 index 0000000..66b0c7c --- /dev/null +++ b/ops/ansible/group_vars/all/vault.yml.example @@ -0,0 +1,16 @@ +# Copy this file to vault.yml, replace values, then encrypt it: +# +# ansible-vault encrypt ops/ansible/group_vars/all/vault.yml +# +# Keep vault.yml and the vault password file out of git. + +involute_auth_token: replace-with-a-long-random-token +involute_viewer_assertion_secret: replace-with-a-long-random-secret +involute_admin_email_allowlist: you@example.com + +# Use a URL-safe password because DATABASE_URL is built from this value. +involute_postgres_password: replace-with-a-long-url-safe-random-password + +involute_google_oauth_client_id: replace-with-google-client-id +involute_google_oauth_client_secret: replace-with-google-client-secret +involute_google_oauth_redirect_uri: https://involute.example.com/auth/google/callback diff --git a/ops/ansible/inventory/hosts.yml.example b/ops/ansible/inventory/hosts.yml.example index f4b4360..e829ca5 100644 --- a/ops/ansible/inventory/hosts.yml.example +++ b/ops/ansible/inventory/hosts.yml.example @@ -1,12 +1,13 @@ all: hosts: - involute_tailscale: + involute_production: ansible_host: tailnet-host.example.ts.net ansible_user: root + involute_stack_profile: production involute_bind_address: 100.x.y.z - involute_stack_profile: tailscale - involute_app_origin: http://100.x.y.z:4201 - involute_seed_database: true - involute_admin_email_allowlist: "first-admin@example.com" - involute_auth_token: change-me - involute_viewer_assertion_secret: change-me + involute_app_domain: involute.example.com + involute_app_origin: https://involute.example.com + involute_smoke_base_url: https://involute.example.com + involute_seed_database: false + involute_require_google_oauth: true + # Put real secrets in ops/ansible/group_vars/all/vault.yml. diff --git a/ops/ansible/playbooks/deploy.yml b/ops/ansible/playbooks/deploy.yml index 2184499..05a079b 100644 --- a/ops/ansible/playbooks/deploy.yml +++ b/ops/ansible/playbooks/deploy.yml @@ -4,21 +4,34 @@ become: true vars: involute_default_deploy_path: /opt/involute - involute_default_stack_profile: tailscale - involute_default_seed_database: true + involute_default_stack_profile: production + involute_default_seed_database: false involute_compose_files: >- {{ - ['docker-compose.yml'] + ['docker-compose.images.yml'] if (involute_stack_profile | default(involute_default_stack_profile)) == 'tailscale' - else ['docker-compose.prod.yml'] + else ['docker-compose.prod.images.yml'] }} involute_compose_services: >- {{ ['db', 'server', 'web'] - if (involute_stack_profile | default(involute_default_stack_profile)) == 'tailscale' + if ( + (involute_stack_profile | default(involute_default_stack_profile)) == 'tailscale' + or not (involute_enable_caddy | default(false) | bool) + ) else [] }} involute_compose_file_args: "{{ involute_compose_files | map('regex_replace', '^', '-f ') | join(' ') }}" + involute_compose_env_file_args: "--env-file {{ involute_env_target }}" + involute_compose_profile_args: >- + {{ + '--profile caddy' + if ( + (involute_stack_profile | default(involute_default_stack_profile)) == 'production' + and (involute_enable_caddy | default(false) | bool) + ) + else '' + }} involute_env_target: >- {{ '.env' @@ -35,14 +48,15 @@ {{ 'http://' ~ (involute_bind_address | default('127.0.0.1')) ~ ':4200/health' if (involute_stack_profile | default(involute_default_stack_profile)) == 'tailscale' - else 'http://127.0.0.1/health' + else 'http://' ~ (involute_server_bind_address | default(involute_bind_address | default('127.0.0.1'))) ~ ':4200/health' }} involute_web_health_url: >- {{ 'http://' ~ (involute_bind_address | default('127.0.0.1')) ~ ':4201/' if (involute_stack_profile | default(involute_default_stack_profile)) == 'tailscale' - else 'http://127.0.0.1/' + else 'http://' ~ (involute_web_bind_address | default(involute_bind_address | default('127.0.0.1'))) ~ ':4201/' }} + involute_smoke_base_url: "{{ involute_app_origin }}" involute_rsync_excludes: - ".git" - "node_modules" @@ -64,6 +78,26 @@ - involute_viewer_assertion_secret | length > 0 - involute_app_origin is defined - involute_app_origin | length > 0 + - >- + ( + (involute_stack_profile | default(involute_default_stack_profile)) != 'production' + ) or ( + involute_postgres_password is defined + and involute_postgres_password | length > 0 + and (involute_postgres_password is match('^[A-Za-z0-9._~-]+$')) + ) + - >- + ( + (involute_stack_profile | default(involute_default_stack_profile)) != 'production' + or not (involute_require_google_oauth | default(true) | bool) + ) or ( + involute_google_oauth_client_id is defined + and involute_google_oauth_client_id | length > 0 + and involute_google_oauth_client_secret is defined + and involute_google_oauth_client_secret | length > 0 + and involute_google_oauth_redirect_uri is defined + and involute_google_oauth_redirect_uri | length > 0 + ) - >- ( (involute_stack_profile | default(involute_default_stack_profile)) != 'tailscale' @@ -78,6 +112,13 @@ or (involute_seed_default_admin | default(false) | bool) or (involute_allow_admin_fallback | default(false) | bool) ) + fail_msg: >- + Missing or invalid deploy variables. Production requires a URL-safe + involute_postgres_password matching ^[A-Za-z0-9._~-]+$ because it is + used in DATABASE_URL. Production also requires Google OAuth client ID, + client secret, and redirect URI when involute_require_google_oauth is + true. Tailscale deployments require involute_bind_address and either + an admin allowlist, seeded default admin, or admin fallback. - name: Ensure deploy directory exists ansible.builtin.file: @@ -106,9 +147,41 @@ dest: "{{ involute_deploy_path | default(involute_default_deploy_path) }}/{{ involute_env_target }}" mode: "0600" - - name: Stop existing stack + - name: Check for legacy source-build compose file + ansible.builtin.stat: + path: "{{ involute_deploy_path | default(involute_default_deploy_path) }}/docker-compose.yml" + register: involute_legacy_compose + + - name: Check for legacy production source-build compose file + ansible.builtin.stat: + path: "{{ involute_deploy_path | default(involute_default_deploy_path) }}/docker-compose.prod.yml" + register: involute_legacy_prod_compose + + - name: Stop legacy source-build stack if present ansible.builtin.shell: > docker compose + --env-file .env + -f docker-compose.yml + down --remove-orphans + args: + chdir: "{{ involute_deploy_path | default(involute_default_deploy_path) }}" + when: involute_legacy_compose.stat.exists + + - name: Stop legacy production source-build stack if present + ansible.builtin.shell: > + docker compose + --env-file .env.production + -f docker-compose.prod.yml + down --remove-orphans + args: + chdir: "{{ involute_deploy_path | default(involute_default_deploy_path) }}" + when: involute_legacy_prod_compose.stat.exists + + - name: Stop existing standardized stack + ansible.builtin.shell: > + docker compose + {{ involute_compose_env_file_args }} + {{ involute_compose_profile_args }} {{ involute_compose_file_args }} down --remove-orphans args: @@ -133,8 +206,10 @@ - name: Start stack ansible.builtin.shell: > docker compose + {{ involute_compose_env_file_args }} + {{ involute_compose_profile_args }} {{ involute_compose_file_args }} - up --build -d + up -d {{ involute_compose_services | join(' ') }} args: chdir: "{{ involute_deploy_path | default(involute_default_deploy_path) }}" @@ -159,9 +234,31 @@ args: chdir: "{{ involute_deploy_path | default(involute_default_deploy_path) }}" + - name: Verify auth session reports Google OAuth configured + ansible.builtin.uri: + url: "{{ involute_smoke_base_url }}/auth/session" + method: GET + return_content: true + status_code: + - 200 + - 401 + register: involute_auth_session_smoke + failed_when: not (involute_auth_session_smoke.json.googleOAuthConfigured | default(false) | bool) + + - name: Verify Google OAuth start redirects to Google + ansible.builtin.uri: + url: "{{ involute_smoke_base_url }}/auth/google/start" + method: GET + follow_redirects: none + status_code: 302 + register: involute_google_start_smoke + failed_when: "'accounts.google.com' not in (involute_google_start_smoke.location | default(''))" + - name: Show compose status ansible.builtin.shell: > docker compose + {{ involute_compose_env_file_args }} + {{ involute_compose_profile_args }} {{ involute_compose_file_args }} ps args: diff --git a/ops/ansible/templates/env.production.j2 b/ops/ansible/templates/env.production.j2 index ffa5d65..92ae7f1 100644 --- a/ops/ansible/templates/env.production.j2 +++ b/ops/ansible/templates/env.production.j2 @@ -4,10 +4,18 @@ APP_ORIGIN={{ involute_app_origin }} POSTGRES_DB={{ involute_postgres_db | default('involute') }} POSTGRES_USER={{ involute_postgres_user | default('involute') }} POSTGRES_PASSWORD={{ involute_postgres_password }} +DATABASE_URL={{ involute_database_url | default('postgresql://' ~ (involute_postgres_user | default('involute')) ~ ':' ~ involute_postgres_password ~ '@db:5432/' ~ (involute_postgres_db | default('involute')) ~ '?schema=public') }} + +SERVER_BIND_ADDRESS={{ involute_server_bind_address | default(involute_bind_address | default('127.0.0.1')) }} +WEB_BIND_ADDRESS={{ involute_web_bind_address | default(involute_bind_address | default('127.0.0.1')) }} +INVOLUTE_IMAGE_REGISTRY={{ involute_image_registry | default('docker.io') }} +INVOLUTE_IMAGE_NAMESPACE={{ involute_image_namespace | default('fakechris') }} +INVOLUTE_IMAGE_TAG={{ involute_image_tag | default('latest') }} AUTH_TOKEN={{ involute_auth_token }} VIEWER_ASSERTION_SECRET={{ involute_viewer_assertion_secret }} SESSION_TTL_SECONDS={{ involute_session_ttl_seconds | default(2592000) }} +REQUIRE_GOOGLE_OAUTH={{ 'true' if involute_require_google_oauth | default(true) | bool else 'false' }} ADMIN_EMAIL_ALLOWLIST={{ involute_admin_email_allowlist | default('') }} SEED_DATABASE={{ 'true' if involute_seed_database | bool else 'false' }} diff --git a/package.json b/package.json index 1ca53a2..58394ca 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,10 @@ "deploy:bootstrap": "sh scripts/ansible-playbook.sh ops/ansible/playbooks/bootstrap-host.yml", "deploy:prod": "INVOLUTE_STACK_PROFILE=production sh scripts/ansible-playbook.sh ops/ansible/playbooks/deploy.yml", "deploy:tailscale": "INVOLUTE_STACK_PROFILE=tailscale sh scripts/ansible-playbook.sh ops/ansible/playbooks/deploy.yml", + "smoke:prod": "scripts/prod-smoke.sh", "compose:up": "docker compose up --build -d db server web", - "compose:prod:build": "docker compose --env-file .env.production -f docker-compose.prod.yml build", - "compose:prod:down": "docker compose --env-file .env.production -f docker-compose.prod.yml down --remove-orphans", - "compose:prod:up": "docker compose --env-file .env.production -f docker-compose.prod.yml up --build -d", + "compose:prod:down": "docker compose --env-file .env.production -f docker-compose.prod.images.yml down --remove-orphans", + "compose:prod:up": "docker compose --env-file .env.production -f docker-compose.prod.images.yml up -d db server web", "compose:pull": "docker compose -f docker-compose.images.yml pull server web cli", "compose:pull:up": "docker compose -f docker-compose.images.yml up -d db server web", "compose:prod:pull": "docker compose --env-file .env.production -f docker-compose.prod.images.yml pull server web cli", diff --git a/packages/server/src/environment.test.ts b/packages/server/src/environment.test.ts index a46f069..e7032fe 100644 --- a/packages/server/src/environment.test.ts +++ b/packages/server/src/environment.test.ts @@ -30,4 +30,23 @@ describe('server environment', () => { expect(environment.adminEmailAllowlist).toEqual(['admin@example.com']); }); + + it('fails fast when required Google OAuth credentials are incomplete', () => { + expect(() => getServerEnvironment({ + GOOGLE_OAUTH_CLIENT_ID: 'client-id', + REQUIRE_GOOGLE_OAUTH: 'true', + })).toThrow(/REQUIRE_GOOGLE_OAUTH=true/); + }); + + it('accepts required Google OAuth credentials when all values are present', () => { + const environment = getServerEnvironment({ + GOOGLE_OAUTH_CLIENT_ID: 'client-id', + GOOGLE_OAUTH_CLIENT_SECRET: 'client-secret', + GOOGLE_OAUTH_REDIRECT_URI: 'https://example.com/auth/google/callback', + REQUIRE_GOOGLE_OAUTH: 'true', + }); + + expect(environment.requireGoogleOAuth).toBe(true); + expect(environment.googleOAuthClientId).toBe('client-id'); + }); }); diff --git a/packages/server/src/environment.ts b/packages/server/src/environment.ts index 6222c1e..c6f3c75 100644 --- a/packages/server/src/environment.ts +++ b/packages/server/src/environment.ts @@ -17,6 +17,7 @@ export interface ServerEnvironment { googleOAuthClientSecret: string | null; googleOAuthRedirectUri: string | null; port: number; + requireGoogleOAuth: boolean; sessionTtlSeconds: number; viewerAssertionSecret: string | null; } @@ -39,26 +40,37 @@ export function getServerEnvironment(env: NodeJS.ProcessEnv = process.env): Serv const port = Number(env.PORT ?? DEFAULT_PORT); const allowAdminFallback = env.ALLOW_ADMIN_FALLBACK === 'true'; const nodeEnvironment = env.NODE_ENV ?? 'development'; + const requireGoogleOAuth = env.REQUIRE_GOOGLE_OAUTH === 'true'; const sessionTtlSeconds = Number(env.SESSION_TTL_SECONDS ?? 60 * 60 * 24 * 30); const adminEmailAllowlist = (env.ADMIN_EMAIL_ALLOWLIST ?? env.GOOGLE_OAUTH_ADMIN_EMAILS ?? '') .split(',') .map((email) => email.trim().toLowerCase()) .filter(Boolean); + const googleOAuthClientId = env.GOOGLE_OAUTH_CLIENT_ID?.trim() || null; + const googleOAuthClientSecret = env.GOOGLE_OAUTH_CLIENT_SECRET?.trim() || null; + const googleOAuthRedirectUri = env.GOOGLE_OAUTH_REDIRECT_URI?.trim() || null; if (allowAdminFallback && nodeEnvironment !== 'development' && nodeEnvironment !== 'test') { throw new Error('ALLOW_ADMIN_FALLBACK=true is only supported in development or test environments.'); } + if (requireGoogleOAuth && (!googleOAuthClientId || !googleOAuthClientSecret || !googleOAuthRedirectUri)) { + throw new Error( + 'REQUIRE_GOOGLE_OAUTH=true requires GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, and GOOGLE_OAUTH_REDIRECT_URI.', + ); + } + return { adminEmailAllowlist, appOrigin: env.APP_ORIGIN?.trim() || 'http://localhost:4201', allowAdminFallback, databaseUrl: env.DATABASE_URL ?? '', authToken: env.AUTH_TOKEN ?? '', - googleOAuthClientId: env.GOOGLE_OAUTH_CLIENT_ID?.trim() || null, - googleOAuthClientSecret: env.GOOGLE_OAUTH_CLIENT_SECRET?.trim() || null, - googleOAuthRedirectUri: env.GOOGLE_OAUTH_REDIRECT_URI?.trim() || null, + googleOAuthClientId, + googleOAuthClientSecret, + googleOAuthRedirectUri, port: Number.isFinite(port) && port > 0 ? port : DEFAULT_PORT, + requireGoogleOAuth, sessionTtlSeconds: Number.isFinite(sessionTtlSeconds) && sessionTtlSeconds > 0 ? Math.trunc(sessionTtlSeconds) : 60 * 60 * 24 * 30, diff --git a/scripts/postgres-backup.sh b/scripts/postgres-backup.sh index 8460b68..36c57ef 100755 --- a/scripts/postgres-backup.sh +++ b/scripts/postgres-backup.sh @@ -3,7 +3,7 @@ set -eu ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env.production}" -COMPOSE_FILE="${COMPOSE_FILE:-$ROOT_DIR/docker-compose.prod.yml}" +COMPOSE_FILE="${COMPOSE_FILE:-$ROOT_DIR/docker-compose.prod.images.yml}" TIMESTAMP="$(date +%Y%m%d-%H%M%S)" OUTPUT_DIR="${OUTPUT_DIR:-$ROOT_DIR/.backups}" OUTPUT_FILE="${OUTPUT_FILE:-$OUTPUT_DIR/involute-$TIMESTAMP.sql.gz}" diff --git a/scripts/prod-smoke.sh b/scripts/prod-smoke.sh new file mode 100755 index 0000000..e180380 --- /dev/null +++ b/scripts/prod-smoke.sh @@ -0,0 +1,53 @@ +#!/bin/sh +set -eu + +BASE_URL="${1:-${INVOLUTE_SMOKE_BASE_URL:-}}" + +if [ -z "$BASE_URL" ]; then + echo "Usage: scripts/prod-smoke.sh " >&2 + exit 2 +fi + +BASE_URL="${BASE_URL%/}" + +curl --connect-timeout 5 --max-time 15 -fsS "$BASE_URL/health" >/dev/null + +SESSION_RESPONSE="$(mktemp)" +SESSION_STATUS="$( + curl --connect-timeout 5 --max-time 15 -sS -o "$SESSION_RESPONSE" -w '%{http_code}' \ + "$BASE_URL/auth/session" +)" +case "$SESSION_STATUS" in + 200|401) ;; + *) + echo "auth/session returned unexpected status: $SESSION_STATUS" >&2 + cat "$SESSION_RESPONSE" >&2 + rm -f "$SESSION_RESPONSE" + exit 1 + ;; +esac +SESSION_PAYLOAD="$(cat "$SESSION_RESPONSE")" +rm -f "$SESSION_RESPONSE" +printf '%s' "$SESSION_PAYLOAD" | python3 -c ' +import json +import sys + +payload = json.load(sys.stdin) +if payload.get("googleOAuthConfigured") is not True: + raise SystemExit("auth/session did not report googleOAuthConfigured=true") +' + +GOOGLE_START_STATUS="$( + curl --connect-timeout 5 --max-time 15 -sS -o /dev/null -w '%{http_code} %{redirect_url}' \ + "$BASE_URL/auth/google/start" +)" + +case "$GOOGLE_START_STATUS" in + "302 https://accounts.google.com/"*) ;; + *) + echo "auth/google/start did not redirect to Google: $GOOGLE_START_STATUS" >&2 + exit 1 + ;; +esac + +printf 'Production smoke passed for %s\n' "$BASE_URL"