From c5aea3ddfe95e78f006d1da95140e2b32e46b600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilhem=20Sant=C3=A9?= <76043368+guilhem-sante@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:22:42 +0100 Subject: [PATCH] feat(market-server-rust)!: extend configuration Extended the configuration of the application to make the server host, port, and tracked symbol configurable. Environment variables can now be overridden from command line arguments. Gave FINWAR_MARKET_ prefix to all environment variables and added a configuration section to the server README.md. Added an /health route to check server status. Closes #45 Closes #49 Closes #51 BREAKING CHANGE: All environment variables have now the FINWAR_MARKET_ prefix. The DATABASE_URL variable have been splitted into DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, and DB_NAME. --- .env.dev | 2 +- docker-compose.yml | 15 ++-- servers/rust-server/Cargo.lock | 10 +-- servers/rust-server/Cargo.toml | 2 +- servers/rust-server/README.md | 21 ++++- servers/rust-server/src/cli.rs | 117 ++++++++++++++++++-------- servers/rust-server/src/clock.rs | 4 +- servers/rust-server/src/config.rs | 133 ++++++++++++++++++++++++++++++ servers/rust-server/src/home.rs | 2 +- servers/rust-server/src/main.rs | 91 ++++++++++++++------ servers/rust-server/src/state.rs | 4 +- servers/rust-server/src/trade.rs | 1 + 12 files changed, 327 insertions(+), 75 deletions(-) create mode 100644 servers/rust-server/src/config.rs diff --git a/.env.dev b/.env.dev index af1da78..96f0c0e 100644 --- a/.env.dev +++ b/.env.dev @@ -1,5 +1,5 @@ POSTGRES_USER=finwar POSTGRES_PASSWORD=password POSTGRES_DB=finwar -DATABASE_URL=postgresql://finwar:password@localhost:5432/finwar +FINWAR_MARKET_DATABASE_URL=postgresql://finwar:password@localhost:5432/finwar RUST_LOG=info diff --git a/docker-compose.yml b/docker-compose.yml index 2253c24..4dd421d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,8 +26,10 @@ services: context: ./servers/rust-server dockerfile: docker/Dockerfile environment: - DATABASE_URL: postgresql://${POSTGRES_USER:-finwar}:${POSTGRES_PASSWORD:-password}@timescaledb:5432/${POSTGRES_DB:-finwar} - RUST_LOG: info + FINWAR_MARKET_DB_USER: ${POSTGRES_USER:-finwar} + FINWAR_MARKET_DB_PASSWORD: ${POSTGRES_PASSWORD:-password} + FINWAR_MARKET_DB_HOST: timescaledb + FINWAR_MARKET_DB_NAME: ${POSTGRES_DB:-finwar} command: migrate depends_on: timescaledb: @@ -40,8 +42,11 @@ services: ports: - "4444:4444" environment: - DATABASE_URL: postgresql://${POSTGRES_USER:-finwar}:${POSTGRES_PASSWORD:-password}@timescaledb:5432/${POSTGRES_DB:-finwar} - RUST_LOG: info + FINWAR_MARKET_DB_USER: ${POSTGRES_USER:-finwar} + FINWAR_MARKET_DB_PASSWORD: ${POSTGRES_PASSWORD:-password} + FINWAR_MARKET_DB_HOST: timescaledb + FINWAR_MARKET_DB_NAME: ${POSTGRES_DB:-finwar} + FINWAR_MARKET_TRACKED_TICKER: AAPL command: serve depends_on: migration: @@ -51,7 +56,7 @@ services: image: docker.io/library/busybox:stable-uclibc command: ["sh", "-c", "tail -f /dev/null"] healthcheck: - test: ["CMD-SHELL", "wget -q --spider --tries=1 http://rust-server:4444/api/time || exit 1"] + test: ["CMD-SHELL", "wget -q --spider --tries=1 http://rust-server:4444/health || exit 1"] interval: 30s timeout: 10s retries: 3 diff --git a/servers/rust-server/Cargo.lock b/servers/rust-server/Cargo.lock index d95a963..3865cbf 100644 --- a/servers/rust-server/Cargo.lock +++ b/servers/rust-server/Cargo.lock @@ -675,9 +675,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.51" +version = "4.5.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +checksum = "aa8120877db0e5c011242f96806ce3c94e0737ab8108532a76a3300a01db2ab8" dependencies = [ "clap_builder", "clap_derive", @@ -685,9 +685,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.51" +version = "4.5.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +checksum = "02576b399397b659c26064fbc92a75fede9d18ffd5f80ca1cd74ddab167016e1" dependencies = [ "anstream", "anstyle", @@ -1081,7 +1081,7 @@ checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" [[package]] name = "finwar-market" -version = "0.2.0" +version = "0.4.0" dependencies = [ "askama", "async-std", diff --git a/servers/rust-server/Cargo.toml b/servers/rust-server/Cargo.toml index de07876..2a4d85c 100644 --- a/servers/rust-server/Cargo.toml +++ b/servers/rust-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "finwar-market" -version = "0.2.0" +version = "0.4.0" authors = ["Coding Kelps "] autotests = true categories = ["finance", "simulation"] diff --git a/servers/rust-server/README.md b/servers/rust-server/README.md index 6a77381..6ee776e 100644 --- a/servers/rust-server/README.md +++ b/servers/rust-server/README.md @@ -45,7 +45,7 @@ sea-orm-cli migrate reset ### Environment Setup -- Set `DATABASE_URL=postgres://finwar:password@localhost/finwar` as an env variable or in a .env +- Set `FINWAR_MARKET_DB_USER=finwar`, `FINWAR_MARKET_DB_PASSWORD=password`, and `FINWAR_MARKET_DB_NAME=finwar` as an env variable or in a .env - Default server runs on `0.0.0.0:4444` - Stock data loaded from `./local/data/Stocks/` directory @@ -68,7 +68,7 @@ docker build -t finwar-rust-server -f ./docker/Dockerfile . **Run only the server container:** ```bash -docker run -p 4444:4444 -e DATABASE_URL=postgresql://finwar:password@host.docker.internal:5432/finwar finwar-rust-server +docker run -p 4444:4444 -e FINWAR_MARKET_DB_USER=finwar FINWAR_MARKET_DB_PASSWORD=password FINWAR_MARKET_DB_NAME=finwar finwar-rust-server ``` **Or use docker-compose (recommended):** @@ -89,3 +89,20 @@ or in powershell: ```powershell Invoke-RestMethod -Uri http://localhost:4444/api/enroll -Method POST -ContentType "application/json" -Body '{"name":"bot0"}' ``` + +## Configuration + +| Environment Variable | Command Line. | Description | Default | +| :------------------------------------ | :------------------ | :--------------------------------------------------------------------------- | :---------------------: | +| `FINWAR_MARKET_LOG_LEVEL` | `--log-level` | The log level of the application. | `info` | +| `FINWAR_MARKET_HOST` | `--host` | The HTTP server address. | `0.0.0.0` | +| `FINWAR_MARKET_PORT` | `--port` | The HTTP server port | `4444` | +| `FINWAR_MARKET_INTERVAL_SECONDS` | `--interval-seconds` | The internal time passed (in seconds) in the Market each real second. | `60` | +| `FINWAR_MARKET_TRACKED_SYMBOL` | `--tracked-symbols` | The tracked stock ticker symbol on which the Market simulation is based on. | `AAPL` | +| `FINWAR_MARKET_DB_USER` | `--db-user` | The username used for the database authentication. | | +| `FINWAR_MARKET_DB_USER_FILE` | `--db-user-file` | The path of the file containing the database auth username. | | +| `FINWAR_MARKET_DB_PASSWORD` | `--db-password` | The password used for the database authentication. | | +| `FINWAR_MARKET_DB_PASSWORD_FILE` | `--db-password-file` | The path of the file containing the database auth password. | | +| `FINWAR_MARKET_DB_HOST` | `--db-host` | The host or address of the database to connect to. | `localhost` | +| `FINWAR_MARKET_DB_PORT` | `--db-port` | The port of the database to connect to. | `5432` | +| `FINWAR_MARKET_DB_NAME` | `--db-name` | The name of the database to connect to. | `postgres` | diff --git a/servers/rust-server/src/cli.rs b/servers/rust-server/src/cli.rs index bf3e319..134968e 100644 --- a/servers/rust-server/src/cli.rs +++ b/servers/rust-server/src/cli.rs @@ -1,48 +1,97 @@ use clap::{Parser, Subcommand}; -use migration::{Migrator, MigratorTrait}; +use std::path::PathBuf; -/// Simple CLI for the market server. Defaults to `serve` when no subcommand -/// is provided. -#[derive(Debug, Parser)] -#[command(author, version, about = "Finwar market server CLI")] -pub struct Opts { - #[command(subcommand)] - pub command: Command, +macro_rules! env_prefix { + ($name:expr) => { + concat!("FINWAR_MARKET_", $name) + }; } -#[derive(Debug, Subcommand)] -pub enum Command { - /// Start the HTTP server (default) - Serve, - /// Run database migrations using the workspace `migration` member - Migrate, +/// Simple CLI for the market server. +#[derive(Debug, Parser, Clone)] +#[command(author, version, about = "Finwar market server CLI")] +pub struct Args { + /// Log level (trace, debug, info, warn, error) + #[arg(long, env = env_prefix!("LOG_LEVEL"), default_value = "info")] + pub log_level: String, + + #[command(subcommand)] + pub command: Commands, } -pub async fn run() -> Result<(), crate::error::Error> { - let opts = Opts::parse(); +#[derive(Subcommand, Debug, Clone)] +pub enum Commands { + Serve { + /// Host to bind to + #[arg(long, env = env_prefix!("HOST"), default_value = "0.0.0.0")] + host: String, + + /// Port to bind to + #[arg(short, long, env = env_prefix!("PORT"), default_value = "4444")] + port: u16, + + /// Database user + #[arg(long, env = env_prefix!("DB_USER"), required_unless_present = "db_user_file")] + db_user: Option, + + /// Database user file path + #[arg(long, env = env_prefix!("DB_USER_FILE"), required_unless_present = "db_user")] + db_user_file: Option, + + /// Database password + #[arg(long, env = env_prefix!("DB_PASSWORD"), required_unless_present = "db_password_file")] + db_password: Option, + + /// Database password file path + #[arg(long, env = env_prefix!("DB_PASSWORD_FILE"), required_unless_present = "db_password")] + db_password_file: Option, + + /// Database host + #[arg(long, env = env_prefix!("DB_HOST"), default_value = "localhost")] + db_host: String, + + /// Database port + #[arg(long, env = env_prefix!("DB_PORT"), default_value = "5432")] + db_port: u16, + + /// Database name + #[arg(long, env = env_prefix!("DB_NAME"), default_value = "postgres")] + db_name: String, + + #[arg(long, env = env_prefix!("TRACKED_SYMBOL"), default_value = "AAPL")] + tracked_symbol: String, + + #[arg(long, env = env_prefix!("INTERVAL_SECONDS"), default_value = "60")] + interval_seconds: u64 + }, - match opts.command { - Command::Serve => { - let database_url = - std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + Migrate { + /// Database user + #[arg(long, env = env_prefix!("DB_USER"), required_unless_present = "db_user_file")] + db_user: Option, - let db_connection = sea_orm::Database::connect(&database_url) - .await - .map_err(crate::error::Error::InitDb)?; + /// Database user file path + #[arg(long, env = env_prefix!("DB_USER_FILE"), required_unless_present = "db_user")] + db_user_file: Option, - crate::run_server(db_connection).await - }, - Command::Migrate => { - let database_url = - std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + /// Database password + #[arg(long, env = env_prefix!("DB_PASSWORD"), required_unless_present = "db_password_file")] + db_password: Option, - let db_connection = sea_orm::Database::connect(&database_url) - .await - .map_err(crate::error::Error::InitDb)?; + /// Database password file path + #[arg(long, env = env_prefix!("DB_PASSWORD_FILE"), required_unless_present = "db_password")] + db_password_file: Option, - Migrator::up(&db_connection, None).await?; + /// Database host + #[arg(long, env = env_prefix!("DB_HOST"), default_value = "localhost")] + db_host: String, + + /// Database port + #[arg(long, env = env_prefix!("DB_PORT"), default_value = "5432")] + db_port: u16, - Ok(()) - }, + /// Database name + #[arg(long, env = env_prefix!("DB_NAME"), default_value = "postgres")] + db_name: String, } } diff --git a/servers/rust-server/src/clock.rs b/servers/rust-server/src/clock.rs index c71bcdf..6677dbd 100644 --- a/servers/rust-server/src/clock.rs +++ b/servers/rust-server/src/clock.rs @@ -28,7 +28,7 @@ impl MarketClock { } pub fn advance(&mut self, seconds: u64) { - self.current_time = self.current_time + Duration::from_secs(seconds * 60); + self.current_time = self.current_time + Duration::from_secs(seconds); } } @@ -50,4 +50,4 @@ pub async fn time(State(state): State) -> Result, + pub password: Sensitive, + pub host: String, + pub port: u16, + pub name: String, +} + +#[derive(Debug, Clone)] +pub struct LogConfig { + pub level: String, +} + +// Wrapper type for sensitive data that masks itself when printed +#[derive(Clone)] +pub struct Sensitive(T); + +impl Sensitive { + fn new(value: T) -> Self { + Sensitive(value) + } + + pub fn expose(&self) -> &T { + &self.0 + } +} + +impl std::fmt::Debug for Sensitive { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "***") + } +} + +impl std::fmt::Display for Sensitive { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "***") + } +} + +impl Config { + pub fn from_args( + args: Args, + ) -> Self { + match args.command { + Commands::Serve { + host, + port, + db_user, + db_user_file, + db_password, + db_password_file, + db_host, + db_port, + db_name, + tracked_symbol, + interval_seconds, + } => { + Config { + server: ServerConfig { host, port }, + database: DatabaseConfig { + user: Sensitive::new(db_user.unwrap_or_else(|| { + fs::read_to_string(db_user_file.expect("missing database user secret file path")) + .expect("failed to read database user secret file").trim_end().to_string() + })), + password: Sensitive::new(db_password.unwrap_or_else(|| { + fs::read_to_string(db_password_file.expect("missing database user secret file path")) + .expect("failed to read database password secret file").trim_end().to_string() + })), + host: db_host, + port: db_port, + name: db_name, + }, + log: LogConfig { + level: args.log_level, + }, + tracked_symbol: tracked_symbol, + interval_seconds: interval_seconds, + } + }, + Commands::Migrate { + db_user, + db_user_file, + db_password, + db_password_file, + db_host, + db_port, + db_name, + } => { + Config { + server: ServerConfig { host: String::from(""), port: 0 }, + database: DatabaseConfig { + user: Sensitive::new(db_user.unwrap_or_else(|| { + fs::read_to_string(db_user_file.expect("missing database user secret file path")) + .expect("failed to read database user secret file").trim_end().to_string() + })), + password: Sensitive::new(db_password.unwrap_or_else(|| { + fs::read_to_string(db_password_file.expect("missing database user secret file path")) + .expect("failed to read database password secret file").trim_end().to_string() + })), + host: db_host, + port: db_port, + name: db_name, + }, + log: LogConfig { + level: args.log_level, + }, + tracked_symbol: String::from(""), + interval_seconds: 0, + } + } + } + + } +} diff --git a/servers/rust-server/src/home.rs b/servers/rust-server/src/home.rs index 8863b16..12b3a45 100644 --- a/servers/rust-server/src/home.rs +++ b/servers/rust-server/src/home.rs @@ -33,7 +33,7 @@ fn calculate_ma(period: usize, data: &[Vec]) -> Vec { pub async fn chart(state: &AppState) -> Result { let records = stocks_history::Entity::find() - .filter(stocks_history::Column::Symbol.eq("AAPL")) + .filter(stocks_history::Column::Symbol.eq(&state.tracked_symbol)) .order_by_asc(stocks_history::Column::Time) .limit(300) .all(&state.db) diff --git a/servers/rust-server/src/main.rs b/servers/rust-server/src/main.rs index 6794a22..a8a98a4 100644 --- a/servers/rust-server/src/main.rs +++ b/servers/rust-server/src/main.rs @@ -1,5 +1,6 @@ pub mod bot; pub mod cli; +pub mod config; pub mod clock; pub mod data; pub mod enroll; @@ -12,17 +13,19 @@ pub mod trade; use axum::{Router, response::Redirect, routing::get, routing::post}; use dotenvy::dotenv; -use sea_orm::DatabaseConnection; -use sea_orm::EntityTrait; -use sea_orm::QueryOrder; +use sea_orm::{ColumnTrait, EntityTrait, QueryOrder, QueryFilter}; use std::sync::Arc; use tokio::sync::RwLock; use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; use tracing::Level; use tracing::event; +use clap::Parser; +use migration::{Migrator, MigratorTrait}; use crate::bot::bot_detail; +use crate::cli::{Args, Commands}; +use crate::config::Config; use crate::clock::time; use crate::clock::{MarketClock, start_clock}; use crate::enroll::enroll; @@ -36,29 +39,51 @@ use crate::trade::{buy, price, sell}; #[tokio::main] async fn main() -> Result<(), Error> { dotenv().ok(); + let args = Args::parse(); + let cfg = Config::from_args(args.clone()); - tracing_subscriber::fmt() - .with_max_level(Level::DEBUG) - .with_test_writer() - .init(); + match cfg.log.level.parse::() { + Ok(level) => { + tracing_subscriber::fmt() + .with_max_level(level) + .with_test_writer() + .init(); + }, + Err(_) => { + tracing_subscriber::fmt() + .with_max_level(Level::INFO) + .with_test_writer() + .init(); - // Delegate to cli module to choose between server or migration commands. - cli::run().await -} + event!(Level::WARN, "log level wrongly set at \"{}\" continuing server at default level \"info\"", cfg.log.level); + } + } + + match args.command { + Commands::Serve { .. } => serve(cfg).await?, + Commands::Migrate { .. } => migrate(cfg).await?, + } -/// Start the HTTP server. Separated out so `main` can dispatch to either -/// the server or other management subcommands (like `migrate`). -pub async fn run_server(db: DatabaseConnection) -> Result<(), Error> { - let tick_interval_seconds = std::env::var("TICK_INTERVAL_SECONDS") - .unwrap_or_else(|_| "1".to_string()) - .parse::() - .expect("TICK_INTERVAL_SECONDS must be a valid integer"); + Ok(()) +} - let addr = "0.0.0.0"; - let port = 4444; +/// Start the HTTP server. +async fn serve(cfg: Config) -> Result<(), Error> { + let db = sea_orm::Database::connect( + format!( + "postgresql://{}:{}@{}:{}/{}", + cfg.database.user.expose(), + cfg.database.password.expose(), + cfg.database.host, + cfg.database.port, + cfg.database.name, + )) + .await + .map_err(crate::error::Error::InitDb)?; let start_time = entity::stocks_history::Entity::find() .order_by_asc(entity::stocks_history::Column::Time) + .filter(entity::stocks_history::Column::Symbol.eq(&cfg.tracked_symbol)) .one(&db) .await .map_err(Error::InitDb)? @@ -69,14 +94,15 @@ pub async fn run_server(db: DatabaseConnection) -> Result<(), Error> { let clock = Arc::new(RwLock::new(MarketClock::new( start_time, - tick_interval_seconds, + cfg.interval_seconds, ))); start_clock(Arc::clone(&clock)); - let state = AppState::new(db, clock).await.map_err(Error::State)?; + let state = AppState::new(db, clock, cfg.tracked_symbol.clone()).await.map_err(Error::State)?; let app = Router::new() .route("/", get(|| async { Redirect::to("/home") })) + .route("/health", get(|| async { "OK" })) .route("/home", get(home)) .route("/leaderboard", get(leaderboard)) .route("/api/bot/{id}", get(bot_detail)) @@ -92,11 +118,30 @@ pub async fn run_server(db: DatabaseConnection) -> Result<(), Error> { .with_state(state) .layer(TraceLayer::new_for_http()); - let listener = tokio::net::TcpListener::bind(format!("{addr}:{port}")) + let listener = tokio::net::TcpListener::bind(format!("{}:{}", cfg.server.host, cfg.server.port)) .await .map_err(Error::Bind)?; - event!(Level::INFO, "server started and listening on http://{addr}:{port}"); + event!(Level::INFO, "server started and listening on http://{}:{}", cfg.server.host, cfg.server.port); axum::serve(listener, app).await.map_err(Error::Run)?; Ok(()) } + +// Migrate the entity to the database +async fn migrate(cfg: Config) -> Result<(), Error> { + let db = sea_orm::Database::connect( + format!( + "postgresql://{}:{}@{}:{}/{}", + cfg.database.user.expose(), + cfg.database.password.expose(), + cfg.database.host, + cfg.database.port, + cfg.database.name, + )) + .await + .map_err(crate::error::Error::InitDb)?; + + Migrator::up(&db, None).await?; + + Ok(()) +} diff --git a/servers/rust-server/src/state.rs b/servers/rust-server/src/state.rs index 5fee55d..f418503 100644 --- a/servers/rust-server/src/state.rs +++ b/servers/rust-server/src/state.rs @@ -30,16 +30,18 @@ pub struct AppState { pub starting_cash: f64, pub starting_assets: i32, pub clock: SharedClock, + pub tracked_symbol: String, } impl AppState { - pub async fn new(db: DatabaseConnection, clock: SharedClock) -> Result { + pub async fn new(db: DatabaseConnection, clock: SharedClock, tracked_symbol: String) -> Result { Ok(AppState { db, uuid_prefix_length: 18, starting_cash: 10000.0, starting_assets: 0, clock, + tracked_symbol, }) } } diff --git a/servers/rust-server/src/trade.rs b/servers/rust-server/src/trade.rs index 73b28e3..bc6749a 100644 --- a/servers/rust-server/src/trade.rs +++ b/servers/rust-server/src/trade.rs @@ -102,6 +102,7 @@ async fn get_current_price(state: &AppState) -> Result { let current_time = state.clock.read().await.current_time(); let stock = stocks_history::Entity::find() + .filter(stocks_history::Column::Symbol.eq(&state.tracked_symbol)) .filter(stocks_history::Column::Time.lte(current_time)) .order_by_desc(stocks_history::Column::Time) .one(&state.db)