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, 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;