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 serde::Deserialize;
16
17use crate::config::{ConfigSource, resolve_env_pattern};
18
19pub const GCORE_CONFIG_FILENAME: &str = "gcore.yaml";
20pub const SERVICES_DIRNAME: &str = "services";
21pub const COMPOSE_FILENAME: &str = "docker-compose.yml";
22
23pub const DEFAULT_POSTGRES_HOST: &str = "127.0.0.1";
24pub const DEFAULT_POSTGRES_PORT: u16 = 60891;
25pub const DEFAULT_POSTGRES_DB: &str = "gobby";
26pub const DEFAULT_POSTGRES_USER: &str = "gobby";
27pub const DEFAULT_POSTGRES_PASSWORD: &str = "gobby_dev";
28
29pub const DEFAULT_FALKORDB_HOST: &str = "127.0.0.1";
30pub const DEFAULT_FALKORDB_PORT: u16 = 16379;
31pub const DEFAULT_FALKORDB_BROWSER_PORT: u16 = 13000;
32pub const DEFAULT_FALKORDB_PASSWORD: &str = "gobbyfalkor";
33
34pub const DEFAULT_QDRANT_HTTP_PORT: u16 = 6333;
35pub const DEFAULT_QDRANT_GRPC_PORT: u16 = 6334;
36
37pub const DEFAULT_LM_STUDIO_API_BASE: &str = "http://localhost:1234/v1";
38pub const DEFAULT_LM_STUDIO_MODEL: &str = "text-embedding-nomic-embed-text-v1.5@f16";
39pub const DEFAULT_OLLAMA_API_BASE: &str = "http://localhost:11434/v1";
40pub const DEFAULT_OLLAMA_MODEL: &str = "nomic-embed-text";
41pub const DEFAULT_EMBEDDING_VECTOR_DIM: usize = 768;
42
43pub const COMPOSE_TEMPLATE: &str = include_str!("../assets/docker-compose.services.yml");
44const PGSEARCH_DOCKERFILE: &str = include_str!("../assets/postgres-pgsearch/Dockerfile");
45const PGSEARCH_VERSION: &str = include_str!("../assets/postgres-pgsearch/version.json");
46const PGSEARCH_INIT_PG_SEARCH: &str =
47    include_str!("../assets/postgres-pgsearch/initdb.d/01-pg_search.sql");
48const PGSEARCH_INIT_PGAUDIT: &str =
49    include_str!("../assets/postgres-pgsearch/initdb.d/02-pgaudit.sql");
50const PG_AUDIT_EXPORT: &str =
51    include_str!("../assets/postgres-pgsearch/scripts/pg_audit_export.sh");
52
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct StandaloneConfig {
55    values: BTreeMap<String, String>,
56}
57
58impl StandaloneConfig {
59    pub fn new(values: BTreeMap<String, String>) -> Self {
60        Self { values }
61    }
62
63    pub fn empty() -> Self {
64        Self::default()
65    }
66
67    pub fn read_at(path: &Path) -> anyhow::Result<Option<Self>> {
68        if !path.exists() {
69            return Ok(None);
70        }
71        let contents = fs::read_to_string(path)
72            .map_err(|err| anyhow::anyhow!("failed to read {}: {err}", path.display()))?;
73        Self::from_yaml_str(&contents)
74            .map(Some)
75            .map_err(|err| anyhow::anyhow!("failed to parse {}: {err}", path.display()))
76    }
77
78    pub fn from_yaml_str(contents: &str) -> anyhow::Result<Self> {
79        if contents.trim().is_empty() {
80            return Ok(Self::default());
81        }
82        let yaml: serde_yaml::Value = serde_yaml::from_str(contents)?;
83        let mut values = BTreeMap::new();
84        flatten_yaml_value(None, &yaml, &mut values)?;
85        Ok(Self { values })
86    }
87
88    pub fn write_at(&self, path: &Path) -> anyhow::Result<()> {
89        if let Some(parent) = path.parent() {
90            fs::create_dir_all(parent)?;
91        }
92        let mut mapping = serde_yaml::Mapping::new();
93        for (key, value) in &self.values {
94            mapping.insert(
95                serde_yaml::Value::String(key.clone()),
96                serde_yaml::Value::String(value.clone()),
97            );
98        }
99        let yaml = serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping))?;
100        fs::write(path, yaml)?;
101        Ok(())
102    }
103
104    pub fn get(&self, key: &str) -> Option<&str> {
105        self.values.get(key).map(String::as_str)
106    }
107
108    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
109        self.values.insert(key.into(), value.into());
110    }
111
112    pub fn remove(&mut self, key: &str) {
113        self.values.remove(key);
114    }
115
116    pub fn values(&self) -> &BTreeMap<String, String> {
117        &self.values
118    }
119}
120
121impl ConfigSource for StandaloneConfig {
122    fn config_value(&mut self, key: &str) -> Option<String> {
123        if key == "embeddings.api_key"
124            && let Some(env_name) = self.values.get("embeddings.api_key_env")
125            && !env_name.trim().is_empty()
126        {
127            return std::env::var(env_name.trim())
128                .ok()
129                .filter(|value| !value.trim().is_empty());
130        }
131        self.values.get(key).cloned().or_else(|| match key {
132            "databases.falkordb.requirepass" => {
133                self.values.get("databases.falkordb.password").cloned()
134            }
135            _ => None,
136        })
137    }
138
139    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
140        if value.contains("$secret:") {
141            anyhow::bail!("secret resolution requires daemon config_store");
142        }
143        resolve_env_pattern(value)?.ok_or_else(|| anyhow::anyhow!("unresolved pattern: {value}"))
144    }
145}
146
147pub fn gcore_config_path(gobby_home: &Path) -> PathBuf {
148    gobby_home.join(GCORE_CONFIG_FILENAME)
149}
150
151pub fn services_dir(gobby_home: &Path) -> PathBuf {
152    gobby_home.join(SERVICES_DIRNAME)
153}
154
155pub fn compose_file_path(gobby_home: &Path) -> PathBuf {
156    services_dir(gobby_home).join(COMPOSE_FILENAME)
157}
158
159pub fn default_database_url(port: u16) -> String {
160    format!(
161        "postgresql://{user}:{password}@localhost:{port}/{db}",
162        user = DEFAULT_POSTGRES_USER,
163        password = DEFAULT_POSTGRES_PASSWORD,
164        db = DEFAULT_POSTGRES_DB
165    )
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct DockerServiceOptions {
170    pub gobby_home: PathBuf,
171    pub postgres_port: u16,
172    pub qdrant_http_port: u16,
173    pub qdrant_grpc_port: u16,
174    pub falkordb_host: String,
175    pub falkordb_port: u16,
176    pub falkordb_browser_port: u16,
177    pub falkordb_password: String,
178}
179
180impl DockerServiceOptions {
181    pub fn new(gobby_home: PathBuf) -> Self {
182        Self {
183            gobby_home,
184            postgres_port: DEFAULT_POSTGRES_PORT,
185            qdrant_http_port: DEFAULT_QDRANT_HTTP_PORT,
186            qdrant_grpc_port: DEFAULT_QDRANT_GRPC_PORT,
187            falkordb_host: DEFAULT_FALKORDB_HOST.to_string(),
188            falkordb_port: DEFAULT_FALKORDB_PORT,
189            falkordb_browser_port: DEFAULT_FALKORDB_BROWSER_PORT,
190            falkordb_password: DEFAULT_FALKORDB_PASSWORD.to_string(),
191        }
192    }
193
194    pub fn database_url(&self) -> String {
195        default_database_url(self.postgres_port)
196    }
197
198    pub fn qdrant_url(&self) -> String {
199        format!("http://localhost:{}", self.qdrant_http_port)
200    }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct ServiceAssetReport {
205    pub services_dir: PathBuf,
206    pub compose_file: PathBuf,
207    pub env_file: PathBuf,
208    pub postgres_asset_dir: PathBuf,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct DockerProvisioningReport {
213    pub services_dir: PathBuf,
214    pub compose_file: PathBuf,
215    pub env_file: PathBuf,
216    pub started_profiles: Vec<String>,
217    pub health_checks: Vec<String>,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct CommandSpec {
222    pub program: String,
223    pub args: Vec<String>,
224    pub env: BTreeMap<String, String>,
225    pub cwd: Option<PathBuf>,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct CommandOutput {
230    pub status: i32,
231    pub stdout: String,
232    pub stderr: String,
233}
234
235pub trait CommandRunner {
236    fn run(&mut self, spec: &CommandSpec) -> std::io::Result<CommandOutput>;
237}
238
239pub struct RealCommandRunner;
240
241impl CommandRunner for RealCommandRunner {
242    fn run(&mut self, spec: &CommandSpec) -> std::io::Result<CommandOutput> {
243        let mut command = Command::new(&spec.program);
244        command.args(&spec.args);
245        if let Some(cwd) = &spec.cwd {
246            command.current_dir(cwd);
247        }
248        for (key, value) in &spec.env {
249            command.env(key, value);
250        }
251        let output = command.output()?;
252        Ok(CommandOutput {
253            status: output.status.code().unwrap_or(1),
254            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
255            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
256        })
257    }
258}
259
260pub trait DockerHealthChecker {
261    fn wait_postgres(&mut self, host: &str, port: u16) -> anyhow::Result<()>;
262    fn wait_qdrant(&mut self, host: &str, port: u16) -> anyhow::Result<()>;
263    fn wait_falkordb(&mut self, host: &str, port: u16) -> anyhow::Result<()>;
264}
265
266pub struct TcpDockerHealthChecker {
267    pub retries: usize,
268    pub interval: Duration,
269}
270
271impl Default for TcpDockerHealthChecker {
272    fn default() -> Self {
273        Self {
274            retries: 30,
275            interval: Duration::from_secs(2),
276        }
277    }
278}
279
280impl DockerHealthChecker for TcpDockerHealthChecker {
281    fn wait_postgres(&mut self, host: &str, port: u16) -> anyhow::Result<()> {
282        wait_for_tcp(host, port, self.retries, self.interval)
283            .map_err(|err| anyhow::anyhow!("PostgreSQL did not become reachable: {err}"))
284    }
285
286    fn wait_qdrant(&mut self, host: &str, port: u16) -> anyhow::Result<()> {
287        let healthz = || -> anyhow::Result<()> {
288            let mut stream = TcpStream::connect((host, port))?;
289            stream.set_read_timeout(Some(Duration::from_secs(3)))?;
290            stream.set_write_timeout(Some(Duration::from_secs(3)))?;
291            stream.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?;
292            let mut body = String::new();
293            stream.read_to_string(&mut body)?;
294            if body.starts_with("HTTP/1.1 200") || body.starts_with("HTTP/1.0 200") {
295                Ok(())
296            } else {
297                anyhow::bail!("unexpected Qdrant health response")
298            }
299        };
300        wait_for(healthz, self.retries, self.interval)
301            .map_err(|err| anyhow::anyhow!("Qdrant did not become healthy: {err}"))
302    }
303
304    fn wait_falkordb(&mut self, host: &str, port: u16) -> anyhow::Result<()> {
305        wait_for_tcp(host, port, self.retries, self.interval)
306            .map_err(|err| anyhow::anyhow!("FalkorDB did not become reachable: {err}"))
307    }
308}
309
310pub fn provision_docker_services(
311    options: &DockerServiceOptions,
312) -> anyhow::Result<DockerProvisioningReport> {
313    let mut runner = RealCommandRunner;
314    let mut health = TcpDockerHealthChecker::default();
315    provision_docker_services_with(options, &mut runner, &mut health)
316}
317
318pub fn provision_docker_services_with(
319    options: &DockerServiceOptions,
320    runner: &mut impl CommandRunner,
321    health: &mut impl DockerHealthChecker,
322) -> anyhow::Result<DockerProvisioningReport> {
323    let assets = prepare_service_assets(options)?;
324    let spec = docker_compose_up_spec(options, &assets.compose_file, &assets.services_dir);
325    let output = runner.run(&spec).map_err(|err| {
326        anyhow::anyhow!("failed to execute docker compose for standalone services: {err}")
327    })?;
328    if output.status != 0 {
329        anyhow::bail!(
330            "docker compose up failed: {}",
331            first_non_empty(&output.stderr, &output.stdout)
332        );
333    }
334
335    health.wait_postgres(DEFAULT_POSTGRES_HOST, options.postgres_port)?;
336    health.wait_qdrant(DEFAULT_POSTGRES_HOST, options.qdrant_http_port)?;
337    health.wait_falkordb(&options.falkordb_host, options.falkordb_port)?;
338
339    Ok(DockerProvisioningReport {
340        services_dir: assets.services_dir,
341        compose_file: assets.compose_file,
342        env_file: assets.env_file,
343        started_profiles: vec!["all".to_string()],
344        health_checks: vec![
345            "postgres".to_string(),
346            "qdrant".to_string(),
347            "falkordb".to_string(),
348        ],
349    })
350}
351
352pub fn prepare_service_assets(
353    options: &DockerServiceOptions,
354) -> anyhow::Result<ServiceAssetReport> {
355    let services = services_dir(&options.gobby_home);
356    let compose = services.join(COMPOSE_FILENAME);
357    let pgsearch = services.join("postgres-pgsearch");
358    let env_file = services.join(".env");
359
360    fs::create_dir_all(pgsearch.join("initdb.d"))?;
361    fs::create_dir_all(pgsearch.join("scripts"))?;
362    fs::write(&compose, COMPOSE_TEMPLATE)?;
363    fs::write(pgsearch.join("Dockerfile"), PGSEARCH_DOCKERFILE)?;
364    fs::write(pgsearch.join("version.json"), PGSEARCH_VERSION)?;
365    fs::write(
366        pgsearch.join("initdb.d").join("01-pg_search.sql"),
367        PGSEARCH_INIT_PG_SEARCH,
368    )?;
369    fs::write(
370        pgsearch.join("initdb.d").join("02-pgaudit.sql"),
371        PGSEARCH_INIT_PGAUDIT,
372    )?;
373    let audit_script = pgsearch.join("scripts").join("pg_audit_export.sh");
374    fs::write(&audit_script, PG_AUDIT_EXPORT)?;
375    make_executable(&audit_script)?;
376
377    let manifest = pgsearch_manifest()?;
378    update_env_file(
379        &env_file,
380        BTreeMap::from([
381            (
382                "GOBBY_PG_SEARCH_VERSION".to_string(),
383                manifest.pg_search_version,
384            ),
385            ("GOBBY_PG_SEARCH_SHA256".to_string(), manifest.sha256),
386            (
387                "GOBBY_POSTGRES_PORT".to_string(),
388                options.postgres_port.to_string(),
389            ),
390            (
391                "GOBBY_POSTGRES_DB".to_string(),
392                DEFAULT_POSTGRES_DB.to_string(),
393            ),
394            (
395                "GOBBY_POSTGRES_USER".to_string(),
396                DEFAULT_POSTGRES_USER.to_string(),
397            ),
398            (
399                "GOBBY_POSTGRES_PASSWORD".to_string(),
400                DEFAULT_POSTGRES_PASSWORD.to_string(),
401            ),
402            (
403                "GOBBY_QDRANT_HTTP_PORT".to_string(),
404                options.qdrant_http_port.to_string(),
405            ),
406            (
407                "GOBBY_QDRANT_GRPC_PORT".to_string(),
408                options.qdrant_grpc_port.to_string(),
409            ),
410            (
411                "GOBBY_FALKORDB_PORT".to_string(),
412                options.falkordb_port.to_string(),
413            ),
414            (
415                "GOBBY_FALKORDB_BROWSER_PORT".to_string(),
416                options.falkordb_browser_port.to_string(),
417            ),
418            (
419                "GOBBY_FALKORDB_PASSWORD".to_string(),
420                options.falkordb_password.clone(),
421            ),
422        ]),
423    )?;
424
425    Ok(ServiceAssetReport {
426        services_dir: services,
427        compose_file: compose,
428        env_file,
429        postgres_asset_dir: pgsearch,
430    })
431}
432
433pub fn docker_compose_up_spec(
434    options: &DockerServiceOptions,
435    compose_file: &Path,
436    services_dir: &Path,
437) -> CommandSpec {
438    CommandSpec {
439        program: "docker".to_string(),
440        args: vec![
441            "compose".to_string(),
442            "-f".to_string(),
443            compose_file.display().to_string(),
444            "--profile".to_string(),
445            "all".to_string(),
446            "up".to_string(),
447            "-d".to_string(),
448            "--remove-orphans".to_string(),
449        ],
450        env: BTreeMap::from([
451            (
452                "GOBBY_FALKORDB_PASSWORD".to_string(),
453                options.falkordb_password.clone(),
454            ),
455            (
456                "GOBBY_POSTGRES_PORT".to_string(),
457                options.postgres_port.to_string(),
458            ),
459            (
460                "GOBBY_QDRANT_HTTP_PORT".to_string(),
461                options.qdrant_http_port.to_string(),
462            ),
463        ]),
464        cwd: Some(services_dir.to_path_buf()),
465    }
466}
467
468#[derive(Debug, Clone, PartialEq, Eq)]
469pub struct EmbeddingBootstrap {
470    pub provider: String,
471    pub api_base: String,
472    pub model: String,
473    pub vector_dim: usize,
474    pub api_key_env: Option<String>,
475}
476
477impl EmbeddingBootstrap {
478    pub fn lm_studio() -> Self {
479        Self {
480            provider: "lm-studio".to_string(),
481            api_base: DEFAULT_LM_STUDIO_API_BASE.to_string(),
482            model: DEFAULT_LM_STUDIO_MODEL.to_string(),
483            vector_dim: DEFAULT_EMBEDDING_VECTOR_DIM,
484            api_key_env: None,
485        }
486    }
487
488    pub fn ollama() -> Self {
489        Self {
490            provider: "ollama".to_string(),
491            api_base: DEFAULT_OLLAMA_API_BASE.to_string(),
492            model: DEFAULT_OLLAMA_MODEL.to_string(),
493            vector_dim: DEFAULT_EMBEDDING_VECTOR_DIM,
494            api_key_env: None,
495        }
496    }
497}
498
499pub fn write_standalone_bootstrap(
500    path: &Path,
501    database_url: &str,
502    options: &DockerServiceOptions,
503    compose_file: Option<&Path>,
504    embedding: Option<&EmbeddingBootstrap>,
505) -> anyhow::Result<StandaloneConfig> {
506    let mut config = StandaloneConfig::empty();
507    config.set("databases.postgres.dsn", database_url);
508    config.set("databases.falkordb.host", &options.falkordb_host);
509    config.set("databases.falkordb.port", options.falkordb_port.to_string());
510    config.set("databases.falkordb.password", &options.falkordb_password);
511    config.set("databases.qdrant.url", options.qdrant_url());
512    if let Some(embedding) = embedding {
513        config.set("embeddings.provider", &embedding.provider);
514        config.set("embeddings.api_base", &embedding.api_base);
515        config.set("embeddings.model", &embedding.model);
516        config.set("embeddings.vector_dim", embedding.vector_dim.to_string());
517        if let Some(api_key_env) = &embedding.api_key_env {
518            config.set("embeddings.api_key_env", api_key_env);
519        }
520    }
521    if let Some(compose_file) = compose_file {
522        config.set("services.compose_file", compose_file.display().to_string());
523    }
524    config.write_at(path)?;
525    Ok(config)
526}
527
528fn flatten_yaml_value(
529    prefix: Option<&str>,
530    value: &serde_yaml::Value,
531    output: &mut BTreeMap<String, String>,
532) -> anyhow::Result<()> {
533    match value {
534        serde_yaml::Value::Null => Ok(()),
535        serde_yaml::Value::Mapping(mapping) => {
536            for (key, value) in mapping {
537                let Some(key) = key.as_str() else {
538                    anyhow::bail!("gcore.yaml keys must be strings");
539                };
540                let joined = match prefix {
541                    Some(prefix) if !prefix.is_empty() => format!("{prefix}.{key}"),
542                    _ => key.to_string(),
543                };
544                match value {
545                    serde_yaml::Value::Mapping(_) if !key.contains('.') => {
546                        flatten_yaml_value(Some(&joined), value, output)?;
547                    }
548                    _ => {
549                        if let Some(text) = scalar_to_string(value)? {
550                            output.insert(joined, text);
551                        }
552                    }
553                }
554            }
555            Ok(())
556        }
557        _ => {
558            let Some(prefix) = prefix else {
559                anyhow::bail!("gcore.yaml must be a mapping");
560            };
561            if let Some(text) = scalar_to_string(value)? {
562                output.insert(prefix.to_string(), text);
563            }
564            Ok(())
565        }
566    }
567}
568
569fn scalar_to_string(value: &serde_yaml::Value) -> anyhow::Result<Option<String>> {
570    Ok(match value {
571        serde_yaml::Value::Null => None,
572        serde_yaml::Value::String(value) => Some(value.clone()),
573        serde_yaml::Value::Bool(value) => Some(value.to_string()),
574        serde_yaml::Value::Number(value) => Some(value.to_string()),
575        other => Some(serde_yaml::to_string(other)?.trim().to_string()),
576    })
577}
578
579#[derive(Debug, Deserialize)]
580struct PgSearchVersionFile {
581    pg_search_version: String,
582    pg_search_sha256: String,
583    pg_search_sha256_by_arch: Option<BTreeMap<String, String>>,
584}
585
586struct PgSearchManifest {
587    pg_search_version: String,
588    sha256: String,
589}
590
591fn pgsearch_manifest() -> anyhow::Result<PgSearchManifest> {
592    let parsed: PgSearchVersionFile = serde_json::from_str(PGSEARCH_VERSION)?;
593    let arch = debian_arch(std::env::consts::ARCH);
594    let sha256 = parsed
595        .pg_search_sha256_by_arch
596        .and_then(|by_arch| by_arch.get(&arch).cloned())
597        .unwrap_or(parsed.pg_search_sha256);
598    Ok(PgSearchManifest {
599        pg_search_version: parsed.pg_search_version,
600        sha256,
601    })
602}
603
604fn debian_arch(arch: &str) -> String {
605    match arch {
606        "x86_64" | "amd64" => "amd64".to_string(),
607        "aarch64" | "arm64" => "arm64".to_string(),
608        other => other.to_string(),
609    }
610}
611
612fn update_env_file(path: &Path, updates: BTreeMap<String, String>) -> anyhow::Result<()> {
613    if let Some(parent) = path.parent() {
614        fs::create_dir_all(parent)?;
615    }
616    let mut lines = Vec::new();
617    if path.exists() {
618        for line in fs::read_to_string(path)?.lines() {
619            let key = line.split_once('=').map(|(key, _)| key).unwrap_or(line);
620            if !updates.contains_key(key) {
621                lines.push(line.to_string());
622            }
623        }
624        if lines.last().is_some_and(|line| !line.trim().is_empty()) {
625            lines.push(String::new());
626        }
627    }
628    for (key, value) in updates {
629        lines.push(format!("{key}={value}"));
630    }
631    fs::write(path, format!("{}\n", lines.join("\n")))?;
632    Ok(())
633}
634
635fn first_non_empty<'a>(first: &'a str, second: &'a str) -> &'a str {
636    if first.trim().is_empty() {
637        second.trim()
638    } else {
639        first.trim()
640    }
641}
642
643fn wait_for_tcp(host: &str, port: u16, retries: usize, interval: Duration) -> anyhow::Result<()> {
644    wait_for(
645        || {
646            TcpStream::connect((host, port))
647                .map(|_| ())
648                .map_err(Into::into)
649        },
650        retries,
651        interval,
652    )
653}
654
655fn wait_for(
656    mut check: impl FnMut() -> anyhow::Result<()>,
657    retries: usize,
658    interval: Duration,
659) -> anyhow::Result<()> {
660    let mut last_error = None;
661    for attempt in 0..retries {
662        match check() {
663            Ok(()) => return Ok(()),
664            Err(err) => last_error = Some(err),
665        }
666        if attempt + 1 < retries {
667            std::thread::sleep(interval);
668        }
669    }
670    Err(last_error.unwrap_or_else(|| anyhow::anyhow!("health check failed")))
671}
672
673fn make_executable(path: &Path) -> anyhow::Result<()> {
674    #[cfg(unix)]
675    {
676        use std::os::unix::fs::PermissionsExt;
677        let mut permissions = fs::metadata(path)?.permissions();
678        permissions.set_mode(0o755);
679        fs::set_permissions(path, permissions)?;
680    }
681    #[cfg(not(unix))]
682    {
683        let _ = path;
684    }
685    Ok(())
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use crate::config::TEST_ENV_LOCK;
692    use std::sync::MutexGuard;
693
694    struct ScopedEnvVar {
695        key: &'static str,
696        previous: Option<String>,
697        _lock: MutexGuard<'static, ()>,
698    }
699
700    impl ScopedEnvVar {
701        fn set(key: &'static str, value: &str) -> Self {
702            let lock = TEST_ENV_LOCK
703                .lock()
704                .unwrap_or_else(|poisoned| poisoned.into_inner());
705            let previous = std::env::var(key).ok();
706            unsafe { std::env::set_var(key, value) };
707            Self {
708                key,
709                previous,
710                _lock: lock,
711            }
712        }
713    }
714
715    impl Drop for ScopedEnvVar {
716        fn drop(&mut self) {
717            match &self.previous {
718                Some(value) => unsafe { std::env::set_var(self.key, value) },
719                None => unsafe { std::env::remove_var(self.key) },
720            }
721        }
722    }
723
724    #[test]
725    fn gcore_yaml_reads_flat_and_nested_keys() {
726        let config = StandaloneConfig::from_yaml_str(
727            r#"
728databases.postgres.dsn: postgresql://flat/db
729databases:
730  falkordb:
731    port: 16379
732embeddings:
733  api_key_env: OPENAI_API_KEY
734"#,
735        )
736        .expect("parse config");
737
738        assert_eq!(
739            config.get("databases.postgres.dsn"),
740            Some("postgresql://flat/db")
741        );
742        assert_eq!(config.get("databases.falkordb.port"), Some("16379"));
743        assert_eq!(config.get("embeddings.api_key_env"), Some("OPENAI_API_KEY"));
744    }
745
746    #[test]
747    fn gcore_yaml_writes_flat_keys() {
748        let dir = tempfile::tempdir().expect("tempdir");
749        let path = dir.path().join(GCORE_CONFIG_FILENAME);
750        let mut config = StandaloneConfig::empty();
751        config.set("databases.postgres.dsn", "postgresql://local/db");
752        config.set("embeddings.vector_dim", "768");
753
754        config.write_at(&path).expect("write config");
755        let raw = fs::read_to_string(&path).expect("read config");
756
757        assert!(raw.contains("databases.postgres.dsn:"));
758        assert!(raw.contains("embeddings.vector_dim:"));
759        assert_eq!(
760            StandaloneConfig::read_at(&path)
761                .expect("read config")
762                .expect("config present")
763                .get("embeddings.vector_dim"),
764            Some("768")
765        );
766    }
767
768    #[test]
769    fn standalone_config_resolves_service_keys_and_api_key_env() {
770        let _env = ScopedEnvVar::set("GCORE_TEST_EMBEDDING_KEY", "test-key");
771        let mut config = StandaloneConfig::from_yaml_str(
772            r#"
773databases.falkordb.host: 127.0.0.1
774databases.falkordb.port: "16379"
775databases.falkordb.password: falkor-pass
776databases.qdrant.url: http://localhost:6333
777embeddings.api_base: http://localhost:1234/v1
778embeddings.model: text-embedding-nomic-embed-text-v1.5@f16
779embeddings.api_key_env: GCORE_TEST_EMBEDDING_KEY
780"#,
781        )
782        .expect("parse config");
783
784        let falkor = crate::config::resolve_falkordb_config(&mut config).expect("falkor");
785        assert_eq!(falkor.password.as_deref(), Some("falkor-pass"));
786        let qdrant = crate::config::resolve_qdrant_config(&mut config).expect("qdrant");
787        assert_eq!(qdrant.url.as_deref(), Some("http://localhost:6333"));
788        let embedding = crate::config::resolve_embedding_config(&mut config).expect("embedding");
789        assert_eq!(embedding.api_key.as_deref(), Some("test-key"));
790    }
791
792    #[test]
793    fn compose_template_matches_daemon_checkout_when_present() {
794        let daemon =
795            Path::new("/Users/josh/Projects/gobby/src/gobby/data/docker-compose.services.yml");
796        if !daemon.exists() {
797            return;
798        }
799        let daemon_template = fs::read_to_string(daemon).expect("read daemon compose template");
800        assert_eq!(COMPOSE_TEMPLATE, daemon_template);
801    }
802
803    #[test]
804    fn docker_provisioning_prepares_assets_runs_compose_and_health_checks() {
805        let dir = tempfile::tempdir().expect("tempdir");
806        let mut runner = RecordingRunner::default();
807        let mut health = RecordingHealth::default();
808        let options = DockerServiceOptions::new(dir.path().join(".gobby"));
809
810        let report = provision_docker_services_with(&options, &mut runner, &mut health)
811            .expect("provision services");
812
813        assert_eq!(runner.commands.len(), 1);
814        assert_eq!(runner.commands[0].program, "docker");
815        assert!(runner.commands[0].args.contains(&"--profile".to_string()));
816        assert!(runner.commands[0].args.contains(&"all".to_string()));
817        assert_eq!(health.checks, vec!["postgres", "qdrant", "falkordb"]);
818        assert_eq!(report.started_profiles, vec!["all"]);
819        assert_eq!(report.health_checks, vec!["postgres", "qdrant", "falkordb"]);
820        assert_eq!(
821            fs::read_to_string(&report.compose_file).expect("read compose"),
822            COMPOSE_TEMPLATE
823        );
824        assert!(
825            report
826                .services_dir
827                .join("postgres-pgsearch")
828                .join("Dockerfile")
829                .exists()
830        );
831        assert!(
832            fs::read_to_string(&report.env_file)
833                .expect("read env")
834                .contains("GOBBY_PG_SEARCH_VERSION=0.23.4")
835        );
836    }
837
838    #[derive(Default)]
839    struct RecordingRunner {
840        commands: Vec<CommandSpec>,
841    }
842
843    impl CommandRunner for RecordingRunner {
844        fn run(&mut self, spec: &CommandSpec) -> std::io::Result<CommandOutput> {
845            self.commands.push(spec.clone());
846            Ok(CommandOutput {
847                status: 0,
848                stdout: String::new(),
849                stderr: String::new(),
850            })
851        }
852    }
853
854    #[derive(Default)]
855    struct RecordingHealth {
856        checks: Vec<&'static str>,
857    }
858
859    impl DockerHealthChecker for RecordingHealth {
860        fn wait_postgres(&mut self, _host: &str, _port: u16) -> anyhow::Result<()> {
861            self.checks.push("postgres");
862            Ok(())
863        }
864
865        fn wait_qdrant(&mut self, _host: &str, _port: u16) -> anyhow::Result<()> {
866            self.checks.push("qdrant");
867            Ok(())
868        }
869
870        fn wait_falkordb(&mut self, _host: &str, _port: u16) -> anyhow::Result<()> {
871            self.checks.push("falkordb");
872            Ok(())
873        }
874    }
875}