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 labelled photos as a training set for chess-vision-train:
81    /// `photos/<id>.<ext>` and `labels.jsonl` (datasets `open-chess-photos`,
82    /// or with `--public-only` `open-chess-public`, in its datasets.toml).
83    /// Only photos kept under the photo licence; each line says its licence.
84    Export {
85        #[command(flatten)]
86        cfg: Config,
87        /// Directory to write (created; existing photos are kept).
88        #[arg(long)]
89        out: std::path::PathBuf,
90        /// Only photos two people agreed on, or an admin labelled, not ones
91        /// only the player who sent them confirmed.
92        #[arg(long)]
93        reviewed_only: bool,
94        /// Only photos their senders released as CC0 (the public dataset).
95        #[arg(long)]
96        public_only: bool,
97    },
98}
99
100fn tracing_init() {
101    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
102        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
103    tracing_subscriber::fmt().with_env_filter(filter).with_target(false).init();
104}
105
106/// Ctrl-C or SIGTERM flips the returned watch to true.
107fn shutdown_signal() -> watch::Receiver<bool> {
108    let (tx, rx) = watch::channel(false);
109    tokio::spawn(async move {
110        let ctrl_c = tokio::signal::ctrl_c();
111        #[cfg(unix)]
112        {
113            let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
114                .expect("SIGTERM handler");
115            tokio::select! {
116                _ = ctrl_c => {}
117                _ = term.recv() => {}
118            }
119        }
120        #[cfg(not(unix))]
121        let _ = ctrl_c.await;
122        info!("shutting down");
123        let _ = tx.send(true);
124    });
125    rx
126}
127
128/// Whether the environment configures a server (`DATABASE_URL` is set):
129/// `open-chess` with no command serves then.
130pub fn configured() -> bool {
131    std::env::var_os("DATABASE_URL").is_some_and(|v| !v.is_empty())
132}
133
134/// Run a server command; `None` serves, configured from the environment.
135pub fn run(cmd: Option<Cmd>) -> Result<()> {
136    tracing_init();
137    tokio::runtime::Builder::new_multi_thread().enable_all().build()?.block_on(dispatch(cmd))
138}
139
140async fn dispatch(cmd: Option<Cmd>) -> Result<()> {
141    let cmd = match cmd {
142        Some(c) => c,
143        None => {
144            Cmd::Serve(ServeOnly::try_parse_from(["open-chess"]).context("DATABASE_URL must be set")?.cfg)
145        }
146    };
147    match cmd {
148        Cmd::Serve(cfg) => serve(cfg).await,
149        Cmd::Analyse { cfg, analyse: a } => {
150            cfg.validate()?;
151            let db = Db::connect(&cfg)?;
152            db.migrate().await?;
153            let ctx = Ctx::new(db.clone(), cfg.clone());
154            let stop = shutdown_signal();
155            let ops = tokio::net::TcpListener::bind(&cfg.metrics_bind)
156                .await
157                .context("binding CHESS_METRICS_BIND")?;
158            let stop_ops = stop.clone();
159            // The worker counts into the registry /metrics serves.
160            let metrics = ctx.metrics.clone();
161            tokio::spawn(async move {
162                let _ = axum::serve(ops, app::ops_router(ctx)).with_graceful_shutdown(wait(stop_ops)).await;
163            });
164            analyse::run(db, metrics, a, stop).await
165        }
166        Cmd::Migrate(cfg) => {
167            cfg.validate()?;
168            Db::connect(&cfg)?.migrate().await?;
169            info!("chess schema up to date");
170            Ok(())
171        }
172        Cmd::Bot { cmd: BotCmd::Create { cfg, name, days } } => bot_create(cfg, &name, days).await,
173        Cmd::Bot { cmd: BotCmd::List(cfg) } => bot_list(cfg).await,
174        Cmd::Photos { cmd: PhotosCmd::Export { cfg, out, reviewed_only, public_only } } => {
175            photos_export(cfg, &out, reviewed_only, public_only).await
176        }
177        Cmd::Healthcheck { metrics_bind } => healthcheck(&metrics_bind),
178    }
179}
180
181async fn wait(mut stop: watch::Receiver<bool>) {
182    stopped(&mut stop).await;
183}
184
185async fn serve(cfg: Config) -> Result<()> {
186    cfg.validate()?;
187    let db = Db::connect(&cfg)?;
188    db.migrate().await?;
189    let ctx = Ctx::new(db.clone(), cfg.clone()).with_reader()?;
190    let stop = shutdown_signal();
191
192    crate::spawn_background(&ctx, &stop);
193
194    let ops = tokio::net::TcpListener::bind(&cfg.metrics_bind).await.context("binding CHESS_METRICS_BIND")?;
195    let ops_ctx = ctx.clone();
196    let stop_ops = stop.clone();
197    tokio::spawn(async move {
198        let _ = axum::serve(ops, app::ops_router(ops_ctx)).with_graceful_shutdown(wait(stop_ops)).await;
199    });
200
201    let listener = tokio::net::TcpListener::bind(&cfg.bind).await.context("binding CHESS_BIND")?;
202    info!(bind = cfg.bind, public = cfg.public_url(), "chess server listening");
203    if cfg.esports_login && cfg.esports().is_none() {
204        tracing::warn!(
205            "ESPORTS_LOGIN is on but ESPORTS_SECRET isn't set (32+ characters): signing in with the site is off"
206        );
207    }
208    axum::serve(listener, app::router(&ctx)?).with_graceful_shutdown(wait(stop)).await?;
209    Ok(())
210}
211
212async fn bot_create(cfg: Config, name: &str, days: i64) -> Result<()> {
213    cfg.validate()?;
214    let db = Db::connect(&cfg)?;
215    db.migrate().await?;
216    let mut c = db.conn().await?;
217    let tx = c.transaction().await?;
218    let player: i64 = tx
219        .query_one(
220            &db.sql("INSERT INTO chess.players (name, key_hash) VALUES ($1, $2) RETURNING id"),
221            &[&name, &auth::hash_key(&auth::random_token(28))],
222        )
223        .await?
224        .get(0);
225    tx.execute(
226        &db.sql("INSERT INTO chess.accounts (player_id, username, kind) VALUES ($1, $2, 'bot')"),
227        &[&player, &name],
228    )
229    .await
230    .context("that username is taken or invalid (letters, digits, _ and -, 2–30 characters)")?;
231    let token = format!("{}{}", auth::TOKEN_PREFIX, auth::random_token(36));
232    let prefix: String = token.chars().take(auth::TOKEN_PREFIX.len() + 6).collect();
233    let scopes: Vec<String> =
234        ["bot:play", "challenge:read", "challenge:write"].iter().map(|s| (*s).to_owned()).collect();
235    let expires = chrono::Utc::now() + chrono::Duration::days(days.clamp(1, 365));
236    tx.execute(
237        &db.sql(
238            "INSERT INTO chess.api_tokens (player_id, name, token_hash, prefix, scopes, expires_at)
239             VALUES ($1, 'chess bot', $2, $3, $4, $5)",
240        ),
241        &[&player, &auth::hash_key(&token), &prefix, &scopes, &expires],
242    )
243    .await?;
244    tx.commit().await?;
245    println!("bot {name} created (player {player}); its token, shown once:\n{token}");
246    Ok(())
247}
248
249async fn bot_list(cfg: Config) -> Result<()> {
250    cfg.validate()?;
251    let db = Db::connect(&cfg)?;
252    let c = db.conn().await?;
253    for r in c
254        .query(
255            &db.sql("SELECT username, player_id, seen_at FROM chess.accounts WHERE kind = 'bot' ORDER BY lower(username)"),
256            &[],
257        )
258        .await?
259    {
260        let seen: Option<chrono::DateTime<chrono::Utc>> = r.get(2);
261        println!(
262            "{:<30} player {:<6} last seen {}",
263            r.get::<_, String>(0),
264            r.get::<_, i64>(1),
265            seen.map_or_else(|| "never".into(), |t| t.to_rfc3339())
266        );
267    }
268    Ok(())
269}
270
271async fn photos_export(
272    cfg: Config,
273    out: &std::path::Path,
274    reviewed_only: bool,
275    public_only: bool,
276) -> Result<()> {
277    cfg.validate()?;
278    let db = Db::connect(&cfg)?;
279    let c = db.conn().await?;
280    std::fs::create_dir_all(out.join("photos")).with_context(|| format!("creating {}", out.display()))?;
281    let rows = c
282        .query(
283            &db.sql(
284                "SELECT id, player_id, created_at, image, mime, label, label_source, public
285                   FROM chess.board_photos
286                  WHERE label IS NOT NULL AND NOT unreadable AND terms IS NOT NULL
287                    AND ($1 = false OR label_source IN ('consensus', 'admin', 'reviewer'))
288                    AND ($2 = false OR public)
289                  ORDER BY id",
290            ),
291            &[&reviewed_only, &public_only],
292        )
293        .await?;
294    let mut lines = String::new();
295    for r in &rows {
296        let id: i64 = r.get(0);
297        let player: Option<i64> = r.get(1);
298        let at: chrono::DateTime<chrono::Utc> = r.get(2);
299        let ext = match r.get::<_, String>(4).as_str() {
300            "image/png" => "png",
301            "image/webp" => "webp",
302            _ => "jpg",
303        };
304        let name = format!("photos/{id}.{ext}");
305        std::fs::write(out.join(&name), r.get::<_, Vec<u8>>(3))?;
306        // One player's photos on one day are probably of the same board, so
307        // they share a group and a split never divides them.
308        let group = format!("player-{}-{}", player.unwrap_or(0), at.format("%Y-%m-%d"));
309        lines.push_str(&serde_json::to_string(&serde_json::json!({
310            "image": name, "placement": r.get::<_, String>(5), "group": group, "source": r.get::<_, Option<String>>(6),
311            "licence": if r.get::<_, bool>(7) { "CC0-1.0" } else { PHOTO_LICENCE },
312        }))?);
313        lines.push('\n');
314    }
315    std::fs::write(out.join("labels.jsonl"), lines)?;
316    info!(photos = rows.len(), out = %out.display(), "exported");
317    Ok(())
318}
319
320/// Photos kept under the photo licence (docs/photo-licence.md), as the
321/// trainer's datasets.toml names it.
322const PHOTO_LICENCE: &str = "LicenseRef-open-chess-photo-licence-1";
323
324fn healthcheck(bind: &str) -> Result<()> {
325    use std::io::{Read, Write};
326    let port = bind.rsplit(':').next().unwrap_or("9465");
327    let mut s = std::net::TcpStream::connect(format!("127.0.0.1:{port}")).context("connecting")?;
328    s.set_read_timeout(Some(Duration::from_secs(5)))?;
329    s.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?;
330    let mut buf = String::new();
331    s.read_to_string(&mut buf)?;
332    if buf.starts_with("HTTP/1.0 200") || buf.starts_with("HTTP/1.1 200") {
333        Ok(())
334    } else {
335        anyhow::bail!("unhealthy: {}", buf.lines().next().unwrap_or(""))
336    }
337}