1use 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
692 #[test]
693 fn gcore_yaml_reads_flat_and_nested_keys() {
694 let config = StandaloneConfig::from_yaml_str(
695 r#"
696databases.postgres.dsn: postgresql://flat/db
697databases:
698 falkordb:
699 port: 16379
700embeddings:
701 api_key_env: OPENAI_API_KEY
702"#,
703 )
704 .expect("parse config");
705
706 assert_eq!(
707 config.get("databases.postgres.dsn"),
708 Some("postgresql://flat/db")
709 );
710 assert_eq!(config.get("databases.falkordb.port"), Some("16379"));
711 assert_eq!(config.get("embeddings.api_key_env"), Some("OPENAI_API_KEY"));
712 }
713
714 #[test]
715 fn gcore_yaml_writes_flat_keys() {
716 let dir = tempfile::tempdir().expect("tempdir");
717 let path = dir.path().join(GCORE_CONFIG_FILENAME);
718 let mut config = StandaloneConfig::empty();
719 config.set("databases.postgres.dsn", "postgresql://local/db");
720 config.set("embeddings.vector_dim", "768");
721
722 config.write_at(&path).expect("write config");
723 let raw = fs::read_to_string(&path).expect("read config");
724
725 assert!(raw.contains("databases.postgres.dsn:"));
726 assert!(raw.contains("embeddings.vector_dim:"));
727 assert_eq!(
728 StandaloneConfig::read_at(&path)
729 .expect("read config")
730 .expect("config present")
731 .get("embeddings.vector_dim"),
732 Some("768")
733 );
734 }
735
736 #[test]
737 fn standalone_config_resolves_service_keys_and_api_key_env() {
738 unsafe { std::env::set_var("GCORE_TEST_EMBEDDING_KEY", "test-key") };
739 let mut config = StandaloneConfig::from_yaml_str(
740 r#"
741databases.falkordb.host: 127.0.0.1
742databases.falkordb.port: "16379"
743databases.falkordb.password: falkor-pass
744databases.qdrant.url: http://localhost:6333
745embeddings.api_base: http://localhost:1234/v1
746embeddings.model: text-embedding-nomic-embed-text-v1.5@f16
747embeddings.api_key_env: GCORE_TEST_EMBEDDING_KEY
748"#,
749 )
750 .expect("parse config");
751
752 let falkor = crate::config::resolve_falkordb_config(&mut config).expect("falkor");
753 assert_eq!(falkor.password.as_deref(), Some("falkor-pass"));
754 let qdrant = crate::config::resolve_qdrant_config(&mut config).expect("qdrant");
755 assert_eq!(qdrant.url.as_deref(), Some("http://localhost:6333"));
756 let embedding = crate::config::resolve_embedding_config(&mut config).expect("embedding");
757 assert_eq!(embedding.api_key.as_deref(), Some("test-key"));
758 unsafe { std::env::remove_var("GCORE_TEST_EMBEDDING_KEY") };
759 }
760
761 #[test]
762 fn compose_template_matches_daemon_checkout_when_present() {
763 let daemon =
764 Path::new("/Users/josh/Projects/gobby/src/gobby/data/docker-compose.services.yml");
765 if !daemon.exists() {
766 return;
767 }
768 let daemon_template = fs::read_to_string(daemon).expect("read daemon compose template");
769 assert_eq!(COMPOSE_TEMPLATE, daemon_template);
770 }
771
772 #[test]
773 fn docker_provisioning_prepares_assets_runs_compose_and_health_checks() {
774 let dir = tempfile::tempdir().expect("tempdir");
775 let mut runner = RecordingRunner::default();
776 let mut health = RecordingHealth::default();
777 let options = DockerServiceOptions::new(dir.path().join(".gobby"));
778
779 let report = provision_docker_services_with(&options, &mut runner, &mut health)
780 .expect("provision services");
781
782 assert_eq!(runner.commands.len(), 1);
783 assert_eq!(runner.commands[0].program, "docker");
784 assert!(runner.commands[0].args.contains(&"--profile".to_string()));
785 assert!(runner.commands[0].args.contains(&"all".to_string()));
786 assert_eq!(health.checks, vec!["postgres", "qdrant", "falkordb"]);
787 assert_eq!(report.started_profiles, vec!["all"]);
788 assert_eq!(report.health_checks, vec!["postgres", "qdrant", "falkordb"]);
789 assert_eq!(
790 fs::read_to_string(&report.compose_file).expect("read compose"),
791 COMPOSE_TEMPLATE
792 );
793 assert!(
794 report
795 .services_dir
796 .join("postgres-pgsearch")
797 .join("Dockerfile")
798 .exists()
799 );
800 assert!(
801 fs::read_to_string(&report.env_file)
802 .expect("read env")
803 .contains("GOBBY_PG_SEARCH_VERSION=0.23.4")
804 );
805 }
806
807 #[derive(Default)]
808 struct RecordingRunner {
809 commands: Vec<CommandSpec>,
810 }
811
812 impl CommandRunner for RecordingRunner {
813 fn run(&mut self, spec: &CommandSpec) -> std::io::Result<CommandOutput> {
814 self.commands.push(spec.clone());
815 Ok(CommandOutput {
816 status: 0,
817 stdout: String::new(),
818 stderr: String::new(),
819 })
820 }
821 }
822
823 #[derive(Default)]
824 struct RecordingHealth {
825 checks: Vec<&'static str>,
826 }
827
828 impl DockerHealthChecker for RecordingHealth {
829 fn wait_postgres(&mut self, _host: &str, _port: u16) -> anyhow::Result<()> {
830 self.checks.push("postgres");
831 Ok(())
832 }
833
834 fn wait_qdrant(&mut self, _host: &str, _port: u16) -> anyhow::Result<()> {
835 self.checks.push("qdrant");
836 Ok(())
837 }
838
839 fn wait_falkordb(&mut self, _host: &str, _port: u16) -> anyhow::Result<()> {
840 self.checks.push("falkordb");
841 Ok(())
842 }
843 }
844}