1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use anyhow::{Context as _, anyhow, bail};
5use gobby_core::provisioning::{GCORE_CONFIG_FILENAME, StandaloneConfig};
6use postgres::{Client, GenericClient};
7use serde::Deserialize;
8
9use crate::models::{CallRelation, CallTargetKind, ImportRelation, Symbol};
10use crate::schema;
11
12const GCODE_DATABASE_URL_ENV: &str = "GCODE_DATABASE_URL";
13const GOBBY_POSTGRES_DSN_ENV: &str = "GOBBY_POSTGRES_DSN";
14const GCODE_CONFIG_FILENAME: &str = "gcode.yaml";
15const LOCAL_CLI_TOKEN_FILENAME: &str = "local_cli_token";
16const BROKER_TIMEOUT: Duration = Duration::from_secs(3);
17
18#[derive(Debug, Deserialize)]
19struct BrokerDatabaseUrlResponse {
20 database_url: String,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24struct BootstrapDatabase {
25 hub_backend: String,
26 database_url: Option<String>,
27}
28
29pub fn gobby_home() -> anyhow::Result<PathBuf> {
31 gobby_core::gobby_home()
32}
33
34pub fn bootstrap_path() -> anyhow::Result<PathBuf> {
35 Ok(gobby_home()?.join("bootstrap.yaml"))
36}
37
38pub fn resolve_database_url() -> anyhow::Result<String> {
43 let home = gobby_home()?;
44 resolve_database_url_from_sources_with_identity_and_reachability(
45 &home,
46 |bootstrap_path| resolve_brokered_database_url_at(&home, bootstrap_path),
47 |name| std::env::var(name).ok(),
48 |url| gobby_core::postgres::connect_readonly(url).is_ok(),
49 gobby_core::provisioning::probe_postgres_hub_identity,
50 )
51}
52
53#[cfg(test)]
54fn resolve_database_url_from_sources(
55 home: &Path,
56 broker_resolver: impl Fn(&Path) -> anyhow::Result<String>,
57 get_var: impl FnMut(&str) -> Option<String>,
58 database_reachable: impl FnMut(&str) -> bool,
59) -> anyhow::Result<String> {
60 resolve_database_url_from_sources_with_identity_and_reachability(
61 home,
62 broker_resolver,
63 get_var,
64 database_reachable,
65 gobby_core::provisioning::probe_postgres_hub_identity,
66 )
67}
68
69#[cfg(test)]
70fn resolve_database_url_from_sources_with_identity(
71 home: &Path,
72 broker_resolver: impl Fn(&Path) -> anyhow::Result<String>,
73 get_var: impl FnMut(&str) -> Option<String>,
74 database_reachable: impl FnMut(&str) -> bool,
75 identity_probe: impl FnMut(&str) -> anyhow::Result<gobby_core::provisioning::HubIdentityProbeResult>,
76) -> anyhow::Result<String> {
77 resolve_database_url_from_sources_with_identity_and_reachability(
78 home,
79 broker_resolver,
80 get_var,
81 database_reachable,
82 identity_probe,
83 )
84}
85
86fn resolve_database_url_from_sources_with_identity_and_reachability(
87 home: &Path,
88 broker_resolver: impl Fn(&Path) -> anyhow::Result<String>,
89 get_var: impl FnMut(&str) -> Option<String>,
90 mut database_reachable: impl FnMut(&str) -> bool,
91 mut identity_probe: impl FnMut(
92 &str,
93 )
94 -> anyhow::Result<gobby_core::provisioning::HubIdentityProbeResult>,
95) -> anyhow::Result<String> {
96 let path = home.join("bootstrap.yaml");
97
98 if let Some(database_url) = resolve_database_url_from_env(get_var) {
99 return Ok(database_url);
100 }
101
102 let gcore_database_url = match resolve_database_url_from_gcore_config(home) {
103 Ok(database_url) => database_url,
104 Err(error) => {
105 log::warn!("failed to read gcore config database URL: {error}");
106 None
107 }
108 };
109
110 if let Ok(database_url) = broker_resolver(&path) {
111 if let Some(database_url) = resolve_recorded_hub_database_url(
112 gcore_database_url.as_deref(),
113 &database_url,
114 &mut database_reachable,
115 &mut identity_probe,
116 )? {
117 return Ok(database_url);
118 }
119 return Ok(database_url);
120 }
121
122 if let Some(database_url) = resolve_database_url_from_bootstrap_file(&path)? {
123 if let Some(database_url) = resolve_recorded_hub_database_url(
124 gcore_database_url.as_deref(),
125 &database_url,
126 &mut database_reachable,
127 &mut identity_probe,
128 )? {
129 return Ok(database_url);
130 }
131 return Ok(database_url);
132 }
133
134 if let Some(database_url) = gcore_database_url {
135 return Ok(database_url);
136 }
137
138 if let Some(database_url) =
139 resolve_database_url_from_config_file(&home.join(GCODE_CONFIG_FILENAME))?
140 {
141 return Ok(database_url);
142 }
143
144 bail!(
145 "missing Gobby PostgreSQL configuration. Run `gcode setup --standalone`, set {GCODE_DATABASE_URL_ENV}, or configure the Gobby daemon bootstrap."
146 )
147}
148
149fn resolve_recorded_hub_database_url(
150 gcore_database_url: Option<&str>,
151 candidate_database_url: &str,
152 database_reachable: &mut impl FnMut(&str) -> bool,
153 identity_probe: &mut impl FnMut(
154 &str,
155 )
156 -> anyhow::Result<gobby_core::provisioning::HubIdentityProbeResult>,
157) -> anyhow::Result<Option<String>> {
158 Ok(gobby_core::provisioning::resolve_recorded_hub_database_url(
159 gcore_database_url,
160 Some(candidate_database_url),
161 database_reachable,
162 identity_probe,
163 )?
164 .map(|resolution| resolution.database_url))
165}
166
167fn resolve_database_url_from_bootstrap_file(path: &Path) -> anyhow::Result<Option<String>> {
168 if !path.exists() {
169 return Ok(None);
170 }
171 let contents = std::fs::read_to_string(path)
172 .with_context(|| format!("failed to read Gobby bootstrap at {}", path.display()))?;
173 let bootstrap = parse_bootstrap_database(&contents)?;
174 resolve_database_url_from_bootstrap(&bootstrap).map(Some)
175}
176
177fn resolve_database_url_from_gcore_config(home: &Path) -> anyhow::Result<Option<String>> {
178 let Some(config) = StandaloneConfig::read_at(&home.join(GCORE_CONFIG_FILENAME))? else {
179 return Ok(None);
180 };
181 Ok(config
182 .get("databases.postgres.dsn")
183 .and_then(|value| non_empty_trimmed(Some(value.to_string()))))
184}
185
186fn resolve_database_url_from_env(
187 mut get_var: impl FnMut(&str) -> Option<String>,
188) -> Option<String> {
189 for name in [GCODE_DATABASE_URL_ENV, GOBBY_POSTGRES_DSN_ENV] {
190 if let Some(value) = non_empty_trimmed(get_var(name)) {
191 return Some(value);
192 }
193 }
194 None
195}
196
197fn resolve_database_url_from_config_file(path: &Path) -> anyhow::Result<Option<String>> {
198 if !path.exists() {
199 return Ok(None);
200 }
201 let contents = std::fs::read_to_string(path)
202 .with_context(|| format!("failed to read {}", path.display()))?;
203 parse_gcode_config_database_url(&contents)
204 .with_context(|| format!("failed to parse {}", path.display()))
205}
206
207fn parse_gcode_config_database_url(contents: &str) -> anyhow::Result<Option<String>> {
208 let yaml: serde_yaml::Value = serde_yaml::from_str(contents)?;
209 let Some(map) = yaml.as_mapping() else {
210 if yaml.is_null() {
211 return Ok(None);
212 }
213 bail!("gcode.yaml must be a mapping");
214 };
215
216 let key = serde_yaml::Value::String("database_url".to_string());
217 match map.get(&key) {
218 Some(value) => match value.as_str() {
219 Some(text) => Ok(non_empty_trimmed(Some(text.to_string()))),
220 None => bail!("gcode.yaml field `database_url` must be a string"),
221 },
222 None => Ok(None),
223 }
224}
225
226fn parse_bootstrap_database(contents: &str) -> anyhow::Result<BootstrapDatabase> {
227 let yaml: serde_yaml::Value =
228 serde_yaml::from_str(contents).context("failed to parse bootstrap.yaml")?;
229 let Some(map) = yaml.as_mapping() else {
230 bail!("bootstrap.yaml must be a mapping");
231 };
232
233 let get_string = |name: &str| -> anyhow::Result<Option<String>> {
234 let key = serde_yaml::Value::String(name.to_string());
235 match map.get(&key) {
236 Some(value) => match value.as_str() {
237 Some(text) if !text.trim().is_empty() => Ok(Some(text.to_string())),
238 Some(_) | None => bail!("bootstrap.yaml field `{name}` must be a string"),
239 },
240 None => Ok(None),
241 }
242 };
243
244 let database_url_ref = get_string("database_url_ref")?;
245 if database_url_ref.is_some() {
246 bail!(
247 "database_url_ref is no longer supported in bootstrap.yaml; store the local PostgreSQL DSN in database_url"
248 );
249 }
250
251 Ok(BootstrapDatabase {
252 hub_backend: get_string("hub_backend")?
253 .context("bootstrap.yaml must include `hub_backend: postgres`")?,
254 database_url: get_string("database_url")?,
255 })
256}
257
258fn resolve_database_url_from_bootstrap(bootstrap: &BootstrapDatabase) -> anyhow::Result<String> {
259 if bootstrap.hub_backend != "postgres" {
260 bail!(
261 "gcode requires `hub_backend: postgres` in bootstrap.yaml. Current hub_backend is `{}`. Configure the Gobby PostgreSQL hub before running gcode.",
262 bootstrap.hub_backend
263 );
264 }
265
266 if let Some(database_url) = bootstrap.database_url.as_deref() {
267 return Ok(database_url.to_string());
268 }
269
270 bail!("hub_backend=postgres requires `database_url` in bootstrap.yaml")
271}
272
273fn non_empty_trimmed(value: Option<String>) -> Option<String> {
274 let trimmed = value.as_ref()?.trim();
275 if trimmed.is_empty() {
276 None
277 } else {
278 Some(trimmed.to_string())
279 }
280}
281
282fn resolve_brokered_database_url_at(
283 gobby_home: &Path,
284 bootstrap_path: &Path,
285) -> anyhow::Result<String> {
286 let token = read_local_cli_token_at(gobby_home)?;
287 let daemon_url = gobby_core::daemon_url::daemon_url_at(bootstrap_path);
288 request_broker_database_url(&daemon_url, &token)
289}
290
291fn read_local_cli_token_at(gobby_home: &Path) -> anyhow::Result<String> {
292 let path = gobby_home.join(LOCAL_CLI_TOKEN_FILENAME);
293 let token = std::fs::read_to_string(&path)
294 .with_context(|| format!("missing local CLI token at {}", path.display()))?;
295 let token = token.trim().to_string();
296 if token.is_empty() {
297 bail!("local CLI token at {} is empty", path.display());
298 }
299 Ok(token)
300}
301
302fn request_broker_database_url(daemon_url: &str, token: &str) -> anyhow::Result<String> {
303 let url = format!(
304 "{}/api/local/runtime/database-url",
305 daemon_url.trim_end_matches('/')
306 );
307 let agent = ureq::AgentBuilder::new().timeout(BROKER_TIMEOUT).build();
308 let response = agent
309 .post(&url)
310 .set("X-Gobby-Local-Token", token)
311 .call()
312 .map_err(|err| anyhow!("database_url broker request failed: {err}"))?;
313 let body: BrokerDatabaseUrlResponse = response
314 .into_json()
315 .context("database_url broker response was not valid JSON")?;
316 let database_url = body.database_url.trim().to_string();
317 if database_url.is_empty() {
318 bail!("database_url broker response was empty");
319 }
320 Ok(database_url)
321}
322
323pub fn connect_readwrite(database_url: &str) -> anyhow::Result<Client> {
329 let mut client = gobby_core::postgres::connect_readwrite(database_url)?;
330 schema::validate_runtime_schema(&mut client)?;
331 Ok(client)
332}
333
334pub fn connect_readonly(database_url: &str) -> anyhow::Result<Client> {
340 let mut client = gobby_core::postgres::connect_readonly(database_url)?;
341 schema::validate_runtime_schema(&mut client)?;
342 Ok(client)
343}
344
345pub fn read_config_value(conn: &mut Client, key: &str) -> anyhow::Result<Option<String>> {
346 gobby_core::postgres::read_config_value(conn, key)
347}
348
349#[derive(Debug, Clone)]
350pub struct GraphFileFacts {
351 pub file_path: String,
352 pub imports: Vec<ImportRelation>,
353 pub definitions: Vec<Symbol>,
354 pub calls: Vec<CallRelation>,
355}
356
357pub fn list_indexed_file_paths(
358 conn: &mut impl GenericClient,
359 project_id: &str,
360) -> anyhow::Result<Vec<String>> {
361 let rows = conn.query(
362 "SELECT file_path FROM code_indexed_files WHERE project_id = $1 ORDER BY file_path",
363 &[&project_id],
364 )?;
365 rows.into_iter()
366 .map(|row| row.try_get("file_path").map_err(Into::into))
367 .collect()
368}
369
370pub fn indexed_project_exists(
371 conn: &mut impl GenericClient,
372 project_id: &str,
373) -> anyhow::Result<bool> {
374 Ok(conn
375 .query_opt(
376 "SELECT 1 FROM code_indexed_projects WHERE id = $1",
377 &[&project_id],
378 )?
379 .is_some())
380}
381
382pub fn read_graph_file_facts(
383 conn: &mut impl GenericClient,
384 project_id: &str,
385 file_path: &str,
386) -> anyhow::Result<GraphFileFacts> {
387 let imports = read_imports_for_file(conn, project_id, file_path)?;
388 let definitions = read_symbols_for_file(conn, project_id, file_path)?;
389 let calls = read_calls_for_file(conn, project_id, file_path)?;
390
391 Ok(GraphFileFacts {
392 file_path: file_path.to_string(),
393 imports,
394 definitions,
395 calls,
396 })
397}
398
399pub fn indexed_file_exists(
400 conn: &mut impl GenericClient,
401 project_id: &str,
402 file_path: &str,
403) -> anyhow::Result<bool> {
404 Ok(conn
405 .query_opt(
406 "SELECT 1 FROM code_indexed_files
407 WHERE project_id = $1 AND file_path = $2",
408 &[&project_id, &file_path],
409 )?
410 .is_some())
411}
412
413pub fn mark_graph_sync_attempted(
414 conn: &mut impl GenericClient,
415 project_id: &str,
416 file_path: &str,
417) -> anyhow::Result<bool> {
418 let updated = conn.execute(
419 "UPDATE code_indexed_files
420 SET graph_synced = false, graph_sync_attempted_at = NOW()
421 WHERE project_id = $1 AND file_path = $2",
422 &[&project_id, &file_path],
423 )?;
424 Ok(updated > 0)
425}
426
427pub fn mark_graph_synced(
428 conn: &mut impl GenericClient,
429 project_id: &str,
430 file_path: &str,
431) -> anyhow::Result<bool> {
432 let updated = conn.execute(
433 "UPDATE code_indexed_files
434 SET graph_synced = true, graph_sync_attempted_at = NOW()
435 WHERE project_id = $1 AND file_path = $2",
436 &[&project_id, &file_path],
437 )?;
438 Ok(updated > 0)
439}
440
441pub fn reset_graph_sync_for_project(
442 conn: &mut impl GenericClient,
443 project_id: &str,
444) -> anyhow::Result<u64> {
445 Ok(conn.execute(
446 "UPDATE code_indexed_files
447 SET graph_synced = false, graph_sync_attempted_at = NULL
448 WHERE project_id = $1",
449 &[&project_id],
450 )?)
451}
452
453pub fn mark_vectors_synced(
454 conn: &mut impl GenericClient,
455 project_id: &str,
456 file_path: &str,
457) -> anyhow::Result<bool> {
458 let updated = conn.execute(
459 "UPDATE code_indexed_files
460 SET vectors_synced = true
461 WHERE project_id = $1 AND file_path = $2",
462 &[&project_id, &file_path],
463 )?;
464 Ok(updated > 0)
465}
466
467pub fn mark_project_vectors_synced(
468 conn: &mut impl GenericClient,
469 project_id: &str,
470) -> anyhow::Result<u64> {
471 Ok(conn.execute(
472 "UPDATE code_indexed_files
473 SET vectors_synced = true
474 WHERE project_id = $1",
475 &[&project_id],
476 )?)
477}
478
479pub fn file_vectors_synced(
484 conn: &mut impl GenericClient,
485 project_id: &str,
486 file_path: &str,
487) -> anyhow::Result<Option<bool>> {
488 let synced = conn
489 .query_opt(
490 "SELECT vectors_synced
491 FROM code_indexed_files
492 WHERE project_id = $1 AND file_path = $2",
493 &[&project_id, &file_path],
494 )?
495 .map(|row| row.try_get::<_, bool>("vectors_synced"))
496 .transpose()?;
497 Ok(synced)
498}
499
500pub fn reset_vectors_sync_for_project(
501 conn: &mut impl GenericClient,
502 project_id: &str,
503) -> anyhow::Result<u64> {
504 Ok(conn.execute(
505 "UPDATE code_indexed_files
506 SET vectors_synced = false
507 WHERE project_id = $1",
508 &[&project_id],
509 )?)
510}
511
512fn read_imports_for_file(
513 conn: &mut impl GenericClient,
514 project_id: &str,
515 file_path: &str,
516) -> anyhow::Result<Vec<ImportRelation>> {
517 let rows = conn.query(
518 "SELECT source_file, target_module
519 FROM code_imports
520 WHERE project_id = $1 AND source_file = $2
521 ORDER BY target_module",
522 &[&project_id, &file_path],
523 )?;
524 rows.into_iter()
525 .map(|row| {
526 Ok(ImportRelation {
527 file_path: row.try_get("source_file")?,
528 module_name: row.try_get("target_module")?,
529 })
530 })
531 .collect()
532}
533
534fn read_symbols_for_file(
535 conn: &mut impl GenericClient,
536 project_id: &str,
537 file_path: &str,
538) -> anyhow::Result<Vec<Symbol>> {
539 let query = format!(
540 "SELECT {} FROM code_symbols s
541 WHERE s.project_id = $1 AND s.file_path = $2
542 ORDER BY s.line_start, s.byte_start",
543 symbol_select_columns("s")
544 );
545 let rows = conn.query(&query, &[&project_id, &file_path])?;
546 rows.iter().map(Symbol::from_row).collect()
547}
548
549fn read_calls_for_file(
550 conn: &mut impl GenericClient,
551 project_id: &str,
552 file_path: &str,
553) -> anyhow::Result<Vec<CallRelation>> {
554 let rows = conn.query(
555 "SELECT caller_symbol_id, callee_symbol_id, callee_name,
556 callee_target_kind, callee_external_module, file_path, line::BIGINT AS line
557 FROM code_calls
558 WHERE project_id = $1 AND file_path = $2
559 ORDER BY line, caller_symbol_id, callee_name",
560 &[&project_id, &file_path],
561 )?;
562 rows.into_iter()
563 .map(|row| {
564 let target_kind: String = row.try_get("callee_target_kind")?;
565 let callee_symbol_id: String = row.try_get("callee_symbol_id")?;
566 let callee_external_module: String = row.try_get("callee_external_module")?;
567 Ok(CallRelation {
568 caller_symbol_id: row.try_get("caller_symbol_id")?,
569 callee_symbol_id: non_empty(callee_symbol_id),
570 callee_name: row.try_get("callee_name")?,
571 callee_target_kind: call_target_kind_from_str(&target_kind)?,
572 callee_external_module: non_empty(callee_external_module),
573 file_path: row.try_get("file_path")?,
574 line: i64_to_usize(row.try_get("line")?, "line")?,
575 })
576 })
577 .collect()
578}
579
580fn non_empty(value: String) -> Option<String> {
581 if value.is_empty() { None } else { Some(value) }
582}
583
584fn call_target_kind_from_str(value: &str) -> anyhow::Result<CallTargetKind> {
585 match value {
586 "symbol" => Ok(CallTargetKind::Symbol),
587 "unresolved" => Ok(CallTargetKind::Unresolved),
588 "external" => Ok(CallTargetKind::External),
589 other => bail!("unknown code_calls.callee_target_kind `{other}`"),
590 }
591}
592
593fn i64_to_usize(value: i64, column: &str) -> anyhow::Result<usize> {
594 value
595 .try_into()
596 .with_context(|| format!("column `{column}` contains negative or too-large value {value}"))
597}
598
599pub fn symbol_select_columns(alias: &str) -> String {
600 let prefix = if alias.is_empty() {
601 String::new()
602 } else {
603 format!("{alias}.")
604 };
605 format!(
606 "{p}id, {p}project_id, {p}file_path, {p}name, {p}qualified_name, \
607 {p}kind, {p}language, {p}byte_start::BIGINT AS byte_start, \
608 {p}byte_end::BIGINT AS byte_end, {p}line_start::BIGINT AS line_start, \
609 {p}line_end::BIGINT AS line_end, {p}signature, {p}docstring, \
610 {p}parent_symbol_id, {p}content_hash, {p}summary, \
611 {p}created_at::TEXT AS created_at, {p}updated_at::TEXT AS updated_at",
612 p = prefix
613 )
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619 use std::io::{Read as _, Write as _};
620 use std::net::TcpListener;
621 use std::thread;
622
623 fn bootstrap(hub_backend: &str, database_url: Option<&str>) -> BootstrapDatabase {
624 BootstrapDatabase {
625 hub_backend: hub_backend.to_string(),
626 database_url: database_url.map(str::to_string),
627 }
628 }
629
630 #[test]
631 fn database_url_env_prefers_gcode_specific_var() {
632 let resolved = resolve_database_url_from_env(|name| match name {
633 GCODE_DATABASE_URL_ENV => Some(" postgresql://env/db ".to_string()),
634 GOBBY_POSTGRES_DSN_ENV => Some("postgresql://gobby/db".to_string()),
635 _ => None,
636 });
637
638 assert_eq!(resolved.as_deref(), Some("postgresql://env/db"));
639 }
640
641 #[test]
642 fn database_url_env_falls_back_to_gobby_postgres_dsn() {
643 let resolved = resolve_database_url_from_env(|name| match name {
644 GOBBY_POSTGRES_DSN_ENV => Some(" postgresql://gobby/db ".to_string()),
645 _ => None,
646 });
647
648 assert_eq!(resolved.as_deref(), Some("postgresql://gobby/db"));
649 }
650
651 #[test]
652 fn database_url_env_ignores_empty_values() {
653 let resolved = resolve_database_url_from_env(|name| match name {
654 GCODE_DATABASE_URL_ENV => Some(" ".to_string()),
655 GOBBY_POSTGRES_DSN_ENV => Some("\n\t".to_string()),
656 _ => None,
657 });
658
659 assert_eq!(resolved, None);
660 }
661
662 #[test]
663 fn database_url_sources_prefer_env_over_daemon_broker() {
664 let home = tempfile::tempdir().expect("temp home");
665
666 let resolved = resolve_database_url_from_sources(
667 home.path(),
668 |_| Ok("postgresql://broker/db".to_string()),
669 |name| match name {
670 GCODE_DATABASE_URL_ENV => Some("postgresql://env/db".to_string()),
671 _ => None,
672 },
673 |_| true,
674 )
675 .expect("resolve database url");
676
677 assert_eq!(resolved, "postgresql://env/db");
678 }
679
680 #[test]
681 fn database_url_sources_use_daemon_broker_after_env() {
682 let home = tempfile::tempdir().expect("temp home");
683
684 let resolved = resolve_database_url_from_sources(
685 home.path(),
686 |_| Ok("postgresql://broker/db".to_string()),
687 |_| None,
688 |_| true,
689 )
690 .expect("resolve database url");
691
692 assert_eq!(resolved, "postgresql://broker/db");
693 }
694
695 #[test]
696 fn database_url_sources_fall_back_to_bootstrap_inline_when_daemon_is_unavailable() {
697 let home = tempfile::tempdir().expect("temp home");
698 std::fs::write(
699 home.path().join("bootstrap.yaml"),
700 "hub_backend: postgres\ndatabase_url: postgresql://inline/db\n",
701 )
702 .expect("write bootstrap");
703
704 let resolved = resolve_database_url_from_sources(
705 home.path(),
706 |_| bail!("daemon unavailable"),
707 |_| None,
708 |_| true,
709 )
710 .expect("resolve database url");
711
712 assert_eq!(resolved, "postgresql://inline/db");
713 }
714
715 #[test]
716 fn database_url_sources_fall_back_to_gcore_before_legacy_gcode_config() {
717 let home = tempfile::tempdir().expect("temp home");
718 std::fs::write(
719 home.path().join(GCORE_CONFIG_FILENAME),
720 "databases.postgres.dsn: postgresql://gcore/db\n",
721 )
722 .expect("write gcore config");
723 std::fs::write(
724 home.path().join(GCODE_CONFIG_FILENAME),
725 "database_url: postgresql://legacy/db\n",
726 )
727 .expect("write legacy config");
728
729 let resolved = resolve_database_url_from_sources(
730 home.path(),
731 |_| bail!("daemon unavailable"),
732 |_| None,
733 |_| true,
734 )
735 .expect("resolve database url");
736
737 assert_eq!(resolved, "postgresql://gcore/db");
738 }
739
740 #[test]
741 fn adopted_hub_resolves_without_conflict() {
742 let home = tempfile::tempdir().expect("temp home");
743 std::fs::write(
744 home.path().join(GCORE_CONFIG_FILENAME),
745 "databases.postgres.dsn: postgresql://adopted/gobby\n",
746 )
747 .expect("write gcore config");
748
749 let resolved = resolve_database_url_from_sources_with_identity(
750 home.path(),
751 |_| Ok("postgresql://adopted/gobby".to_string()),
752 |_| None,
753 |_| true,
754 |_| {
755 Ok(gobby_core::provisioning::HubIdentityProbeResult::Known(
756 gobby_core::provisioning::HubIdentity {
757 system_identifier: "cluster-a".to_string(),
758 database_name: "gobby".to_string(),
759 },
760 ))
761 },
762 )
763 .expect("resolve adopted hub");
764
765 assert_eq!(resolved, "postgresql://adopted/gobby");
766 }
767
768 #[test]
769 fn gcode_config_accepts_database_url() {
770 let home = tempfile::tempdir().expect("temp home");
771 let path = home.path().join(GCODE_CONFIG_FILENAME);
772 std::fs::write(&path, "database_url: postgresql://config/db\n").expect("write config");
773
774 let resolved = resolve_database_url_from_config_file(&path)
775 .expect("config parses")
776 .expect("database_url present");
777
778 assert_eq!(resolved, "postgresql://config/db");
779 }
780
781 #[test]
782 fn gcode_config_missing_file_is_not_an_override() {
783 let home = tempfile::tempdir().expect("temp home");
784 let path = home.path().join(GCODE_CONFIG_FILENAME);
785
786 let resolved = resolve_database_url_from_config_file(&path).expect("missing config ok");
787
788 assert_eq!(resolved, None);
789 }
790
791 #[test]
792 fn gcode_config_empty_file_is_not_an_override() {
793 let home = tempfile::tempdir().expect("temp home");
794 let path = home.path().join(GCODE_CONFIG_FILENAME);
795 std::fs::write(&path, "").expect("write config");
796
797 let resolved = resolve_database_url_from_config_file(&path).expect("empty config ok");
798
799 assert_eq!(resolved, None);
800 }
801
802 #[test]
803 fn postgres_bootstrap_accepts_inline_url() {
804 let resolved = resolve_database_url_from_bootstrap(&bootstrap(
805 "postgres",
806 Some("postgresql://inline/db"),
807 ))
808 .expect("resolve inline url");
809
810 assert_eq!(resolved, "postgresql://inline/db");
811 }
812
813 #[test]
814 fn postgres_bootstrap_rejects_database_url_ref() {
815 let err = parse_bootstrap_database(
816 "hub_backend: postgres\n\
817 database_url_ref: deprecated\n",
818 )
819 .expect_err("database_url_ref must fail");
820
821 let message = err.to_string();
822 assert!(message.contains("database_url_ref is no longer supported"));
823 assert!(message.contains("database_url"));
824 }
825
826 #[test]
827 fn postgres_bootstrap_rejects_database_url_ref_even_with_inline_url() {
828 let err = parse_bootstrap_database(
829 "hub_backend: postgres\n\
830 database_url: postgresql://inline/db\n\
831 database_url_ref: deprecated\n",
832 )
833 .expect_err("database_url_ref must fail");
834
835 assert!(
836 err.to_string()
837 .contains("database_url_ref is no longer supported")
838 );
839 }
840
841 #[test]
842 fn non_postgres_bootstrap_fails_clearly() {
843 let err = resolve_database_url_from_bootstrap(&bootstrap("legacy-local", None))
844 .expect_err("non-postgres backend must fail");
845
846 let message = err.to_string();
847 assert!(message.contains("hub_backend: postgres"));
848 assert!(message.contains("legacy-local"));
849 }
850
851 #[test]
852 fn missing_hub_backend_fails_clearly() {
853 let err = parse_bootstrap_database("database_url: postgresql://inline/db\n")
854 .expect_err("missing hub_backend must fail");
855
856 assert!(err.to_string().contains("hub_backend: postgres"));
857 }
858
859 #[test]
860 fn missing_postgres_dsn_fails_clearly() {
861 let err = resolve_database_url_from_bootstrap(&bootstrap("postgres", None))
862 .expect_err("missing dsn must fail");
863
864 assert!(err.to_string().contains("database_url"));
865 }
866
867 #[test]
868 fn parse_bootstrap_database_reads_postgres_fields() {
869 let parsed = parse_bootstrap_database(
870 "hub_backend: postgres\n\
871 database_url: postgresql://inline/db\n",
872 )
873 .expect("parse bootstrap");
874
875 assert_eq!(parsed.hub_backend, "postgres");
876 assert_eq!(
877 parsed.database_url.as_deref(),
878 Some("postgresql://inline/db")
879 );
880 }
881
882 #[test]
883 fn broker_request_returns_database_url_and_sends_local_token() {
884 let (daemon_url, request) = spawn_http_response(http_response(
885 "200 OK",
886 r#"{"database_url":"postgresql://broker/db"}"#,
887 ));
888
889 let resolved =
890 request_broker_database_url(&daemon_url, "token-123").expect("broker resolves");
891 let request = request.join().expect("read request");
892
893 assert_eq!(resolved, "postgresql://broker/db");
894 assert!(request.starts_with("POST /api/local/runtime/database-url HTTP/1.1"));
895 assert!(
896 request
897 .to_ascii_lowercase()
898 .contains("x-gobby-local-token: token-123")
899 );
900 }
901
902 #[test]
903 fn broker_request_allows_cold_daemon_latency() {
904 let (daemon_url, request) = spawn_http_response_after(
905 http_response("200 OK", r#"{"database_url":"postgresql://broker/db"}"#),
906 Duration::from_millis(1100),
907 );
908
909 let resolved =
910 request_broker_database_url(&daemon_url, "token-123").expect("broker resolves");
911 let _ = request.join().expect("read request");
912
913 assert_eq!(resolved, "postgresql://broker/db");
914 }
915
916 #[test]
917 fn broker_missing_token_fails() {
918 let home = tempfile::tempdir().expect("temp home");
919 let bootstrap_path = write_bootstrap(home.path(), 60887);
920
921 let err = resolve_brokered_database_url_at(home.path(), &bootstrap_path)
922 .expect_err("missing token must fail");
923
924 assert!(err.to_string().contains("missing local CLI token"));
925 }
926
927 #[test]
928 fn broker_daemon_down_fails() {
929 let home = tempfile::tempdir().expect("temp home");
930 std::fs::write(home.path().join(LOCAL_CLI_TOKEN_FILENAME), "token\n").expect("write token");
931 let bootstrap_path = write_bootstrap(home.path(), 9);
932
933 let err = resolve_brokered_database_url_at(home.path(), &bootstrap_path)
934 .expect_err("daemon down must fail");
935
936 assert!(
937 err.to_string()
938 .contains("database_url broker request failed")
939 );
940 }
941
942 #[test]
943 fn broker_auth_failure_fails() {
944 let (daemon_url, request) = spawn_http_response(http_response(
945 "401 Unauthorized",
946 r#"{"error":"bad token"}"#,
947 ));
948
949 let err = request_broker_database_url(&daemon_url, "bad-token")
950 .expect_err("auth failure must fail");
951 let _ = request.join().expect("read request");
952
953 assert!(
954 err.to_string()
955 .contains("database_url broker request failed")
956 );
957 }
958
959 #[test]
960 fn broker_non_success_status_fails() {
961 let (daemon_url, request) = spawn_http_response(http_response(
962 "503 Service Unavailable",
963 r#"{"error":"unavailable"}"#,
964 ));
965
966 let err = request_broker_database_url(&daemon_url, "token")
967 .expect_err("non-success status must fail");
968 let _ = request.join().expect("read request");
969
970 assert!(
971 err.to_string()
972 .contains("database_url broker request failed")
973 );
974 }
975
976 #[test]
977 fn broker_invalid_json_fails() {
978 let (daemon_url, request) = spawn_http_response(http_response("200 OK", "not json"));
979
980 let err =
981 request_broker_database_url(&daemon_url, "token").expect_err("invalid JSON must fail");
982 let _ = request.join().expect("read request");
983
984 assert!(
985 err.to_string()
986 .contains("database_url broker response was not valid JSON")
987 );
988 }
989
990 #[test]
991 fn broker_empty_database_url_fails() {
992 let (daemon_url, request) =
993 spawn_http_response(http_response("200 OK", r#"{"database_url":" "}"#));
994
995 let err =
996 request_broker_database_url(&daemon_url, "token").expect_err("empty DSN must fail");
997 let _ = request.join().expect("read request");
998
999 assert!(
1000 err.to_string()
1001 .contains("database_url broker response was empty")
1002 );
1003 }
1004
1005 fn write_bootstrap(home: &Path, daemon_port: u16) -> PathBuf {
1006 let path = home.join("bootstrap.yaml");
1007 std::fs::write(
1008 &path,
1009 format!("hub_backend: postgres\ndaemon_port: {daemon_port}\nbind_host: 127.0.0.1\n"),
1010 )
1011 .expect("write bootstrap");
1012 path
1013 }
1014
1015 fn http_response(status: &str, body: &str) -> String {
1016 format!(
1017 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1018 body.len()
1019 )
1020 }
1021
1022 fn spawn_http_response(response: String) -> (String, thread::JoinHandle<String>) {
1023 spawn_http_response_after(response, Duration::ZERO)
1024 }
1025
1026 fn spawn_http_response_after(
1027 response: String,
1028 delay: Duration,
1029 ) -> (String, thread::JoinHandle<String>) {
1030 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
1031 let addr = listener.local_addr().expect("local addr");
1032 let handle = thread::spawn(move || {
1033 let (mut stream, _) = listener.accept().expect("accept request");
1034 let mut request = Vec::new();
1035 let mut buffer = [0_u8; 1024];
1036 loop {
1037 let read = stream.read(&mut buffer).expect("read request");
1038 if read == 0 {
1039 break;
1040 }
1041 request.extend_from_slice(&buffer[..read]);
1042 if request.windows(4).any(|window| window == b"\r\n\r\n") {
1043 break;
1044 }
1045 }
1046 thread::sleep(delay);
1047 stream
1048 .write_all(response.as_bytes())
1049 .expect("write response");
1050 String::from_utf8_lossy(&request).into_owned()
1051 });
1052 (format!("http://{addr}"), handle)
1053 }
1054}