Skip to main content

xbp_cli/strategies/
deployment_config.rs

1//! deployment configuration management module
2//!
3//! handles reading and writing deployment configurations
4//! including xbp.yaml/xbp.json files and cli argument parsing
5//! supports both legacy single service and new multi service formats
6//! validates service configurations for duplicates and invalid targets
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs;
11use std::path::PathBuf;
12
13use crate::config::LinearConfig;
14use crate::utils::{
15    collapse_project_path, find_xbp_config_upwards, maybe_auto_convert_legacy_xbp_json_to_yaml,
16    parse_config_with_auto_heal, resolve_env_placeholders, resolve_project_path,
17};
18
19fn default_xbp_version() -> String {
20    "0.1.0".to_string()
21}
22
23fn default_auto_push_on_commit() -> bool {
24    true
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ServiceCommands {
29    #[serde(default)]
30    pub pre: Option<String>,
31    #[serde(default)]
32    pub install: Option<String>,
33    #[serde(default)]
34    pub build: Option<String>,
35    #[serde(default)]
36    pub start: Option<String>,
37    #[serde(default)]
38    pub dev: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42pub struct SystemdConfig {
43    #[serde(default)]
44    pub environment_files: Vec<String>,
45    #[serde(default)]
46    pub config_paths: Vec<String>,
47    #[serde(default)]
48    pub read_write_paths: Vec<String>,
49    #[serde(default)]
50    pub runtime_directories: Vec<String>,
51    #[serde(default)]
52    pub state_directories: Vec<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct DatabaseConfig {
57    #[serde(default)]
58    pub enabled: Option<bool>,
59    #[serde(default)]
60    pub backend: Option<String>,
61    #[serde(default)]
62    pub url_env: Option<String>,
63    #[serde(default)]
64    pub key_env: Option<String>,
65    #[serde(default)]
66    pub schema: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ServiceConfig {
71    pub name: String,
72    pub target: String,
73    pub branch: String,
74    pub port: u16,
75    #[serde(default)]
76    pub root_directory: Option<String>,
77    #[serde(default)]
78    pub environment: Option<HashMap<String, String>>,
79    #[serde(default)]
80    pub url: Option<String>,
81    #[serde(default)]
82    pub healthcheck_path: Option<String>,
83    #[serde(default)]
84    pub restart_policy: Option<String>,
85    #[serde(default)]
86    pub restart_policy_max_failure_count: Option<u32>,
87    #[serde(default)]
88    pub start_wrapper: Option<String>,
89    #[serde(default)]
90    pub commands: Option<ServiceCommands>,
91    #[serde(default)]
92    pub force_run_from_root: Option<bool>,
93    #[serde(default)]
94    pub systemd_service_name: Option<String>,
95    #[serde(default)]
96    pub systemd: Option<SystemdConfig>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct XbpConfig {
101    pub project_name: String,
102    #[serde(default = "default_xbp_version")]
103    pub version: String,
104    pub port: u16,
105    pub build_dir: String,
106    #[serde(default)]
107    pub app_type: Option<String>,
108    #[serde(default)]
109    pub build_command: Option<String>,
110    #[serde(default)]
111    pub start_command: Option<String>,
112    #[serde(default)]
113    pub install_command: Option<String>,
114    #[serde(default)]
115    pub environment: Option<HashMap<String, String>>,
116    #[serde(default)]
117    pub services: Option<Vec<ServiceConfig>>,
118    #[serde(default)]
119    pub systemd_service_name: Option<String>,
120    #[serde(default)]
121    pub systemd: Option<SystemdConfig>,
122    #[serde(default)]
123    pub kafka_brokers: Option<String>,
124    #[serde(default)]
125    pub kafka_topic: Option<String>,
126    #[serde(default)]
127    pub kafka_public_url: Option<String>,
128    #[serde(default)]
129    pub log_files: Option<Vec<String>>,
130    #[serde(default)]
131    pub monitor_url: Option<String>,
132    #[serde(default)]
133    pub monitor_method: Option<String>,
134    #[serde(default)]
135    pub monitor_expected_code: Option<u16>,
136    #[serde(default)]
137    pub monitor_interval: Option<u64>,
138    #[serde(default)]
139    pub database: Option<DatabaseConfig>,
140    // Legacy fields for backward compatibility
141    #[serde(default)]
142    pub target: Option<String>,
143    #[serde(default)]
144    pub branch: Option<String>,
145    #[serde(default)]
146    pub crate_name: Option<String>,
147    #[serde(default)]
148    pub npm_script: Option<String>,
149    #[serde(default)]
150    pub port_storybook: Option<u16>,
151    #[serde(default)]
152    pub url: Option<String>,
153    #[serde(default)]
154    pub url_storybook: Option<String>,
155    #[serde(default)]
156    pub linear: Option<LinearConfig>,
157    #[serde(default)]
158    pub github: Option<GitHubProjectConfig>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, Default)]
162pub struct GitHubProjectConfig {
163    #[serde(default = "default_auto_push_on_commit")]
164    pub auto_push_on_commit: bool,
165}
166
167#[derive(Debug, Clone)]
168pub struct DeploymentConfig {
169    pub app_name: String,
170    pub port: u16,
171    pub app_dir: PathBuf,
172    pub build_command: Option<String>,
173    pub start_command: Option<String>,
174    pub install_command: Option<String>,
175    pub environment: HashMap<String, String>,
176}
177
178pub fn normalize_config_paths_for_persistence(
179    config: &mut XbpConfig,
180    project_root: &std::path::Path,
181) {
182    config.build_dir = collapse_project_path(project_root, &config.build_dir);
183    if let Some(services) = &mut config.services {
184        for service in services {
185            if let Some(root_directory) = &service.root_directory {
186                service.root_directory = Some(collapse_project_path(project_root, root_directory));
187            }
188        }
189    }
190}
191
192pub fn resolve_config_paths_for_runtime(config: &mut XbpConfig, project_root: &std::path::Path) {
193    config.build_dir = resolve_project_path(project_root, &config.build_dir);
194    if let Some(services) = &mut config.services {
195        for service in services {
196            if let Some(root_directory) = &service.root_directory {
197                service.root_directory = Some(resolve_project_path(project_root, root_directory));
198            }
199        }
200    }
201}
202
203impl XbpConfig {
204    pub fn auto_push_on_commit_enabled(&self) -> bool {
205        self.github
206            .as_ref()
207            .map(|config| config.auto_push_on_commit)
208            .unwrap_or(true)
209    }
210}
211
212impl DeploymentConfig {
213    /// Create deployment config from CLI arguments and xbp config fallback
214    pub async fn from_args_or_config(
215        app_name: Option<String>,
216        port: Option<u16>,
217        app_dir: Option<PathBuf>,
218        config_path: Option<PathBuf>,
219    ) -> Result<Self, String> {
220        // Try to load from xbp config if not all args provided
221        let xbp_config = if app_name.is_none() || port.is_none() || app_dir.is_none() {
222            Self::load_xbp_config(config_path).await.ok()
223        } else {
224            None
225        };
226
227        let app_name = app_name
228            .or_else(|| xbp_config.as_ref().map(|c| c.project_name.clone()))
229            .ok_or("Missing app name")?;
230
231        let port = port
232            .or_else(|| xbp_config.as_ref().map(|c| c.port))
233            .ok_or("Missing port")?;
234
235        let app_dir = app_dir
236            .or_else(|| xbp_config.as_ref().map(|c| PathBuf::from(&c.build_dir)))
237            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
238
239        let app_dir = app_dir
240            .canonicalize()
241            .map_err(|e| format!("Failed to resolve app directory: {}", e))?;
242
243        // Get build/start commands from config if available
244        let build_command = xbp_config.as_ref().and_then(|c| c.build_command.clone());
245        let start_command = xbp_config.as_ref().and_then(|c| c.start_command.clone());
246        let install_command = xbp_config.as_ref().and_then(|c| c.install_command.clone());
247        let environment = xbp_config
248            .as_ref()
249            .and_then(|c| c.environment.clone())
250            .unwrap_or_default();
251        let environment = resolve_env_placeholders(&app_dir, &environment);
252
253        Ok(DeploymentConfig {
254            app_name,
255            port,
256            app_dir,
257            build_command,
258            start_command,
259            install_command,
260            environment,
261        })
262    }
263
264    /// Load xbp configuration from file
265    pub async fn load_xbp_config(config_path: Option<PathBuf>) -> Result<XbpConfig, String> {
266        let cwd = std::env::current_dir().unwrap_or_default();
267
268        let (project_root, resolved_path, resolved_kind) = if let Some(p) = config_path.clone() {
269            let root = p
270                .parent()
271                .map(|pp| pp.to_path_buf())
272                .unwrap_or_else(|| cwd.clone());
273            (root, p, "auto")
274        } else {
275            let found = find_xbp_config_upwards(&cwd)
276                .ok_or_else(|| "Configuration file not found".to_string())?;
277            (found.project_root, found.config_path, found.kind)
278        };
279
280        let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &resolved_path);
281
282        let (config_path, kind) = (resolved_path, resolved_kind);
283
284        let content = fs::read_to_string(&config_path)
285            .map_err(|e| format!("Failed to read config: {}", e))?;
286
287        let effective_kind = match kind {
288            "yaml" | "json" => kind,
289            _ => {
290                if config_path
291                    .extension()
292                    .and_then(|s| s.to_str())
293                    .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
294                    .unwrap_or(false)
295                {
296                    "yaml"
297                } else {
298                    "json"
299                }
300            }
301        };
302
303        let (mut config, healed_content): (XbpConfig, Option<String>) =
304            parse_config_with_auto_heal(&content, effective_kind).map_err(|e| {
305                if effective_kind == "yaml" {
306                    format!("Failed to parse YAML config: {}", e)
307                } else {
308                    format!("Failed to parse JSON config: {}", e)
309                }
310            })?;
311
312        if let Some(healed_content) = healed_content {
313            let _ = fs::write(&config_path, healed_content);
314        }
315
316        resolve_config_paths_for_runtime(&mut config, &project_root);
317
318        // Validate services if present
319        if let Some(services) = &config.services {
320            validate_services(services)?;
321        }
322
323        Ok(config)
324    }
325
326    /// Save updated configuration back to project config files
327    pub async fn save_xbp_config(&self, config_path: Option<PathBuf>) -> Result<(), String> {
328        let dir = self.app_dir.join(".xbp");
329        let json_path = dir.join("xbp.json");
330        let yaml_path = dir.join("xbp.yaml");
331
332        // Create .xbp directory if it doesn't exist
333        fs::create_dir_all(&dir)
334            .map_err(|e| format!("Failed to create config directory: {}", e))?;
335
336        let mut xbp_config = XbpConfig {
337            project_name: self.app_name.clone(),
338            version: default_xbp_version(),
339            port: self.port,
340            build_dir: self.app_dir.to_string_lossy().to_string(),
341            app_type: None,
342            build_command: self.build_command.clone(),
343            start_command: self.start_command.clone(),
344            install_command: self.install_command.clone(),
345            environment: if self.environment.is_empty() {
346                None
347            } else {
348                Some(self.environment.clone())
349            },
350            services: None,
351            systemd_service_name: None,
352            systemd: None,
353            kafka_brokers: None,
354            kafka_topic: None,
355            kafka_public_url: None,
356            log_files: None,
357            monitor_url: None,
358            monitor_method: None,
359            monitor_expected_code: None,
360            monitor_interval: None,
361            database: None,
362            target: None,
363            branch: None,
364            crate_name: None,
365            npm_script: None,
366            port_storybook: None,
367            url: None,
368            url_storybook: None,
369            linear: None,
370            github: None,
371        };
372
373        normalize_config_paths_for_persistence(&mut xbp_config, &self.app_dir);
374
375        let yaml = serde_yaml::to_string(&xbp_config)
376            .map_err(|e| format!("Failed to serialize config (yaml): {}", e))?;
377        let json = serde_json::to_string_pretty(&xbp_config)
378            .map_err(|e| format!("Failed to serialize config (json): {}", e))?;
379
380        let explicit_path = config_path;
381        let explicit_is_json = explicit_path
382            .as_ref()
383            .and_then(|path| path.extension().and_then(|ext| ext.to_str()))
384            .map(|ext| ext.eq_ignore_ascii_case("json"))
385            .unwrap_or(false);
386
387        fs::write(&yaml_path, &yaml)
388            .map_err(|e| format!("Failed to write yaml config {}: {}", yaml_path.display(), e))?;
389
390        if explicit_is_json {
391            let out_path = explicit_path.expect("explicit path should exist");
392            fs::write(&out_path, &json).map_err(|e| {
393                format!(
394                    "Failed to write legacy JSON config {}: {}",
395                    out_path.display(),
396                    e
397                )
398            })?;
399        } else if json_path.exists() {
400            fs::write(&json_path, &json).map_err(|e| {
401                format!(
402                    "Failed to sync legacy JSON config {}: {}",
403                    json_path.display(),
404                    e
405                )
406            })?;
407        }
408
409        Ok(())
410    }
411
412    /// Update port in the configuration
413    pub fn update_port(&mut self, new_port: u16) {
414        self.port = new_port;
415    }
416
417    /// Merge with deployment recommendations from project detection
418    pub fn merge_with_recommendations(
419        &mut self,
420        recommendations: &super::project_detector::DeploymentRecommendations,
421    ) {
422        // Use recommendations if not already set
423        if self.build_command.is_none() {
424            self.build_command = recommendations.build_command.clone();
425        }
426
427        if self.start_command.is_none() {
428            self.start_command = recommendations.start_command.clone();
429        }
430
431        if self.install_command.is_none() {
432            self.install_command = recommendations.install_command.clone();
433        }
434
435        // Use recommended process name if app_name is generic
436        if let Some(recommended_name) = &recommendations.process_name {
437            if self.app_name == "app" || self.app_name == "unknown" {
438                self.app_name = recommended_name.clone();
439            }
440        }
441    }
442}
443
444/// Validate services configuration
445pub fn validate_services(services: &[ServiceConfig]) -> Result<(), String> {
446    let mut names = std::collections::HashSet::new();
447    let mut ports = std::collections::HashSet::new();
448    let mut urls = std::collections::HashSet::new();
449
450    for service in services {
451        // Check for duplicate names
452        if !names.insert(&service.name) {
453            return Err(format!("Duplicate service name found: {}", service.name));
454        }
455
456        // Check for duplicate ports
457        if !ports.insert(service.port) {
458            return Err(format!("Duplicate port found: {}", service.port));
459        }
460
461        // Check for duplicate URLs
462        if let Some(url) = &service.url {
463            if !urls.insert(url) {
464                return Err(format!("Duplicate URL found: {}", url));
465            }
466        }
467
468        // Validate target
469        let valid_targets = ["python", "expressjs", "nextjs", "rust"];
470        if !valid_targets.contains(&service.target.as_str()) {
471            return Err(format!(
472                "Invalid target '{}' for service '{}'. Must be one of: python, expressjs, nextjs, rust",
473                service.target, service.name
474            ));
475        }
476    }
477
478    Ok(())
479}
480
481/// Find a service by name
482pub fn get_service_by_name(config: &XbpConfig, name: &str) -> Result<ServiceConfig, String> {
483    if let Some(services) = &config.services {
484        services
485            .iter()
486            .find(|s| s.name == name)
487            .cloned()
488            .ok_or_else(|| format!("Service '{}' not found in configuration", name))
489    } else {
490        Err("No services configured. This project uses legacy single-service format.".to_string())
491    }
492}
493
494/// Get all services from config (or create a virtual one from legacy config)
495pub fn get_all_services(config: &XbpConfig) -> Vec<ServiceConfig> {
496    if let Some(services) = &config.services {
497        services.clone()
498    } else {
499        // Backward compatibility: create a virtual service from top-level config
500        vec![ServiceConfig {
501            name: config.project_name.clone(),
502            target: config.target.clone().unwrap_or_else(|| "rust".to_string()),
503            branch: config.branch.clone().unwrap_or_else(|| "main".to_string()),
504            port: config.port,
505            root_directory: Some(config.build_dir.clone()),
506            environment: config.environment.clone(),
507            url: config.url.clone(),
508            healthcheck_path: None,
509            restart_policy: Some("on_failure".to_string()),
510            restart_policy_max_failure_count: Some(10),
511            start_wrapper: Some("pm2".to_string()),
512            commands: Some(ServiceCommands {
513                pre: None,
514                install: config.install_command.clone(),
515                build: config.build_command.clone(),
516                start: config.start_command.clone(),
517                dev: None,
518            }),
519            force_run_from_root: Some(false),
520            systemd_service_name: config.systemd_service_name.clone(),
521            systemd: config.systemd.clone(),
522        }]
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::XbpConfig;
529
530    #[test]
531    fn github_auto_push_defaults_to_true_when_missing() {
532        let config: XbpConfig = serde_yaml::from_str(
533            r#"
534project_name: demo
535version: 0.1.0
536port: 3000
537build_dir: ./
538"#,
539        )
540        .expect("parse config");
541
542        assert!(config.auto_push_on_commit_enabled());
543    }
544
545    #[test]
546    fn github_auto_push_can_be_disabled_per_project() {
547        let config: XbpConfig = serde_yaml::from_str(
548            r#"
549project_name: demo
550version: 0.1.0
551port: 3000
552build_dir: ./
553github:
554  auto_push_on_commit: false
555"#,
556        )
557        .expect("parse config");
558
559        assert!(!config.auto_push_on_commit_enabled());
560    }
561}