1use std::collections::BTreeMap;
8use std::fs;
9use std::io::{Read as _, Write as _};
10use std::net::TcpStream;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::time::Duration;
14
15use anyhow::Context as _;
16use serde::Deserialize;
17
18use crate::config::{ConfigSource, embedding_keys, resolve_env_pattern};
19use crate::degradation::CoreError;
20
21pub const GCORE_CONFIG_FILENAME: &str = "gcore.yaml";
22pub const SERVICES_DIRNAME: &str = "services";
23pub const COMPOSE_FILENAME: &str = "docker-compose.yml";
24
25pub const DEFAULT_POSTGRES_HOST: &str = "127.0.0.1";
26pub const DEFAULT_POSTGRES_PORT: u16 = 60891;
27pub const DEFAULT_POSTGRES_DB: &str = "gobby";
28pub const DEFAULT_POSTGRES_USER: &str = "gobby";
29pub const DEFAULT_POSTGRES_PASSWORD: &str = "gobby_dev";
30
31pub const DEFAULT_FALKORDB_HOST: &str = "127.0.0.1";
32pub const DEFAULT_FALKORDB_PORT: u16 = 16379;
33pub const DEFAULT_FALKORDB_BROWSER_PORT: u16 = 13000;
34pub const DEFAULT_FALKORDB_PASSWORD: &str = "gobbyfalkor";
35
36pub const DEFAULT_QDRANT_HTTP_PORT: u16 = 6333;
37pub const DEFAULT_QDRANT_GRPC_PORT: u16 = 6334;
38
39pub const DEFAULT_LM_STUDIO_API_BASE: &str = "http://localhost:1234/v1";
40pub const DEFAULT_LM_STUDIO_MODEL: &str = "text-embedding-nomic-embed-text-v1.5@f16";
41pub const DEFAULT_OLLAMA_API_BASE: &str = "http://localhost:11434/v1";
42pub const DEFAULT_OLLAMA_MODEL: &str = "nomic-embed-text";
43pub const DEFAULT_EMBEDDING_VECTOR_DIM: usize = 768;
44
45pub const COMPOSE_TEMPLATE: &str = include_str!("../assets/docker-compose.services.yml");
46const PGSEARCH_DOCKERFILE: &str = include_str!("../assets/postgres-pgsearch/Dockerfile");
47const PGSEARCH_VERSION: &str = include_str!("../assets/postgres-pgsearch/version.json");
48const PGSEARCH_INIT_PG_SEARCH: &str =
49 include_str!("../assets/postgres-pgsearch/initdb.d/01-pg_search.sql");
50const PGSEARCH_INIT_PGAUDIT: &str =
51 include_str!("../assets/postgres-pgsearch/initdb.d/02-pgaudit.sql");
52const PG_AUDIT_EXPORT: &str =
53 include_str!("../assets/postgres-pgsearch/scripts/pg_audit_export.sh");
54
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub struct StandaloneConfig {
57 values: BTreeMap<String, String>,
58}
59
60impl StandaloneConfig {
61 pub fn new(values: BTreeMap<String, String>) -> Self {
62 Self { values }
63 }
64
65 pub fn empty() -> Self {
66 Self::default()
67 }
68
69 pub fn read_at(path: &Path) -> anyhow::Result<Option<Self>> {
70 if !path.exists() {
71 return Ok(None);
72 }
73 let contents = fs::read_to_string(path)
74 .map_err(|err| anyhow::anyhow!("failed to read {}: {err}", path.display()))?;
75 Self::from_yaml_str(&contents)
76 .map(Some)
77 .map_err(|err| anyhow::anyhow!("failed to parse {}: {err}", path.display()))
78 }
79
80 pub fn from_yaml_str(contents: &str) -> anyhow::Result<Self> {
81 if contents.trim().is_empty() {
82 return Ok(Self::default());
83 }
84 let yaml: serde_yaml::Value = serde_yaml::from_str(contents)?;
85 let mut values = BTreeMap::new();
86 flatten_yaml_value(None, &yaml, &mut values)?;
87 Ok(Self { values })
88 }
89
90 pub fn write_at(&self, path: &Path) -> anyhow::Result<()> {
91 if let Some(parent) = path.parent() {
92 fs::create_dir_all(parent)?;
93 }
94 let mut mapping = serde_yaml::Mapping::new();
95 for (key, value) in &self.values {
96 mapping.insert(
97 serde_yaml::Value::String(key.clone()),
98 serde_yaml::Value::String(value.clone()),
99 );
100 }
101 let yaml = serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping))?;
102 fs::write(path, yaml)?;
103 Ok(())
104 }
105
106 pub fn get(&self, key: &str) -> Option<&str> {
107 self.values.get(key).map(String::as_str)
108 }
109
110 pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
111 self.values.insert(key.into(), value.into());
112 }
113
114 pub fn remove(&mut self, key: &str) {
115 self.values.remove(key);
116 }
117
118 pub fn values(&self) -> &BTreeMap<String, String> {
119 &self.values
120 }
121}
122
123impl ConfigSource for StandaloneConfig {
124 fn config_value(&mut self, key: &str) -> Option<String> {
125 self.values.get(key).cloned().or_else(|| match key {
126 "databases.falkordb.requirepass" => {
127 self.values.get("databases.falkordb.password").cloned()
128 }
129 _ => None,
130 })
131 }
132
133 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
134 if value.contains("$secret:") {
135 anyhow::bail!("secret resolution requires daemon config_store");
136 }
137 resolve_env_pattern(value)?.ok_or_else(|| anyhow::anyhow!("unresolved pattern: {value}"))
138 }
139}
140
141pub fn gcore_config_path(gobby_home: &Path) -> PathBuf {
142 gobby_home.join(GCORE_CONFIG_FILENAME)
143}
144
145pub fn services_dir(gobby_home: &Path) -> PathBuf {
146 gobby_home.join(SERVICES_DIRNAME)
147}
148
149pub fn compose_file_path(gobby_home: &Path) -> PathBuf {
150 services_dir(gobby_home).join(COMPOSE_FILENAME)
151}
152
153pub fn default_database_url(port: u16) -> String {
154 format!(
155 "postgresql://{user}:{password}@localhost:{port}/{db}",
156 user = DEFAULT_POSTGRES_USER,
157 password = DEFAULT_POSTGRES_PASSWORD,
158 db = DEFAULT_POSTGRES_DB
159 )
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct EnsureHubOptions {
164 pub gobby_home: PathBuf,
165 pub service_options: DockerServiceOptions,
166 pub candidate_database_urls: Vec<String>,
167 pub provision_services: bool,
168}
169
170impl EnsureHubOptions {
171 pub fn new(gobby_home: PathBuf) -> Self {
172 Self {
173 service_options: DockerServiceOptions::new(gobby_home.clone()),
174 gobby_home,
175 candidate_database_urls: Vec::new(),
176 provision_services: true,
177 }
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct DockerServiceOptions {
183 pub gobby_home: PathBuf,
184 pub postgres_port: u16,
185 pub qdrant_http_port: u16,
186 pub qdrant_grpc_port: u16,
187 pub falkordb_host: String,
188 pub falkordb_port: u16,
189 pub falkordb_browser_port: u16,
190 pub falkordb_password: String,
191}
192
193impl DockerServiceOptions {
194 pub fn new(gobby_home: PathBuf) -> Self {
195 Self {
196 gobby_home,
197 postgres_port: DEFAULT_POSTGRES_PORT,
198 qdrant_http_port: DEFAULT_QDRANT_HTTP_PORT,
199 qdrant_grpc_port: DEFAULT_QDRANT_GRPC_PORT,
200 falkordb_host: DEFAULT_FALKORDB_HOST.to_string(),
201 falkordb_port: DEFAULT_FALKORDB_PORT,
202 falkordb_browser_port: DEFAULT_FALKORDB_BROWSER_PORT,
203 falkordb_password: DEFAULT_FALKORDB_PASSWORD.to_string(),
204 }
205 }
206
207 pub fn database_url(&self) -> String {
208 default_database_url(self.postgres_port)
209 }
210
211 pub fn qdrant_url(&self) -> String {
212 format!("http://localhost:{}", self.qdrant_http_port)
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct ServiceAssetReport {
218 pub services_dir: PathBuf,
219 pub compose_file: PathBuf,
220 pub env_file: PathBuf,
221 pub postgres_asset_dir: PathBuf,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct DockerProvisioningReport {
226 pub services_dir: PathBuf,
227 pub compose_file: PathBuf,
228 pub env_file: PathBuf,
229 pub started_profiles: Vec<String>,
230 pub health_checks: Vec<String>,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct CommandSpec {
235 pub program: String,
236 pub args: Vec<String>,
237 pub env: BTreeMap<String, String>,
238 pub cwd: Option<PathBuf>,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct CommandOutput {
243 pub status: i32,
244 pub stdout: String,
245 pub stderr: String,
246}
247
248pub trait CommandRunner {
249 fn run(&mut self, spec: &CommandSpec) -> std::io::Result<CommandOutput>;
250}
251
252pub struct RealCommandRunner;
253
254impl CommandRunner for RealCommandRunner {
255 fn run(&mut self, spec: &CommandSpec) -> std::io::Result<CommandOutput> {
256 let mut command = Command::new(&spec.program);
257 command.args(&spec.args);
258 if let Some(cwd) = &spec.cwd {
259 command.current_dir(cwd);
260 }
261 for (key, value) in &spec.env {
262 command.env(key, value);
263 }
264 let output = command.output()?;
265 Ok(CommandOutput {
266 status: output.status.code().unwrap_or(1),
267 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
268 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
269 })
270 }
271}
272
273pub trait DockerHealthChecker {
274 fn wait_postgres(&mut self, host: &str, port: u16) -> anyhow::Result<()>;
275 fn wait_qdrant(&mut self, host: &str, port: u16) -> anyhow::Result<()>;
276 fn wait_falkordb(&mut self, host: &str, port: u16) -> anyhow::Result<()>;
277}
278
279pub struct TcpDockerHealthChecker {
280 pub retries: usize,
281 pub interval: Duration,
282}
283
284impl Default for TcpDockerHealthChecker {
285 fn default() -> Self {
286 Self {
287 retries: 30,
288 interval: Duration::from_secs(2),
289 }
290 }
291}
292
293impl DockerHealthChecker for TcpDockerHealthChecker {
294 fn wait_postgres(&mut self, host: &str, port: u16) -> anyhow::Result<()> {
295 wait_for_tcp(host, port, self.retries, self.interval)
296 .map_err(|err| anyhow::anyhow!("PostgreSQL did not become reachable: {err}"))
297 }
298
299 fn wait_qdrant(&mut self, host: &str, port: u16) -> anyhow::Result<()> {
300 let healthz = || -> anyhow::Result<()> {
301 let mut stream = TcpStream::connect((host, port))?;
302 stream.set_read_timeout(Some(Duration::from_secs(3)))?;
303 stream.set_write_timeout(Some(Duration::from_secs(3)))?;
304 stream.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?;
305 let mut body = String::new();
306 stream.read_to_string(&mut body)?;
307 if body.starts_with("HTTP/1.1 200") || body.starts_with("HTTP/1.0 200") {
308 Ok(())
309 } else {
310 anyhow::bail!("unexpected Qdrant health response")
311 }
312 };
313 wait_for(healthz, self.retries, self.interval)
314 .map_err(|err| anyhow::anyhow!("Qdrant did not become healthy: {err}"))
315 }
316
317 fn wait_falkordb(&mut self, host: &str, port: u16) -> anyhow::Result<()> {
318 wait_for_tcp(host, port, self.retries, self.interval)
319 .map_err(|err| anyhow::anyhow!("FalkorDB did not become reachable: {err}"))
320 }
321}
322
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct HubIdentity {
325 pub system_identifier: String,
326 pub database_name: String,
327}
328
329impl HubIdentity {
330 fn display_label(&self) -> String {
331 format!(
332 "system_identifier={}, database={}",
333 self.system_identifier, self.database_name
334 )
335 }
336}
337
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub enum HubIdentityProbeResult {
340 Known(HubIdentity),
341 UnknownInsufficientPrivilege { message: String },
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub enum RecordedHubIdentityStatus {
346 SingleReachable,
347 VerifiedSameHub,
348 IdentityUnknownInsufficientPrivilege { message: String },
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
352pub struct RecordedHubResolution {
353 pub database_url: String,
354 pub identity_status: RecordedHubIdentityStatus,
355}
356
357pub fn ensure_hub(
358 options: &EnsureHubOptions,
359) -> anyhow::Result<(String, Option<DockerProvisioningReport>)> {
360 ensure_hub_with_identity(
361 options,
362 |name| std::env::var(name).ok(),
363 postgres_database_reachable,
364 probe_postgres_hub_identity,
365 provision_docker_services,
366 )
367}
368
369#[cfg(test)]
370fn ensure_hub_with(
371 options: &EnsureHubOptions,
372 get_env: impl FnMut(&str) -> Option<String>,
373 database_reachable: impl FnMut(&str) -> bool,
374 provision: impl FnOnce(&DockerServiceOptions) -> anyhow::Result<DockerProvisioningReport>,
375) -> anyhow::Result<(String, Option<DockerProvisioningReport>)> {
376 ensure_hub_with_identity(
377 options,
378 get_env,
379 database_reachable,
380 |_| {
381 Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
382 message: "identity_unknown_insufficient_privilege: test probe not configured"
383 .to_string(),
384 })
385 },
386 provision,
387 )
388}
389
390fn ensure_hub_with_identity(
391 options: &EnsureHubOptions,
392 mut get_env: impl FnMut(&str) -> Option<String>,
393 mut database_reachable: impl FnMut(&str) -> bool,
394 mut identity_probe: impl FnMut(&str) -> anyhow::Result<HubIdentityProbeResult>,
395 provision: impl FnOnce(&DockerServiceOptions) -> anyhow::Result<DockerProvisioningReport>,
396) -> anyhow::Result<(String, Option<DockerProvisioningReport>)> {
397 let mut override_database_url = None;
398 let mut gcore_database_url = None;
399 let mut bootstrap_database_url = None;
400
401 for candidate in resolve_hub_database_urls(options, &mut get_env)? {
402 match candidate.source {
403 HubDatabaseUrlSource::Candidate | HubDatabaseUrlSource::Env => {
404 if override_database_url.is_none()
405 && explicit_database_url_reachable(
406 &candidate.database_url,
407 &mut database_reachable,
408 )
409 {
410 override_database_url = Some(candidate.database_url);
411 }
412 }
413 HubDatabaseUrlSource::GcoreConfig => {
414 gcore_database_url = Some(candidate.database_url);
415 }
416 HubDatabaseUrlSource::Bootstrap => {
417 bootstrap_database_url = Some(candidate.database_url);
418 }
419 }
420 }
421
422 let recorded_resolution = resolve_recorded_hub_database_url(
423 gcore_database_url.as_deref(),
424 bootstrap_database_url.as_deref(),
425 &mut database_reachable,
426 &mut identity_probe,
427 )?;
428
429 if let Some(override_database_url) = override_database_url {
430 if let Some(recorded) = recorded_resolution
431 && let Some(resolution) = resolve_recorded_hub_database_url(
432 Some(&recorded.database_url),
433 Some(&override_database_url),
434 &mut database_reachable,
435 &mut identity_probe,
436 )?
437 {
438 if let RecordedHubIdentityStatus::IdentityUnknownInsufficientPrivilege { message } =
439 &resolution.identity_status
440 {
441 log::warn!("{message}");
442 }
443 return Ok((resolution.database_url, None));
444 }
445 return Ok((override_database_url, None));
446 }
447
448 if let Some(resolution) = recorded_resolution {
449 if let RecordedHubIdentityStatus::IdentityUnknownInsufficientPrivilege { message } =
450 &resolution.identity_status
451 {
452 log::warn!("{message}");
453 }
454 return Ok((resolution.database_url, None));
455 }
456
457 if !options.provision_services {
458 anyhow::bail!(
459 "no reachable Gobby PostgreSQL hub found and service provisioning is disabled"
460 );
461 }
462
463 let report = provision(&options.service_options).context("failed to provision Gobby hub")?;
464 Ok((
465 default_database_url(options.service_options.postgres_port),
466 Some(report),
467 ))
468}
469
470pub fn resolve_recorded_hub_database_url(
471 existing_database_url: Option<&str>,
472 daemon_database_url: Option<&str>,
473 mut database_reachable: impl FnMut(&str) -> bool,
474 mut identity_probe: impl FnMut(&str) -> anyhow::Result<HubIdentityProbeResult>,
475) -> anyhow::Result<Option<RecordedHubResolution>> {
476 let existing_database_url =
477 existing_database_url.and_then(|value| non_empty_trimmed(Some(value.to_string())));
478 let daemon_database_url =
479 daemon_database_url.and_then(|value| non_empty_trimmed(Some(value.to_string())));
480
481 match (existing_database_url, daemon_database_url) {
482 (None, None) => Ok(None),
483 (Some(existing), None) => {
484 if database_reachable(&existing) {
485 Ok(Some(RecordedHubResolution {
486 database_url: existing,
487 identity_status: RecordedHubIdentityStatus::SingleReachable,
488 }))
489 } else {
490 Ok(None)
491 }
492 }
493 (None, Some(daemon)) => {
494 if database_reachable(&daemon) {
495 Ok(Some(RecordedHubResolution {
496 database_url: daemon,
497 identity_status: RecordedHubIdentityStatus::SingleReachable,
498 }))
499 } else {
500 Ok(None)
501 }
502 }
503 (Some(existing), Some(daemon)) if existing == daemon => {
504 if database_reachable(&daemon) {
505 Ok(Some(RecordedHubResolution {
506 database_url: daemon,
507 identity_status: RecordedHubIdentityStatus::VerifiedSameHub,
508 }))
509 } else {
510 Ok(None)
511 }
512 }
513 (Some(existing), Some(daemon)) => {
514 let existing_reachable = database_reachable(&existing);
515 let daemon_reachable = database_reachable(&daemon);
516
517 match (existing_reachable, daemon_reachable) {
518 (false, false) => Ok(None),
519 (true, false) => Ok(Some(RecordedHubResolution {
520 database_url: existing,
521 identity_status: RecordedHubIdentityStatus::SingleReachable,
522 })),
523 (false, true) => Ok(Some(RecordedHubResolution {
524 database_url: daemon,
525 identity_status: RecordedHubIdentityStatus::SingleReachable,
526 })),
527 (true, true) => {
528 let existing_redacted = redact_database_url_for_error(&existing);
529 let daemon_redacted = redact_database_url_for_error(&daemon);
530 let existing_identity = identity_probe(&existing).with_context(|| {
531 format!("failed to probe PostgreSQL hub identity for {existing_redacted}")
532 })?;
533 let daemon_identity = identity_probe(&daemon).with_context(|| {
534 format!("failed to probe PostgreSQL hub identity for {daemon_redacted}")
535 })?;
536
537 match (existing_identity, daemon_identity) {
538 (
539 HubIdentityProbeResult::Known(existing_identity),
540 HubIdentityProbeResult::Known(daemon_identity),
541 ) if existing_identity == daemon_identity => {
542 Ok(Some(RecordedHubResolution {
543 database_url: daemon,
544 identity_status: RecordedHubIdentityStatus::VerifiedSameHub,
545 }))
546 }
547 (
548 HubIdentityProbeResult::Known(existing_identity),
549 HubIdentityProbeResult::Known(daemon_identity),
550 ) => Err(CoreError::HubConflict {
551 existing_database_url: existing_redacted,
552 existing_identity: existing_identity.display_label(),
553 daemon_database_url: daemon_redacted,
554 daemon_identity: daemon_identity.display_label(),
555 }
556 .into()),
557 (
558 HubIdentityProbeResult::UnknownInsufficientPrivilege { message },
559 _,
560 )
561 | (
562 _,
563 HubIdentityProbeResult::UnknownInsufficientPrivilege { message },
564 ) => Ok(Some(RecordedHubResolution {
565 database_url: existing.clone(),
566 identity_status:
567 RecordedHubIdentityStatus::IdentityUnknownInsufficientPrivilege {
568 message: format!(
569 "identity_unknown_insufficient_privilege: preserving existing recorded hub {}; daemon hub {} was not adopted because identity could not be verified ({message})",
570 redact_database_url_for_error(&existing),
571 redact_database_url_for_error(&daemon),
572 ),
573 },
574 })),
575 }
576 }
577 }
578 }
579 }
580}
581
582fn redact_database_url_for_error(_database_url: &str) -> String {
583 "<redacted-postgres-dsn>".to_string()
584}
585
586#[cfg(feature = "postgres")]
587pub fn probe_postgres_hub_identity(database_url: &str) -> anyhow::Result<HubIdentityProbeResult> {
588 use anyhow::Context;
589 use postgres::error::SqlState;
590
591 fn insufficient_privilege(error: &postgres::Error) -> bool {
592 error.code() == Some(&SqlState::INSUFFICIENT_PRIVILEGE)
593 }
594
595 let mut conn = crate::postgres::connect_readonly(database_url)?;
596 let has_privilege = match conn.query_one(
597 "SELECT has_function_privilege(current_user, 'pg_control_system()', 'execute')",
598 &[],
599 ) {
600 Ok(row) => row.get::<_, bool>(0),
601 Err(error) if insufficient_privilege(&error) => {
602 return Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
603 message: "identity_unknown_insufficient_privilege: current role cannot preflight pg_control_system()".to_string(),
604 });
605 }
606 Err(error) => {
607 return Err(error).context("failed to preflight pg_control_system() privilege");
608 }
609 };
610
611 if !has_privilege {
612 return Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
613 message: "identity_unknown_insufficient_privilege: current role cannot execute pg_control_system()".to_string(),
614 });
615 }
616
617 let row = match conn.query_one(
618 "SELECT system_identifier::text AS system_identifier, current_database() AS database_name FROM pg_control_system()",
619 &[],
620 ) {
621 Ok(row) => row,
622 Err(error) if insufficient_privilege(&error) => {
623 return Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
624 message: "identity_unknown_insufficient_privilege: current role cannot execute pg_control_system()".to_string(),
625 });
626 }
627 Err(error) => return Err(error).context("failed to query PostgreSQL hub identity"),
628 };
629
630 Ok(HubIdentityProbeResult::Known(HubIdentity {
631 system_identifier: row
632 .try_get("system_identifier")
633 .context("PostgreSQL hub identity did not include system_identifier")?,
634 database_name: row
635 .try_get("database_name")
636 .context("PostgreSQL hub identity did not include database_name")?,
637 }))
638}
639
640#[cfg(not(feature = "postgres"))]
641pub fn probe_postgres_hub_identity(_database_url: &str) -> anyhow::Result<HubIdentityProbeResult> {
642 Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
643 message: "identity_unknown_insufficient_privilege: gobby-core was built without PostgreSQL support".to_string(),
644 })
645}
646
647#[derive(Debug, Clone, Copy, PartialEq, Eq)]
648enum HubDatabaseUrlSource {
649 Candidate,
650 Env,
651 GcoreConfig,
652 Bootstrap,
653}
654
655#[derive(Debug, Clone, PartialEq, Eq)]
656struct HubDatabaseUrl {
657 source: HubDatabaseUrlSource,
658 database_url: String,
659}
660
661fn resolve_hub_database_urls(
662 options: &EnsureHubOptions,
663 get_env: &mut impl FnMut(&str) -> Option<String>,
664) -> anyhow::Result<Vec<HubDatabaseUrl>> {
665 let mut urls = Vec::new();
666 urls.extend(
667 options
668 .candidate_database_urls
669 .iter()
670 .filter_map(|value| non_empty_trimmed(Some(value.clone())))
671 .map(|database_url| HubDatabaseUrl {
672 source: HubDatabaseUrlSource::Candidate,
673 database_url,
674 }),
675 );
676 if let Some(database_url) = non_empty_trimmed(get_env("GOBBY_POSTGRES_DSN")) {
677 urls.push(HubDatabaseUrl {
678 source: HubDatabaseUrlSource::Env,
679 database_url,
680 });
681 }
682 if let Some(database_url) = resolve_database_url_from_gcore_config(&options.gobby_home)? {
683 urls.push(HubDatabaseUrl {
684 source: HubDatabaseUrlSource::GcoreConfig,
685 database_url,
686 });
687 }
688 if let Some(database_url) =
689 resolve_database_url_from_bootstrap_file(&options.gobby_home.join("bootstrap.yaml"))?
690 {
691 urls.push(HubDatabaseUrl {
692 source: HubDatabaseUrlSource::Bootstrap,
693 database_url,
694 });
695 }
696 Ok(urls)
697}
698
699fn resolve_database_url_from_gcore_config(home: &Path) -> anyhow::Result<Option<String>> {
700 if !services_dir(home).is_dir() || !compose_file_path(home).is_file() {
701 return Ok(None);
702 }
703 let Some(config) = StandaloneConfig::read_at(&gcore_config_path(home))? else {
704 return Ok(None);
705 };
706 Ok(config
707 .get("databases.postgres.dsn")
708 .and_then(|value| non_empty_trimmed(Some(value.to_string()))))
709}
710
711#[derive(Debug, Deserialize)]
712struct HubBootstrap {
713 hub_backend: Option<String>,
714 database_url: Option<String>,
715}
716
717fn resolve_database_url_from_bootstrap_file(path: &Path) -> anyhow::Result<Option<String>> {
718 if !path.exists() {
719 return Ok(None);
720 }
721 let contents = fs::read_to_string(path)
722 .with_context(|| format!("failed to read Gobby bootstrap at {}", path.display()))?;
723 let bootstrap: HubBootstrap = serde_yaml::from_str(&contents)
724 .with_context(|| format!("failed to parse {}", path.display()))?;
725 if matches!(bootstrap.hub_backend.as_deref(), Some(backend) if backend != "postgres") {
726 return Ok(None);
727 }
728 Ok(non_empty_trimmed(bootstrap.database_url))
729}
730
731fn non_empty_trimmed(value: Option<String>) -> Option<String> {
732 let trimmed = value.as_ref()?.trim();
733 if trimmed.is_empty() {
734 None
735 } else {
736 Some(trimmed.to_string())
737 }
738}
739
740#[cfg(feature = "postgres")]
741fn postgres_database_reachable(database_url: &str) -> bool {
742 crate::postgres::connect_readonly(database_url).is_ok()
743}
744
745#[cfg(not(feature = "postgres"))]
746fn postgres_database_reachable(_database_url: &str) -> bool {
747 false
748}
749
750#[cfg(feature = "postgres")]
751fn explicit_database_url_reachable(
752 database_url: &str,
753 database_reachable: &mut impl FnMut(&str) -> bool,
754) -> bool {
755 database_reachable(database_url)
756}
757
758#[cfg(not(feature = "postgres"))]
759fn explicit_database_url_reachable(
760 _database_url: &str,
761 _database_reachable: &mut impl FnMut(&str) -> bool,
762) -> bool {
763 true
764}
765
766pub fn provision_docker_services(
767 options: &DockerServiceOptions,
768) -> anyhow::Result<DockerProvisioningReport> {
769 let mut runner = RealCommandRunner;
770 let mut health = TcpDockerHealthChecker::default();
771 provision_docker_services_with(options, &mut runner, &mut health)
772}
773
774pub fn provision_docker_services_with(
775 options: &DockerServiceOptions,
776 runner: &mut impl CommandRunner,
777 health: &mut impl DockerHealthChecker,
778) -> anyhow::Result<DockerProvisioningReport> {
779 let assets = prepare_service_assets(options)?;
780 let spec = docker_compose_up_spec(options, &assets.compose_file, &assets.services_dir);
781 let output = runner.run(&spec).map_err(|err| {
782 anyhow::anyhow!("failed to execute docker compose for standalone services: {err}")
783 })?;
784 if output.status != 0 {
785 anyhow::bail!(
786 "docker compose up failed: {}",
787 first_non_empty(&output.stderr, &output.stdout)
788 );
789 }
790
791 health.wait_postgres(DEFAULT_POSTGRES_HOST, options.postgres_port)?;
792 health.wait_qdrant(DEFAULT_POSTGRES_HOST, options.qdrant_http_port)?;
793 health.wait_falkordb(&options.falkordb_host, options.falkordb_port)?;
794
795 Ok(DockerProvisioningReport {
796 services_dir: assets.services_dir,
797 compose_file: assets.compose_file,
798 env_file: assets.env_file,
799 started_profiles: vec!["all".to_string()],
800 health_checks: vec![
801 "postgres".to_string(),
802 "qdrant".to_string(),
803 "falkordb".to_string(),
804 ],
805 })
806}
807
808pub fn prepare_service_assets(
809 options: &DockerServiceOptions,
810) -> anyhow::Result<ServiceAssetReport> {
811 let services = services_dir(&options.gobby_home);
812 let compose = services.join(COMPOSE_FILENAME);
813 let pgsearch = services.join("postgres-pgsearch");
814 let env_file = services.join(".env");
815
816 fs::create_dir_all(pgsearch.join("initdb.d"))?;
817 fs::create_dir_all(pgsearch.join("scripts"))?;
818 fs::write(&compose, COMPOSE_TEMPLATE)?;
819 fs::write(pgsearch.join("Dockerfile"), PGSEARCH_DOCKERFILE)?;
820 fs::write(pgsearch.join("version.json"), PGSEARCH_VERSION)?;
821 fs::write(
822 pgsearch.join("initdb.d").join("01-pg_search.sql"),
823 PGSEARCH_INIT_PG_SEARCH,
824 )?;
825 fs::write(
826 pgsearch.join("initdb.d").join("02-pgaudit.sql"),
827 PGSEARCH_INIT_PGAUDIT,
828 )?;
829 let audit_script = pgsearch.join("scripts").join("pg_audit_export.sh");
830 fs::write(&audit_script, PG_AUDIT_EXPORT)?;
831 make_executable(&audit_script)?;
832
833 let manifest = pgsearch_manifest()?;
834 update_env_file(
835 &env_file,
836 BTreeMap::from([
837 (
838 "GOBBY_PG_SEARCH_VERSION".to_string(),
839 manifest.pg_search_version,
840 ),
841 ("GOBBY_PG_SEARCH_SHA256".to_string(), manifest.sha256),
842 (
843 "GOBBY_POSTGRES_PORT".to_string(),
844 options.postgres_port.to_string(),
845 ),
846 (
847 "GOBBY_POSTGRES_DB".to_string(),
848 DEFAULT_POSTGRES_DB.to_string(),
849 ),
850 (
851 "GOBBY_POSTGRES_USER".to_string(),
852 DEFAULT_POSTGRES_USER.to_string(),
853 ),
854 (
855 "GOBBY_POSTGRES_PASSWORD".to_string(),
856 DEFAULT_POSTGRES_PASSWORD.to_string(),
857 ),
858 (
859 "GOBBY_QDRANT_HTTP_PORT".to_string(),
860 options.qdrant_http_port.to_string(),
861 ),
862 (
863 "GOBBY_QDRANT_GRPC_PORT".to_string(),
864 options.qdrant_grpc_port.to_string(),
865 ),
866 (
867 "GOBBY_FALKORDB_PORT".to_string(),
868 options.falkordb_port.to_string(),
869 ),
870 (
871 "GOBBY_FALKORDB_BROWSER_PORT".to_string(),
872 options.falkordb_browser_port.to_string(),
873 ),
874 (
875 "GOBBY_FALKORDB_PASSWORD".to_string(),
876 options.falkordb_password.clone(),
877 ),
878 ]),
879 )?;
880
881 Ok(ServiceAssetReport {
882 services_dir: services,
883 compose_file: compose,
884 env_file,
885 postgres_asset_dir: pgsearch,
886 })
887}
888
889pub fn docker_compose_up_spec(
890 options: &DockerServiceOptions,
891 compose_file: &Path,
892 services_dir: &Path,
893) -> CommandSpec {
894 CommandSpec {
895 program: "docker".to_string(),
896 args: vec![
897 "compose".to_string(),
898 "-f".to_string(),
899 compose_file.display().to_string(),
900 "--profile".to_string(),
901 "all".to_string(),
902 "up".to_string(),
903 "-d".to_string(),
904 "--remove-orphans".to_string(),
905 ],
906 env: BTreeMap::from([
907 (
908 "GOBBY_FALKORDB_PASSWORD".to_string(),
909 options.falkordb_password.clone(),
910 ),
911 (
912 "GOBBY_POSTGRES_PORT".to_string(),
913 options.postgres_port.to_string(),
914 ),
915 (
916 "GOBBY_QDRANT_HTTP_PORT".to_string(),
917 options.qdrant_http_port.to_string(),
918 ),
919 ]),
920 cwd: Some(services_dir.to_path_buf()),
921 }
922}
923
924#[derive(Debug, Clone, PartialEq, Eq)]
925pub struct EmbeddingBootstrap {
926 pub provider: String,
927 pub api_base: String,
928 pub model: String,
929 pub vector_dim: usize,
930 pub query_prefix: Option<String>,
931 pub api_key: Option<String>,
932}
933
934impl EmbeddingBootstrap {
935 pub fn lm_studio() -> Self {
936 Self {
937 provider: "lm-studio".to_string(),
938 api_base: DEFAULT_LM_STUDIO_API_BASE.to_string(),
939 model: DEFAULT_LM_STUDIO_MODEL.to_string(),
940 vector_dim: DEFAULT_EMBEDDING_VECTOR_DIM,
941 query_prefix: None,
942 api_key: None,
943 }
944 }
945
946 pub fn ollama() -> Self {
947 Self {
948 provider: "ollama".to_string(),
949 api_base: DEFAULT_OLLAMA_API_BASE.to_string(),
950 model: DEFAULT_OLLAMA_MODEL.to_string(),
951 vector_dim: DEFAULT_EMBEDDING_VECTOR_DIM,
952 query_prefix: None,
953 api_key: None,
954 }
955 }
956}
957
958pub fn write_standalone_bootstrap(
959 path: &Path,
960 database_url: &str,
961 options: &DockerServiceOptions,
962 compose_file: Option<&Path>,
963 embedding: Option<&EmbeddingBootstrap>,
964) -> anyhow::Result<StandaloneConfig> {
965 let mut config = StandaloneConfig::empty();
966 config.set("databases.postgres.dsn", database_url);
967 config.set("databases.falkordb.host", &options.falkordb_host);
968 config.set("databases.falkordb.port", options.falkordb_port.to_string());
969 config.set("databases.falkordb.password", &options.falkordb_password);
970 config.set("databases.qdrant.url", options.qdrant_url());
971 if let Some(embedding) = embedding {
972 remove_legacy_embedding_keys(&mut config);
973 config.set(embedding_keys::AI_PROVIDER, &embedding.provider);
974 config.set(embedding_keys::AI_API_BASE, &embedding.api_base);
975 config.set(embedding_keys::AI_MODEL, &embedding.model);
976 config.set(embedding_keys::AI_DIM, embedding.vector_dim.to_string());
977 if let Some(query_prefix) = &embedding.query_prefix {
978 config.set(embedding_keys::AI_QUERY_PREFIX, query_prefix);
979 }
980 if let Some(api_key) = &embedding.api_key {
981 config.set(embedding_keys::AI_API_KEY, api_key);
982 }
983 }
984 if let Some(compose_file) = compose_file {
985 config.set("services.compose_file", compose_file.display().to_string());
986 }
987 config.write_at(path)?;
988 Ok(config)
989}
990
991fn remove_legacy_embedding_keys(config: &mut StandaloneConfig) {
992 let legacy_keys = embedding_keys::legacy_keys();
993 let removed = legacy_keys
994 .iter()
995 .filter(|key| config.get(key).is_some())
996 .cloned()
997 .collect::<Vec<_>>();
998 if !removed.is_empty() {
999 log::warn!(
1000 "removing legacy embedding config keys [{}]; embedding config now lives under \
1001 ai.embeddings.* and unsupported legacy values are dropped. See \
1002 hub-install-contract.md and ai-daemon-contract.md for migration guidance.",
1003 removed.join(", ")
1004 );
1005 }
1006 for key in legacy_keys {
1007 config.remove(&key);
1008 }
1009}
1010
1011fn flatten_yaml_value(
1012 prefix: Option<&str>,
1013 value: &serde_yaml::Value,
1014 output: &mut BTreeMap<String, String>,
1015) -> anyhow::Result<()> {
1016 match value {
1017 serde_yaml::Value::Null => Ok(()),
1018 serde_yaml::Value::Mapping(mapping) => {
1019 for (key, value) in mapping {
1020 let Some(key) = key.as_str() else {
1021 anyhow::bail!("gcore.yaml keys must be strings");
1022 };
1023 let joined = match prefix {
1024 Some(prefix) if !prefix.is_empty() => format!("{prefix}.{key}"),
1025 _ => key.to_string(),
1026 };
1027 match value {
1028 serde_yaml::Value::Mapping(_) if !key.contains('.') => {
1029 flatten_yaml_value(Some(&joined), value, output)?;
1030 }
1031 _ => {
1032 if let Some(text) = scalar_to_string(value)? {
1033 output.insert(joined, text);
1034 }
1035 }
1036 }
1037 }
1038 Ok(())
1039 }
1040 _ => {
1041 let Some(prefix) = prefix else {
1042 anyhow::bail!("gcore.yaml must be a mapping");
1043 };
1044 if let Some(text) = scalar_to_string(value)? {
1045 output.insert(prefix.to_string(), text);
1046 }
1047 Ok(())
1048 }
1049 }
1050}
1051
1052fn scalar_to_string(value: &serde_yaml::Value) -> anyhow::Result<Option<String>> {
1053 Ok(match value {
1054 serde_yaml::Value::Null => None,
1055 serde_yaml::Value::String(value) => Some(value.clone()),
1056 serde_yaml::Value::Bool(value) => Some(value.to_string()),
1057 serde_yaml::Value::Number(value) => Some(value.to_string()),
1058 other => Some(serde_yaml::to_string(other)?.trim().to_string()),
1059 })
1060}
1061
1062#[derive(Debug, Deserialize)]
1063struct PgSearchVersionFile {
1064 pg_search_version: String,
1065 pg_search_sha256: String,
1066 pg_search_sha256_by_arch: Option<BTreeMap<String, String>>,
1067}
1068
1069struct PgSearchManifest {
1070 pg_search_version: String,
1071 sha256: String,
1072}
1073
1074fn pgsearch_manifest() -> anyhow::Result<PgSearchManifest> {
1075 let parsed: PgSearchVersionFile = serde_json::from_str(PGSEARCH_VERSION)?;
1076 let arch = debian_arch(std::env::consts::ARCH);
1077 let sha256 = parsed
1078 .pg_search_sha256_by_arch
1079 .and_then(|by_arch| by_arch.get(&arch).cloned())
1080 .unwrap_or(parsed.pg_search_sha256);
1081 Ok(PgSearchManifest {
1082 pg_search_version: parsed.pg_search_version,
1083 sha256,
1084 })
1085}
1086
1087fn debian_arch(arch: &str) -> String {
1088 match arch {
1089 "x86_64" | "amd64" => "amd64".to_string(),
1090 "aarch64" | "arm64" => "arm64".to_string(),
1091 other => other.to_string(),
1092 }
1093}
1094
1095fn update_env_file(path: &Path, updates: BTreeMap<String, String>) -> anyhow::Result<()> {
1096 if let Some(parent) = path.parent() {
1097 fs::create_dir_all(parent)?;
1098 }
1099 let mut lines = Vec::new();
1100 if path.exists() {
1101 for line in fs::read_to_string(path)?.lines() {
1102 let key = line.split_once('=').map(|(key, _)| key).unwrap_or(line);
1103 if !updates.contains_key(key) {
1104 lines.push(line.to_string());
1105 }
1106 }
1107 if lines.last().is_some_and(|line| !line.trim().is_empty()) {
1108 lines.push(String::new());
1109 }
1110 }
1111 for (key, value) in updates {
1112 lines.push(format!("{key}={value}"));
1113 }
1114 fs::write(path, format!("{}\n", lines.join("\n")))?;
1115 Ok(())
1116}
1117
1118fn first_non_empty<'a>(first: &'a str, second: &'a str) -> &'a str {
1119 if first.trim().is_empty() {
1120 second.trim()
1121 } else {
1122 first.trim()
1123 }
1124}
1125
1126fn wait_for_tcp(host: &str, port: u16, retries: usize, interval: Duration) -> anyhow::Result<()> {
1127 wait_for(
1128 || {
1129 TcpStream::connect((host, port))
1130 .map(|_| ())
1131 .map_err(Into::into)
1132 },
1133 retries,
1134 interval,
1135 )
1136}
1137
1138fn wait_for(
1139 mut check: impl FnMut() -> anyhow::Result<()>,
1140 retries: usize,
1141 interval: Duration,
1142) -> anyhow::Result<()> {
1143 let mut last_error = None;
1144 for attempt in 0..retries {
1145 match check() {
1146 Ok(()) => return Ok(()),
1147 Err(err) => last_error = Some(err),
1148 }
1149 if attempt + 1 < retries {
1150 std::thread::sleep(interval);
1151 }
1152 }
1153 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("health check failed")))
1154}
1155
1156fn make_executable(path: &Path) -> anyhow::Result<()> {
1157 #[cfg(unix)]
1158 {
1159 use std::os::unix::fs::PermissionsExt;
1160 let mut permissions = fs::metadata(path)?.permissions();
1161 permissions.set_mode(0o755);
1162 fs::set_permissions(path, permissions)?;
1163 }
1164 #[cfg(not(unix))]
1165 {
1166 let _ = path;
1167 }
1168 Ok(())
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173 use super::*;
1174 use crate::config::TEST_ENV_LOCK;
1175 use std::sync::MutexGuard;
1176
1177 struct EnvGuard {
1178 _lock: MutexGuard<'static, ()>,
1179 }
1180
1181 impl EnvGuard {
1182 fn new() -> Self {
1183 let guard = Self {
1184 _lock: TEST_ENV_LOCK
1185 .lock()
1186 .unwrap_or_else(|poisoned| poisoned.into_inner()),
1187 };
1188 guard.clear();
1189 guard
1190 }
1191
1192 fn clear(&self) {
1193 let lock_is_held = matches!(
1194 TEST_ENV_LOCK.try_lock(),
1195 Err(std::sync::TryLockError::WouldBlock)
1196 );
1197 assert!(
1198 lock_is_held,
1199 "TEST_ENV_LOCK must be held before mutating test environment"
1200 );
1201 for key in [
1202 "GOBBY_FALKORDB_HOST",
1203 "GOBBY_FALKORDB_PORT",
1204 "GOBBY_FALKORDB_PASSWORD",
1205 "GOBBY_POSTGRES_DSN",
1206 "GOBBY_QDRANT_URL",
1207 "GOBBY_QDRANT_API_KEY",
1208 "GOBBY_EMBEDDING_URL",
1209 "GOBBY_EMBEDDING_MODEL",
1210 "GOBBY_EMBEDDING_API_KEY",
1211 "GOBBY_EMBEDDING_QUERY_PREFIX",
1212 "GOBBY_EMBEDDING_TIMEOUT_SECONDS",
1213 ] {
1214 unsafe { std::env::remove_var(key) };
1217 }
1218 }
1219 }
1220
1221 impl Drop for EnvGuard {
1222 fn drop(&mut self) {
1223 self.clear();
1224 }
1225 }
1226
1227 fn write_services_stack(home: &Path) {
1228 fs::create_dir_all(services_dir(home)).expect("create services dir");
1229 fs::write(compose_file_path(home), "services: {}\n").expect("write compose file");
1230 }
1231
1232 #[test]
1233 fn gcore_yaml_reads_flat_and_nested_keys() {
1234 let config = StandaloneConfig::from_yaml_str(&format!(
1235 r#"
1236databases.postgres.dsn: postgresql://flat/db
1237databases:
1238 falkordb:
1239 port: 16379
1240{api_key}: local-api-key
1241"#,
1242 api_key = embedding_keys::AI_API_KEY,
1243 ))
1244 .expect("parse config");
1245
1246 assert_eq!(
1247 config.get("databases.postgres.dsn"),
1248 Some("postgresql://flat/db")
1249 );
1250 assert_eq!(config.get("databases.falkordb.port"), Some("16379"));
1251 assert_eq!(
1252 config.get(embedding_keys::AI_API_KEY),
1253 Some("local-api-key")
1254 );
1255 }
1256
1257 #[test]
1258 fn gcore_yaml_writes_flat_keys() {
1259 let dir = tempfile::tempdir().expect("tempdir");
1260 let path = dir.path().join(GCORE_CONFIG_FILENAME);
1261 let mut config = StandaloneConfig::empty();
1262 config.set("databases.postgres.dsn", "postgresql://local/db");
1263 config.set(embedding_keys::AI_DIM, "768");
1264
1265 config.write_at(&path).expect("write config");
1266 let raw = fs::read_to_string(&path).expect("read config");
1267
1268 assert!(raw.contains("databases.postgres.dsn:"));
1269 assert!(raw.contains(&format!("{}:", embedding_keys::AI_DIM)));
1270 assert_eq!(
1271 StandaloneConfig::read_at(&path)
1272 .expect("read config")
1273 .expect("config present")
1274 .get(embedding_keys::AI_DIM),
1275 Some("768")
1276 );
1277 }
1278
1279 #[test]
1280 fn standalone_config_resolves_service_keys_and_plain_api_key() {
1281 let _env = EnvGuard::new();
1282 let mut config = StandaloneConfig::from_yaml_str(&format!(
1283 r#"
1284databases.falkordb.host: 127.0.0.1
1285databases.falkordb.port: "16379"
1286databases.falkordb.password: falkor-pass
1287databases.qdrant.url: http://localhost:6333
1288{api_base}: http://localhost:1234/v1
1289{model}: text-embedding-nomic-embed-text-v1.5@f16
1290{api_key}: test-key
1291"#,
1292 api_base = embedding_keys::AI_API_BASE,
1293 model = embedding_keys::AI_MODEL,
1294 api_key = embedding_keys::AI_API_KEY,
1295 ))
1296 .expect("parse config");
1297
1298 let falkor = crate::config::resolve_falkordb_config(&mut config).expect("falkor");
1299 assert_eq!(falkor.password.as_deref(), Some("falkor-pass"));
1300 let qdrant = crate::config::resolve_qdrant_config(&mut config).expect("qdrant");
1301 assert_eq!(qdrant.url.as_deref(), Some("http://localhost:6333"));
1302 let embedding = crate::config::resolve_embedding_config(&mut config).expect("embedding");
1303 assert_eq!(embedding.api_key.as_deref(), Some("test-key"));
1304 }
1305
1306 #[test]
1307 fn writes_ai_embeddings_standalone_api_key() {
1308 let dir = tempfile::tempdir().expect("tempdir");
1309 let path = dir.path().join(GCORE_CONFIG_FILENAME);
1310 let options = DockerServiceOptions::new(dir.path().join(".gobby"));
1311 let embedding = EmbeddingBootstrap {
1312 provider: "openai-compatible".to_string(),
1313 api_base: "http://localhost:1234/v1".to_string(),
1314 model: "embed-small".to_string(),
1315 vector_dim: 1024,
1316 query_prefix: Some("query: ".to_string()),
1317 api_key: Some("local-api-key".to_string()),
1318 };
1319
1320 let config = write_standalone_bootstrap(
1321 &path,
1322 "postgresql://localhost/gobby",
1323 &options,
1324 None,
1325 Some(&embedding),
1326 )
1327 .expect("write standalone bootstrap");
1328
1329 assert_eq!(
1330 config.get(embedding_keys::AI_PROVIDER),
1331 Some("openai-compatible")
1332 );
1333 assert_eq!(
1334 config.get(embedding_keys::AI_API_BASE),
1335 Some("http://localhost:1234/v1")
1336 );
1337 assert_eq!(config.get(embedding_keys::AI_MODEL), Some("embed-small"));
1338 assert_eq!(config.get(embedding_keys::AI_DIM), Some("1024"));
1339 assert_eq!(config.get(embedding_keys::AI_QUERY_PREFIX), Some("query: "));
1340 assert_eq!(
1341 config.get(embedding_keys::AI_API_KEY),
1342 Some("local-api-key")
1343 );
1344 for key in embedding_keys::legacy_keys() {
1345 assert_eq!(config.get(&key), None, "legacy key was written: {key}");
1346 }
1347 }
1348
1349 #[test]
1350 fn compose_template_matches_daemon_checkout_when_present() {
1351 let daemon =
1352 Path::new("/Users/josh/Projects/gobby/src/gobby/data/docker-compose.services.yml");
1353 if !daemon.exists() {
1354 return;
1355 }
1356 let daemon_template = fs::read_to_string(daemon).expect("read daemon compose template");
1357 assert_eq!(COMPOSE_TEMPLATE, daemon_template);
1358 }
1359
1360 #[test]
1361 fn docker_provisioning_prepares_assets_runs_compose_and_health_checks() {
1362 let dir = tempfile::tempdir().expect("tempdir");
1363 let mut runner = RecordingRunner::default();
1364 let mut health = RecordingHealth::default();
1365 let options = DockerServiceOptions::new(dir.path().join(".gobby"));
1366
1367 let report = provision_docker_services_with(&options, &mut runner, &mut health)
1368 .expect("provision services");
1369
1370 assert_eq!(runner.commands.len(), 1);
1371 assert_eq!(runner.commands[0].program, "docker");
1372 assert!(runner.commands[0].args.contains(&"--profile".to_string()));
1373 assert!(runner.commands[0].args.contains(&"all".to_string()));
1374 assert_eq!(health.checks, vec!["postgres", "qdrant", "falkordb"]);
1375 assert_eq!(report.started_profiles, vec!["all"]);
1376 assert_eq!(report.health_checks, vec!["postgres", "qdrant", "falkordb"]);
1377 assert_eq!(
1378 fs::read_to_string(&report.compose_file).expect("read compose"),
1379 COMPOSE_TEMPLATE
1380 );
1381 assert!(
1382 report
1383 .services_dir
1384 .join("postgres-pgsearch")
1385 .join("Dockerfile")
1386 .exists()
1387 );
1388 assert!(
1389 fs::read_to_string(&report.env_file)
1390 .expect("read env")
1391 .contains("GOBBY_PG_SEARCH_VERSION=0.23.4")
1392 );
1393 }
1394
1395 #[test]
1396 fn ensure_hub_reuses_then_provisions() {
1397 let _env = EnvGuard::new();
1398 let dir = tempfile::tempdir().expect("tempdir");
1399 let home = dir.path().join(".gobby");
1400 fs::create_dir_all(&home).expect("create gobby home");
1401 write_services_stack(&home);
1402 let mut config = StandaloneConfig::empty();
1403 config.set("databases.postgres.dsn", "postgresql://reachable/gobby");
1404 config
1405 .write_at(&gcore_config_path(&home))
1406 .expect("write gcore config");
1407
1408 let options = EnsureHubOptions::new(home.clone());
1409 let (database_url, report) = ensure_hub_with(
1410 &options,
1411 |_| None,
1412 |url| url == "postgresql://reachable/gobby",
1413 |_| panic!("reachable DSN should not provision services"),
1414 )
1415 .expect("reuse reachable DSN");
1416 assert_eq!(database_url, "postgresql://reachable/gobby");
1417 assert!(report.is_none());
1418
1419 let mut options = EnsureHubOptions::new(home);
1420 options.service_options.postgres_port = 15432;
1421 options.candidate_database_urls = vec!["postgresql://unreachable/gobby".to_string()];
1422 let (database_url, report) = ensure_hub_with(
1423 &options,
1424 |_| None,
1425 |_| false,
1426 |service_options| {
1427 Ok(DockerProvisioningReport {
1428 services_dir: service_options.gobby_home.join("services"),
1429 compose_file: compose_file_path(&service_options.gobby_home),
1430 env_file: service_options.gobby_home.join("services/.env"),
1431 started_profiles: vec!["all".to_string()],
1432 health_checks: vec!["postgres".to_string()],
1433 })
1434 },
1435 )
1436 .expect("provision fallback hub");
1437 #[cfg(feature = "postgres")]
1438 {
1439 assert_eq!(database_url, default_database_url(15432));
1440 assert_eq!(
1441 report.expect("provisioning report").health_checks,
1442 vec!["postgres"]
1443 );
1444 }
1445 #[cfg(not(feature = "postgres"))]
1446 {
1447 assert_eq!(database_url, "postgresql://unreachable/gobby");
1448 assert!(report.is_none());
1449 }
1450 }
1451
1452 #[test]
1453 fn gcore_yaml_database_url_requires_services_stack() {
1454 let _env = EnvGuard::new();
1455 let dir = tempfile::tempdir().expect("tempdir");
1456 let home = dir.path().join(".gobby");
1457 fs::create_dir_all(&home).expect("create gobby home");
1458 let mut config = StandaloneConfig::empty();
1459 config.set("databases.postgres.dsn", "postgresql://recorded/gobby");
1460 config
1461 .write_at(&gcore_config_path(&home))
1462 .expect("write gcore config");
1463
1464 let mut options = EnsureHubOptions::new(home);
1465 options.service_options.postgres_port = 15432;
1466 let (database_url, report) = ensure_hub_with(
1467 &options,
1468 |_| None,
1469 |url| url == "postgresql://recorded/gobby",
1470 |service_options| {
1471 Ok(DockerProvisioningReport {
1472 services_dir: service_options.gobby_home.join("services"),
1473 compose_file: compose_file_path(&service_options.gobby_home),
1474 env_file: service_options.gobby_home.join("services/.env"),
1475 started_profiles: vec!["all".to_string()],
1476 health_checks: vec!["postgres".to_string()],
1477 })
1478 },
1479 )
1480 .expect("missing services stack should provision fallback hub");
1481
1482 assert_eq!(database_url, default_database_url(15432));
1483 assert!(report.is_some());
1484 }
1485
1486 #[test]
1487 fn no_double_provision_when_reachable() {
1488 let _env = EnvGuard::new();
1489 let dir = tempfile::tempdir().expect("tempdir");
1490 let home = dir.path().join(".gobby");
1491 fs::create_dir_all(&home).expect("create gobby home");
1492 write_services_stack(&home);
1493 let mut config = StandaloneConfig::empty();
1494 config.set("databases.postgres.dsn", "postgresql://recorded/gobby");
1495 config
1496 .write_at(&gcore_config_path(&home))
1497 .expect("write gcore config");
1498
1499 let mut options = EnsureHubOptions::new(home);
1500 options.service_options.postgres_port = 15432;
1501 let (database_url, report) = ensure_hub_with_identity(
1502 &options,
1503 |_| None,
1504 |url| url == "postgresql://recorded/gobby",
1505 |_| {
1506 Ok(HubIdentityProbeResult::Known(HubIdentity {
1507 system_identifier: "cluster-a".to_string(),
1508 database_name: "gobby".to_string(),
1509 }))
1510 },
1511 |_| panic!("reachable recorded DSN should not provision services"),
1512 )
1513 .expect("reuse reachable recorded hub");
1514
1515 assert_eq!(database_url, "postgresql://recorded/gobby");
1516 assert!(report.is_none());
1517 }
1518
1519 #[test]
1520 fn divergent_hubs_surface_conflict() {
1521 let _env = EnvGuard::new();
1522 let dir = tempfile::tempdir().expect("tempdir");
1523 let home = dir.path().join(".gobby");
1524 fs::create_dir_all(&home).expect("create gobby home");
1525 write_services_stack(&home);
1526 let mut config = StandaloneConfig::empty();
1527 config.set(
1528 "databases.postgres.dsn",
1529 "postgresql://standalone:secret@standalone/gobby?sslmode=require",
1530 );
1531 config
1532 .write_at(&gcore_config_path(&home))
1533 .expect("write gcore config");
1534 fs::write(
1535 home.join("bootstrap.yaml"),
1536 "hub_backend: postgres\ndatabase_url: postgresql://daemon:secret@daemon/gobby?application_name=gobby\n",
1537 )
1538 .expect("write bootstrap");
1539
1540 let err = ensure_hub_with_identity(
1541 &EnsureHubOptions::new(home),
1542 |_| None,
1543 |url| {
1544 matches!(
1545 url,
1546 "postgresql://standalone:secret@standalone/gobby?sslmode=require"
1547 | "postgresql://daemon:secret@daemon/gobby?application_name=gobby"
1548 )
1549 },
1550 |url| {
1551 let system_identifier =
1552 if url.starts_with("postgresql://standalone:secret@standalone/") {
1553 "cluster-a"
1554 } else {
1555 "cluster-b"
1556 };
1557 Ok(HubIdentityProbeResult::Known(HubIdentity {
1558 system_identifier: system_identifier.to_string(),
1559 database_name: "gobby".to_string(),
1560 }))
1561 },
1562 |_| panic!("conflicting reachable hubs should not provision services"),
1563 )
1564 .expect_err("surface divergent hub conflict");
1565
1566 let message = err.to_string();
1567 assert!(message.contains("system_identifier=cluster-a, database=gobby"));
1568 assert!(message.contains("system_identifier=cluster-b, database=gobby"));
1569 assert!(!message.contains("postgresql://"));
1570 assert!(!message.contains("secret"));
1571 assert!(!message.contains("sslmode"));
1572 assert!(!message.contains("application_name"));
1573 }
1574
1575 #[test]
1576 fn reachable_env_database_url_conflicts_with_recorded_hub() {
1577 let _env = EnvGuard::new();
1578 let dir = tempfile::tempdir().expect("tempdir");
1579 let home = dir.path().join(".gobby");
1580 fs::create_dir_all(&home).expect("create gobby home");
1581 write_services_stack(&home);
1582 let mut config = StandaloneConfig::empty();
1583 config.set("databases.postgres.dsn", "postgresql://recorded/gobby");
1584 config
1585 .write_at(&gcore_config_path(&home))
1586 .expect("write gcore config");
1587
1588 let err = ensure_hub_with_identity(
1589 &EnsureHubOptions::new(home),
1590 |name| (name == "GOBBY_POSTGRES_DSN").then(|| "postgresql://env/gobby".to_string()),
1591 |url| {
1592 matches!(
1593 url,
1594 "postgresql://recorded/gobby" | "postgresql://env/gobby"
1595 )
1596 },
1597 |url| {
1598 let system_identifier = if url == "postgresql://recorded/gobby" {
1599 "cluster-a"
1600 } else {
1601 "cluster-b"
1602 };
1603 Ok(HubIdentityProbeResult::Known(HubIdentity {
1604 system_identifier: system_identifier.to_string(),
1605 database_name: "gobby".to_string(),
1606 }))
1607 },
1608 |_| panic!("conflicting reachable hubs should not provision services"),
1609 )
1610 .expect_err("env DSN should be validated against recorded hub");
1611
1612 let message = err.to_string();
1613 assert!(message.contains("system_identifier=cluster-a, database=gobby"));
1614 assert!(message.contains("system_identifier=cluster-b, database=gobby"));
1615 assert!(!message.contains("postgresql://"));
1616 }
1617
1618 #[test]
1619 fn insufficient_identity_privilege_preserves_hub() {
1620 let _env = EnvGuard::new();
1621 let dir = tempfile::tempdir().expect("tempdir");
1622 let home = dir.path().join(".gobby");
1623 fs::create_dir_all(&home).expect("create gobby home");
1624 write_services_stack(&home);
1625 let mut config = StandaloneConfig::empty();
1626 config.set("databases.postgres.dsn", "postgresql://standalone/gobby");
1627 config
1628 .write_at(&gcore_config_path(&home))
1629 .expect("write gcore config");
1630 fs::write(
1631 home.join("bootstrap.yaml"),
1632 "hub_backend: postgres\ndatabase_url: postgresql://daemon/gobby\n",
1633 )
1634 .expect("write bootstrap");
1635
1636 let (database_url, report) = ensure_hub_with_identity(
1637 &EnsureHubOptions::new(home),
1638 |_| None,
1639 |url| {
1640 matches!(
1641 url,
1642 "postgresql://standalone/gobby" | "postgresql://daemon/gobby"
1643 )
1644 },
1645 |_| {
1646 Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
1647 message: "identity_unknown_insufficient_privilege".to_string(),
1648 })
1649 },
1650 |_| panic!("unknown identity for reachable hubs should not provision services"),
1651 )
1652 .expect("preserve existing recorded hub");
1653
1654 assert_eq!(database_url, "postgresql://standalone/gobby");
1655 assert!(report.is_none());
1656
1657 let resolution = resolve_recorded_hub_database_url(
1658 Some("postgresql://standalone:secret@standalone/gobby"),
1659 Some("postgresql://daemon:secret@daemon/gobby"),
1660 |url| {
1661 matches!(
1662 url,
1663 "postgresql://standalone:secret@standalone/gobby"
1664 | "postgresql://daemon:secret@daemon/gobby"
1665 )
1666 },
1667 |_| {
1668 Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
1669 message: "identity_unknown_insufficient_privilege".to_string(),
1670 })
1671 },
1672 )
1673 .expect("resolve unknown identity")
1674 .expect("resolution");
1675 let RecordedHubIdentityStatus::IdentityUnknownInsufficientPrivilege { message } =
1676 resolution.identity_status
1677 else {
1678 panic!("expected insufficient privilege status");
1679 };
1680 assert!(!message.contains("postgresql://"));
1681 assert!(!message.contains("secret"));
1682 }
1683
1684 #[derive(Default)]
1685 struct RecordingRunner {
1686 commands: Vec<CommandSpec>,
1687 }
1688
1689 impl CommandRunner for RecordingRunner {
1690 fn run(&mut self, spec: &CommandSpec) -> std::io::Result<CommandOutput> {
1691 self.commands.push(spec.clone());
1692 Ok(CommandOutput {
1693 status: 0,
1694 stdout: String::new(),
1695 stderr: String::new(),
1696 })
1697 }
1698 }
1699
1700 #[derive(Default)]
1701 struct RecordingHealth {
1702 checks: Vec<&'static str>,
1703 }
1704
1705 impl DockerHealthChecker for RecordingHealth {
1706 fn wait_postgres(&mut self, _host: &str, _port: u16) -> anyhow::Result<()> {
1707 self.checks.push("postgres");
1708 Ok(())
1709 }
1710
1711 fn wait_qdrant(&mut self, _host: &str, _port: u16) -> anyhow::Result<()> {
1712 self.checks.push("qdrant");
1713 Ok(())
1714 }
1715
1716 fn wait_falkordb(&mut self, _host: &str, _port: u16) -> anyhow::Result<()> {
1717 self.checks.push("falkordb");
1718 Ok(())
1719 }
1720 }
1721}