Skip to main content

gobby_core/
bootstrap.rs

1//! Bootstrap config resolution.
2//!
3//! Reads `~/.gobby/bootstrap.yaml` to discover how the Gobby daemon is
4//! reachable: its TCP port and bind host. Falls back to loopback defaults
5//! when the file is missing, unreadable, or malformed — clients should
6//! always get *something* usable rather than error on startup.
7//!
8//! The daemon advertises `bind_host` as a listen address. `0.0.0.0` and
9//! `::` are valid listen addresses but invalid dial addresses — a user who
10//! sets `bind_host: 0.0.0.0` to expose the daemon on their LAN must still
11//! connect to `127.0.0.1` locally. Normalization lives in [`daemon_url`]
12//! (the caller concerned with dialing), not here; this module returns the
13//! raw endpoint as written.
14//!
15//! [`daemon_url`]: crate::daemon_url
16
17use std::path::{Path, PathBuf};
18
19/// Default daemon port when bootstrap.yaml is missing or malformed.
20pub const DEFAULT_DAEMON_PORT: u16 = 60887;
21
22/// Default bind host when bootstrap.yaml is missing or malformed.
23pub const DEFAULT_BIND_HOST: &str = "127.0.0.1";
24
25const BOOTSTRAP_FILENAME: &str = "bootstrap.yaml";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct HubDatabaseBootstrap {
29    pub hub_backend: Option<String>,
30    pub database_url: Option<String>,
31}
32
33/// A daemon endpoint as advertised by bootstrap.yaml.
34///
35/// `host` is returned verbatim from the config (or [`DEFAULT_BIND_HOST`]);
36/// callers that dial should apply [`crate::daemon_url`] to normalize
37/// unroutable listen addresses.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct DaemonEndpoint {
40    pub host: String,
41    pub port: u16,
42}
43
44impl Default for DaemonEndpoint {
45    fn default() -> Self {
46        Self {
47            host: DEFAULT_BIND_HOST.to_string(),
48            port: DEFAULT_DAEMON_PORT,
49        }
50    }
51}
52
53/// Resolve the path to `bootstrap.yaml` inside the Gobby home directory.
54///
55/// Respects `GOBBY_HOME` via [`crate::gobby_home`], falling back to
56/// `~/.gobby`. Returns `None` when neither `GOBBY_HOME` nor the home
57/// directory can be determined.
58pub fn bootstrap_path() -> Option<PathBuf> {
59    crate::gobby_home().ok().map(|h| h.join(BOOTSTRAP_FILENAME))
60}
61
62/// Read the daemon endpoint from the default bootstrap path.
63///
64/// Falls back to [`DaemonEndpoint::default`] on any failure — missing file,
65/// unreadable file, malformed YAML, missing fields, or no home directory.
66pub fn read_daemon_endpoint() -> DaemonEndpoint {
67    match bootstrap_path() {
68        Some(path) => read_daemon_endpoint_at(&path),
69        None => DaemonEndpoint::default(),
70    }
71}
72
73/// Read the daemon endpoint from a specific bootstrap file path.
74///
75/// Exposed for tests and for callers that know the path explicitly.
76/// Same fallback semantics as [`read_daemon_endpoint`].
77pub fn read_daemon_endpoint_at(path: &Path) -> DaemonEndpoint {
78    let Ok(contents) = std::fs::read_to_string(path) else {
79        return DaemonEndpoint::default();
80    };
81    let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents) else {
82        return DaemonEndpoint::default();
83    };
84
85    let port = yaml
86        .get("daemon_port")
87        .and_then(|v| v.as_u64())
88        .and_then(|n| u16::try_from(n).ok())
89        .unwrap_or(DEFAULT_DAEMON_PORT);
90
91    let host = yaml
92        .get("bind_host")
93        .and_then(|v| v.as_str())
94        .map(str::to_owned)
95        .unwrap_or_else(|| DEFAULT_BIND_HOST.to_string());
96
97    DaemonEndpoint { host, port }
98}
99
100pub fn read_hub_database_bootstrap_file(
101    path: &Path,
102) -> anyhow::Result<Option<HubDatabaseBootstrap>> {
103    if !path.exists() {
104        return Ok(None);
105    }
106
107    let contents = std::fs::read_to_string(path).map_err(|error| {
108        anyhow::anyhow!(
109            "failed to read Gobby bootstrap at {}: {error}",
110            path.display()
111        )
112    })?;
113    parse_hub_database_bootstrap(&contents)
114        .map_err(|error| anyhow::anyhow!("failed to parse {}: {error}", path.display()))
115}
116
117pub fn parse_hub_database_bootstrap(
118    contents: &str,
119) -> anyhow::Result<Option<HubDatabaseBootstrap>> {
120    if contents.trim().is_empty() {
121        return Ok(None);
122    }
123    let yaml: serde_yaml::Value = serde_yaml::from_str(contents)?;
124    if yaml.is_null() {
125        return Ok(None);
126    }
127    let Some(map) = yaml.as_mapping() else {
128        anyhow::bail!("bootstrap.yaml must be a mapping");
129    };
130
131    Ok(Some(HubDatabaseBootstrap {
132        hub_backend: optional_string_field(map, "hub_backend")?,
133        database_url: optional_string_field(map, "database_url")?,
134    }))
135}
136
137pub fn postgres_database_url_from_bootstrap_file(path: &Path) -> anyhow::Result<Option<String>> {
138    let Some(bootstrap) = read_hub_database_bootstrap_file(path)? else {
139        return Ok(None);
140    };
141    Ok(postgres_database_url_from_bootstrap(&bootstrap))
142}
143
144pub fn postgres_database_url_from_bootstrap(bootstrap: &HubDatabaseBootstrap) -> Option<String> {
145    if matches!(bootstrap.hub_backend.as_deref(), Some("postgres")) {
146        bootstrap.database_url.clone()
147    } else {
148        None
149    }
150}
151
152fn optional_string_field(map: &serde_yaml::Mapping, name: &str) -> anyhow::Result<Option<String>> {
153    let key = serde_yaml::Value::String(name.to_string());
154    match map.get(&key) {
155        Some(value) => match value.as_str() {
156            Some(text) => Ok(non_empty_trimmed(text)),
157            None => anyhow::bail!("bootstrap.yaml field `{name}` must be a string"),
158        },
159        None => Ok(None),
160    }
161}
162
163fn non_empty_trimmed(value: &str) -> Option<String> {
164    let trimmed = value.trim();
165    (!trimmed.is_empty()).then(|| trimmed.to_string())
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use std::fs;
172    use tempfile::tempdir;
173
174    #[test]
175    fn missing_file_returns_defaults() {
176        let dir = tempdir().unwrap();
177        let path = dir.path().join("does-not-exist.yaml");
178        assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
179    }
180
181    #[test]
182    fn malformed_yaml_returns_defaults() {
183        let dir = tempdir().unwrap();
184        let path = dir.path().join("bootstrap.yaml");
185        fs::write(&path, ": : not valid yaml ::\n\t").unwrap();
186        assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
187    }
188
189    #[test]
190    fn empty_file_returns_defaults() {
191        let dir = tempdir().unwrap();
192        let path = dir.path().join("bootstrap.yaml");
193        fs::write(&path, "").unwrap();
194        assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
195    }
196
197    #[test]
198    fn missing_fields_return_defaults() {
199        let dir = tempdir().unwrap();
200        let path = dir.path().join("bootstrap.yaml");
201        fs::write(&path, "other_field: value\n").unwrap();
202        assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
203    }
204
205    #[test]
206    fn reads_custom_port() {
207        let dir = tempdir().unwrap();
208        let path = dir.path().join("bootstrap.yaml");
209        fs::write(&path, "daemon_port: 61234\n").unwrap();
210        let ep = read_daemon_endpoint_at(&path);
211        assert_eq!(ep.port, 61234);
212        assert_eq!(ep.host, DEFAULT_BIND_HOST);
213    }
214
215    #[test]
216    fn reads_custom_host_and_port() {
217        let dir = tempdir().unwrap();
218        let path = dir.path().join("bootstrap.yaml");
219        fs::write(&path, "daemon_port: 60887\nbind_host: 0.0.0.0\n").unwrap();
220        let ep = read_daemon_endpoint_at(&path);
221        assert_eq!(ep.port, 60887);
222        assert_eq!(ep.host, "0.0.0.0");
223    }
224
225    #[test]
226    fn out_of_range_port_falls_back() {
227        let dir = tempdir().unwrap();
228        let path = dir.path().join("bootstrap.yaml");
229        fs::write(&path, "daemon_port: 70000\n").unwrap();
230        assert_eq!(read_daemon_endpoint_at(&path).port, DEFAULT_DAEMON_PORT);
231    }
232
233    #[test]
234    fn bootstrap_path_respects_gobby_home() {
235        let _lock = crate::config::TEST_ENV_LOCK
236            .lock()
237            .unwrap_or_else(|poisoned| poisoned.into_inner());
238        let previous = std::env::var_os("GOBBY_HOME");
239        let dir = tempdir().unwrap();
240        // SAFETY: GOBBY_HOME mutation is serialized through TEST_ENV_LOCK and
241        // restored before the lock is released.
242        unsafe { std::env::set_var("GOBBY_HOME", dir.path()) };
243        let path = bootstrap_path();
244        // SAFETY: still holding TEST_ENV_LOCK; restores the original value.
245        unsafe {
246            match &previous {
247                Some(value) => std::env::set_var("GOBBY_HOME", value),
248                None => std::env::remove_var("GOBBY_HOME"),
249            }
250        }
251        assert_eq!(path, Some(dir.path().join("bootstrap.yaml")));
252    }
253
254    #[test]
255    fn postgres_database_url_missing_file_returns_none() {
256        let dir = tempdir().unwrap();
257        let path = dir.path().join("does-not-exist.yaml");
258
259        assert_eq!(
260            postgres_database_url_from_bootstrap_file(&path).unwrap(),
261            None
262        );
263    }
264
265    #[test]
266    fn postgres_database_url_empty_file_returns_none() {
267        let dir = tempdir().unwrap();
268        let path = dir.path().join("bootstrap.yaml");
269        fs::write(&path, "").unwrap();
270
271        assert_eq!(
272            postgres_database_url_from_bootstrap_file(&path).unwrap(),
273            None
274        );
275    }
276
277    #[test]
278    fn postgres_database_url_null_file_returns_none() {
279        let dir = tempdir().unwrap();
280        let path = dir.path().join("bootstrap.yaml");
281        fs::write(&path, "null\n").unwrap();
282
283        assert_eq!(
284            postgres_database_url_from_bootstrap_file(&path).unwrap(),
285            None
286        );
287    }
288
289    #[test]
290    fn postgres_database_url_reads_postgres_url() {
291        let dir = tempdir().unwrap();
292        let path = dir.path().join("bootstrap.yaml");
293        fs::write(
294            &path,
295            "hub_backend: postgres\ndatabase_url: postgresql://localhost/gobby\n",
296        )
297        .unwrap();
298
299        assert_eq!(
300            postgres_database_url_from_bootstrap_file(&path)
301                .unwrap()
302                .as_deref(),
303            Some("postgresql://localhost/gobby")
304        );
305    }
306
307    #[test]
308    fn postgres_database_url_trims_url() {
309        let dir = tempdir().unwrap();
310        let path = dir.path().join("bootstrap.yaml");
311        fs::write(
312            &path,
313            "hub_backend: postgres\ndatabase_url: '  postgresql://localhost/gobby  '\n",
314        )
315        .unwrap();
316
317        assert_eq!(
318            postgres_database_url_from_bootstrap_file(&path)
319                .unwrap()
320                .as_deref(),
321            Some("postgresql://localhost/gobby")
322        );
323    }
324
325    #[test]
326    fn postgres_database_url_ignores_non_postgres_backend() {
327        let dir = tempdir().unwrap();
328        let path = dir.path().join("bootstrap.yaml");
329        fs::write(
330            &path,
331            "hub_backend: local-file\ndatabase_url: postgresql://localhost/gobby\n",
332        )
333        .unwrap();
334
335        assert_eq!(
336            postgres_database_url_from_bootstrap_file(&path).unwrap(),
337            None
338        );
339    }
340
341    #[test]
342    fn postgres_database_url_malformed_yaml_errors() {
343        let dir = tempdir().unwrap();
344        let path = dir.path().join("bootstrap.yaml");
345        fs::write(&path, "hub_backend: [").unwrap();
346
347        assert!(postgres_database_url_from_bootstrap_file(&path).is_err());
348    }
349
350    #[test]
351    fn postgres_database_url_non_empty_scalar_yaml_errors() {
352        let dir = tempdir().unwrap();
353        let path = dir.path().join("bootstrap.yaml");
354        fs::write(&path, "postgres\n").unwrap();
355
356        assert!(postgres_database_url_from_bootstrap_file(&path).is_err());
357    }
358}