Skip to main content

open_chess_server/
cli.rs

1//! The server's commands, as the `open-chess` program runs them (the
2//! `open-chess` crate; built with its `server` feature):
3//!
4//!   open-chess serve      the Lichess-compatible API
5//!   open-chess analyse    the fair-play worker (needs Stockfish)
6//!   open-chess migrate    create or update the chess schema, then exit
7//!   open-chess bot create --name esl-stockfish-1800   a bot account and its token
8//!   open-chess healthcheck   for container health checks
9
10use std::time::Duration;
11
12use anyhow::{Context, Result};
13use clap::{Parser, Subcommand};
14use tokio::sync::watch;
15use tracing::info;
16
17use crate::app::{self, Ctx};
18use crate::config::{AnalyseConfig, Config};
19use crate::db::Db;
20use crate::{analyse, auth, stopped};
21
22/// `open-chess` with no subcommand: serve, configured from the environment.
23#[derive(Parser)]
24struct ServeOnly {
25    #[command(flatten)]
26    cfg: Config,
27}
28
29/// The server's commands.
30#[derive(Subcommand)]
31pub enum Cmd {
32    /// Serve the API (and sweep clocks and challenges).
33    Serve(Config),
34    /// Screen finished games for fair play with Stockfish.
35    Analyse {
36        #[command(flatten)]
37        cfg: Config,
38        #[command(flatten)]
39        analyse: AnalyseConfig,
40    },
41    /// Create or update the chess schema and exit.
42    Migrate(Config),
43    /// Board photos kept for review.
44    Photos {
45        #[command(subcommand)]
46        cmd: PhotosCmd,
47    },
48    /// Bot accounts.
49    Bot {
50        #[command(subcommand)]
51        cmd: BotCmd,
52    },
53    /// Exit 0 if the local server answers /healthz.
54    Healthcheck {
55        #[arg(long, env = "CHESS_METRICS_BIND", default_value = "0.0.0.0:9465")]
56        metrics_bind: String,
57    },
58}
59
60#[derive(Subcommand)]
61pub enum BotCmd {
62    /// Make a bot: a site player, its bot account here and an API token
63    /// (printed once).
64    Create {
65        #[command(flatten)]
66        cfg: Config,
67        /// Username, e.g. esl-stockfish-1800.
68        #[arg(long)]
69        name: String,
70        /// Token lifetime.
71        #[arg(long, default_value_t = 365)]
72        days: i64,
73    },
74    /// List bot accounts.
75    List(Config),
76}
77
78#[derive(Subcommand)]
79pub enum PhotosCmd {
80    /// Write the photos a person confirmed as a training set for
81    /// chess-vision-train (dataset `esl-review`): `photos/<id>.<ext>` and `labels.jsonl`
82    /// (dataset `esl-review` in its datasets.toml).
83    Export {
84        #[command(flatten)]
85        cfg: Config,
86        /// Directory to write (created; existing photos are kept).
87        #[arg(long)]
88        out: std::path::PathBuf,
89        /// Only photos a reviewer confirmed, not ones only the player who
90        /// sent them did.
91        #[arg(long)]
92        reviewed_only: bool,
93    },
94}
95
96fn tracing_init() {
97    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
98        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
99    tracing_subscriber::fmt().with_env_filter(filter).with_target(false).init();
100}
101
102/// Ctrl-C or SIGTERM flips the returned watch to true.
103fn shutdown_signal() -> watch::Receiver<bool> {
104    let (tx, rx) = watch::channel(false);
105    tokio::spawn(async move {
106        let ctrl_c = tokio::signal::ctrl_c();
107        #[cfg(unix)]
108        {
109            let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
110                .expect("SIGTERM handler");
111            tokio::select! {
112                _ = ctrl_c => {}
113                _ = term.recv() => {}
114            }
115        }
116        #[cfg(not(unix))]
117        let _ = ctrl_c.await;
118        info!("shutting down");
119        let _ = tx.send(true);
120    });
121    rx
122}
123
124/// Whether the environment configures a server (`DATABASE_URL` is set):
125/// `open-chess` with no command serves then.
126pub fn configured() -> bool {
127    std::env::var_os("DATABASE_URL").is_some_and(|v| !v.is_empty())
128}
129
130/// Run a server command; `None` serves, configured from the environment.
131pub fn run(cmd: Option<Cmd>) -> Result<()> {
132    tracing_init();
133    tokio::runtime::Builder::new_multi_thread().enable_all().build()?.block_on(dispatch(cmd))
134}
135
136async fn dispatch(cmd: Option<Cmd>) -> Result<()> {
137    let cmd = match cmd {
138        Some(c) => c,
139        None => {
140            Cmd::Serve(ServeOnly::try_parse_from(["open-chess"]).context("DATABASE_URL must be set")?.cfg)
141        }
142    };
143    match cmd {
144        Cmd::Serve(cfg) => serve(cfg).await,
145        Cmd::Analyse { cfg, analyse: a } => {
146            cfg.validate()?;
147            let db = Db::connect(&cfg)?;
148            db.migrate().await?;
149            let ctx = Ctx::new(db.clone(), cfg.clone());
150            let stop = shutdown_signal();
151            let ops = tokio::net::TcpListener::bind(&cfg.metrics_bind)
152                .await
153                .context("binding CHESS_METRICS_BIND")?;
154            let stop_ops = stop.clone();
155            // The worker counts into the registry /metrics serves.
156            let metrics = ctx.metrics.clone();
157            tokio::spawn(async move {
158                let _ = axum::serve(ops, app::ops_router(ctx)).with_graceful_shutdown(wait(stop_ops)).await;
159            });
160            analyse::run(db, metrics, a, stop).await
161        }
162        Cmd::Migrate(cfg) => {
163            cfg.validate()?;
164            Db::connect(&cfg)?.migrate().await?;
165            info!("chess schema up to date");
166            Ok(())
167        }
168        Cmd::Bot { cmd: BotCmd::Create { cfg, name, days } } => bot_create(cfg, &name, days).await,
169        Cmd::Bot { cmd: BotCmd::List(cfg) } => bot_list(cfg).await,
170        Cmd::Photos { cmd: PhotosCmd::Export { cfg, out, reviewed_only } } => {
171            photos_export(cfg, &out, reviewed_only).await
172        }
173        Cmd::Healthcheck { metrics_bind } => healthcheck(&metrics_bind),
174    }
175}
176
177async fn wait(mut stop: watch::Receiver<bool>) {
178    stopped(&mut stop).await;
179}
180
181async fn serve(cfg: Config) -> Result<()> {
182    cfg.validate()?;
183    let db = Db::connect(&cfg)?;
184    db.migrate().await?;
185    let ctx = Ctx::new(db.clone(), cfg.clone()).with_reader()?;
186    let stop = shutdown_signal();
187
188    crate::spawn_background(&ctx, &stop);
189
190    let ops = tokio::net::TcpListener::bind(&cfg.metrics_bind).await.context("binding CHESS_METRICS_BIND")?;
191    let ops_ctx = ctx.clone();
192    let stop_ops = stop.clone();
193    tokio::spawn(async move {
194        let _ = axum::serve(ops, app::ops_router(ops_ctx)).with_graceful_shutdown(wait(stop_ops)).await;
195    });
196
197    let listener = tokio::net::TcpListener::bind(&cfg.bind).await.context("binding CHESS_BIND")?;
198    info!(bind = cfg.bind, public = cfg.public_url(), "chess server listening");
199    if cfg.esports_login && cfg.esports().is_none() {
200        tracing::warn!(
201            "ESPORTS_LOGIN is on but ESPORTS_SECRET isn't set (32+ characters): signing in with the site is off"
202        );
203    }
204    axum::serve(listener, app::router(&ctx)?).with_graceful_shutdown(wait(stop)).await?;
205    Ok(())
206}
207
208async fn bot_create(cfg: Config, name: &str, days: i64) -> Result<()> {
209    cfg.validate()?;
210    let db = Db::connect(&cfg)?;
211    db.migrate().await?;
212    let mut c = db.conn().await?;
213    let tx = c.transaction().await?;
214    let player: i64 = tx
215        .query_one(
216            &db.sql("INSERT INTO chess.players (name, key_hash) VALUES ($1, $2) RETURNING id"),
217            &[&name, &auth::hash_key(&auth::random_token(28))],
218        )
219        .await?
220        .get(0);
221    tx.execute(
222        &db.sql("INSERT INTO chess.accounts (player_id, username, kind) VALUES ($1, $2, 'bot')"),
223        &[&player, &name],
224    )
225    .await
226    .context("that username is taken or invalid (letters, digits, _ and -, 2–30 characters)")?;
227    let token = format!("{}{}", auth::TOKEN_PREFIX, auth::random_token(36));
228    let prefix: String = token.chars().take(auth::TOKEN_PREFIX.len() + 6).collect();
229    let scopes: Vec<String> =
230        ["bot:play", "challenge:read", "challenge:write"].iter().map(|s| (*s).to_owned()).collect();
231    let expires = chrono::Utc::now() + chrono::Duration::days(days.clamp(1, 365));
232    tx.execute(
233        &db.sql(
234            "INSERT INTO chess.api_tokens (player_id, name, token_hash, prefix, scopes, expires_at)
235             VALUES ($1, 'chess bot', $2, $3, $4, $5)",
236        ),
237        &[&player, &auth::hash_key(&token), &prefix, &scopes, &expires],
238    )
239    .await?;
240    tx.commit().await?;
241    println!("bot {name} created (player {player}); its token, shown once:\n{token}");
242    Ok(())
243}
244
245async fn bot_list(cfg: Config) -> Result<()> {
246    cfg.validate()?;
247    let db = Db::connect(&cfg)?;
248    let c = db.conn().await?;
249    for r in c
250        .query(
251            &db.sql("SELECT username, player_id, seen_at FROM chess.accounts WHERE kind = 'bot' ORDER BY lower(username)"),
252            &[],
253        )
254        .await?
255    {
256        let seen: Option<chrono::DateTime<chrono::Utc>> = r.get(2);
257        println!(
258            "{:<30} player {:<6} last seen {}",
259            r.get::<_, String>(0),
260            r.get::<_, i64>(1),
261            seen.map_or_else(|| "never".into(), |t| t.to_rfc3339())
262        );
263    }
264    Ok(())
265}
266
267async fn photos_export(cfg: Config, out: &std::path::Path, reviewed_only: bool) -> Result<()> {
268    cfg.validate()?;
269    let db = Db::connect(&cfg)?;
270    let c = db.conn().await?;
271    std::fs::create_dir_all(out.join("photos")).with_context(|| format!("creating {}", out.display()))?;
272    let rows = c
273        .query(
274            &db.sql(
275                "SELECT id, player_id, created_at, image, mime, label, label_source
276                   FROM chess.board_photos
277                  WHERE label IS NOT NULL AND NOT unreadable AND ($1 = false OR label_source = 'reviewer')
278                  ORDER BY id",
279            ),
280            &[&reviewed_only],
281        )
282        .await?;
283    let mut lines = String::new();
284    for r in &rows {
285        let id: i64 = r.get(0);
286        let player: Option<i64> = r.get(1);
287        let at: chrono::DateTime<chrono::Utc> = r.get(2);
288        let ext = match r.get::<_, String>(4).as_str() {
289            "image/png" => "png",
290            "image/webp" => "webp",
291            _ => "jpg",
292        };
293        let name = format!("photos/{id}.{ext}");
294        std::fs::write(out.join(&name), r.get::<_, Vec<u8>>(3))?;
295        // One player's photos on one day are probably of the same board, so
296        // they share a group and a split never divides them.
297        let group = format!("player-{}-{}", player.unwrap_or(0), at.format("%Y-%m-%d"));
298        lines.push_str(&serde_json::to_string(&serde_json::json!({
299            "image": name, "placement": r.get::<_, String>(5), "group": group, "source": r.get::<_, Option<String>>(6),
300        }))?);
301        lines.push('\n');
302    }
303    std::fs::write(out.join("labels.jsonl"), lines)?;
304    info!(photos = rows.len(), out = %out.display(), "exported");
305    Ok(())
306}
307
308fn healthcheck(bind: &str) -> Result<()> {
309    use std::io::{Read, Write};
310    let port = bind.rsplit(':').next().unwrap_or("9465");
311    let mut s = std::net::TcpStream::connect(format!("127.0.0.1:{port}")).context("connecting")?;
312    s.set_read_timeout(Some(Duration::from_secs(5)))?;
313    s.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?;
314    let mut buf = String::new();
315    s.read_to_string(&mut buf)?;
316    if buf.starts_with("HTTP/1.0 200") || buf.starts_with("HTTP/1.1 200") {
317        Ok(())
318    } else {
319        anyhow::bail!("unhealthy: {}", buf.lines().next().unwrap_or(""))
320    }
321}