codex_memory/
config.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::env;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Config {
8    /// PostgreSQL database connection URL
9    pub database_url: String,
10
11    /// Embedding service configuration
12    pub embedding: EmbeddingConfig,
13
14    /// HTTP server port
15    pub http_port: u16,
16
17    /// MCP server port (if different from HTTP)
18    pub mcp_port: Option<u16>,
19
20    /// Memory tier configuration
21    pub tier_config: TierConfig,
22
23    /// Operational settings
24    pub operational: OperationalConfig,
25
26    /// Backup and disaster recovery settings
27    pub backup: BackupConfiguration,
28
29    /// Security and compliance settings
30    pub security: SecurityConfiguration,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct EmbeddingConfig {
35    /// Embedding provider (openai or ollama)
36    pub provider: String,
37
38    /// Model name to use for embeddings
39    pub model: String,
40
41    /// API key (for OpenAI, empty for Ollama)
42    pub api_key: String,
43
44    /// Base URL for the embedding service
45    pub base_url: String,
46
47    /// Request timeout in seconds
48    pub timeout_seconds: u64,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct TierConfig {
53    /// Maximum memories in working tier
54    pub working_tier_limit: usize,
55
56    /// Maximum memories in warm tier  
57    pub warm_tier_limit: usize,
58
59    /// Days before moving from working to warm
60    pub working_to_warm_days: u32,
61
62    /// Days before moving from warm to cold
63    pub warm_to_cold_days: u32,
64
65    /// Importance threshold for tier promotion
66    pub importance_threshold: f32,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct OperationalConfig {
71    /// Maximum database connections
72    pub max_db_connections: u32,
73
74    /// Request timeout in seconds
75    pub request_timeout_seconds: u64,
76
77    /// Enable metrics endpoint
78    pub enable_metrics: bool,
79
80    /// Log level (error, warn, info, debug, trace)
81    pub log_level: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct BackupConfiguration {
86    /// Enable automated backups
87    pub enabled: bool,
88
89    /// Directory for backup storage
90    pub backup_directory: PathBuf,
91
92    /// WAL archive directory
93    pub wal_archive_directory: PathBuf,
94
95    /// Backup retention in days
96    pub retention_days: u32,
97
98    /// Enable backup encryption
99    pub enable_encryption: bool,
100
101    /// Backup schedule (cron format)
102    pub schedule: String,
103
104    /// Recovery time objective in minutes
105    pub rto_minutes: u32,
106
107    /// Recovery point objective in minutes
108    pub rpo_minutes: u32,
109
110    /// Enable backup verification
111    pub enable_verification: bool,
112}
113
114impl Default for Config {
115    fn default() -> Self {
116        Self {
117            database_url: "postgresql://postgres:postgres@localhost:5432/codex_memory".to_string(),
118            embedding: EmbeddingConfig::default(),
119            http_port: 8080,
120            mcp_port: None,
121            tier_config: TierConfig::default(),
122            operational: OperationalConfig::default(),
123            backup: BackupConfiguration::default(),
124            security: SecurityConfiguration::default(),
125        }
126    }
127}
128
129impl Default for EmbeddingConfig {
130    fn default() -> Self {
131        Self {
132            provider: "ollama".to_string(),
133            model: "nomic-embed-text".to_string(),
134            api_key: String::new(),
135            base_url: "http://192.168.1.110:11434".to_string(),
136            timeout_seconds: 60,
137        }
138    }
139}
140
141impl Default for TierConfig {
142    fn default() -> Self {
143        Self {
144            working_tier_limit: 1000,
145            warm_tier_limit: 10000,
146            working_to_warm_days: 7,
147            warm_to_cold_days: 30,
148            importance_threshold: 0.7,
149        }
150    }
151}
152
153impl Default for OperationalConfig {
154    fn default() -> Self {
155        Self {
156            max_db_connections: 10,
157            request_timeout_seconds: 30,
158            enable_metrics: true,
159            log_level: "info".to_string(),
160        }
161    }
162}
163
164impl Config {
165    /// Load configuration from environment variables with MCP-friendly error handling
166    pub fn from_env() -> Result<Self> {
167        dotenv::dotenv().ok(); // Load .env file if present
168
169        let mut config = Config::default();
170
171        // Database URL - support multiple environment variable names for flexibility
172        config.database_url = Self::get_database_url_from_env()
173            .map_err(|e| anyhow::anyhow!("Database configuration error: {}", e))?;
174
175        // Embedding configuration
176        if let Ok(provider) = env::var("EMBEDDING_PROVIDER") {
177            config.embedding.provider = provider;
178        }
179
180        if let Ok(model) = env::var("EMBEDDING_MODEL") {
181            config.embedding.model = model;
182        }
183
184        if let Ok(base_url) = env::var("EMBEDDING_BASE_URL") {
185            config.embedding.base_url = base_url;
186        }
187
188        if let Ok(timeout) = env::var("EMBEDDING_TIMEOUT_SECONDS") {
189            config.embedding.timeout_seconds = timeout
190                .parse()
191                .map_err(|e| anyhow::anyhow!("Invalid EMBEDDING_TIMEOUT_SECONDS: {}", e))?;
192        }
193
194        // API key is optional (not needed for Ollama)
195        if let Ok(api_key) = env::var("OPENAI_API_KEY") {
196            config.embedding.api_key = api_key;
197        }
198
199        // For backward compatibility, also check EMBEDDING_API_KEY
200        if let Ok(api_key) = env::var("EMBEDDING_API_KEY") {
201            config.embedding.api_key = api_key;
202        }
203
204        // Optional environment variables
205        if let Ok(port) = env::var("HTTP_PORT") {
206            config.http_port = port
207                .parse()
208                .map_err(|e| anyhow::anyhow!("Invalid HTTP_PORT: {}", e))?;
209        }
210
211        if let Ok(port) = env::var("MCP_PORT") {
212            config.mcp_port = Some(
213                port.parse()
214                    .map_err(|e| anyhow::anyhow!("Invalid MCP_PORT: {}", e))?,
215            );
216        }
217
218        // Tier configuration
219        if let Ok(limit) = env::var("WORKING_TIER_LIMIT") {
220            config.tier_config.working_tier_limit = limit
221                .parse()
222                .map_err(|e| anyhow::anyhow!("Invalid WORKING_TIER_LIMIT: {}", e))?;
223        }
224
225        if let Ok(limit) = env::var("WARM_TIER_LIMIT") {
226            config.tier_config.warm_tier_limit = limit
227                .parse()
228                .map_err(|e| anyhow::anyhow!("Invalid WARM_TIER_LIMIT: {}", e))?;
229        }
230
231        if let Ok(days) = env::var("WORKING_TO_WARM_DAYS") {
232            config.tier_config.working_to_warm_days = days
233                .parse()
234                .map_err(|e| anyhow::anyhow!("Invalid WORKING_TO_WARM_DAYS: {}", e))?;
235        }
236
237        if let Ok(days) = env::var("WARM_TO_COLD_DAYS") {
238            config.tier_config.warm_to_cold_days = days
239                .parse()
240                .map_err(|e| anyhow::anyhow!("Invalid WARM_TO_COLD_DAYS: {}", e))?;
241        }
242
243        if let Ok(threshold) = env::var("IMPORTANCE_THRESHOLD") {
244            config.tier_config.importance_threshold = threshold
245                .parse()
246                .map_err(|e| anyhow::anyhow!("Invalid IMPORTANCE_THRESHOLD: {}", e))?;
247        }
248
249        // Operational configuration
250        if let Ok(conns) = env::var("MAX_DB_CONNECTIONS") {
251            config.operational.max_db_connections = conns
252                .parse()
253                .map_err(|e| anyhow::anyhow!("Invalid MAX_DB_CONNECTIONS: {}", e))?;
254        }
255
256        if let Ok(timeout) = env::var("REQUEST_TIMEOUT_SECONDS") {
257            config.operational.request_timeout_seconds = timeout
258                .parse()
259                .map_err(|e| anyhow::anyhow!("Invalid REQUEST_TIMEOUT_SECONDS: {}", e))?;
260        }
261
262        if let Ok(enable) = env::var("ENABLE_METRICS") {
263            config.operational.enable_metrics = enable
264                .parse()
265                .map_err(|e| anyhow::anyhow!("Invalid ENABLE_METRICS: {}", e))?;
266        }
267
268        if let Ok(level) = env::var("LOG_LEVEL") {
269            config.operational.log_level = level;
270        }
271
272        Ok(config)
273    }
274
275    /// Validate the configuration
276    pub fn validate(&self) -> Result<()> {
277        if self.database_url.is_empty() {
278            return Err(anyhow::anyhow!("Database URL is required"));
279        }
280
281        // Validate embedding configuration
282        match self.embedding.provider.as_str() {
283            "openai" => {
284                if self.embedding.api_key.is_empty() {
285                    return Err(anyhow::anyhow!("API key is required for OpenAI provider"));
286                }
287            }
288            "ollama" => {
289                if self.embedding.base_url.is_empty() {
290                    return Err(anyhow::anyhow!("Base URL is required for Ollama provider"));
291                }
292            }
293            "mock" => {
294                // Mock provider for testing - no additional validation needed
295            }
296            _ => {
297                return Err(anyhow::anyhow!(
298                    "Invalid embedding provider: {}. Must be 'openai', 'ollama', or 'mock'",
299                    self.embedding.provider
300                ));
301            }
302        }
303
304        if self.embedding.model.is_empty() {
305            return Err(anyhow::anyhow!("Embedding model is required"));
306        }
307
308        if self.tier_config.working_tier_limit == 0 {
309            return Err(anyhow::anyhow!("Working tier limit must be greater than 0"));
310        }
311
312        if self.tier_config.warm_tier_limit == 0 {
313            return Err(anyhow::anyhow!("Warm tier limit must be greater than 0"));
314        }
315
316        if self.tier_config.importance_threshold < 0.0
317            || self.tier_config.importance_threshold > 1.0
318        {
319            return Err(anyhow::anyhow!(
320                "Importance threshold must be between 0.0 and 1.0"
321            ));
322        }
323
324        Ok(())
325    }
326
327    /// Get database URL from environment variables with multiple fallback options
328    /// for MCP compatibility
329    fn get_database_url_from_env() -> Result<String> {
330        // Try DATABASE_URL first (standard convention)
331        if let Ok(url) = env::var("DATABASE_URL") {
332            return Ok(url);
333        }
334
335        // Try individual components for MCP-style configuration
336        if let (Ok(host), Ok(user), Ok(db)) = (
337            env::var("DB_HOST"),
338            env::var("DB_USER"),
339            env::var("DB_NAME"),
340        ) {
341            let password = env::var("DB_PASSWORD").unwrap_or_default();
342            let port = env::var("DB_PORT").unwrap_or_else(|_| "5432".to_string());
343            
344            if password.is_empty() {
345                return Ok(format!("postgresql://{}@{}:{}/{}", user, host, port, db));
346            } else {
347                return Ok(format!("postgresql://{}:{}@{}:{}/{}", user, password, host, port, db));
348            }
349        }
350
351        // Fall back to DB_CONN if provided
352        if let Ok(conn) = env::var("DB_CONN") {
353            return Ok(conn);
354        }
355
356        Err(anyhow::anyhow!(
357            "Database credentials not found. Please provide either:\n\
358             1. DATABASE_URL environment variable, or\n\
359             2. DB_HOST, DB_USER, DB_NAME (and optionally DB_PASSWORD, DB_PORT), or\n\
360             3. DB_CONN environment variable\n\n\
361             Example:\n\
362             DATABASE_URL=postgresql://user:password@localhost:5432/database\n\
363             or\n\
364             DB_HOST=localhost\n\
365             DB_USER=myuser\n\
366             DB_PASSWORD=mypassword\n\
367             DB_NAME=mydatabase"
368        ))
369    }
370
371    /// Generate a safe connection string for logging (masks password)
372    pub fn safe_database_url(&self) -> String {
373        if let Some(at_pos) = self.database_url.find('@') {
374            if let Some(colon_pos) = self.database_url[..at_pos].rfind(':') {
375                // postgresql://user:password@host:port/db -> postgresql://user:***@host:port/db
376                let mut masked = self.database_url.clone();
377                masked.replace_range(colon_pos + 1..at_pos, "***");
378                return masked;
379            }
380        }
381        // If we can't parse it, just show the prefix
382        format!("postgresql://[credentials-hidden]{}", 
383                self.database_url.split_once('@').map(|(_, rest)| rest).unwrap_or(""))
384    }
385
386    /// Validate MCP environment configuration
387    pub fn validate_mcp_environment(&self) -> Result<()> {
388        // Standard validation first
389        self.validate()?;
390
391        // MCP-specific validations
392        if self.embedding.provider == "openai" && self.embedding.api_key.len() < 20 {
393            return Err(anyhow::anyhow!("OpenAI API key appears to be invalid (too short)"));
394        }
395
396        // Check for reasonable port configuration
397        if self.http_port < 1024 {
398            tracing::warn!(
399                "HTTP port {} requires root privileges. Consider using port >= 1024 for MCP deployment.",
400                self.http_port
401            );
402        }
403
404        if let Some(mcp_port) = self.mcp_port {
405            if mcp_port == self.http_port {
406                return Err(anyhow::anyhow!(
407                    "MCP port and HTTP port cannot be the same ({})", 
408                    mcp_port
409                ));
410            }
411        }
412
413        Ok(())
414    }
415
416    /// Create a diagnostic report for troubleshooting MCP setup
417    pub fn create_diagnostic_report(&self) -> String {
418        let mut report = String::new();
419        report.push_str("=== Agentic Memory System - MCP Configuration Report ===\n\n");
420
421        // Database configuration
422        report.push_str("Database Configuration:\n");
423        report.push_str(&format!("  Connection: {}\n", self.safe_database_url()));
424        
425        // Embedding configuration
426        report.push_str("\nEmbedding Configuration:\n");
427        report.push_str(&format!("  Provider: {}\n", self.embedding.provider));
428        report.push_str(&format!("  Model: {}\n", self.embedding.model));
429        report.push_str(&format!("  Base URL: {}\n", self.embedding.base_url));
430        report.push_str(&format!("  Timeout: {}s\n", self.embedding.timeout_seconds));
431        report.push_str(&format!("  API Key: {}\n", 
432            if self.embedding.api_key.is_empty() { "Not set" } else { "***configured***" }
433        ));
434
435        // Server configuration
436        report.push_str("\nServer Configuration:\n");
437        report.push_str(&format!("  HTTP Port: {}\n", self.http_port));
438        report.push_str(&format!("  MCP Port: {}\n", 
439            self.mcp_port.map(|p| p.to_string()).unwrap_or_else(|| "Not set".to_string())
440        ));
441
442        // Memory tier configuration
443        report.push_str("\nMemory Tier Configuration:\n");
444        report.push_str(&format!("  Working Tier Limit: {}\n", self.tier_config.working_tier_limit));
445        report.push_str(&format!("  Warm Tier Limit: {}\n", self.tier_config.warm_tier_limit));
446        report.push_str(&format!("  Working->Warm: {} days\n", self.tier_config.working_to_warm_days));
447        report.push_str(&format!("  Warm->Cold: {} days\n", self.tier_config.warm_to_cold_days));
448
449        // Validation results
450        report.push_str("\nValidation Results:\n");
451        match self.validate_mcp_environment() {
452            Ok(_) => report.push_str("  ✅ All configuration checks passed\n"),
453            Err(e) => report.push_str(&format!("  ❌ Configuration error: {}\n", e)),
454        }
455
456        report.push_str("\n=== End Configuration Report ===\n");
457        report
458    }
459}
460
461impl Default for BackupConfiguration {
462    fn default() -> Self {
463        Self {
464            enabled: true,
465            backup_directory: PathBuf::from("/var/lib/codex/backups"),
466            wal_archive_directory: PathBuf::from("/var/lib/codex/wal_archive"),
467            retention_days: 30,
468            enable_encryption: true,
469            schedule: "0 2 * * *".to_string(), // Daily at 2 AM
470            rto_minutes: 60,                   // 1 hour
471            rpo_minutes: 5,                    // 5 minutes
472            enable_verification: true,
473        }
474    }
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct SecurityConfiguration {
479    /// Enable security features
480    pub enabled: bool,
481    /// TLS configuration
482    pub tls_enabled: bool,
483    pub tls_cert_path: PathBuf,
484    pub tls_key_path: PathBuf,
485    pub tls_port: u16,
486    /// Authentication configuration
487    pub auth_enabled: bool,
488    pub jwt_secret: String,
489    pub jwt_expiry_hours: u32,
490    pub api_key_enabled: bool,
491    /// Rate limiting configuration
492    pub rate_limiting_enabled: bool,
493    pub requests_per_minute: u32,
494    pub rate_limit_burst: u32,
495    /// Audit logging configuration
496    pub audit_enabled: bool,
497    pub audit_retention_days: u32,
498    /// PII detection configuration
499    pub pii_detection_enabled: bool,
500    pub pii_mask_logs: bool,
501    /// GDPR compliance configuration
502    pub gdpr_enabled: bool,
503    pub gdpr_retention_days: u32,
504    pub right_to_be_forgotten: bool,
505    /// Secrets management configuration
506    pub vault_enabled: bool,
507    pub vault_address: Option<String>,
508    pub vault_token_path: Option<PathBuf>,
509    /// Input validation configuration
510    pub input_validation_enabled: bool,
511    pub max_request_size_mb: u32,
512}
513
514impl Default for SecurityConfiguration {
515    fn default() -> Self {
516        Self {
517            enabled: true,
518            tls_enabled: false,
519            tls_cert_path: PathBuf::from("/etc/ssl/certs/codex.crt"),
520            tls_key_path: PathBuf::from("/etc/ssl/private/codex.key"),
521            tls_port: 8443,
522            auth_enabled: false,
523            jwt_secret: "change-me-in-production".to_string(),
524            jwt_expiry_hours: 24,
525            api_key_enabled: false,
526            rate_limiting_enabled: false,
527            requests_per_minute: 100,
528            rate_limit_burst: 20,
529            audit_enabled: false,
530            audit_retention_days: 90,
531            pii_detection_enabled: false,
532            pii_mask_logs: true,
533            gdpr_enabled: false,
534            gdpr_retention_days: 730, // 2 years
535            right_to_be_forgotten: false,
536            vault_enabled: false,
537            vault_address: None,
538            vault_token_path: None,
539            input_validation_enabled: true,
540            max_request_size_mb: 10,
541        }
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use std::env;
549
550    #[test]
551    fn test_default_config() {
552        let config = Config::default();
553        assert_eq!(config.http_port, 8080);
554        assert_eq!(config.embedding.model, "nomic-embed-text");
555        assert_eq!(config.embedding.provider, "ollama");
556        assert_eq!(config.tier_config.working_tier_limit, 1000);
557    }
558
559    #[test]
560    fn test_config_validation() {
561        let mut config = Config::default();
562        // Ollama provider with base_url should be valid
563        assert!(config.validate().is_ok());
564
565        // OpenAI provider without API key should fail
566        config.embedding.provider = "openai".to_string();
567        config.embedding.api_key = String::new();
568        assert!(config.validate().is_err());
569
570        // OpenAI provider with API key should pass
571        config.embedding.api_key = "test-key".to_string();
572        assert!(config.validate().is_ok());
573    }
574
575    #[test]
576    fn test_embedding_config_validation() {
577        let mut config = Config::default();
578        
579        // Invalid provider should fail
580        config.embedding.provider = "invalid".to_string();
581        assert!(config.validate().is_err());
582
583        // Empty model should fail
584        config.embedding.provider = "ollama".to_string();
585        config.embedding.model = String::new();
586        assert!(config.validate().is_err());
587
588        // Ollama without base_url should fail
589        config.embedding.model = "test-model".to_string();
590        config.embedding.base_url = String::new();
591        assert!(config.validate().is_err());
592    }
593}