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
21pub 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
30pub 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}