Skip to main content

gobby_code/db/
resolution.rs

1use std::net::ToSocketAddrs;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use anyhow::{Context as _, anyhow, bail};
6use gobby_core::bootstrap::HubDatabaseBootstrap;
7use gobby_core::provisioning::{GCORE_CONFIG_FILENAME, StandaloneConfig};
8use serde::Deserialize;
9
10const GCODE_DATABASE_URL_ENV: &str = "GCODE_DATABASE_URL";
11const GOBBY_POSTGRES_DSN_ENV: &str = "GOBBY_POSTGRES_DSN";
12const GCODE_BROKER_TIMEOUT_MS_ENV: &str = "GCODE_BROKER_TIMEOUT_MS";
13const LOCAL_CLI_TOKEN_FILENAME: &str = "local_cli_token";
14const DEFAULT_BROKER_TIMEOUT: Duration = Duration::from_millis(7000);
15
16#[derive(Debug, Deserialize)]
17struct BrokerDatabaseUrlResponse {
18    database_url: String,
19}
20
21/// Return Gobby home, respecting `GOBBY_HOME` when the daemon was configured with it.
22pub fn gobby_home() -> anyhow::Result<PathBuf> {
23    gobby_core::gobby_home()
24}
25
26pub fn bootstrap_path() -> anyhow::Result<PathBuf> {
27    Ok(gobby_home()?.join("bootstrap.yaml"))
28}
29
30/// Resolve the PostgreSQL hub DSN from explicit overrides or Gobby bootstrap config.
31///
32/// gcode intentionally has no local database fallback. It asks the long-lived daemon
33/// broker first, then falls back to explicit DSN sources for daemonless operation.
34pub fn resolve_database_url() -> anyhow::Result<String> {
35    let home = gobby_home()?;
36    resolve_database_url_from_sources_with_identity_and_reachability(
37        &home,
38        |bootstrap_path| resolve_brokered_database_url_at(&home, bootstrap_path),
39        |name| std::env::var(name).ok(),
40        |url| gobby_core::postgres::connect_readonly(url).is_ok(),
41        gobby_core::provisioning::probe_postgres_hub_identity,
42    )
43}
44
45#[cfg(test)]
46fn resolve_database_url_from_sources(
47    home: &Path,
48    broker_resolver: impl Fn(&Path) -> anyhow::Result<String>,
49    get_var: impl FnMut(&str) -> Option<String>,
50    database_reachable: impl FnMut(&str) -> bool,
51) -> anyhow::Result<String> {
52    resolve_database_url_from_sources_with_identity_and_reachability(
53        home,
54        broker_resolver,
55        get_var,
56        database_reachable,
57        gobby_core::provisioning::probe_postgres_hub_identity,
58    )
59}
60
61#[cfg(test)]
62fn resolve_database_url_from_sources_with_identity(
63    home: &Path,
64    broker_resolver: impl Fn(&Path) -> anyhow::Result<String>,
65    get_var: impl FnMut(&str) -> Option<String>,
66    database_reachable: impl FnMut(&str) -> bool,
67    identity_probe: impl FnMut(&str) -> anyhow::Result<gobby_core::provisioning::HubIdentityProbeResult>,
68) -> anyhow::Result<String> {
69    resolve_database_url_from_sources_with_identity_and_reachability(
70        home,
71        broker_resolver,
72        get_var,
73        database_reachable,
74        identity_probe,
75    )
76}
77
78fn resolve_database_url_from_sources_with_identity_and_reachability(
79    home: &Path,
80    broker_resolver: impl Fn(&Path) -> anyhow::Result<String>,
81    get_var: impl FnMut(&str) -> Option<String>,
82    mut database_reachable: impl FnMut(&str) -> bool,
83    mut identity_probe: impl FnMut(
84        &str,
85    )
86        -> anyhow::Result<gobby_core::provisioning::HubIdentityProbeResult>,
87) -> anyhow::Result<String> {
88    let path = home.join("bootstrap.yaml");
89
90    if let Some(database_url) = resolve_database_url_from_env(get_var) {
91        return Ok(database_url);
92    }
93
94    let gcore_database_url = match resolve_database_url_from_gcore_config(home) {
95        Ok(database_url) => database_url,
96        Err(error) => {
97            log::warn!("failed to read gcore config database URL: {error}");
98            None
99        }
100    };
101
102    if let Ok(database_url) = broker_resolver(&path) {
103        if let Some(database_url) = resolve_recorded_hub_database_url(
104            gcore_database_url.as_deref(),
105            &database_url,
106            &mut database_reachable,
107            &mut identity_probe,
108        )? {
109            return Ok(database_url);
110        }
111        return Ok(database_url);
112    }
113
114    if let Some(database_url) = resolve_database_url_from_bootstrap_file(&path)? {
115        if let Some(database_url) = resolve_recorded_hub_database_url(
116            gcore_database_url.as_deref(),
117            &database_url,
118            &mut database_reachable,
119            &mut identity_probe,
120        )? {
121            return Ok(database_url);
122        }
123        return Ok(database_url);
124    }
125
126    if let Some(database_url) = gcore_database_url {
127        return Ok(database_url);
128    }
129
130    bail!(
131        "missing Gobby PostgreSQL configuration. Run `gcode setup --standalone`, set {GCODE_DATABASE_URL_ENV}, or configure the Gobby daemon bootstrap."
132    )
133}
134
135fn resolve_recorded_hub_database_url(
136    gcore_database_url: Option<&str>,
137    candidate_database_url: &str,
138    database_reachable: &mut impl FnMut(&str) -> bool,
139    identity_probe: &mut impl FnMut(
140        &str,
141    )
142        -> anyhow::Result<gobby_core::provisioning::HubIdentityProbeResult>,
143) -> anyhow::Result<Option<String>> {
144    Ok(gobby_core::provisioning::resolve_recorded_hub_database_url(
145        gcore_database_url,
146        Some(candidate_database_url),
147        database_reachable,
148        identity_probe,
149    )?
150    .map(|resolution| resolution.database_url))
151}
152
153fn resolve_database_url_from_bootstrap_file(path: &Path) -> anyhow::Result<Option<String>> {
154    let Some(bootstrap) = gobby_core::bootstrap::read_hub_database_bootstrap_file(path)? else {
155        return Ok(None);
156    };
157    resolve_database_url_from_bootstrap(&bootstrap).map(Some)
158}
159
160fn resolve_database_url_from_gcore_config(home: &Path) -> anyhow::Result<Option<String>> {
161    let Some(config) = StandaloneConfig::read_at(&home.join(GCORE_CONFIG_FILENAME))? else {
162        return Ok(None);
163    };
164    Ok(config
165        .get("databases.postgres.dsn")
166        .and_then(|value| non_empty_trimmed(Some(value.to_string()))))
167}
168
169fn resolve_database_url_from_env(
170    mut get_var: impl FnMut(&str) -> Option<String>,
171) -> Option<String> {
172    for name in [GCODE_DATABASE_URL_ENV, GOBBY_POSTGRES_DSN_ENV] {
173        if let Some(value) = non_empty_trimmed(get_var(name)) {
174            return Some(value);
175        }
176    }
177    None
178}
179
180fn resolve_database_url_from_bootstrap(bootstrap: &HubDatabaseBootstrap) -> anyhow::Result<String> {
181    let hub_backend = bootstrap
182        .hub_backend
183        .as_deref()
184        .context("bootstrap.yaml must include `hub_backend: postgres`")?;
185    if hub_backend != "postgres" {
186        bail!(
187            "gcode requires `hub_backend: postgres` in bootstrap.yaml. Current hub_backend is `{}`. Configure the Gobby PostgreSQL hub before running gcode.",
188            hub_backend
189        );
190    }
191
192    if let Some(database_url) = bootstrap.database_url.as_deref() {
193        return Ok(database_url.to_string());
194    }
195
196    bail!("hub_backend=postgres requires `database_url` in bootstrap.yaml")
197}
198
199fn non_empty_trimmed(value: Option<String>) -> Option<String> {
200    let trimmed = value.as_ref()?.trim();
201    if trimmed.is_empty() {
202        None
203    } else {
204        Some(trimmed.to_string())
205    }
206}
207
208fn resolve_brokered_database_url_at(
209    gobby_home: &Path,
210    bootstrap_path: &Path,
211) -> anyhow::Result<String> {
212    let token = read_local_cli_token_at(gobby_home)?;
213    let daemon_url = gobby_core::daemon_url::daemon_url_at(bootstrap_path);
214    request_broker_database_url(&daemon_url, &token)
215}
216
217fn read_local_cli_token_at(gobby_home: &Path) -> anyhow::Result<String> {
218    let path = gobby_home.join(LOCAL_CLI_TOKEN_FILENAME);
219    let token = std::fs::read_to_string(&path)
220        .with_context(|| format!("missing local CLI token at {}", path.display()))?;
221    let token = token.trim().to_string();
222    if token.is_empty() {
223        bail!("local CLI token at {} is empty", path.display());
224    }
225    Ok(token)
226}
227
228fn request_broker_database_url(daemon_url: &str, token: &str) -> anyhow::Result<String> {
229    validate_loopback_daemon_url(daemon_url)?;
230    let url = format!(
231        "{}/api/local/runtime/database-url",
232        daemon_url.trim_end_matches('/')
233    );
234    let timeout = broker_timeout();
235    let agent = ureq::AgentBuilder::new().timeout(timeout).build();
236    let response = agent
237        .post(&url)
238        .set("X-Gobby-Local-Token", token)
239        .call()
240        .map_err(|err| {
241            anyhow!(
242                "database_url broker request failed after {}ms: {err}",
243                timeout.as_millis()
244            )
245        })?;
246    let body: BrokerDatabaseUrlResponse = response
247        .into_json()
248        .context("database_url broker response was not valid JSON")?;
249    let database_url = body.database_url.trim().to_string();
250    validate_broker_database_url(&database_url)
251}
252
253fn broker_timeout() -> Duration {
254    broker_timeout_from_env(|name| std::env::var(name).ok())
255}
256
257fn broker_timeout_from_env(env: impl Fn(&str) -> Option<String>) -> Duration {
258    let Some(raw) = env(GCODE_BROKER_TIMEOUT_MS_ENV) else {
259        return DEFAULT_BROKER_TIMEOUT;
260    };
261    match raw.trim().parse::<u64>() {
262        Ok(value) if value > 0 => Duration::from_millis(value),
263        _ => {
264            log::warn!(
265                "invalid {GCODE_BROKER_TIMEOUT_MS_ENV}={raw:?}; using default {}ms",
266                DEFAULT_BROKER_TIMEOUT.as_millis()
267            );
268            DEFAULT_BROKER_TIMEOUT
269        }
270    }
271}
272
273fn validate_loopback_daemon_url(daemon_url: &str) -> anyhow::Result<()> {
274    let url = reqwest::Url::parse(daemon_url)
275        .with_context(|| format!("database_url broker daemon URL is invalid: {daemon_url}"))?;
276    let host = url
277        .host_str()
278        .ok_or_else(|| anyhow!("database_url broker daemon URL must include a host"))?;
279    let port = url.port_or_known_default().ok_or_else(|| {
280        anyhow!("database_url broker daemon URL must include a port or known scheme")
281    })?;
282    let mut resolved = (host, port)
283        .to_socket_addrs()
284        .with_context(|| format!("resolve database_url broker daemon host `{host}`"))?
285        .peekable();
286    if resolved.peek().is_none() {
287        bail!("database_url broker daemon host `{host}` resolved no addresses");
288    }
289    if resolved.all(|addr| addr.ip().is_loopback()) {
290        Ok(())
291    } else {
292        bail!("database_url broker daemon host `{host}` must resolve only to loopback addresses");
293    }
294}
295
296fn validate_broker_database_url(database_url: &str) -> anyhow::Result<String> {
297    if database_url.is_empty() {
298        bail!("database_url broker response was empty");
299    }
300    let Some(without_scheme) = database_url
301        .strip_prefix("postgres://")
302        .or_else(|| database_url.strip_prefix("postgresql://"))
303    else {
304        bail!("database_url broker response must use postgres:// or postgresql://");
305    };
306    let Some((authority, path_and_query)) = without_scheme.split_once('/') else {
307        bail!("database_url broker response must include a database path");
308    };
309    let host_port = authority.rsplit('@').next().unwrap_or_default();
310    let has_host = if let Some(rest) = host_port.strip_prefix('[') {
311        rest.split_once(']')
312            .is_some_and(|(host, _)| !host.is_empty())
313    } else {
314        !host_port.split(':').next().unwrap_or_default().is_empty()
315    };
316    if !has_host {
317        bail!("database_url broker response must include a host");
318    }
319    let database_path = path_and_query.split('?').next().unwrap_or_default();
320    if database_path.is_empty() {
321        bail!("database_url broker response must include a database path");
322    }
323    Ok(database_url.to_string())
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use std::io::{Read as _, Write as _};
330    use std::net::TcpListener;
331    use std::thread;
332
333    fn bootstrap(hub_backend: &str, database_url: Option<&str>) -> HubDatabaseBootstrap {
334        HubDatabaseBootstrap {
335            hub_backend: Some(hub_backend.to_string()),
336            database_url: database_url.map(str::to_string),
337        }
338    }
339
340    #[test]
341    fn database_url_env_prefers_gcode_specific_var() {
342        let resolved = resolve_database_url_from_env(|name| match name {
343            GCODE_DATABASE_URL_ENV => Some(" postgresql://env/db ".to_string()),
344            GOBBY_POSTGRES_DSN_ENV => Some("postgresql://gobby/db".to_string()),
345            _ => None,
346        });
347
348        assert_eq!(resolved.as_deref(), Some("postgresql://env/db"));
349    }
350
351    #[test]
352    fn database_url_env_falls_back_to_gobby_postgres_dsn() {
353        let resolved = resolve_database_url_from_env(|name| match name {
354            GOBBY_POSTGRES_DSN_ENV => Some(" postgresql://gobby/db ".to_string()),
355            _ => None,
356        });
357
358        assert_eq!(resolved.as_deref(), Some("postgresql://gobby/db"));
359    }
360
361    #[test]
362    fn database_url_env_ignores_empty_values() {
363        let resolved = resolve_database_url_from_env(|name| match name {
364            GCODE_DATABASE_URL_ENV => Some("  ".to_string()),
365            GOBBY_POSTGRES_DSN_ENV => Some("\n\t".to_string()),
366            _ => None,
367        });
368
369        assert_eq!(resolved, None);
370    }
371
372    #[test]
373    fn database_url_sources_prefer_env_over_daemon_broker() {
374        let home = tempfile::tempdir().expect("temp home");
375
376        let resolved = resolve_database_url_from_sources(
377            home.path(),
378            |_| Ok("postgresql://broker/db".to_string()),
379            |name| match name {
380                GCODE_DATABASE_URL_ENV => Some("postgresql://env/db".to_string()),
381                _ => None,
382            },
383            |_| true,
384        )
385        .expect("resolve database url");
386
387        assert_eq!(resolved, "postgresql://env/db");
388    }
389
390    #[test]
391    fn database_url_sources_use_daemon_broker_after_env() {
392        let home = tempfile::tempdir().expect("temp home");
393
394        let resolved = resolve_database_url_from_sources(
395            home.path(),
396            |_| Ok("postgresql://broker/db".to_string()),
397            |_| None,
398            |_| true,
399        )
400        .expect("resolve database url");
401
402        assert_eq!(resolved, "postgresql://broker/db");
403    }
404
405    #[test]
406    fn database_url_sources_fall_back_to_bootstrap_inline_when_daemon_is_unavailable() {
407        let home = tempfile::tempdir().expect("temp home");
408        std::fs::write(
409            home.path().join("bootstrap.yaml"),
410            "hub_backend: postgres\ndatabase_url: postgresql://inline/db\n",
411        )
412        .expect("write bootstrap");
413
414        let resolved = resolve_database_url_from_sources(
415            home.path(),
416            |_| bail!("daemon unavailable"),
417            |_| None,
418            |_| true,
419        )
420        .expect("resolve database url");
421
422        assert_eq!(resolved, "postgresql://inline/db");
423    }
424
425    #[test]
426    fn database_url_sources_use_gcore_after_daemon_and_bootstrap() {
427        let home = tempfile::tempdir().expect("temp home");
428        std::fs::write(
429            home.path().join(GCORE_CONFIG_FILENAME),
430            "databases.postgres.dsn: postgresql://gcore/db\n",
431        )
432        .expect("write gcore config");
433
434        let resolved = resolve_database_url_from_sources(
435            home.path(),
436            |_| bail!("daemon unavailable"),
437            |_| None,
438            |_| true,
439        )
440        .expect("resolve database url");
441
442        assert_eq!(resolved, "postgresql://gcore/db");
443    }
444
445    #[test]
446    fn adopted_hub_resolves_without_conflict() {
447        let home = tempfile::tempdir().expect("temp home");
448        std::fs::write(
449            home.path().join(GCORE_CONFIG_FILENAME),
450            "databases.postgres.dsn: postgresql://adopted/gobby\n",
451        )
452        .expect("write gcore config");
453
454        let resolved = resolve_database_url_from_sources_with_identity(
455            home.path(),
456            |_| Ok("postgresql://adopted/gobby".to_string()),
457            |_| None,
458            |_| true,
459            |_| {
460                Ok(gobby_core::provisioning::HubIdentityProbeResult::Known(
461                    gobby_core::provisioning::HubIdentity {
462                        system_identifier: "cluster-a".to_string(),
463                        database_name: "gobby".to_string(),
464                    },
465                ))
466            },
467        )
468        .expect("resolve adopted hub");
469
470        assert_eq!(resolved, "postgresql://adopted/gobby");
471    }
472
473    #[test]
474    fn postgres_bootstrap_accepts_inline_url() {
475        let resolved = resolve_database_url_from_bootstrap(&bootstrap(
476            "postgres",
477            Some("postgresql://inline/db"),
478        ))
479        .expect("resolve inline url");
480
481        assert_eq!(resolved, "postgresql://inline/db");
482    }
483
484    #[test]
485    fn non_postgres_bootstrap_fails_clearly() {
486        let err = resolve_database_url_from_bootstrap(&bootstrap("local-file", None))
487            .expect_err("non-postgres backend must fail");
488
489        let message = err.to_string();
490        assert!(message.contains("hub_backend: postgres"));
491        assert!(message.contains("local-file"));
492    }
493
494    #[test]
495    fn missing_hub_backend_fails_clearly() {
496        let bootstrap = gobby_core::bootstrap::parse_hub_database_bootstrap(
497            "database_url: postgresql://inline/db\n",
498        )
499        .expect("parse bootstrap")
500        .expect("bootstrap data");
501        let err = resolve_database_url_from_bootstrap(&bootstrap)
502            .expect_err("missing hub_backend must fail");
503
504        assert!(err.to_string().contains("hub_backend: postgres"));
505    }
506
507    #[test]
508    fn missing_postgres_dsn_fails_clearly() {
509        let err = resolve_database_url_from_bootstrap(&bootstrap("postgres", None))
510            .expect_err("missing dsn must fail");
511
512        assert!(err.to_string().contains("database_url"));
513    }
514
515    #[test]
516    fn parse_bootstrap_database_reads_postgres_fields() {
517        let parsed = gobby_core::bootstrap::parse_hub_database_bootstrap(
518            "hub_backend: postgres\n\
519             database_url: postgresql://inline/db\n",
520        )
521        .expect("parse bootstrap")
522        .expect("bootstrap data");
523
524        assert_eq!(parsed.hub_backend.as_deref(), Some("postgres"));
525        assert_eq!(
526            parsed.database_url.as_deref(),
527            Some("postgresql://inline/db")
528        );
529    }
530
531    #[test]
532    fn broker_request_returns_database_url_and_sends_local_token() {
533        let (daemon_url, request) = spawn_http_response(http_response(
534            "200 OK",
535            r#"{"database_url":"postgresql://broker/db"}"#,
536        ));
537
538        let resolved =
539            request_broker_database_url(&daemon_url, "token-123").expect("broker resolves");
540        let request = request.join().expect("read request");
541
542        assert_eq!(resolved, "postgresql://broker/db");
543        assert!(request.starts_with("POST /api/local/runtime/database-url HTTP/1.1"));
544        assert!(
545            request
546                .to_ascii_lowercase()
547                .contains("x-gobby-local-token: token-123")
548        );
549    }
550
551    #[test]
552    fn broker_request_rejects_non_loopback_daemon_url_before_sending_local_token() {
553        let err = request_broker_database_url("http://192.0.2.1:60887", "token-123")
554            .expect_err("non-loopback daemon URL must fail");
555
556        assert!(
557            err.to_string()
558                .contains("must resolve only to loopback addresses")
559        );
560    }
561
562    #[test]
563    fn broker_request_allows_cold_daemon_latency() {
564        let (daemon_url, request) = spawn_http_response_after(
565            http_response("200 OK", r#"{"database_url":"postgresql://broker/db"}"#),
566            Duration::from_millis(1100),
567        );
568
569        let resolved =
570            request_broker_database_url(&daemon_url, "token-123").expect("broker resolves");
571        let _ = request.join().expect("read request");
572
573        assert_eq!(resolved, "postgresql://broker/db");
574    }
575
576    #[test]
577    fn broker_timeout_defaults_to_seven_seconds() {
578        let timeout = broker_timeout_from_env(|_| None);
579
580        assert_eq!(timeout, Duration::from_millis(7000));
581    }
582
583    #[test]
584    fn broker_timeout_reads_positive_env_value() {
585        let timeout = broker_timeout_from_env(|name| {
586            (name == GCODE_BROKER_TIMEOUT_MS_ENV).then(|| "1250".to_string())
587        });
588
589        assert_eq!(timeout, Duration::from_millis(1250));
590    }
591
592    #[test]
593    fn broker_timeout_ignores_invalid_env_value() {
594        let timeout = broker_timeout_from_env(|name| {
595            (name == GCODE_BROKER_TIMEOUT_MS_ENV).then(|| "0".to_string())
596        });
597
598        assert_eq!(timeout, DEFAULT_BROKER_TIMEOUT);
599    }
600
601    #[test]
602    fn broker_missing_token_fails() {
603        let home = tempfile::tempdir().expect("temp home");
604        let bootstrap_path = write_bootstrap(home.path(), 60887);
605
606        let err = resolve_brokered_database_url_at(home.path(), &bootstrap_path)
607            .expect_err("missing token must fail");
608
609        assert!(err.to_string().contains("missing local CLI token"));
610    }
611
612    #[test]
613    fn broker_daemon_down_fails() {
614        let home = tempfile::tempdir().expect("temp home");
615        std::fs::write(home.path().join(LOCAL_CLI_TOKEN_FILENAME), "token\n").expect("write token");
616        let bootstrap_path = write_bootstrap(home.path(), 9);
617
618        let err = resolve_brokered_database_url_at(home.path(), &bootstrap_path)
619            .expect_err("daemon down must fail");
620
621        assert!(
622            err.to_string()
623                .contains("database_url broker request failed")
624        );
625    }
626
627    #[test]
628    fn broker_auth_failure_fails() {
629        let (daemon_url, request) = spawn_http_response(http_response(
630            "401 Unauthorized",
631            r#"{"error":"bad token"}"#,
632        ));
633
634        let err = request_broker_database_url(&daemon_url, "bad-token")
635            .expect_err("auth failure must fail");
636        let _ = request.join().expect("read request");
637
638        assert!(
639            err.to_string()
640                .contains("database_url broker request failed")
641        );
642    }
643
644    #[test]
645    fn broker_non_success_status_fails() {
646        let (daemon_url, request) = spawn_http_response(http_response(
647            "503 Service Unavailable",
648            r#"{"error":"unavailable"}"#,
649        ));
650
651        let err = request_broker_database_url(&daemon_url, "token")
652            .expect_err("non-success status must fail");
653        let _ = request.join().expect("read request");
654
655        assert!(
656            err.to_string()
657                .contains("database_url broker request failed")
658        );
659    }
660
661    #[test]
662    fn broker_invalid_json_fails() {
663        let (daemon_url, request) = spawn_http_response(http_response("200 OK", "not json"));
664
665        let err =
666            request_broker_database_url(&daemon_url, "token").expect_err("invalid JSON must fail");
667        let _ = request.join().expect("read request");
668
669        assert!(
670            err.to_string()
671                .contains("database_url broker response was not valid JSON")
672        );
673    }
674
675    #[test]
676    fn broker_empty_database_url_fails() {
677        let (daemon_url, request) =
678            spawn_http_response(http_response("200 OK", r#"{"database_url":"  "}"#));
679
680        let err =
681            request_broker_database_url(&daemon_url, "token").expect_err("empty DSN must fail");
682        let _ = request.join().expect("read request");
683
684        assert!(
685            err.to_string()
686                .contains("database_url broker response was empty")
687        );
688    }
689
690    #[test]
691    fn broker_invalid_database_url_scheme_fails() {
692        let err = validate_broker_database_url("http://broker/db")
693            .expect_err("non-postgres scheme must fail");
694
695        assert!(
696            err.to_string()
697                .contains("must use postgres:// or postgresql://")
698        );
699    }
700
701    #[test]
702    fn broker_missing_database_url_host_fails() {
703        let err =
704            validate_broker_database_url("postgresql:///db").expect_err("missing host must fail");
705
706        assert!(
707            err.to_string()
708                .contains("database_url broker response must include a host")
709        );
710    }
711
712    #[test]
713    fn broker_missing_database_url_path_fails() {
714        let err = validate_broker_database_url("postgresql://broker/")
715            .expect_err("missing path must fail");
716
717        assert!(
718            err.to_string()
719                .contains("database_url broker response must include a database path")
720        );
721    }
722
723    fn write_bootstrap(home: &Path, daemon_port: u16) -> PathBuf {
724        let path = home.join("bootstrap.yaml");
725        std::fs::write(
726            &path,
727            format!("hub_backend: postgres\ndaemon_port: {daemon_port}\nbind_host: 127.0.0.1\n"),
728        )
729        .expect("write bootstrap");
730        path
731    }
732
733    fn http_response(status: &str, body: &str) -> String {
734        format!(
735            "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
736            body.len()
737        )
738    }
739
740    fn spawn_http_response(response: String) -> (String, thread::JoinHandle<String>) {
741        spawn_http_response_after(response, Duration::ZERO)
742    }
743
744    fn spawn_http_response_after(
745        response: String,
746        delay: Duration,
747    ) -> (String, thread::JoinHandle<String>) {
748        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
749        let addr = listener.local_addr().expect("local addr");
750        let handle = thread::spawn(move || {
751            let (mut stream, _) = listener.accept().expect("accept request");
752            let mut request = Vec::new();
753            let mut buffer = [0_u8; 1024];
754            loop {
755                let read = stream.read(&mut buffer).expect("read request");
756                if read == 0 {
757                    break;
758                }
759                request.extend_from_slice(&buffer[..read]);
760                if request.windows(4).any(|window| window == b"\r\n\r\n") {
761                    break;
762                }
763            }
764            thread::sleep(delay);
765            stream
766                .write_all(response.as_bytes())
767                .expect("write response");
768            String::from_utf8_lossy(&request).into_owned()
769        });
770        (format!("http://{addr}"), handle)
771    }
772}