Skip to content

Commit e1b19a2

Browse files
authored
Merge pull request #32 from CurtisTechSolutions/feat/postgres-server-config
feat(server): support PostgreSQL-backed local matches
2 parents e6312f8 + 0b1457a commit e1b19a2

10 files changed

Lines changed: 215 additions & 25 deletions

File tree

‎.github/scripts/local-server-smoke.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Exercise the actual CLI, HTTP listener, graceful shutdown, and restart."""
22
import json
3+
import os
34
from pathlib import Path
45
import re
56
import selectors
@@ -11,8 +12,10 @@
1112

1213

1314
def start(database):
15+
variable = os.environ.get("GFA_SMOKE_POSTGRES_ENV")
16+
storage = ["--postgres-env", variable] if variable else ["--sqlite", str(database)]
1417
process = subprocess.Popen(
15-
[str(binary), "serve", "--sqlite", str(database), "--port", "0"],
18+
[str(binary), "serve", *storage, "--port", "0"],
1619
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
1720
)
1821
try:
@@ -68,4 +71,4 @@ def request(base, path, body=None):
6871
assert replay["states"][1] == moved["state"]
6972
finally:
7073
stop(process)
71-
print("Local CLI/HTTP/SQLite restart smoke test passed.")
74+
print("Local CLI/HTTP/database restart smoke test passed.")

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ jobs:
3030
- run: cargo test --workspace --all-features --locked
3131
env:
3232
SQLX_OFFLINE: "true"
33+
- run: cargo test -p gfa-cli --locked
3334
- run: cargo build -p gfa-cli --locked
3435
- run: python3 .github/scripts/local-server-smoke.py
3536
- name: Export generated OpenAPI

‎.github/workflows/postgres.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,11 @@ jobs:
4242
run: cargo test -p gfa-store --no-default-features --features postgres --locked -- --ignored
4343
- name: Verify PostgreSQL query metadata
4444
run: python3 .github/scripts/postgres-query-check.py
45+
- name: Build PostgreSQL-enabled CLI using both backends' offline queries
46+
run: cargo build -p gfa-cli --features postgres --locked
47+
env:
48+
SQLX_OFFLINE: "true"
49+
- name: Exercise CLI and HTTP restart against PostgreSQL
50+
run: python3 .github/scripts/local-server-smoke.py
51+
env:
52+
GFA_SMOKE_POSTGRES_ENV: GFA_TEST_POSTGRES_URL

‎README.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,26 @@
33
A game library that serves as a training and evaluation environment for AI systems. It has Rust game engines behind one standardized API (REST, WebSocket and MCP), built-in opponents such as Stockfish, persistent match history, and a React + Phaser frontend for playing games, watching live matches and viewing replays.
44

55
See the [Product Requirements Document](docs/PRD.md).
6+
7+
## Run locally
8+
9+
```sh
10+
cargo run -p gfa-cli --locked -- serve --sqlite ./gfa.sqlite --port 8080
11+
```
12+
13+
Open `http://127.0.0.1:8080/docs/` for the interactive API reference. Local mode
14+
binds to loopback and treats the local caller as the match owner.
15+
16+
For PostgreSQL, enable the backend and put the connection URL in an environment
17+
variable so credentials do not appear in the command arguments:
18+
19+
```sh
20+
# Set GFA_DATABASE_URL through your shell or secret manager first.
21+
cargo run -p gfa-cli --features postgres --locked -- serve --postgres-env GFA_DATABASE_URL
22+
```
23+
24+
Use a dedicated database; startup applies embedded migrations. For remote
25+
PostgreSQL, require server certificate verification with `sslmode=verify-full`.
26+
SQLite is the default. The storage flags are mutually exclusive. Both backends
27+
persist moves and idempotency receipts atomically and recover replays after a
28+
graceful shutdown and restart.

‎crates/apps/gfa-cli/Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ license.workspace = true
88
name = "gfa"
99
path = "src/main.rs"
1010

11+
[features]
12+
default = []
13+
postgres = ["gfa-server/postgres"]
14+
1115
[dependencies]
1216
gfa-server.workspace = true
1317
tokio.workspace = true

‎crates/apps/gfa-cli/src/main.rs‎

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,61 @@
11
//! GamesForAI command-line entry point for local play.
2-
use gfa_server::{Config, ServerError};
2+
use gfa_server::{Config, Database, ServerError};
33
use std::path::PathBuf;
44

5-
const USAGE: &str = "Usage: gfa serve [--sqlite PATH] [--port PORT]\n\nStarts a local API on 127.0.0.1 (default port 8080).\nSQLite defaults to ./gfa.sqlite. Port 0 selects an available port.";
5+
const USAGE: &str = "Usage: gfa serve [--sqlite PATH | --postgres-env VARIABLE] [--port PORT]\n\nStarts a local API on 127.0.0.1 (default port 8080).\nSQLite defaults to ./gfa.sqlite. Port 0 selects an available port.\nPostgreSQL requires the postgres build feature and reads its URL from VARIABLE.";
66

77
fn parse(args: impl IntoIterator<Item = String>) -> Result<Option<Config>, String> {
8+
parse_with_env(args, |name| std::env::var(name).ok())
9+
}
10+
11+
fn parse_with_env(
12+
args: impl IntoIterator<Item = String>,
13+
env: impl Fn(&str) -> Option<String>,
14+
) -> Result<Option<Config>, String> {
15+
// Keep the same parser signature in SQLite-only builds.
16+
let _ = &env;
817
let mut args = args.into_iter();
918
match args.next().as_deref() {
1019
None | Some("--help" | "-h") => return Ok(None),
1120
Some("serve") => {}
1221
Some(command) => return Err(format!("Unknown command: {command}")),
1322
}
1423
let mut config = Config::default();
15-
let mut sqlite_seen = false;
24+
let mut database_seen = false;
1625
let mut port_seen = false;
1726
while let Some(option) = args.next() {
1827
match option.as_str() {
1928
"--help" | "-h" => return Ok(None),
20-
"--sqlite" if !sqlite_seen => {
29+
"--sqlite" if !database_seen => {
2130
let path = args.next().ok_or("--sqlite requires a path")?;
2231
if path.is_empty() || path.starts_with("--") {
2332
return Err("--sqlite requires a path".into());
2433
}
25-
config.sqlite = PathBuf::from(path);
26-
sqlite_seen = true;
34+
config.database = Database::Sqlite(PathBuf::from(path));
35+
database_seen = true;
36+
}
37+
#[cfg(feature = "postgres")]
38+
"--postgres-env" if !database_seen => {
39+
let name = args
40+
.next()
41+
.ok_or("--postgres-env requires an environment variable name")?;
42+
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
43+
{
44+
return Err("--postgres-env requires an environment variable name".into());
45+
}
46+
let url = env(&name)
47+
.ok_or("PostgreSQL environment variable is missing or not Unicode")?;
48+
if !(url.starts_with("postgres://") || url.starts_with("postgresql://")) {
49+
return Err(
50+
"PostgreSQL environment variable must contain a PostgreSQL URL".into(),
51+
);
52+
}
53+
config.database = Database::Postgres(url);
54+
database_seen = true;
55+
}
56+
#[cfg(not(feature = "postgres"))]
57+
"--postgres-env" => {
58+
return Err("PostgreSQL requires a build with --features postgres".into())
2759
}
2860
"--port" if !port_seen => {
2961
config.port = args
@@ -90,7 +122,7 @@ mod tests {
90122
assert_eq!(
91123
options(&["serve", "--sqlite", "matches.sqlite", "--port", "0"])?,
92124
Some(Config {
93-
sqlite: "matches.sqlite".into(),
125+
database: Database::Sqlite("matches.sqlite".into()),
94126
port: 0
95127
})
96128
);
@@ -99,6 +131,66 @@ mod tests {
99131
Ok(())
100132
}
101133

134+
#[cfg(feature = "postgres")]
135+
#[test]
136+
fn reads_postgres_from_environment_without_exposing_the_url() -> Result<(), String> {
137+
let url = "postgres://alice:secret@localhost/gfa?sslmode=verify-full";
138+
let parsed = parse_with_env(
139+
["serve", "--postgres-env", "GFA_DATABASE_URL"].map(str::to_owned),
140+
|name| (name == "GFA_DATABASE_URL").then(|| url.to_owned()),
141+
)?
142+
.ok_or("missing config")?;
143+
assert_eq!(parsed.database, Database::Postgres(url.to_owned()));
144+
assert!(!format!("{parsed:?}").contains("secret"));
145+
for options in [
146+
vec!["serve", "--postgres-env"],
147+
vec!["serve", "--postgres-env", "missing"],
148+
vec!["serve", "--postgres-env", "--port"],
149+
vec![
150+
"serve",
151+
"--sqlite",
152+
"x.sqlite",
153+
"--postgres-env",
154+
"GFA_DATABASE_URL",
155+
],
156+
vec![
157+
"serve",
158+
"--postgres-env",
159+
"GFA_DATABASE_URL",
160+
"--sqlite",
161+
"x.sqlite",
162+
],
163+
vec![
164+
"serve",
165+
"--postgres-env",
166+
"GFA_DATABASE_URL",
167+
"--postgres-env",
168+
"GFA_DATABASE_URL",
169+
],
170+
] {
171+
let result = parse_with_env(options.iter().map(|s| (*s).to_owned()), |name| {
172+
(name == "GFA_DATABASE_URL").then(|| url.to_owned())
173+
});
174+
assert!(result.is_err(), "{options:?}");
175+
}
176+
let error = parse_with_env(
177+
["serve", "--postgres-env", "GFA_DATABASE_URL"].map(str::to_owned),
178+
|_| Some("invalid-secret-url".into()),
179+
)
180+
.err()
181+
.ok_or("invalid URL accepted")?;
182+
assert!(!error.contains("invalid-secret-url"));
183+
Ok(())
184+
}
185+
186+
#[cfg(not(feature = "postgres"))]
187+
#[test]
188+
fn explains_missing_postgres_feature() {
189+
assert!(options(&["serve", "--postgres-env", "GFA_DATABASE_URL"])
190+
.err()
191+
.is_some_and(|error| error.contains("--features postgres")));
192+
}
193+
102194
#[test]
103195
fn rejects_invalid_or_ambiguous_options() {
104196
for args in [

‎crates/apps/gfa-server/Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ version.workspace = true
44
edition.workspace = true
55
license.workspace = true
66

7+
[features]
8+
default = []
9+
postgres = ["gfa-store/postgres"]
10+
711
[dependencies]
812
gfa-games.workspace = true
913
gfa-service.workspace = true

‎crates/apps/gfa-server/src/lib.rs‎

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
//! Local server composition: registry, lifecycle service, SQLite, and HTTP.
2-
use gfa_service::{Clock, GameService, MatchIds};
1+
//! Local server composition: registry, lifecycle service, persistence, and HTTP.
2+
use gfa_service::{Clock, GameService, MatchIds, MatchStore};
33
use gfa_store::SqliteMatchStore;
44
use std::{
55
error::Error,
@@ -18,21 +18,77 @@ pub type ServerError = Box<dyn Error + Send + Sync>;
1818
/// Configuration for auth-disabled, single-user local play.
1919
#[derive(Clone, Debug, PartialEq, Eq)]
2020
pub struct Config {
21-
/// Database file; its parent directory must already exist.
22-
pub sqlite: PathBuf,
21+
/// Persistent storage backend.
22+
pub database: Database,
2323
/// Loopback port. Zero lets the OS select an available port.
2424
pub port: u16,
2525
}
2626

2727
impl Default for Config {
2828
fn default() -> Self {
2929
Self {
30-
sqlite: "gfa.sqlite".into(),
30+
database: Database::Sqlite("gfa.sqlite".into()),
3131
port: 8080,
3232
}
3333
}
3434
}
3535

36+
/// Storage backend selected at startup.
37+
#[derive(Clone, PartialEq, Eq)]
38+
pub enum Database {
39+
/// SQLite file; its parent directory must already exist.
40+
Sqlite(PathBuf),
41+
/// PostgreSQL URL. Debug output redacts credentials and connection details.
42+
#[cfg(feature = "postgres")]
43+
Postgres(String),
44+
}
45+
46+
impl std::fmt::Debug for Database {
47+
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48+
match self {
49+
Self::Sqlite(path) => formatter.debug_tuple("Sqlite").field(path).finish(),
50+
#[cfg(feature = "postgres")]
51+
Self::Postgres(_) => formatter.write_str("Postgres([redacted])"),
52+
}
53+
}
54+
}
55+
56+
enum Store {
57+
Sqlite(Arc<SqliteMatchStore>),
58+
#[cfg(feature = "postgres")]
59+
Postgres(Arc<gfa_store::PostgresMatchStore>),
60+
}
61+
62+
impl Store {
63+
async fn open(database: &Database) -> Result<Self, ServerError> {
64+
match database {
65+
Database::Sqlite(path) => {
66+
Ok(Self::Sqlite(Arc::new(SqliteMatchStore::open(path).await?)))
67+
}
68+
#[cfg(feature = "postgres")]
69+
Database::Postgres(url) => Ok(Self::Postgres(Arc::new(
70+
gfa_store::PostgresMatchStore::connect(url).await?,
71+
))),
72+
}
73+
}
74+
75+
fn port(&self) -> Arc<dyn MatchStore> {
76+
match self {
77+
Self::Sqlite(store) => store.clone(),
78+
#[cfg(feature = "postgres")]
79+
Self::Postgres(store) => store.clone(),
80+
}
81+
}
82+
83+
async fn close(&self) {
84+
match self {
85+
Self::Sqlite(store) => store.close().await,
86+
#[cfg(feature = "postgres")]
87+
Self::Postgres(store) => store.close().await,
88+
}
89+
}
90+
}
91+
3692
struct Host;
3793
impl Clock for Host {
3894
fn now_ms(&self) -> u64 {
@@ -56,20 +112,19 @@ impl MatchIds for Host {
56112

57113
struct Application {
58114
router: axum::Router,
59-
store: Arc<SqliteMatchStore>,
115+
store: Store,
60116
updates: Arc<gfa_http::LiveUpdates>,
61117
#[cfg(test)]
62118
service: Arc<GameService>,
63119
}
64120

65121
async fn application(config: &Config, address: SocketAddr) -> Result<Application, ServerError> {
66122
let registry = gfa_games::registry()?;
67-
let store = Arc::new(SqliteMatchStore::open(&config.sqlite).await?);
123+
let store = Store::open(&config.database).await?;
68124
let host = Arc::new(Host);
69125
let updates = Arc::new(gfa_http::LiveUpdates::default());
70126
let service = Arc::new(
71-
GameService::new(registry, store.clone(), host.clone(), host)
72-
.with_observer(updates.clone()),
127+
GameService::new(registry, store.port(), host.clone(), host).with_observer(updates.clone()),
73128
);
74129
let router = gfa_http::local_router_with_updates(service.clone(), address, updates.clone())?;
75130
Ok(Application {
@@ -84,7 +139,7 @@ async fn application(config: &Config, address: SocketAddr) -> Result<Application
84139
/// Bind loopback, migrate the database, and serve until shutdown completes.
85140
///
86141
/// The address is printed after startup succeeds. A graceful shutdown drains HTTP
87-
/// requests before closing SQLite. This local mode has no agent authentication.
142+
/// requests before closing the database pool. This local mode has no agent authentication.
88143
pub async fn serve(
89144
config: Config,
90145
shutdown: impl Future<Output = ()> + Send + 'static,

0 commit comments

Comments
 (0)