Skip to main content

dragoon_server/
settings.rs

1//! Server runtime settings, loaded from TOML or constructed in tests.
2//!
3//! Mirrors `python/.../server/settings.py`. Frozen-by-construction — no
4//! interior mutability — so handlers can stash an `Arc<Settings>` and
5//! never worry about racing config reloads.
6
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use serde::Deserialize;
11
12use dragoon_proto::constants;
13
14#[derive(Debug, Clone, Deserialize)]
15#[serde(deny_unknown_fields)]
16struct ServerSection {
17    data_dir: PathBuf,
18    #[serde(default = "default_bind_host")]
19    bind_host: String,
20    #[serde(default = "default_bind_port")]
21    bind_port: u16,
22    #[serde(default = "default_public_url")]
23    public_url: String,
24}
25
26#[derive(Debug, Clone, Default, Deserialize)]
27#[serde(deny_unknown_fields)]
28struct StorageSection {
29    soft_quota_bytes: Option<u64>,
30    default_ttl_days: Option<u64>,
31    hard_min_free_pct: Option<u32>,
32}
33
34#[derive(Debug, Clone, Default, Deserialize)]
35#[serde(deny_unknown_fields)]
36struct SecuritySection {
37    session_ttl_hours: Option<i64>,
38    totp_issuer: Option<String>,
39    worker_register_code_ttl_sec: Option<i64>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43#[serde(deny_unknown_fields)]
44struct ConfigFile {
45    server: ServerSection,
46    #[serde(default)]
47    storage: StorageSection,
48    #[serde(default)]
49    security: SecuritySection,
50}
51
52fn default_bind_host() -> String {
53    "127.0.0.1".into()
54}
55fn default_bind_port() -> u16 {
56    8000
57}
58fn default_public_url() -> String {
59    "http://127.0.0.1:8000".into()
60}
61
62/// Frozen runtime settings shared across every axum handler.
63#[derive(Debug, Clone)]
64pub struct Settings {
65    pub data_dir: PathBuf,
66    pub public_url: String,
67    pub bind_host: String,
68    pub bind_port: u16,
69    pub session_ttl_hours: i64,
70    pub totp_issuer: String,
71    pub worker_register_code_ttl_sec: i64,
72    pub soft_quota_bytes: u64,
73    pub default_ttl_days: u64,
74    pub hard_min_free_pct: u32,
75    pub log_long_poll_sec: f64,
76    pub log_long_poll_step_sec: f64,
77}
78
79impl Settings {
80    pub fn from_toml_path(path: impl AsRef<Path>) -> Result<Self> {
81        let raw = std::fs::read_to_string(path.as_ref())
82            .with_context(|| format!("read {}", path.as_ref().display()))?;
83        let cfg: ConfigFile = toml::from_str(&raw).with_context(|| "parse server toml")?;
84        Ok(Settings::from(cfg))
85    }
86
87    pub fn for_test(data_dir: impl Into<PathBuf>) -> Self {
88        Self {
89            data_dir: data_dir.into(),
90            public_url: default_public_url(),
91            bind_host: default_bind_host(),
92            bind_port: 0,
93            session_ttl_hours: constants::SESSION_TTL_HOURS_DEFAULT,
94            totp_issuer: "RemoteExecutor".into(),
95            worker_register_code_ttl_sec: constants::WORKER_REGISTER_CODE_TTL_SEC,
96            soft_quota_bytes: constants::DEFAULT_SOFT_QUOTA_BYTES,
97            default_ttl_days: constants::DEFAULT_TTL_DAYS,
98            hard_min_free_pct: constants::DEFAULT_HARD_MIN_FREE_PCT,
99            log_long_poll_sec: 0.5,
100            log_long_poll_step_sec: 0.05,
101        }
102    }
103
104    pub fn db_path(&self) -> PathBuf {
105        self.data_dir.join("re.sqlite")
106    }
107
108    pub fn blobs_dir(&self) -> PathBuf {
109        self.data_dir.join("blobs")
110    }
111}
112
113impl From<ConfigFile> for Settings {
114    fn from(cfg: ConfigFile) -> Self {
115        Self {
116            data_dir: cfg.server.data_dir,
117            public_url: cfg.server.public_url,
118            bind_host: cfg.server.bind_host,
119            bind_port: cfg.server.bind_port,
120            session_ttl_hours: cfg
121                .security
122                .session_ttl_hours
123                .unwrap_or(constants::SESSION_TTL_HOURS_DEFAULT),
124            totp_issuer: cfg
125                .security
126                .totp_issuer
127                .unwrap_or_else(|| "RemoteExecutor".into()),
128            worker_register_code_ttl_sec: cfg
129                .security
130                .worker_register_code_ttl_sec
131                .unwrap_or(constants::WORKER_REGISTER_CODE_TTL_SEC),
132            soft_quota_bytes: cfg
133                .storage
134                .soft_quota_bytes
135                .unwrap_or(constants::DEFAULT_SOFT_QUOTA_BYTES),
136            default_ttl_days: cfg
137                .storage
138                .default_ttl_days
139                .unwrap_or(constants::DEFAULT_TTL_DAYS),
140            hard_min_free_pct: cfg
141                .storage
142                .hard_min_free_pct
143                .unwrap_or(constants::DEFAULT_HARD_MIN_FREE_PCT),
144            log_long_poll_sec: 25.0,
145            log_long_poll_step_sec: 0.5,
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn loads_minimal_toml() {
156        let dir = tempfile::tempdir().unwrap();
157        let cfg_path = dir.path().join("server.toml");
158        std::fs::write(
159            &cfg_path,
160            "[server]\ndata_dir = \"/tmp/re\"\nbind_port = 9000\n",
161        )
162        .unwrap();
163        let s = Settings::from_toml_path(&cfg_path).unwrap();
164        assert_eq!(s.data_dir, PathBuf::from("/tmp/re"));
165        assert_eq!(s.bind_port, 9000);
166        // defaults filled in
167        assert_eq!(s.bind_host, "127.0.0.1");
168        assert_eq!(s.session_ttl_hours, constants::SESSION_TTL_HOURS_DEFAULT);
169    }
170
171    #[test]
172    fn rejects_unknown_keys() {
173        let dir = tempfile::tempdir().unwrap();
174        let cfg_path = dir.path().join("server.toml");
175        std::fs::write(
176            &cfg_path,
177            "[server]\ndata_dir = \"/tmp/re\"\nbogus = true\n",
178        )
179        .unwrap();
180        assert!(Settings::from_toml_path(&cfg_path).is_err());
181    }
182}