gobby_core/provisioning/
mod.rs1use 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;