OXYGENHUB
# ced6d3afc5cf oxygenhub / crates / hub / src / config.rs
10.1 KiB · Rust · last changed OxygenFlow runs: pushes start pipeline runs, a runner API with job tokens, jobs as checks, and a Flow tab with the job graph and live logs 1 month ago source blame history raw
d23464c0 Andy McCall ↶ 1
//! Configuration (hub spec §17.1): `hub.toml` in the data directory, every
2
//! key overridable by `OXYHUB_<SECTION>_<KEY>`.
3
4
use serde::Deserialize;
5
use std::net::SocketAddr;
6
use std::path::{Path, PathBuf};
7
8
#[derive(Clone, Debug, Deserialize)]
9
#[serde(default, deny_unknown_fields)]
10
pub struct Config {
11
    /// Set from the command line, never from the file.
12
    #[serde(skip)]
13
    pub data: PathBuf,
14
    pub site: Site,
15
    pub server: Server,
16
    pub tls: Tls,
17
    pub auth: Auth,
19bb31ff Andy McCall ↶ 18
    pub registration: Registration,
18e381ea Andy McCall ↶ 19
    pub email: Email,
d23464c0 Andy McCall ↶ 20
    pub limits: Limits,
21
    pub retention: Retention,
42fa9554 Andy McCall ↶ 22
    pub flow: Flow,
23
}
24
25
/// OxygenFlow runs (hub spec §13.2).
26
#[derive(Clone, Debug, Deserialize)]
27
#[serde(default, deny_unknown_fields)]
28
pub struct Flow {
29
    /// Bearer tokens runners present to claim jobs.
30
    pub runner_tokens: Vec<String>,
31
    /// `owner/*` or `owner/name`: repositories whose pushes start runs.
32
    pub repos: Vec<String>,
33
    pub job_timeout_minutes: u64,
34
}
35
36
impl Default for Flow {
37
    fn default() -> Self {
38
        Flow { runner_tokens: vec![], repos: vec![], job_timeout_minutes: 60 }
39
    }
d23464c0 Andy McCall ↶ 40
}
41
42fa9554 Andy McCall ↶ 42
impl Flow {
43
    /// Whether pushes to `owner/name` start runs.
44
    pub fn allows(&self, owner: &str, name: &str) -> bool {
45
        self.repos.iter().any(|p| match p.split_once('/') {
46
            Some((o, "*")) => o == owner,
47
            Some((o, n)) => o == owner && n == name,
48
            None => false,
49
        })
50
    }
51
}
52
18e381ea Andy McCall ↶ 53
#[derive(Clone, Debug, Default, Deserialize)]
54
#[serde(default, deny_unknown_fields)]
55
pub struct Email {
56
    /// `smtp://user:pass@host:port` or `smtps://…`; empty disables email.
57
    pub smtp: String,
8d2caf12 Andy McCall ↶ 58
    /// `OxygenHub <hub@example>`.
18e381ea Andy McCall ↶ 59
    pub from: String,
60
}
61
d23464c0 Andy McCall ↶ 62
#[derive(Clone, Debug, Deserialize)]
63
#[serde(default, deny_unknown_fields)]
19bb31ff Andy McCall ↶ 64
pub struct Registration {
65
    /// `open`, `invite` or `closed`; the admin page can override it.
66
    pub mode: String,
67
}
68
69
impl Default for Registration {
70
    fn default() -> Self {
71
        Registration { mode: "invite".into() }
72
    }
73
}
74
75
#[derive(Clone, Debug, Deserialize)]
76
#[serde(default, deny_unknown_fields)]
d23464c0 Andy McCall ↶ 77
pub struct Site {
78
    pub name: String,
79
    pub description: String,
80
}
81
82
#[derive(Clone, Debug, Deserialize)]
83
#[serde(default, deny_unknown_fields)]
84
pub struct Server {
85
    /// Plain HTTP; empty disables it.
86
    pub bind: String,
87
    /// HTTPS; empty disables it. Needs `tls`.
88
    pub tls_bind: String,
89
    /// What links and clone URLs start with.
90
    pub base_url: String,
91
    /// Trust `X-Forwarded-*` from a terminating proxy.
92
    pub behind_proxy: bool,
93
}
94
95
#[derive(Clone, Debug, Deserialize)]
96
#[serde(default, deny_unknown_fields)]
97
pub struct Tls {
98
    /// Generate a certificate at `init` (developer edition).
99
    pub self_signed: bool,
100
    /// PEM files; relative paths are under the data directory.
101
    pub cert: String,
102
    pub key: String,
103
}
104
105
#[derive(Clone, Debug, Default, Deserialize)]
106
#[serde(default, deny_unknown_fields)]
107
pub struct Auth {
108
    /// Bearer tokens allowed to push until accounts exist (hub 0.2). Empty
109
    /// means every push is accepted, as `oxy serve` does.
110
    pub push_tokens: Vec<String>,
111
}
112
113
#[derive(Clone, Debug, Deserialize)]
114
#[serde(default, deny_unknown_fields)]
115
pub struct Limits {
116
    /// Largest file rendered in the browser, e.g. `2MiB`.
117
    pub render_limit: ByteSize,
118
    pub asset_limit: ByteSize,
119
    pub push_body_limit: ByteSize,
120
}
121
122
#[derive(Clone, Debug, Deserialize)]
123
#[serde(default, deny_unknown_fields)]
124
pub struct Retention {
125
    pub trash_days: u32,
126
    pub webhook_log_days: u32,
127
}
128
129
/// A byte count written as `512`, `2MiB`, `4GiB`, `100kB`.
130
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131
pub struct ByteSize(pub u64);
132
133
impl ByteSize {
134
    pub fn parse(s: &str) -> Option<ByteSize> {
135
        let s = s.trim();
136
        let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
137
        let unit = s[digits.len()..].trim();
138
        let n: u64 = digits.parse().ok()?;
139
        let mult = match unit {
140
            "" | "B" => 1,
141
            "k" | "kB" | "KB" => 1000,
142
            "KiB" | "K" => 1 << 10,
143
            "M" | "MB" => 1_000_000,
144
            "MiB" => 1 << 20,
145
            "G" | "GB" => 1_000_000_000,
146
            "GiB" => 1 << 30,
147
            _ => return None,
148
        };
149
        Some(ByteSize(n.checked_mul(mult)?))
150
    }
151
}
152
153
impl<'de> Deserialize<'de> for ByteSize {
154
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<ByteSize, D::Error> {
155
        #[derive(Deserialize)]
156
        #[serde(untagged)]
157
        enum Raw {
158
            Number(u64),
159
            Text(String),
160
        }
161
        match Raw::deserialize(d)? {
162
            Raw::Number(n) => Ok(ByteSize(n)),
163
            Raw::Text(t) => ByteSize::parse(&t).ok_or_else(|| serde::de::Error::custom(format!("not a byte size: {t:?}"))),
164
        }
165
    }
166
}
167
168
impl Default for Site {
169
    fn default() -> Self {
8d2caf12 Andy McCall ↶ 170
        Site { name: "OxygenHub".into(), description: "Repositories, proposals and issues for Oxygen".into() }
d23464c0 Andy McCall ↶ 171
    }
172
}
173
174
impl Default for Server {
175
    fn default() -> Self {
176
        Server { bind: "127.0.0.1:7300".into(), tls_bind: String::new(), base_url: "http://127.0.0.1:7300".into(), behind_proxy: false }
177
    }
178
}
179
180
impl Default for Tls {
181
    fn default() -> Self {
182
        Tls { self_signed: false, cert: "keys/tls.crt".into(), key: "keys/tls.key".into() }
183
    }
184
}
185
186
impl Default for Limits {
187
    fn default() -> Self {
188
        Limits { render_limit: ByteSize(2 << 20), asset_limit: ByteSize(512 << 20), push_body_limit: ByteSize(4 << 30) }
189
    }
190
}
191
192
impl Default for Retention {
193
    fn default() -> Self {
194
        Retention { trash_days: 30, webhook_log_days: 30 }
195
    }
196
}
197
198
impl Default for Config {
199
    fn default() -> Self {
200
        Config {
201
            data: PathBuf::from("data"),
202
            site: Site::default(),
203
            server: Server::default(),
204
            tls: Tls::default(),
205
            auth: Auth::default(),
19bb31ff Andy McCall ↶ 206
            registration: Registration::default(),
18e381ea Andy McCall ↶ 207
            email: Email::default(),
d23464c0 Andy McCall ↶ 208
            limits: Limits::default(),
209
            retention: Retention::default(),
42fa9554 Andy McCall ↶ 210
            flow: Flow::default(),
d23464c0 Andy McCall ↶ 211
        }
212
    }
213
}
214
215
impl Config {
216
    /// Read `<data>/hub.toml` if present, then apply environment overrides.
217
    pub fn load(data: &Path) -> anyhow::Result<Config> {
218
        let path = data.join("hub.toml");
219
        let mut cfg = match std::fs::read_to_string(&path) {
220
            Ok(text) => Config::parse(&text)?,
221
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
222
            Err(e) => return Err(anyhow::anyhow!("reading {}: {e}", path.display())),
223
        };
224
        cfg.data = data.to_path_buf();
225
        cfg.apply_env(|k| std::env::var(k).ok());
226
        Ok(cfg)
227
    }
228
229
    pub fn parse(text: &str) -> anyhow::Result<Config> {
230
        Ok(toml::from_str(text)?)
231
    }
232
233
    /// `OXYHUB_SERVER_BIND`, `OXYHUB_SITE_NAME`, … override the file.
234
    pub fn apply_env(&mut self, get: impl Fn(&str) -> Option<String>) {
235
        if let Some(v) = get("OXYHUB_SITE_NAME") {
236
            self.site.name = v;
237
        }
238
        if let Some(v) = get("OXYHUB_SITE_DESCRIPTION") {
239
            self.site.description = v;
240
        }
241
        if let Some(v) = get("OXYHUB_SERVER_BIND") {
242
            self.server.bind = v;
243
        }
244
        if let Some(v) = get("OXYHUB_SERVER_TLS_BIND") {
245
            self.server.tls_bind = v;
246
        }
247
        if let Some(v) = get("OXYHUB_SERVER_BASE_URL") {
248
            self.server.base_url = v;
249
        }
250
        if let Some(v) = get("OXYHUB_SERVER_BEHIND_PROXY") {
251
            self.server.behind_proxy = matches!(v.as_str(), "1" | "true" | "yes");
252
        }
253
        if let Some(v) = get("OXYHUB_TLS_SELF_SIGNED") {
254
            self.tls.self_signed = matches!(v.as_str(), "1" | "true" | "yes");
255
        }
19bb31ff Andy McCall ↶ 256
        if let Some(v) = get("OXYHUB_REGISTRATION_MODE") {
257
            self.registration.mode = v;
258
        }
d23464c0 Andy McCall ↶ 259
        if let Some(v) = get("OXYHUB_AUTH_PUSH_TOKENS") {
260
            self.auth.push_tokens = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
261
        }
262
        if let Some(v) = get("OXYHUB_LIMITS_RENDER_LIMIT").and_then(|v| ByteSize::parse(&v)) {
263
            self.limits.render_limit = v;
264
        }
265
    }
266
267
    pub fn bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
268
        parse_addr(&self.server.bind)
269
    }
