From 0e8de27d5bdfbe08e2ccef7b93ceff3750fd1626 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 23 Jul 2026 08:05:44 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20self-bootstrapping=20startup=20?= =?UTF-8?q?=E2=80=94=20zero=20ordered=20setup=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app now provisions itself on boot: before the runtime pool connects it orchestrates the existing idempotent scripts (provision → migrate → access → seed) as the admin role, then serves as the least-privilege oms_user. Instruments auto-sync from every credentialed broker in the background so the ~8-min option-chain fetch never blocks listening. A fresh checkout is now just `run the app` — no make commands in any order. Routing + identity gaps that "instruments only" would have left: - broker_connection is created at boot for every broker whose creds are present — it's routing config, not fixture data; without it a configured broker can't take an order. - dev identity chain (principal/account/portfolio/api-key) is created when OMS_DEV_IDENTITY=true, so a fresh install can place a paper order with no admin setup. Off by default (it mints a known API secret). Knobs: OMS_BOOTSTRAP (auto|off), OMS_SYNC_ON_BOOT (if-empty|never), OMS_SYNC_UNDERLYINGS, OMS_DEV_IDENTITY. Bootstrap skips gracefully when provisioning creds are incomplete, so it never breaks an already-working DB. preflight: an empty instrument catalog is expected (not fatal) when a background sync will fill it. Fixture split by ownership — minimal_seed.sql keeps master-data only; dev_identity.sql holds the oms-schema chain. --- .env.example | 35 +++- cockpit/src/pages/Architecture.tsx | 7 +- db/scripts/fixtures.sh | 29 +++ readme.md | 31 +-- scripts/fixtures/dev_identity.sql | 48 +++++ scripts/fixtures/minimal_seed.sql | 62 +----- src/main.rs | 32 ++- src/preflight.rs | 39 +++- src/setup/bootstrap.rs | 314 +++++++++++++++++++++++++++++ src/setup/brokers.rs | 52 ++++- src/setup/mod.rs | 1 + 11 files changed, 564 insertions(+), 86 deletions(-) create mode 100755 db/scripts/fixtures.sh create mode 100644 scripts/fixtures/dev_identity.sql create mode 100644 src/setup/bootstrap.rs diff --git a/.env.example b/.env.example index 94c8922..2d1071a 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,17 @@ -# openoms configuration. Copy to `.env`, edit, then run `make db-setup`. +# openoms configuration. Copy to `.env`, edit, then just run the app — it +# self-provisions on boot (roles, schema, ref-data) and syncs instruments in the +# background. `make db-setup` still works for manual/ordered setup if you prefer. # ===================================================================== # --- Postgres host --- DB_HOST=localhost DB_PORT=5432 -# --- Install-time admin (a Postgres superuser) — used by `make db-provision`/ -# db-migrate/db-access/db-seed to create roles+db and apply schema. Not runtime. --- +# --- Install-time admin (a Postgres superuser) — creates roles+db and applies +# schema/ref-data. Used by `make db-*` AND by the app's boot-time self-provision +# (see OMS_BOOTSTRAP). Not the runtime connection. If these + the role passwords +# below are incomplete, the app assumes the DB is already provisioned and skips +# bootstrap rather than failing. --- ADMIN_USER=postgres ADMIN_PASSWORD=postgres @@ -28,15 +33,33 @@ OMS_BIND_ADDR=localhost:3001 OMS_ADMIN_TOKEN=change-me-to-a-secure-random-token OMS_ADMIN_AUTH_ENABLED=false +# --- Boot-time self-bootstrap (so a fresh checkout is just `run the app`) --- +# OMS_BOOTSTRAP auto (default) runs provision/migrate/access/seed at boot as +# the admin role; `off` skips it (infra provisioned elsewhere). +# OMS_SYNC_ON_BOOT if-empty (default) syncs instruments from brokers with creds +# when the catalog is empty; `never` disables auto-sync. +# OMS_SYNC_UNDERLYINGS comma-separated option underlyings to seed at boot (Alpaca), +# e.g. SPY,QQQ. Empty = equities/spot only. +# OMS_DEV_IDENTITY true creates a dev principal/account with a KNOWN api secret +# (test-trader-key : test-secret) so a fresh install can place +# an order immediately. PAPER/dev only — never in production. +# OMS_BOOTSTRAP=auto +# OMS_SYNC_ON_BOOT=if-empty +# OMS_SYNC_UNDERLYINGS= +# OMS_DEV_IDENTITY=false + +# A broker_connection is auto-created at boot for every broker whose creds are set +# below — no manual step needed to make a configured broker routable. + # --- Kafka --- KAFKA_BROKER=localhost:9092 KAFKA_TOPIC=orders KAFKA_CLIENT_ID=rust-oms KAFKA_PROJECTOR_GROUP_ID=position-projector-v1 -# --- Optional: instrument seeding (`make sync-broker` = instruments + broker -# mapping). On-demand, NOT scheduled. Runs in-process, writes as DB_USER -# (oms_user). Data feeds need no seeding — each derives what it can price. --- +# --- Broker creds. Present here, the app auto-syncs that broker's catalog at boot +# (see OMS_SYNC_ON_BOOT); also used by `make sync-broker`. Writes as oms_user. +# Data feeds need no seeding — each derives what it can price. --- # DATABENTO_API_KEY= ALPACA_ENV=PAPER ALPACA_PAPER_API_KEY= diff --git a/cockpit/src/pages/Architecture.tsx b/cockpit/src/pages/Architecture.tsx index a5c1562..5da940e 100644 --- a/cockpit/src/pages/Architecture.tsx +++ b/cockpit/src/pages/Architecture.tsx @@ -318,8 +318,11 @@ export function ArchitecturePage() { Two steps, and the order is a hard dependency rather than a convention. Each reads what the one before it wrote, and nothing ever points backwards — a data feed never creates an - instrument, and an instrument never creates a venue. make seed-live runs the - chain idempotently. Feeds are not part of it: they derive their mapping at startup. + instrument, and an instrument never creates a venue. Feeds are not part of it: they derive + their mapping at startup. You rarely run these by hand: the app self-provisions on boot + (roles, schema, ref-data via the admin role) and background-syncs instruments when the + catalog is empty — the make targets are the manual equivalent, kept as an escape hatch + (OMS_BOOTSTRAP=off). diff --git a/db/scripts/fixtures.sh b/db/scripts/fixtures.sh new file mode 100755 index 0000000..965a22a --- /dev/null +++ b/db/scripts/fixtures.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# Load the minimal no-creds fixture (SPY) so a fresh install has one tradeable +# instrument without any broker/data-provider credentials. Idempotent — the fixture +# upserts on natural keys (ON CONFLICT DO NOTHING). +# +# Extracted from the `db-fixtures` make recipe so boot-time bootstrap can orchestrate +# it the same way as provision/migrate/access/seed. Needs an ADMIN role (the fixture +# writes master tables owned by mdm_master). +set -euo pipefail + +# Repo root is two levels up (db/scripts → db → repo); the fixture lives under the +# top-level scripts/ dir, not db/scripts/. +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" +[[ -f .env ]] && { set -a; . ./.env; set +a; } + +: "${DB_HOST:?DB_HOST must be set}" +: "${DB_PORT:?DB_PORT must be set}" +: "${ADMIN_USER:?ADMIN_USER must be set}" +: "${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set}" +ODS_DB="${ODS_DB:-ods}" + +echo "→ loading minimal fixture (SPY)" +PGPASSWORD="$ADMIN_PASSWORD" psql -v ON_ERROR_STOP=1 \ + -h "$DB_HOST" -p "$DB_PORT" -U "$ADMIN_USER" -d "$ODS_DB" \ + -f scripts/fixtures/minimal_seed.sql + +echo "fixture loaded." diff --git a/readme.md b/readme.md index 7d859e2..e1cd4a3 100644 --- a/readme.md +++ b/readme.md @@ -24,25 +24,32 @@ git clone git@github.com:maxkuttner/openoms.git && cd openoms cp .env.example .env # then edit passwords / bind addr ``` -## Setup +## Run -One-shot DB bootstrap — roles, schema, reference data, and a no-creds SPY fixture (needs the `ADMIN_*` superuser creds in `.env`): +With the `ADMIN_*` superuser + role passwords set in `.env`, just start the app — it +self-provisions on boot (roles, schema, reference data) and, if broker creds are +present, syncs that broker's instrument catalog in the background. No ordered setup +commands. ```sh -make db-setup +cargo run # OMS on OMS_BIND_ADDR (default localhost:3001) ``` -Optional live data (on-demand, needs vendor creds in `.env`): +First boot on an empty database runs provision → migrate → seed before listening (a +few seconds), then the server binds immediately while instruments populate in the +background. Subsequent boots are near-instant no-ops. Set `OMS_SYNC_ON_BOOT=never` to +skip auto-sync, or `OMS_BOOTSTRAP=off` when infrastructure is provisioned elsewhere. -```sh -make sync-broker BROKER=alpaca # seed instruments + broker mapping from Alpaca (ALPACA_PAPER_*) -make sync-broker BROKER=alpaca UNDERLYINGS=SPY,QQQ # also seed those option chains -``` +The SPY fixture seeds `alpaca-paper` + a test user (`test-trader-key` : `test-secret`), +so you can place a paper order immediately. Admin webapp: `cd cockpit && npm install && npm run dev`. -## Run +### Manual setup (optional) + +The bootstrap just orchestrates the same idempotent make targets, if you'd rather run +them yourself (or set `OMS_BOOTSTRAP=off`): ```sh -cargo run # OMS on OMS_BIND_ADDR (default localhost:3001) +make db-setup # roles, schema, ref-data, SPY fixture +make sync-broker BROKER=alpaca # instruments + broker mapping from Alpaca +make sync-broker BROKER=alpaca UNDERLYINGS=SPY,QQQ # also seed those option chains ``` - -The SPY fixture seeds `alpaca-paper` + a test user (`test-trader-key` : `test-secret`), so you can place a paper order immediately. Admin webapp: `cd cockpit && npm install && npm run dev`. diff --git a/scripts/fixtures/dev_identity.sql b/scripts/fixtures/dev_identity.sql new file mode 100644 index 0000000..218adc2 --- /dev/null +++ b/scripts/fixtures/dev_identity.sql @@ -0,0 +1,48 @@ +-- Dev identity chain: a ready-to-trade principal so a fresh install can place a +-- paper order over the API with no admin setup. Gated by OMS_DEV_IDENTITY — this +-- creates a known API secret and is for PAPER/dev only, never a real deployment. +-- Idempotent via natural keys. All in `oms`, so the app runs it as oms_user. +-- +-- HTTP Basic auth: test-trader-key : test-secret (PAPER/dev only — not for prod) + +-- A broker connection the test account can route through. Real credentialed brokers +-- also get their own connection auto-created (ensure_broker_connections); this one +-- is self-contained so the dev chain works even with no broker creds set. +INSERT INTO oms.broker_connection (code, broker_code, environment, status) +VALUES ('alpaca-paper', 'ALPACA', 'PAPER', 'ACTIVE') +ON CONFLICT (code) DO NOTHING; + +-- UUID ids are generated, then resolved by code for the FK references +-- (principal/account/portfolio all have UNIQUE code). +INSERT INTO oms.principal (id, code, principal_type, display_name, status) +VALUES (gen_random_uuid(), 'test-trader', 'HUMAN', 'Test Trader', 'ACTIVE') +ON CONFLICT (code) DO NOTHING; + +-- Custodial account on the alpaca-paper connection. external_account_ref is the +-- broker's own account handle; Alpaca routes on the API key, so it's informational. +INSERT INTO oms.account (id, code, broker_connection_code, external_account_ref, status) +VALUES (gen_random_uuid(), 'test-account', 'alpaca-paper', 'PAPER', 'ACTIVE') +ON CONFLICT (code) DO NOTHING; + +-- Portfolio whose default route is the test account, so submits need no account_id. +INSERT INTO oms.portfolio (id, code, name, status, default_account_id) +SELECT gen_random_uuid(), 'test-portfolio', 'Test Portfolio', 'ACTIVE', a.id +FROM oms.account a WHERE a.code = 'test-account' +ON CONFLICT (code) DO NOTHING; + +-- Entitle the trader to trade the portfolio. +INSERT INTO oms.principal_portfolio_grant + (id, principal_id, portfolio_id, can_trade, can_view, can_allocate) +SELECT gen_random_uuid(), p.id, pf.id, true, true, false +FROM oms.principal p, oms.portfolio pf +WHERE p.code = 'test-trader' AND pf.code = 'test-portfolio' +ON CONFLICT (principal_id, portfolio_id) DO NOTHING; + +-- API key for the trader. key_id 'test-trader-key', secret 'test-secret'. +-- secret_hash is a precomputed bcrypt ($2y$, cost 12) of 'test-secret' — pgcrypto +-- isn't available on the server, and the app's bcrypt::verify accepts $2y$. +INSERT INTO oms.api_key (principal_id, key_id, secret_hash, name) +SELECT p.id, 'test-trader-key', + '$2y$12$haJ5Dxm/IzRmKITzcrZ.sOvTyt2KCvS7KAEC2OvDaycL1TmojvuSm', 'test-key' +FROM oms.principal p WHERE p.code = 'test-trader' +ON CONFLICT (key_id) DO NOTHING; diff --git a/scripts/fixtures/minimal_seed.sql b/scripts/fixtures/minimal_seed.sql index 8d91eea..9907835 100644 --- a/scripts/fixtures/minimal_seed.sql +++ b/scripts/fixtures/minimal_seed.sql @@ -1,9 +1,15 @@ --- openoms minimal seed: one tradeable instrument so a fresh install can place a --- paper order without any data-provider creds. Idempotent (re-runnable). +-- openoms minimal seed: one tradeable instrument so a fresh no-creds install has +-- something in the catalog. Idempotent (re-runnable). Writes master tables in +-- `public` (owned by mdm_master), so this runs as the admin role via fixtures.sh. +-- +-- Routing config (broker_connection) and the test identity chain are NOT here — they +-- live in `oms` and are created by the app at boot (see src/setup/bootstrap.rs: +-- ensure_broker_connections + ensure_dev_identity). Keeping master-data seeding +-- separate from oms-owned config is what lets each run under the right role. -- -- Depends on `make db-seed` having loaded the FK targets (currency USD, venue ARCX). -- instrument.id is GENERATED ALWAYS AS IDENTITY, so we never hardcode it: insert by --- the (symbol, venue) natural key, then resolve the id for the mappings. +-- the (symbol, venue) natural key, then resolve the id for the mapping. INSERT INTO instrument (symbol, venue, currency, asset_class, instrument_class, name, @@ -26,52 +32,4 @@ ON CONFLICT (instrument_id, broker_code) DO NOTHING; -- No feed mapping row: each feed derives what it can price from its own symbology -- at startup. Note that nothing prices SPY *equity* today — the Databento feed -- carries OPRA options only — so a position in it will be reported as unmarkable --- by preflight. That was equally true before, when a feed_instrument row here --- claimed otherwise and the subscription silently matched nothing. - --- A test broker connection so an account can be created and orders can route. --- Creds are resolved from env by (broker_code, environment); this is just the --- routing target an account references. Schema-qualified — it lives in `oms`. -INSERT INTO oms.broker_connection (code, broker_code, environment, status) -VALUES ('alpaca-paper', 'ALPACA', 'PAPER', 'ACTIVE') -ON CONFLICT (code) DO NOTHING; - --- --- Test identity chain: a ready-to-trade principal so a fresh install can place --- a paper order over the API with no admin setup. Idempotent via natural keys. --- --- HTTP Basic auth: test-trader-key : test-secret (PAPER/dev only — not for prod) --- --- UUID ids are generated, then resolved by code for the FK references --- (principal/account/portfolio all have UNIQUE code). -INSERT INTO oms.principal (id, code, principal_type, display_name, status) -VALUES (gen_random_uuid(), 'test-trader', 'HUMAN', 'Test Trader', 'ACTIVE') -ON CONFLICT (code) DO NOTHING; - --- Custodial account on the alpaca-paper connection. external_account_ref is the --- broker's own account handle; Alpaca routes on the API key, so it's informational. -INSERT INTO oms.account (id, code, broker_connection_code, external_account_ref, status) -VALUES (gen_random_uuid(), 'test-account', 'alpaca-paper', 'PAPER', 'ACTIVE') -ON CONFLICT (code) DO NOTHING; - --- Portfolio whose default route is the test account, so submits need no account_id. -INSERT INTO oms.portfolio (id, code, name, status, default_account_id) -SELECT gen_random_uuid(), 'test-portfolio', 'Test Portfolio', 'ACTIVE', a.id -FROM oms.account a WHERE a.code = 'test-account' -ON CONFLICT (code) DO NOTHING; - --- Entitle the trader to trade the portfolio. -INSERT INTO oms.principal_portfolio_grant - (id, principal_id, portfolio_id, can_trade, can_view, can_allocate) -SELECT gen_random_uuid(), p.id, pf.id, true, true, false -FROM oms.principal p, oms.portfolio pf -WHERE p.code = 'test-trader' AND pf.code = 'test-portfolio' -ON CONFLICT (principal_id, portfolio_id) DO NOTHING; - --- API key for the trader. key_id 'test-trader-key', secret 'test-secret'. --- secret_hash is a precomputed bcrypt ($2y$, cost 12) of 'test-secret' — pgcrypto --- isn't available on the server, and the app's bcrypt::verify accepts $2y$. -INSERT INTO oms.api_key (principal_id, key_id, secret_hash, name) -SELECT p.id, 'test-trader-key', - '$2y$12$haJ5Dxm/IzRmKITzcrZ.sOvTyt2KCvS7KAEC2OvDaycL1TmojvuSm', 'test-key' -FROM oms.principal p WHERE p.code = 'test-trader' -ON CONFLICT (key_id) DO NOTHING; +-- by preflight. diff --git a/src/main.rs b/src/main.rs index d312955..d7ddf8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -196,6 +196,15 @@ async fn main() { // Server entry point (default when no subcommand is given). async fn serve() { + // Self-provision before the runtime pool connects: create roles/db, apply + // migrations, seed reference data — all idempotent, all as the admin role. Skips + // itself when OMS_BOOTSTRAP=off or no admin creds are present. This is what makes + // a fresh checkout `run the app` with no ordered setup commands. + if let Err(e) = setup::bootstrap::ensure_ready().await { + error!("{e}"); + return; + } + // parse db config or panic let db_user = env::var("DB_USER").expect("DB_USER must be set"); let db_host = env::var("DB_HOST").expect("DB_HOST must be set"); @@ -216,10 +225,22 @@ async fn serve() { } }; + // On a no-creds fresh install, drop in the SPY fixture so there is one tradeable + // instrument even when no broker will populate the catalog. Best-effort. + setup::bootstrap::ensure_fixture_if_no_brokers(&pool).await; + + // Routing config for every credentialed broker — without a broker_connection a + // configured broker still cannot take an order. Then the optional dev identity + // chain (OMS_DEV_IDENTITY), so a fresh install can trade immediately. + setup::bootstrap::ensure_broker_connections(&pool).await; + setup::bootstrap::ensure_dev_identity(&pool).await; + // Refuse to start on a catalog that cannot work, and name what is merely // degraded. Before any feed spawns, so a broken catalog surfaces here rather - // than as a feed that quietly subscribes to nothing. - if let Err(e) = preflight::run(&pool).await { + // than as a feed that quietly subscribes to nothing. An empty catalog is not + // fatal when a background sync is about to fill it. + let auto_sync_pending = setup::bootstrap::will_sync_on_boot(&pool).await; + if let Err(e) = preflight::run(&pool, auto_sync_pending).await { error!("preflight failed: {e}"); return; } @@ -457,6 +478,13 @@ async fn serve() { } }); + // Populate an empty catalog from brokers in the background, so the minutes-long + // option-chain fetch never delays the server binding below. `auto_sync_pending` + // was computed above (catalog empty + a broker has creds). + if auto_sync_pending { + setup::bootstrap::spawn_sync(); + } + // Register routes // 1) Register order routes diff --git a/src/preflight.rs b/src/preflight.rs index 9f75ab2..aa430a3 100644 --- a/src/preflight.rs +++ b/src/preflight.rs @@ -32,22 +32,27 @@ impl std::fmt::Display for Fatal { } /// Run every check. `Err` means do not start. -pub async fn run(pool: &PgPool) -> Result<(), Fatal> { - check_catalog(pool).await?; +/// +/// `auto_sync_pending` is true when a background broker sync is about to populate an +/// empty catalog (see `setup::bootstrap`); it downgrades an empty `instrument` table +/// from fatal to expected. +pub async fn run(pool: &PgPool, auto_sync_pending: bool) -> Result<(), Fatal> { + check_catalog(pool, auto_sync_pending).await?; report_held(pool).await; Ok(()) } /// Fatal checks: the master catalog and the FK targets it depends on. /// -/// An empty `venue` or `currency` table is the signature of a half-run `make -/// db-seed`; every subsequent `sync-broker` would skip every instrument on an FK -/// miss and report success over an empty catalog. -async fn check_catalog(pool: &PgPool) -> Result<(), Fatal> { +/// An empty `venue` or `currency` table is the signature of a DB that never got +/// seeded; with bootstrap on these are seeded before we ever get here, so a failure +/// now means `OMS_BOOTSTRAP=off` over an unprepared DB. An empty `instrument` is only +/// fatal when nothing is about to fill it — a pending background sync makes it +/// expected, not broken. +async fn check_catalog(pool: &PgPool, auto_sync_pending: bool) -> Result<(), Fatal> { for (table, hint) in [ - ("venue", "run `make db-seed`"), - ("currency", "run `make db-seed`"), - ("instrument", "run `make sync-broker BROKER=alpaca`"), + ("venue", "run `make db-seed` (or enable bootstrap)"), + ("currency", "run `make db-seed` (or enable bootstrap)"), ] { let n: i64 = sqlx::query_scalar(&format!("SELECT count(*) FROM {table}")) .fetch_one(pool) @@ -57,6 +62,22 @@ async fn check_catalog(pool: &PgPool) -> Result<(), Fatal> { return Err(Fatal(format!("{table} is empty — {hint}"))); } } + + let instruments: i64 = sqlx::query_scalar("SELECT count(*) FROM instrument") + .fetch_one(pool) + .await + .map_err(|e| Fatal(format!("preflight: reading instrument failed: {e}")))?; + if instruments == 0 { + if auto_sync_pending { + info!("preflight: catalog empty — a background broker sync will populate it"); + } else { + return Err(Fatal( + "instrument is empty — set broker creds (auto-sync), run \ + `make sync-broker BROKER=alpaca`, or `make db-fixtures` for the SPY-only set" + .to_string(), + )); + } + } Ok(()) } diff --git a/src/setup/bootstrap.rs b/src/setup/bootstrap.rs new file mode 100644 index 0000000..0b7e8c1 --- /dev/null +++ b/src/setup/bootstrap.rs @@ -0,0 +1,314 @@ +//! Boot-time self-provisioning, so a fresh checkout is `run the app` with no ordered +//! setup commands. +//! +//! Two phases, deliberately split by *when* they run and *which role* they use: +//! +//! * [`ensure_ready`] runs **before** the runtime pool connects, orchestrating the +//! existing idempotent scripts (`provision` → `migrate` → `access` → `seed`, plus +//! the SPY fixture on a no-creds install) as the **admin** role. The scripts use +//! psql meta-commands (`\gexec`, `\i`, `SET ROLE`) that sqlx can't run, so we +//! shell out rather than reimplement tested SQL. +//! * [`spawn_sync`] runs **after** the server is listening, as the ordinary +//! `oms_user`, populating the instrument catalog from each broker whose creds are +//! present. It is slow (Alpaca's option chain is minutes) so it never blocks boot. +//! +//! The admin phase is gated by `OMS_BOOTSTRAP` (`auto` default | `off`) and skips +//! itself when the provisioning creds are incomplete — that is the +//! externally-provisioned path, and it means bootstrap never turns a DB that already +//! boots into one that doesn't. The least-privilege posture is unchanged: the +//! *runtime* pool is still `oms_user`; only this bounded boot step touches admin +//! creds, which already live in `.env`. + +use std::env; +use std::path::PathBuf; +use std::process::Stdio; + +use sqlx::PgPool; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tracing::{error, info, warn}; + +use crate::setup::brokers::{self, Broker}; + +/// Where the `db/scripts/*.sh` orchestration scripts live. Default is relative to +/// the working directory (repo root, as `cargo run` uses); override for an unusual +/// layout. +fn scripts_dir() -> PathBuf { + env::var("OMS_DB_SCRIPTS_DIR") + .unwrap_or_else(|_| "db/scripts".to_string()) + .into() +} + +/// Is admin-phase bootstrap enabled? `off` opts out entirely (infra managed +/// elsewhere); anything else — including unset — is `auto`. +fn bootstrap_enabled() -> bool { + !env::var("OMS_BOOTSTRAP").is_ok_and(|v| v.eq_ignore_ascii_case("off")) +} + +/// The env vars the bootstrap scripts require (provision needs the role passwords; +/// migrate/access/seed need the admin login). If any is absent we skip bootstrap +/// entirely rather than fail — a DB provisioned earlier from a fuller `.env` must +/// still boot from a leaner one. Bootstrap only *adds* the "fresh install just +/// works" path; it must never make an already-working setup worse. +fn have_provision_creds() -> bool { + let set = |k: &str| env::var(k).is_ok_and(|v| !v.is_empty()); + ["ADMIN_USER", "ADMIN_PASSWORD", "MDM_MASTER_PASSWORD", "OMS_USER_PASSWORD"] + .iter() + .all(|k| set(k)) +} + +/// Provision/migrate/seed the database if needed, before the runtime pool connects. +/// +/// Returns `Err` only when a script actually fails — a disabled or credential-less +/// bootstrap is `Ok(())` (the caller then connects and lets preflight judge the DB). +pub async fn ensure_ready() -> Result<(), Box> { + if !bootstrap_enabled() { + info!("bootstrap: OMS_BOOTSTRAP=off — skipping (assuming externally provisioned)"); + return Ok(()); + } + if !have_provision_creds() { + info!("bootstrap: provisioning creds incomplete — skipping (assuming externally provisioned)"); + return Ok(()); + } + + // Idempotent, ordered: each reads what the one before it wrote. Re-running a + // fully-provisioned DB is a cheap sequence of no-ops. + run_script("provision.sh", &[]).await?; + run_script("migrate.sh", &["up"]).await?; + run_script("access.sh", &[]).await?; + run_script("seed.sh", &[]).await?; + + Ok(()) +} + +/// Load the SPY fixture when the catalog is empty and no broker can populate it, so +/// a pure no-creds install still has one tradeable instrument. Best-effort: a +/// failure here logs and is swallowed — it is a convenience, not a prerequisite. +pub async fn ensure_fixture_if_no_brokers(pool: &PgPool) { + if !bootstrap_enabled() || !have_provision_creds() { + return; + } + if Broker::ALL.iter().any(|b| b.has_creds()) { + return; // a broker will populate the catalog via sync_instruments + } + if catalog_nonempty(pool).await.unwrap_or(true) { + return; // already have instruments; nothing to seed + } + if let Err(e) = run_script("fixtures.sh", &[]).await { + warn!("bootstrap: fixture load failed (non-fatal): {e}"); + } +} + +/// Run one `db/scripts/` script, streaming its output into the log with a +/// `bootstrap:` prefix. The child inherits our environment, so the admin/DB creds +/// loaded from `.env` reach the script without re-parsing it. +async fn run_script(name: &str, args: &[&str]) -> Result<(), Box> { + let path = scripts_dir().join(name); + info!("bootstrap: running {}", path.display()); + + let mut child = Command::new("bash") + .arg(&path) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("bootstrap: could not start {}: {e}", path.display()))?; + + // Drain both pipes concurrently so a chatty script can't deadlock on a full one. + if let Some(s) = child.stdout.take() { + tokio::spawn(log_lines(name.to_string(), s, false)); + } + if let Some(s) = child.stderr.take() { + tokio::spawn(log_lines(name.to_string(), s, true)); + } + + let status = child.wait().await?; + if !status.success() { + return Err(format!( + "bootstrap: {name} exited with {status}. Fix the DB/creds, or set \ + OMS_BOOTSTRAP=off if infrastructure is provisioned elsewhere." + ) + .into()); + } + Ok(()) +} + +/// Forward a child pipe to the log, one line at a time, tagged with the script name. +async fn log_lines(script: String, reader: R, is_err: bool) +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if is_err { + warn!("bootstrap[{script}]: {line}"); + } else { + info!("bootstrap[{script}]: {line}"); + } + } +} + +async fn catalog_nonempty(pool: &PgPool) -> Result { + let n: i64 = sqlx::query_scalar("SELECT count(*) FROM instrument").fetch_one(pool).await?; + Ok(n > 0) +} + +/// Ensure a `broker_connection` exists for every broker whose creds are present. +/// +/// This is routing config, not a fixture: a credentialed broker with no connection +/// row cannot take an order at all. Runs as `oms_user` (the `oms` schema is its own), +/// idempotent on `code`. Independent of `OMS_DEV_IDENTITY` — real routing must work +/// without the dev chain. +pub async fn ensure_broker_connections(pool: &PgPool) { + for broker in Broker::ALL.iter().copied().filter(|b| b.has_creds()) { + let code = broker.connection_code(); + let res = sqlx::query( + "INSERT INTO oms.broker_connection (code, broker_code, environment, status) \ + VALUES ($1, $2, $3, 'ACTIVE') ON CONFLICT (code) DO NOTHING", + ) + .bind(&code) + .bind(broker.code()) + .bind(broker.environment()) + .execute(pool) + .await; + match res { + Ok(r) if r.rows_affected() > 0 => info!("bootstrap: created broker_connection {code}"), + Ok(_) => {} // already existed + Err(e) => error!("bootstrap: could not ensure broker_connection {code}: {e}"), + } + } +} + +/// Is the dev identity chain enabled? Off unless `OMS_DEV_IDENTITY` is truthy — it +/// creates a known API secret and must never fire in a real deployment by default. +fn dev_identity_enabled() -> bool { + env::var("OMS_DEV_IDENTITY") + .is_ok_and(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes")) +} + +/// Create the dev principal/account/portfolio/api-key so a fresh install can place a +/// paper order with no admin setup. Gated by `OMS_DEV_IDENTITY`; best-effort. +/// +/// The SQL is compiled in (`include_str!`), so this needs no scripts on disk, and +/// runs as `oms_user` — every row it writes lives in the `oms` schema. +pub async fn ensure_dev_identity(pool: &PgPool) { + if !dev_identity_enabled() { + return; + } + const SQL: &str = include_str!("../../scripts/fixtures/dev_identity.sql"); + match sqlx::raw_sql(SQL).execute(pool).await { + Ok(_) => info!("bootstrap: dev identity ready (test-trader-key : test-secret)"), + Err(e) => warn!("bootstrap: dev identity setup failed (non-fatal): {e}"), + } +} + +/// True when the app should populate an empty catalog from brokers at boot. +/// +/// `OMS_SYNC_ON_BOOT=never` opts out; anything else — including unset — is +/// `if-empty`. +pub fn sync_on_boot_enabled() -> bool { + !env::var("OMS_SYNC_ON_BOOT").is_ok_and(|v| v.eq_ignore_ascii_case("never")) +} + +/// Whether a boot-time sync will run: enabled, catalog empty, and some broker has +/// creds. The caller uses this to tell preflight an empty catalog is expected. +pub async fn will_sync_on_boot(pool: &PgPool) -> bool { + sync_on_boot_enabled() + && Broker::ALL.iter().any(|b| b.has_creds()) + && !catalog_nonempty(pool).await.unwrap_or(true) +} + +/// Populate the instrument catalog from every broker whose creds are present, on a +/// dedicated background thread. +/// +/// A separate thread with its own current-thread runtime, not `tokio::spawn`: the +/// sync path threads a non-`Send` boxed error across its awaits, and this keeps that +/// contained instead of forcing `Send + Sync` through every adapter's error type. +/// The job is genuinely independent — `setup::brokers::run` opens its own pool as +/// `oms_user` — so nothing is shared with the server runtime. Caller checks +/// [`will_sync_on_boot`] first. +pub fn spawn_sync() { + std::thread::spawn(|| { + let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() { + Ok(rt) => rt, + Err(e) => { + error!("bootstrap: could not start sync runtime: {e}"); + return; + } + }; + rt.block_on(sync_all_brokers()); + }); +} + +/// Run `sync-broker` for each broker whose creds are present. Each is independent: +/// one being unreachable logs and does not stop the others, nor the server. +async fn sync_all_brokers() { + let underlyings = env::var("OMS_SYNC_UNDERLYINGS").unwrap_or_default(); + let brokers: Vec = Broker::ALL.iter().copied().filter(|b| b.has_creds()).collect(); + info!("bootstrap: catalog empty — syncing {} broker(s) in background", brokers.len()); + + for broker in brokers { + let args = brokers::Args { + broker, + underlyings: underlyings.clone(), + no_enrich: false, + dry_run: false, + allow_skips: true, // a partial catalog beats none; preflight still warns + }; + match brokers::run(args).await { + Ok(()) => info!("bootstrap: {} sync complete", broker.code()), + Err(e) => error!("bootstrap: {} sync failed (server stays up): {e}", broker.code()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Cred detection reads the `{BROKER}_{ENV}_*` vars for the active env. These + /// tests mutate process env, so they must not run in parallel with anything else + /// reading the same keys — serialized here by living in one test. + #[test] + fn alpaca_creds_detected_only_when_both_present() { + env::set_var("ALPACA_ENV", "PAPER"); + env::remove_var("ALPACA_PAPER_API_KEY"); + env::remove_var("ALPACA_PAPER_API_SECRET"); + assert!(!Broker::Alpaca.has_creds()); + + env::set_var("ALPACA_PAPER_API_KEY", "k"); + assert!(!Broker::Alpaca.has_creds(), "key alone is not enough"); + + env::set_var("ALPACA_PAPER_API_SECRET", "s"); + assert!(Broker::Alpaca.has_creds()); + + // An empty value is not a credential. + env::set_var("ALPACA_PAPER_API_SECRET", ""); + assert!(!Broker::Alpaca.has_creds()); + + env::remove_var("ALPACA_PAPER_API_KEY"); + env::remove_var("ALPACA_PAPER_API_SECRET"); + } + + #[test] + fn bootstrap_off_is_the_only_disabling_value() { + env::set_var("OMS_BOOTSTRAP", "off"); + assert!(!bootstrap_enabled()); + env::set_var("OMS_BOOTSTRAP", "OFF"); + assert!(!bootstrap_enabled(), "case-insensitive"); + env::set_var("OMS_BOOTSTRAP", "auto"); + assert!(bootstrap_enabled()); + env::remove_var("OMS_BOOTSTRAP"); + assert!(bootstrap_enabled(), "unset defaults to enabled"); + } + + #[test] + fn sync_on_boot_defaults_on_and_only_never_disables() { + env::set_var("OMS_SYNC_ON_BOOT", "never"); + assert!(!sync_on_boot_enabled()); + env::set_var("OMS_SYNC_ON_BOOT", "if-empty"); + assert!(sync_on_boot_enabled()); + env::remove_var("OMS_SYNC_ON_BOOT"); + assert!(sync_on_boot_enabled()); + } +} diff --git a/src/setup/brokers.rs b/src/setup/brokers.rs index ef6ecf3..a3336c3 100644 --- a/src/setup/brokers.rs +++ b/src/setup/brokers.rs @@ -30,12 +30,58 @@ pub enum Broker { } impl Broker { - fn code(self) -> &'static str { + /// Every broker the app can sync — the set boot-time auto-sync iterates. + pub const ALL: &'static [Broker] = &[Broker::Alpaca, Broker::Binance]; + + pub fn code(self) -> &'static str { match self { Broker::Alpaca => "ALPACA", Broker::Binance => "BINANCE", } } + + /// The configured environment for this broker (`PAPER` | `LIVE`), from + /// `{BROKER}_ENV`, defaulting to `PAPER`. Together with [`code`](Self::code) this + /// is the `(broker_code, environment)` pair a `broker_connection` routes on. + pub fn environment(self) -> String { + match self { + Broker::Alpaca => alpaca_env(), + Broker::Binance => binance_env(), + } + } + + /// The conventional `broker_connection.code` for this broker+env, e.g. + /// `alpaca-paper`. Matches the code the dev-identity fixture references. + pub fn connection_code(self) -> String { + format!("{}-{}", self.code().to_lowercase(), self.environment().to_lowercase()) + } + + /// Whether this broker's credentials are present in the environment. + /// + /// The single source of truth for "can we sync this broker": the same env vars + /// `build_alpaca`/`build_binance` require, so cred *detection* (boot-time + /// auto-sync) and cred *use* (constructing the adapter) can never disagree. + pub fn has_creds(self) -> bool { + let set = |k: &str| env::var(k).is_ok_and(|v| !v.is_empty()); + match self { + Broker::Alpaca => { + let e = alpaca_env(); + set(&format!("ALPACA_{e}_API_KEY")) && set(&format!("ALPACA_{e}_API_SECRET")) + } + Broker::Binance => { + let e = binance_env(); + set(&format!("BINANCE_{e}_API_KEY")) && set(&format!("BINANCE_{e}_PRIVATE_KEY_PATH")) + } + } + } +} + +fn alpaca_env() -> String { + env::var("ALPACA_ENV").unwrap_or_else(|_| "PAPER".into()).to_uppercase() +} + +fn binance_env() -> String { + env::var("BINANCE_ENV").unwrap_or_else(|_| "PAPER".into()).to_uppercase() } #[derive(ClapArgs, Debug, Clone)] @@ -141,7 +187,7 @@ pub async fn run(args: Args) -> Result<(), Box> { /// Build an `AlpacaAdapter` standalone from env (`ALPACA_ENV` + `ALPACA_{ENV}_API_KEY/SECRET`). fn build_alpaca() -> Result> { - let env_name = env::var("ALPACA_ENV").unwrap_or_else(|_| "PAPER".into()).to_uppercase(); + let env_name = alpaca_env(); let key = env::var(format!("ALPACA_{env_name}_API_KEY")) .map_err(|_| format!("ALPACA_{env_name}_API_KEY must be set"))?; let secret = env::var(format!("ALPACA_{env_name}_API_SECRET")) @@ -153,7 +199,7 @@ fn build_alpaca() -> Result> { /// public, but the adapter constructor needs a valid key pair; reuse the server /// wiring (`BINANCE_{ENV}_API_KEY` + `BINANCE_{ENV}_PRIVATE_KEY_PATH`). fn build_binance() -> Result> { - let env_name = env::var("BINANCE_ENV").unwrap_or_else(|_| "PAPER".into()).to_uppercase(); + let env_name = binance_env(); let key = env::var(format!("BINANCE_{env_name}_API_KEY")) .map_err(|_| format!("BINANCE_{env_name}_API_KEY must be set"))?; let pem_path = env::var(format!("BINANCE_{env_name}_PRIVATE_KEY_PATH")) diff --git a/src/setup/mod.rs b/src/setup/mod.rs index b8cdcf0..2837990 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1,5 +1,6 @@ //! Setup / seeding subcommands invoked via `oms setup …`. +pub mod bootstrap; pub mod brokers; pub mod catalog;