use std::path::{Path, PathBuf};
pub const DEFAULT_DAEMON_PORT: u16 = 60887;
pub const DEFAULT_BIND_HOST: &str = "127.0.0.1";
const BOOTSTRAP_FILENAME: &str = "bootstrap.yaml";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HubDatabaseBootstrap {
pub hub_backend: Option<String>,
pub database_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DaemonEndpoint {
pub host: String,
pub port: u16,
}
impl Default for DaemonEndpoint {
fn default() -> Self {
Self {
host: DEFAULT_BIND_HOST.to_string(),
port: DEFAULT_DAEMON_PORT,
}
}
}
pub fn bootstrap_path() -> Option<PathBuf> {
crate::gobby_home().ok().map(|h| h.join(BOOTSTRAP_FILENAME))
}
pub fn read_daemon_endpoint() -> DaemonEndpoint {
match bootstrap_path() {
Some(path) => read_daemon_endpoint_at(&path),
None => DaemonEndpoint::default(),
}
}
pub fn read_daemon_endpoint_at(path: &Path) -> DaemonEndpoint {
let Ok(contents) = std::fs::read_to_string(path) else {
return DaemonEndpoint::default();
};
let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents) else {
return DaemonEndpoint::default();
};
let port = yaml
.get("daemon_port")
.and_then(|v| v.as_u64())
.and_then(|n| u16::try_from(n).ok())
.unwrap_or(DEFAULT_DAEMON_PORT);
let host = yaml
.get("bind_host")
.and_then(|v| v.as_str())
.map(str::to_owned)
.unwrap_or_else(|| DEFAULT_BIND_HOST.to_string());
DaemonEndpoint { host, port }
}
pub fn read_hub_database_bootstrap_file(
path: &Path,
) -> anyhow::Result<Option<HubDatabaseBootstrap>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path).map_err(|error| {
anyhow::anyhow!(
"failed to read Gobby bootstrap at {}: {error}",
path.display()
)
})?;
parse_hub_database_bootstrap(&contents)
.map_err(|error| anyhow::anyhow!("failed to parse {}: {error}", path.display()))
}
pub fn parse_hub_database_bootstrap(
contents: &str,
) -> anyhow::Result<Option<HubDatabaseBootstrap>> {
if contents.trim().is_empty() {
return Ok(None);
}
let yaml: serde_yaml::Value = serde_yaml::from_str(contents)?;
if yaml.is_null() {
return Ok(None);
}
let Some(map) = yaml.as_mapping() else {
anyhow::bail!("bootstrap.yaml must be a mapping");
};
Ok(Some(HubDatabaseBootstrap {
hub_backend: optional_string_field(map, "hub_backend")?,
database_url: optional_string_field(map, "database_url")?,
}))
}
pub fn postgres_database_url_from_bootstrap_file(path: &Path) -> anyhow::Result<Option<String>> {
let Some(bootstrap) = read_hub_database_bootstrap_file(path)? else {
return Ok(None);
};
Ok(postgres_database_url_from_bootstrap(&bootstrap))
}
pub fn postgres_database_url_from_bootstrap(bootstrap: &HubDatabaseBootstrap) -> Option<String> {
if matches!(bootstrap.hub_backend.as_deref(), Some("postgres")) {
bootstrap.database_url.clone()
} else {
None
}
}
fn optional_string_field(map: &serde_yaml::Mapping, name: &str) -> anyhow::Result<Option<String>> {
let key = serde_yaml::Value::String(name.to_string());
match map.get(&key) {
Some(value) => match value.as_str() {
Some(text) => Ok(non_empty_trimmed(text)),
None => anyhow::bail!("bootstrap.yaml field `{name}` must be a string"),
},
None => Ok(None),
}
}
fn non_empty_trimmed(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn missing_file_returns_defaults() {
let dir = tempdir().unwrap();
let path = dir.path().join("does-not-exist.yaml");
assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
}
#[test]
fn malformed_yaml_returns_defaults() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.yaml");
fs::write(&path, ": : not valid yaml ::\n\t").unwrap();
assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
}
#[test]
fn empty_file_returns_defaults() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.yaml");
fs::write(&path, "").unwrap();
assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
}
#[test]
fn missing_fields_return_defaults() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.yaml");
fs::write(&path, "other_field: value\n").unwrap();
assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
}
#[test]
fn reads_custom_port() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.yaml");
fs::write(&path, "daemon_port: 61234\n").unwrap();
let ep = read_daemon_endpoint_at(&path);
assert_eq!(ep.port, 61234);
assert_eq!(ep.host, DEFAULT_BIND_HOST);
}
#[test]
fn reads_custom_host_and_port() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.yaml");
fs::write(&path, "daemon_port: 60887\nbind_host: 0.0.0.0\n").unwrap();
let ep = read_daemon_endpoint_at(&path);
assert_eq!(ep.port, 60887);
assert_eq!(ep.host, "0.0.0.0");
}
#[test]
fn out_of_range_port_falls_back() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.yaml");
fs::write(&path, "daemon_port: 70000\n").unwrap();
assert_eq!(read_daemon_endpoint_at(&path).port, DEFAULT_DAEMON_PORT);
}
#[test]
fn bootstrap_path_respects_gobby_home() {
let _lock = crate::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let previous = std::env::var_os("GOBBY_HOME");
let dir = tempdir().unwrap();
unsafe { std::env::set_var("GOBBY_HOME", dir.path()) };
let path = bootstrap_path();
unsafe {
match &previous {
Some(value) => std::env::set_var("GOBBY_HOME", value),
None => std::env::remove_var("GOBBY_HOME"),
}
}
assert_eq!(path, Some(dir.path().join("bootstrap.yaml")));
}
#[test]
fn postgres_database_url_missing_file_returns_none() {
let dir = tempdir().unwrap();
let path = dir.path().join("does-not-exist.yaml");
assert_eq!(
postgres_database_url_from_bootstrap_file(&path).unwrap(),
None
);
}
#[test]
fn postgres_database_url_empty_file_returns_none() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.yaml");
fs::write(&path, "").unwrap();
assert_eq!(
postgres_database_url_from_bootstrap_file(&path).unwrap(),
None
);
}
#[test]
fn postgres_database_url_null_file_returns_none() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.yaml");
fs::write(&path, "null\n").unwrap();
assert_eq!(
postgres_database_url_from_bootstrap_file(&path).unwrap(),
None
);
}
#[test]
fn postgres_database_url_reads_postgres_url() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.yaml");
fs::write(
&path,
"hub_backend: postgres\ndatabase_url: postgresql://localhost/gobby\n",
)
.unwrap();
assert_eq!(
postgres_database_url_from_bootstrap_file(&path)
.unwrap()
.as_deref(),
Some("postgresql://localhost/gobby")
);
}
#[test]
fn postgres_database_url_trims_url() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.yaml");
fs::write(
&path,
"hub_backend: postgres\ndatabase_url: ' postgresql://localhost/gobby '\n",
)
.unwrap();
assert_eq!(
postgres_database_url_from_bootstrap_file(&path)
.unwrap()
.as_deref(),
Some("postgresql://localhost/gobby")
);
}
#[test]
fn postgres_database_url_ignores_non_postgres_backend() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.yaml");
fs::write(
&path,
"hub_backend: local-file\ndatabase_url: postgresql://localhost/gobby\n",
)
.unwrap();
assert_eq!(
postgres_database_url_from_bootstrap_file(&path).unwrap(),
None
);
}
#[test]
fn postgres_database_url_malformed_yaml_errors() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.yaml");
fs::write(&path, "hub_backend: [").unwrap();
assert!(postgres_database_url_from_bootstrap_file(&path).is_err());
}
#[test]
fn postgres_database_url_non_empty_scalar_yaml_errors() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.yaml");
fs::write(&path, "postgres\n").unwrap();
assert!(postgres_database_url_from_bootstrap_file(&path).is_err());
}
}