270
271
    pub fn tls_bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
272
        parse_addr(&self.server.tls_bind)
273
    }
274
275
    pub fn tls_cert(&self) -> PathBuf {
276
        self.data.join(&self.tls.cert)
277
    }
278
279
    pub fn tls_key(&self) -> PathBuf {
280
        self.data.join(&self.tls.key)
281
    }
282
283
    pub fn repos_dir(&self) -> PathBuf {
284
        self.data.join("repos")
285
    }
286
287
    pub fn keys_dir(&self) -> PathBuf {
288
        self.data.join("keys")
289
    }
290
291
    pub fn db_path(&self) -> PathBuf {
292
        self.data.join("hub.db")
293
    }
294
295
    /// The base URL without a trailing slash.
296
    pub fn base_url(&self) -> &str {
297
        self.server.base_url.trim_end_matches('/')
298
    }
299
}
300
301
fn parse_addr(s: &str) -> anyhow::Result<Option<SocketAddr>> {
302
    if s.trim().is_empty() {
303
        return Ok(None);
304
    }
305
    Ok(Some(s.parse().map_err(|e| anyhow::anyhow!("bind address {s:?}: {e}"))?))
306
}
307
308
#[cfg(test)]
309
mod tests {
310
    use super::*;
311
312
    #[test]
313
    fn byte_sizes() {
314
        assert_eq!(ByteSize::parse("2MiB"), Some(ByteSize(2 << 20)));
315
        assert_eq!(ByteSize::parse("512"), Some(ByteSize(512)));
316
        assert_eq!(ByteSize::parse("4 GiB"), Some(ByteSize(4 << 30)));
317
        assert_eq!(ByteSize::parse("100kB"), Some(ByteSize(100_000)));
318
        assert_eq!(ByteSize::parse("lots"), None);
319
    }
320
321
    #[test]
322
    fn file_then_environment() {
323
        let mut cfg = Config::parse("[server]\nbind = \"0.0.0.0:7300\"\n[limits]\nrender_limit = \"1MiB\"\n").unwrap();
324
        assert_eq!(cfg.server.bind, "0.0.0.0:7300");
325
        assert_eq!(cfg.limits.render_limit, ByteSize(1 << 20));
8d2caf12 Andy McCall ↶ 326
        assert_eq!(cfg.site.name, "OxygenHub");
d23464c0 Andy McCall ↶ 327
        cfg.apply_env(|k| match k {
328
            "OXYHUB_SITE_NAME" => Some("Earth".into()),
329
            "OXYHUB_AUTH_PUSH_TOKENS" => Some("a, b".into()),
330
            _ => None,
331
        });
332
        assert_eq!(cfg.site.name, "Earth");
333
        assert_eq!(cfg.auth.push_tokens, ["a", "b"]);
334
        assert!(Config::parse("[nope]\nx = 1\n").is_err(), "unknown sections are errors, not silence");
335
    }
336
}