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