Skip to main content

gobby_core/
provisioning.rs

1//! Standalone bootstrap and Docker service provisioning.
2//!
3//! The bundled service assets mirror the Python daemon package layout. Runtime
4//! callers can copy them into `~/.gobby/services` and start the same profiles
5//! the daemon manages, then persist daemon-style bootstrap keys in `gcore.yaml`.
6
7use 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                // SAFETY: TEST_ENV_LOCK serializes all test environment mutation
1215                // here, and the loop only touches the fixed key list above.
1216                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}