Skip to content

Latest commit

 

History

History
236 lines (203 loc) · 11 KB

File metadata and controls

236 lines (203 loc) · 11 KB

Deploying quantic (beta.quantic.es)

Kamal 2 deploy to the existing ecosystem VPS, alongside the legacy services (dividend-portfolio, pulse, logo-service, trends). Everything quantic is namespaced and additive; the legacy stack has no backups yet, so the rules below are not optional.

Safety rules (read before every operation)

  • Never run kamal proxy reboot / kamal proxy remove. kamal-proxy is shared with the legacy apps; killing it takes quantic.es, pulse.quantic.es, and logos.quantic.es down with it. kamal deploy reuses the running proxy and only registers the beta.quantic.es route.
  • Never touch legacy volumes (pulse_data, trends_postgres_data, the logo-service volume, dividend-portfolio storage). Quantic's volumes are quantic_logos and quantic_postgres_data — nothing else.
  • The Postgres accessory binds to 127.0.0.1:5434. No other Postgres runs on the host today (trends — the only legacy service with one — was never deployed), but if you change the port, check docker ps on the host first.
  • kamal accessory remove db deletes the container, not the data directory — but don't run it casually either.

One-time setup

  1. Local tooling (mise-managed): mise install (Ruby is in .tool-versions), then gem install kamal -v 2.7.0pinned: newer kamal demands a newer kamal-proxy and refuses to deploy, suggesting kamal proxy reboot, which is forbidden here (shared proxy, see safety rules). Same pin as pulse's CI. Upgrading kamal ecosystem-wide is a deliberate maintenance event for after backups exist. The executable lands on PATH via mise shims. The bw CLI: npm install -g @bitwarden/cli, then point it at the EU server (the account lives there — the US default rejects the API key with "client_id or client_secret is incorrect"):

    bw config server https://vault.bitwarden.eu

    And SSH: the repo never carries the server's address — deploy.yml targets the quantic-vps alias, which each machine defines in ~/.ssh/config:

    Host quantic-vps
      HostName <the VPS IP — from your password manager>
      User root
    

    Then add this machine's key (ssh-copy-id quantic-vps), or kamal prompts for the root password on every connection.

  2. DNS: beta.quantic.es A record → the VPS IP.

  3. Bitwarden item quantic-phoenix with: KAMAL_REGISTRY_PASSWORD, SECRET_KEY_BASE (mix phx.gen.secret), POSTGRES_PASSWORD, DATABASE_URL (ecto://quantic:<POSTGRES_PASSWORD>@quantic-db/quantic_phoenix), GOOGLE_OAUTH_CLIENT_ID/SECRET, RESEND_API_KEY, EVENTS_API_KEYS.

  4. Google OAuth console: add https://beta.quantic.es/auth/google/callback to the authorized redirect URIs.

  5. Shell env for kamal: only BW_ACCOUNT is strictly required for local deploys — when the vault is locked, the bw CLI prompts for the master password interactively. The other three vars in .env.example (BW_CLIENTID, BW_CLIENTSECRET, BW_PASSWORD) exist for the non-interactive path — bw login --apikey + export BW_SESSION=$(bw unlock --raw --passwordenv BW_PASSWORD) — which is how pulse's GitHub Actions deploy works, and what a future quantic deploy workflow would use (vars live in Actions secrets there).

  6. Every field must exist in the Bitwarden item with a non-empty value — Bitwarden stores empty fields as null and kamal's adapter raises "Could not find secret" on them. If you don't have a real value yet: EVENTS_API_KEYS → generate one (openssl rand -hex 32, doubles as your curl probe key); RESEND_API_KEY → the literal string disabled (only read at email-send time, so magic links fail gracefully and everything else works).

First deploy

kamal setup        # boots the db accessory + app; reuses the running proxy

kamal setup is idempotent on a host that already runs kamal apps — it will NOT reinstall or restart the shared proxy.

Every deploy after

Merging a PR to main deploys automatically (.github/workflows/deploy.yml, gated on CI passing, native ARM runner). Manual deploys remain possible from any machine set up per this doc:

kamal deploy

CI deploy secrets (GitHub Actions → repo secrets)

  • BW_ACCOUNT, BW_CLIENTID, BW_CLIENTSECRET, BW_PASSWORD — the same four Bitwarden vars as local (.env.example).
  • SSH_PRIVATE_KEY — a dedicated deploy key (not a personal one); its public half goes in the VPS root authorized_keys.
  • VPS_HOST — the server address (kept out of the repo, same rule as the local quantic-vps alias).

Migrations run automatically on boot (bin/server runs Quantic.Release.migrate before starting the endpoint), so a deploy can never serve code against a stale schema. bin/migrate still exists for running them standalone.

Release runbook bits (no Mix in releases)

  • Migrations: automatic on boot; standalone via bin/migrate or bin/quantic eval "Quantic.Release.migrate".
  • Legacy imports run via eval, e.g. bin/quantic eval 'Quantic.Logos.LegacyImport.run("/app/data/import/logos.db")'.
  • Logo files: rsync the legacy volume into quantic_logos (host-side copy between /var/lib/docker/volumes/... paths) — the on-disk layout is identical by design.
  • Remote shell: kamal app exec -i 'bin/quantic remote'.

Rehearsal imports (beta as the standing staging dry-run)

Run against copies of legacy data, never the live files:

  • Logos: rsync a copy of the volume + LegacyImport.run/1 (idempotent — re-run freely).
  • Visit analytics: Community.import_legacy_visits/2 increments on conflict (correct once at cutover) — on rehearsal re-runs, truncate community_visits first.
  • dividend-portfolio users/stocks/holdings/radar: bin/quantic eval 'Quantic.LegacyImport.run("/app/data/import/production.sqlite3") |> IO.inspect()' — idempotent by natural keys (email / symbol / user+stock); re-running applies snapshot-wins semantics (beta-side edits to imported fields are rolled back). Copy the SQLite snapshot into the volume first, e.g. scp to the host + docker cp into the container's /app/data/import/.

Production cutover (Step 9)

The flip is a kamal-proxy host re-route, NOT a DNS change. Legacy (dividend-portfolio) and the monolith run on the same VPS, same IP; the shared kamal-proxy demultiplexes by Host header. The quantic.es and www.quantic.es A records already point at this IP (that's how legacy serves them today), so no DNS record changes. Cutover = telling kamal-proxy that quantic.es now targets the monolith container instead of the legacy one. kamal-proxy keys routes by host and reassigns on deploy (last-writer-wins, with draining) — the same mechanism it uses for zero-downtime within an app.

Why this beats a DNS flip: the swap is atomic at the proxy (no TTL / propagation wait) and instantly reversible (reassign the host back to legacy — standby still holds the frozen data).

Pre-cutover (do ahead of the window)

  1. Google OAuth console: add https://quantic.es/auth/google/callback and https://www.quantic.es/auth/google/callback to the authorized redirect URIs (keep the beta one until standby ends).
  2. Stage the deploy.yml change (don't deploy yet) — see step 5.
  3. Rehearse the data import once more against a fresh snapshot (above).

The window (~30–60 min)

  1. Read-only legacy — banner + disable mutating endpoints on dividend-portfolio so no writes land after the final snapshot.
  2. Final snapshot of the legacy SQLite (sqlite3 … ".backup …").
  3. Final importdocker cp the snapshot into the monolith container, then bin/quantic eval 'Quantic.LegacyImport.run("/app/data/import/production.sqlite3") |> IO.inspect()' (idempotent, snapshot-wins). Also: logos rsync into quantic_logos + Quantic.Logos.LegacyImport.run/1, and visit analytics via Community.import_legacy_visits/2 (truncate community_visits first if it was populated in a rehearsal).
  4. Telegram flip (the monolith ships dormant and never registers on boot — this is the one explicit step that detaches legacy's webhook and attaches ours; same bot token). Prereq: TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_HANDLE, TELEGRAM_WEBHOOK_SECRET in Bitwarden + TELEGRAM_NOTIFICATIONS: "true" in deploy.yml (already wired) and the three vars added to .kamal/secrets's fetch+extract list (kamal hard-fails the deploy otherwise — "Secret … not found in .kamal/secrets"). Then, after the deploy, use rpc not eval — these run on the live server node (which has Repo + Finch started); eval boots a bare node where register_webhook's HTTP client isn't running:
    # carry the active links (matched to users by email; idempotent)
    docker exec "$QUANTIC" bin/quantic rpc \
      'Quantic.Telegram.LegacyImport.run("/tmp/cutover.sqlite3") |> IO.inspect()'
    # attach the webhook to the monolith (detaches it from legacy)
    docker exec "$QUANTIC" bin/quantic rpc \
      'Quantic.Telegram.Client.register_webhook("https://quantic.es/telegram/webhook") |> IO.inspect()'
    Verify by sending the bot a /start and a question; the digest cron then runs at 06:30 UTC. (The snapshot must still be in the container — it's the same production.sqlite3, which also holds the links.)
  5. The flip — edit config/deploy.yml:
    proxy:
      ssl: true
      hosts:
        - quantic.es
        - www.quantic.es
        - beta.quantic.es   # keep during standby
    and PHX_HOST: quantic.es (+ the env.clear.PHX_HOST) so generated URLs, magic-link emails, and share links use the real domain. Then kamal deploy. kamal-proxy reassigns quantic.es/www to the monolith and provisions Let's Encrypt certs for them (apex already resolves here → ACME http-01 passes; first HTTPS hit is slightly slower — pre-warm with curl https://quantic.es/up). Never kamal proxy rebootkamal deploy reuses the running shared proxy (safety rule).
  6. Release the host on the legacy side — drop quantic.es/www from the legacy app's deploy.yml so a future legacy deploy can't re-claim them (no immediate legacy redeploy needed; the monolith deploy already took the route).
  7. Smoke-test on quantic.es: sign in (OAuth), portfolio renders with live prices, /dividends, public /p//r share-image capture, Telegram /start.
  8. Retire the rest — reassign pulse.quantic.es → the monolith (/community) the same way if desired; logos.quantic.es is just dropped (consumers are in-app now).

Standby & rollback (~1 week)

Leave the legacy containers running, no traffic. To roll back: reassign the host at the proxy — kamal-proxy deploy --host quantic.es --target <legacy-container> (or redeploy legacy claiming the host) and remove it from the monolith. The legacy standby has the frozen pre-cutover data. After the standby window with no issues, tear down legacy containers (keep volumes until backups are confirmed).