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, ai_keys, 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_LM_STUDIO_TEXT_MODEL: &str = "qwen2.5-vl-7b-instruct";
39pub const DEFAULT_OLLAMA_API_BASE: &str = "http://localhost:11434/v1";
40pub const DEFAULT_OLLAMA_MODEL: &str = "nomic-embed-text";
41pub const DEFAULT_OLLAMA_TEXT_MODEL: &str = "qwen3-coder";
42pub const DEFAULT_EMBEDDING_VECTOR_DIM: usize = 768;
43
44pub const COMPOSE_TEMPLATE: &str = include_str!("../../assets/docker-compose.services.yml");
45const PGSEARCH_DOCKERFILE: &str = include_str!("../../assets/postgres-pgsearch/Dockerfile");
46const PGSEARCH_VERSION: &str = include_str!("../../assets/postgres-pgsearch/version.json");
47const PGSEARCH_INIT_PG_SEARCH: &str =
48    include_str!("../../assets/postgres-pgsearch/initdb.d/01-pg_search.sql");
49const PGSEARCH_INIT_PGAUDIT: &str =
50    include_str!("../../assets/postgres-pgsearch/initdb.d/02-pgaudit.sql");
51const PG_AUDIT_EXPORT: &str =
52    include_str!("../../assets/postgres-pgsearch/scripts/pg_audit_export.sh");
53
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
55pub struct StandaloneConfig {
56    values: BTreeMap<String, String>,
57}
58
59impl StandaloneConfig {
60    pub fn new(values: BTreeMap<String, String>) -> Self {
61        Self { values }
62    }
63
64    pub fn empty() -> Self {
65        Self::default()
66    }
67
68    pub fn read_at(path: &Path) -> anyhow::Result<Option<Self>> {
69        if !path.exists() {
70            return Ok(None);
71        }
72        let contents = fs::read_to_string(path)
73            .map_err(|err| anyhow::anyhow!("failed to read {}: {err}", path.display()))?;
74        Self::from_yaml_str(&contents)
75            .map(Some)
76            .map_err(|err| anyhow::anyhow!("failed to parse {}: {err}", path.display()))
77    }
78
79    pub fn from_yaml_str(contents: &str) -> anyhow::Result<Self> {
80        if contents.trim().is_empty() {
81            return Ok(Self::default());
82        }
83        let yaml: serde_yaml::Value = serde_yaml::from_str(contents)?;
84        let mut values = BTreeMap::new();
85        flatten_yaml_value(None, &yaml, &mut values)?;
86        let mut config = Self { values };
87        config.apply_text_generation_defaults_from_embeddings();
88        Ok(config)
89    }
90
91    pub fn write_at(&self, path: &Path) -> anyhow::Result<()> {
92        if let Some(parent) = path.parent() {
93            fs::create_dir_all(parent)?;
94        }
95        let mut mapping = serde_yaml::Mapping::new();
96        for (key, value) in &self.values {
97            insert_nested_yaml_value(&mut mapping, key, value)?;
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    fn apply_text_generation_defaults_from_embeddings(&mut self) {
121        if self.get(ai_keys::TEXT_GENERATE_ROUTING).is_some()
122            || self.get(ai_keys::TEXT_GENERATE_API_BASE).is_some()
123            || self.get(ai_keys::TEXT_GENERATE_MODEL).is_some()
124        {
125            return;
126        }
127
128        let api_key = self.get(embedding_keys::AI_API_KEY).map(str::to_string);
129        apply_text_generation_bootstrap(
130            self,
131            &TextGenerationBootstrap::from_endpoint(None, DEFAULT_LM_STUDIO_API_BASE, api_key),
132        );
133    }
134}
135
136impl ConfigSource for StandaloneConfig {
137    fn config_value(&mut self, key: &str) -> Option<String> {
138        self.values.get(key).cloned()
139    }
140
141    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
142        if value.contains("$secret:") {
143            anyhow::bail!("secret resolution requires daemon config_store");
144        }
145        resolve_env_pattern(value)?.ok_or_else(|| anyhow::anyhow!("unresolved pattern: {value}"))
146    }
147}
148
149pub fn gcore_config_path(gobby_home: &Path) -> PathBuf {
150    gobby_home.join(GCORE_CONFIG_FILENAME)
151}
152
153pub fn services_dir(gobby_home: &Path) -> PathBuf {
154    gobby_home.join(SERVICES_DIRNAME)
155}
156
157pub fn compose_file_path(gobby_home: &Path) -> PathBuf {
158    services_dir(gobby_home).join(COMPOSE_FILENAME)
159}
160
161pub fn default_database_url(port: u16) -> String {
162    format!(
163        "postgresql://{user}:{password}@{host}:{port}/{db}",
164        user = DEFAULT_POSTGRES_USER,
165        password = DEFAULT_POSTGRES_PASSWORD,
166        host = DEFAULT_POSTGRES_HOST,
167        port = port,
168        db = DEFAULT_POSTGRES_DB
169    )
170}
171
172fn insert_nested_yaml_value(
173    mapping: &mut serde_yaml::Mapping,
174    key: &str,
175    value: &str,
176) -> anyhow::Result<()> {
177    let parts = key
178        .split('.')
179        .filter(|part| !part.is_empty())
180        .collect::<Vec<_>>();
181    if !parts.is_empty() {
182        insert_nested_yaml_parts(mapping, &parts, value, key, String::new())?;
183    }
184    Ok(())
185}
186
187fn insert_nested_yaml_parts(
188    mapping: &mut serde_yaml::Mapping,
189    parts: &[&str],
190    value: &str,
191    full_key: &str,
192    prefix: String,
193) -> anyhow::Result<()> {
194    let yaml_key = serde_yaml::Value::String(parts[0].to_string());
195    let current_path = if prefix.is_empty() {
196        parts[0].to_string()
197    } else {
198        format!("{prefix}.{}", parts[0])
199    };
200    if parts.len() == 1 {
201        if matches!(mapping.get(&yaml_key), Some(serde_yaml::Value::Mapping(_))) {
202            anyhow::bail!(
203                "gcore config key '{full_key}' collides with nested YAML mapping '{current_path}'"
204            );
205        }
206        mapping.insert(yaml_key, serde_yaml::Value::String(value.to_string()));
207        return Ok(());
208    }
209
210    let entry = mapping
211        .entry(yaml_key)
212        .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
213    if !matches!(entry, serde_yaml::Value::Mapping(_)) {
214        anyhow::bail!(
215            "gcore config key '{full_key}' cannot be nested under scalar YAML key '{current_path}'"
216        );
217    }
218    let serde_yaml::Value::Mapping(child) = entry else {
219        unreachable!("entry was normalized to a mapping");
220    };
221    insert_nested_yaml_parts(child, &parts[1..], value, full_key, current_path)
222}
223
224mod bootstrap;
225mod docker;
226mod hub;
227
228pub use bootstrap::*;
229pub use docker::*;
230pub use hub::*;
231
232#[cfg(test)]
233mod tests;