Skip to main content

gobby_core/provisioning/
mod.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::path::{Path, PathBuf};
10
11use anyhow::Context as _;
12use serde::Deserialize;
13
14use crate::config::{ConfigSource, embedding_keys, resolve_env_pattern};
15use crate::degradation::CoreError;
16
17pub const GCORE_CONFIG_FILENAME: &str = "gcore.yaml";
18pub const SERVICES_DIRNAME: &str = "services";
19pub const COMPOSE_FILENAME: &str = "docker-compose.yml";
20
21pub const DEFAULT_POSTGRES_HOST: &str = "127.0.0.1";
22pub const DEFAULT_POSTGRES_PORT: u16 = 60891;
23pub const DEFAULT_POSTGRES_DB: &str = "gobby";
24pub const DEFAULT_POSTGRES_USER: &str = "gobby";
25pub const DEFAULT_POSTGRES_PASSWORD: &str = "gobby_dev";
26
27pub const DEFAULT_FALKORDB_HOST: &str = "127.0.0.1";
28pub const DEFAULT_FALKORDB_PORT: u16 = 16379;
29pub const DEFAULT_FALKORDB_BROWSER_PORT: u16 = 13000;
30pub const DEFAULT_FALKORDB_PASSWORD: &str = "gobbyfalkor";
31
32pub const DEFAULT_QDRANT_HOST: &str = "127.0.0.1";
33pub const DEFAULT_QDRANT_HTTP_PORT: u16 = 6333;
34pub const DEFAULT_QDRANT_GRPC_PORT: u16 = 6334;
35
36pub const DEFAULT_LM_STUDIO_API_BASE: &str = "http://localhost:1234/v1";
37pub const DEFAULT_LM_STUDIO_MODEL: &str = "text-embedding-nomic-embed-text-v1.5@f16";
38pub const DEFAULT_OLLAMA_API_BASE: &str = "http://localhost:11434/v1";
39pub const DEFAULT_OLLAMA_MODEL: &str = "nomic-embed-text";
40pub const DEFAULT_EMBEDDING_VECTOR_DIM: usize = 768;
41
42pub const COMPOSE_TEMPLATE: &str = include_str!("../../assets/docker-compose.services.yml");
43const PGSEARCH_DOCKERFILE: &str = include_str!("../../assets/postgres-pgsearch/Dockerfile");
44const PGSEARCH_VERSION: &str = include_str!("../../assets/postgres-pgsearch/version.json");
45const PGSEARCH_INIT_PG_SEARCH: &str =
46    include_str!("../../assets/postgres-pgsearch/initdb.d/01-pg_search.sql");
47const PGSEARCH_INIT_PGAUDIT: &str =
48    include_str!("../../assets/postgres-pgsearch/initdb.d/02-pgaudit.sql");
49const PG_AUDIT_EXPORT: &str =
50    include_str!("../../assets/postgres-pgsearch/scripts/pg_audit_export.sh");
51
52#[derive(Debug, Clone, Default, PartialEq, Eq)]
53pub struct StandaloneConfig {
54    values: BTreeMap<String, String>,
55}
56
57impl StandaloneConfig {
58    pub fn new(values: BTreeMap<String, String>) -> Self {
59        Self { values }
60    }
61
62    pub fn empty() -> Self {
63        Self::default()
64    }
65
66    pub fn read_at(path: &Path) -> anyhow::Result<Option<Self>> {
67        if !path.exists() {
68            return Ok(None);
69        }
70        let contents = fs::read_to_string(path)
71            .map_err(|err| anyhow::anyhow!("failed to read {}: {err}", path.display()))?;
72        Self::from_yaml_str(&contents)
73            .map(Some)
74            .map_err(|err| anyhow::anyhow!("failed to parse {}: {err}", path.display()))
75    }
76
77    pub fn from_yaml_str(contents: &str) -> anyhow::Result<Self> {
78        if contents.trim().is_empty() {
79            return Ok(Self::default());
80        }
81        let yaml: serde_yaml::Value = serde_yaml::from_str(contents)?;
82        let mut values = BTreeMap::new();
83        flatten_yaml_value(None, &yaml, &mut values)?;
84        Ok(Self { values })
85    }
86
87    pub fn write_at(&self, path: &Path) -> anyhow::Result<()> {
88        if let Some(parent) = path.parent() {
89            fs::create_dir_all(parent)?;
90        }
91        let mut mapping = serde_yaml::Mapping::new();
92        for (key, value) in &self.values {
93            insert_nested_yaml_value(&mut mapping, key, value)?;
94        }
95        let yaml = serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping))?;
96        fs::write(path, yaml)?;
97        Ok(())
98    }
99
100    pub fn get(&self, key: &str) -> Option<&str> {
101        self.values.get(key).map(String::as_str)
102    }
103
104    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
105        self.values.insert(key.into(), value.into());
106    }
107
108    pub fn remove(&mut self, key: &str) {
109        self.values.remove(key);
110    }
111
112    pub fn values(&self) -> &BTreeMap<String, String> {
113        &self.values
114    }
115}
116
117impl ConfigSource for StandaloneConfig {
118    fn config_value(&mut self, key: &str) -> Option<String> {
119        self.values.get(key).cloned().or_else(|| match key {
120            "databases.falkordb.requirepass" => {
121                self.values.get("databases.falkordb.password").cloned()
122            }
123            _ => None,
124        })
125    }
126
127    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
128        if value.contains("$secret:") {
129            anyhow::bail!("secret resolution requires daemon config_store");
130        }
131        resolve_env_pattern(value)?.ok_or_else(|| anyhow::anyhow!("unresolved pattern: {value}"))
132    }
133}
134
135pub fn gcore_config_path(gobby_home: &Path) -> PathBuf {
136    gobby_home.join(GCORE_CONFIG_FILENAME)
137}
138
139pub fn services_dir(gobby_home: &Path) -> PathBuf {
140    gobby_home.join(SERVICES_DIRNAME)
141}
142
143pub fn compose_file_path(gobby_home: &Path) -> PathBuf {
144    services_dir(gobby_home).join(COMPOSE_FILENAME)
145}
146
147pub fn default_database_url(port: u16) -> String {
148    format!(
149        "postgresql://{user}:{password}@{host}:{port}/{db}",
150        user = DEFAULT_POSTGRES_USER,
151        password = DEFAULT_POSTGRES_PASSWORD,
152        host = DEFAULT_POSTGRES_HOST,
153        port = port,
154        db = DEFAULT_POSTGRES_DB
155    )
156}
157
158fn insert_nested_yaml_value(
159    mapping: &mut serde_yaml::Mapping,
160    key: &str,
161    value: &str,
162) -> anyhow::Result<()> {
163    let parts = key
164        .split('.')
165        .filter(|part| !part.is_empty())
166        .collect::<Vec<_>>();
167    if !parts.is_empty() {
168        insert_nested_yaml_parts(mapping, &parts, value, key, String::new())?;
169    }
170    Ok(())
171}
172
173fn insert_nested_yaml_parts(
174    mapping: &mut serde_yaml::Mapping,
175    parts: &[&str],
176    value: &str,
177    full_key: &str,
178    prefix: String,
179) -> anyhow::Result<()> {
180    let yaml_key = serde_yaml::Value::String(parts[0].to_string());
181    let current_path = if prefix.is_empty() {
182        parts[0].to_string()
183    } else {
184        format!("{prefix}.{}", parts[0])
185    };
186    if parts.len() == 1 {
187        if matches!(mapping.get(&yaml_key), Some(serde_yaml::Value::Mapping(_))) {
188            anyhow::bail!(
189                "gcore config key '{full_key}' collides with nested YAML mapping '{current_path}'"
190            );
191        }
192        mapping.insert(yaml_key, serde_yaml::Value::String(value.to_string()));
193        return Ok(());
194    }
195
196    let entry = mapping
197        .entry(yaml_key)
198        .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
199    if !matches!(entry, serde_yaml::Value::Mapping(_)) {
200        anyhow::bail!(
201            "gcore config key '{full_key}' cannot be nested under scalar YAML key '{current_path}'"
202        );
203    }
204    let serde_yaml::Value::Mapping(child) = entry else {
205        unreachable!("entry was normalized to a mapping");
206    };
207    insert_nested_yaml_parts(child, &parts[1..], value, full_key, current_path)
208}
209
210mod bootstrap;
211mod docker;
212mod hub;
213
214pub use bootstrap::*;
215pub use docker::*;
216pub use hub::*;
217
218#[cfg(test)]
219mod tests;