Skip to main content

xbp_cli/commands/deploy_engine/
mod.rs

1//! CLI bridge for `xbp deploy`.
2//!
3//! Builds [`xbp_deploy::DeployContext`] from project config and delegates to
4//! the declarative engine (planner / runner / verifier). No business logic here.
5
6pub mod map_config;
7
8pub use xbp_deploy::{DeployMode, DeployTarget};
9
10use std::sync::Arc;
11
12use colored::Colorize;
13use xbp_deploy::{
14    DefaultDeployPlanner, DefaultDeployRunner, DefaultDeployVerifier, DeployContext, DeployFlags,
15    DeployHistoryStore, DeployPlanner, DeployRunner, DeployVerifier,
16};
17use xbp_k8s::KubectlAdapter;
18use xbp_oci::{
19    resolve_auth_from_candidates, DefaultOciPromoter, DefaultOciResolver, OciAuthRequest, OciClient,
20};
21
22use crate::commands::service::load_xbp_config_with_root;
23use crate::oci::auth_candidates_for_registry;
24
25use self::map_config::map_project_config;
26
27#[derive(Debug, Clone)]
28pub struct DeployRequest {
29    pub target: String,
30    pub env: Option<String>,
31    pub mode: DeployMode,
32    pub flags: DeployFlags,
33    pub debug: bool,
34}
35
36/// Entry point for `xbp deploy <target> [flags]`.
37pub async fn run_deploy(request: DeployRequest) -> Result<(), String> {
38    let (project_root, xbp_config) = load_xbp_config_with_root().await?;
39    let config = map_project_config(&project_root, &xbp_config);
40    let env = request
41        .env
42        .as_deref()
43        .map(str::trim)
44        .filter(|s| !s.is_empty())
45        .map(str::to_string)
46        .or_else(|| config.default_env.clone())
47        .unwrap_or_else(|| "production".into());
48
49    if matches!(request.mode, DeployMode::History) {
50        let store = DeployHistoryStore::new(&config.history_dir);
51        let entries = store
52            .list(&request.target, &env, request.flags.history_limit.max(1))
53            .map_err(|e| e.to_string())?;
54        if entries.is_empty() {
55            println!("{}", "No matching deploy history entries.".dimmed());
56            return Ok(());
57        }
58        for e in entries {
59            let status = if e.status == "success" {
60                "ok".bright_green().to_string()
61            } else {
62                e.status.bright_red().to_string()
63            };
64            println!(
65                "{}  {}  {}  {}  {}",
66                e.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
67                status,
68                e.target.bright_white(),
69                e.env.bright_yellow(),
70                e.id
71            );
72        }
73        return Ok(());
74    }
75
76    if matches!(request.mode, DeployMode::Status) {
77        let store = DeployHistoryStore::new(&config.history_dir);
78        match store
79            .latest_status(&request.target, &env)
80            .map_err(|e| e.to_string())?
81        {
82            Some(e) => {
83                println!(
84                    "{} {} env={} status={}",
85                    "Status".bright_cyan().bold(),
86                    e.target.bright_white(),
87                    e.env.bright_yellow(),
88                    e.status
89                );
90                println!("id={} path={}", e.id, e.path);
91            }
92            None => println!("{}", "No deploy status recorded.".dimmed()),
93        }
94        return Ok(());
95    }
96
97    // Provisional target; planner re-resolves with group>service priority.
98    let target = if request.target.eq_ignore_ascii_case("all") {
99        DeployTarget::All
100    } else if config.groups.contains_key(&request.target) {
101        DeployTarget::Group(request.target.clone())
102    } else {
103        DeployTarget::Service(request.target.clone())
104    };
105
106    let ctx = DeployContext {
107        env: env.clone(),
108        target,
109        config: config.clone(),
110        flags: request.flags.clone(),
111        mode: request.mode,
112    };
113
114    let oci_resolver = build_oci_resolver(&xbp_config, request.debug);
115    let planner = DefaultDeployPlanner::new(oci_resolver.clone());
116    let plan = planner.plan(&ctx).await.map_err(|e| e.to_string())?;
117
118    println!(
119        "{} {} → {} service(s) env={}",
120        "Deploy".bright_cyan().bold(),
121        plan.target.label().bright_white().bold(),
122        plan.order.len().to_string().bright_green(),
123        env.bright_yellow()
124    );
125    println!("{}", plan.render_human());
126
127    if let Some(path) = &request.flags.output {
128        if let Some(parent) = path.parent() {
129            let _ = std::fs::create_dir_all(parent);
130        }
131        std::fs::write(
132            path,
133            serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())?,
134        )
135        .map_err(|e| format!("write --output: {e}"))?;
136        println!("{} {}", "wrote".bright_cyan(), path.display());
137    }
138
139    match request.mode {
140        DeployMode::Plan => {
141            if request.flags.json {
142                println!(
143                    "{}",
144                    serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())?
145                );
146            }
147            println!(
148                "{}",
149                "Plan only (no mutations). Re-run with --run to apply.".dimmed()
150            );
151            Ok(())
152        }
153        DeployMode::Verify => {
154            let k8s = Arc::new(KubectlAdapter::new(request.debug));
155            let verifier = DefaultDeployVerifier { k8s };
156            let report = verifier.verify(&ctx, &plan).await.map_err(|e| e.to_string())?;
157            for line in &report.lines {
158                println!("{line}");
159            }
160            for drift in &report.drifts {
161                println!("{} {drift}", "drift".bright_red());
162            }
163            if request.flags.json {
164                println!(
165                    "{}",
166                    serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?
167                );
168            }
169            if report.ok {
170                println!("{}", "Result: ok".bright_green().bold());
171                Ok(())
172            } else {
173                Err(format!("verify failed: {}", report.drifts.join("; ")))
174            }
175        }
176        DeployMode::Run | DeployMode::Promote => {
177            let k8s = Arc::new(KubectlAdapter::new(request.debug));
178            let promoter = build_oci_promoter(&xbp_config);
179            let confirm: Arc<dyn Fn(String) -> bool + Send + Sync> = Arc::new(|msg| {
180                if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
181                    return false;
182                }
183                dialoguer::Confirm::new()
184                    .with_prompt(msg)
185                    .default(false)
186                    .interact()
187                    .unwrap_or(false)
188            });
189            let runner = DefaultDeployRunner {
190                k8s,
191                promoter,
192                confirm: Some(confirm),
193            };
194            let result = runner.run(&ctx, &plan).await.map_err(|e| e.to_string())?;
195            for line in &result.lines {
196                println!("{line}");
197            }
198            if request.flags.json {
199                println!(
200                    "{}",
201                    serde_json::to_string_pretty(&result).map_err(|e| e.to_string())?
202                );
203            }
204            if result.ok {
205                println!("{}", "Result: ok".bright_green().bold());
206                Ok(())
207            } else {
208                Err(result.error.unwrap_or_else(|| "deploy failed".into()))
209            }
210        }
211        DeployMode::History | DeployMode::Status => unreachable!("handled above"),
212    }
213}
214
215fn build_oci_resolver(
216    xbp_config: &crate::strategies::XbpConfig,
217    _debug: bool,
218) -> Option<Arc<dyn xbp_oci::OciResolver>> {
219    // Anonymous/default client; per-image auth can be refined later.
220    let auth = resolve_auth_from_candidates(
221        &OciAuthRequest {
222            registry: "ghcr.io".into(),
223            ..Default::default()
224        },
225        &auth_candidates_for_registry("ghcr.io", xbp_config),
226    );
227    let client = OciClient::new(auth);
228    Some(Arc::new(DefaultOciResolver::new(client)) as Arc<dyn xbp_oci::OciResolver>)
229}
230
231fn build_oci_promoter(
232    xbp_config: &crate::strategies::XbpConfig,
233) -> Option<Arc<dyn xbp_oci::OciPromoter>> {
234    let auth = resolve_auth_from_candidates(
235        &OciAuthRequest {
236            registry: "ghcr.io".into(),
237            ..Default::default()
238        },
239        &auth_candidates_for_registry("ghcr.io", xbp_config),
240    );
241    let client = OciClient::new(auth);
242    Some(Arc::new(DefaultOciPromoter::new(client)) as Arc<dyn xbp_oci::OciPromoter>)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use xbp_deploy::{resolve_target, DeployGroupView, ProjectConfig, ServiceConfigView};
249    use std::collections::HashMap;
250    use std::path::PathBuf;
251
252    #[test]
253    fn group_preferred_over_service_name_collision_is_engine_level() {
254        // Smoke: engine target resolver is available to CLI via crate.
255        let mut groups = HashMap::new();
256        groups.insert(
257            "core".into(),
258            DeployGroupView {
259                description: None,
260                services: vec!["api".into()],
261                order: vec!["api".into()],
262            },
263        );
264        let config = ProjectConfig {
265            project_name: "demo".into(),
266            version: "1.0.0".into(),
267            project_root: PathBuf::from("."),
268            services: vec![ServiceConfigView {
269                name: "api".into(),
270                root_directory: None,
271                version: None,
272                depends_on: vec![],
273                oci: None,
274                deploy: Some(xbp_deploy::ServiceDeployView {
275                    provider: "kubernetes".into(),
276                    envs: {
277                        let mut e = HashMap::new();
278                        e.insert(
279                            "production".into(),
280                            xbp_deploy::ServiceDeployEnvView {
281                                namespace: Some("ns".into()),
282                                replicas: None,
283                                health: vec![],
284                                kubernetes: None,
285                            },
286                        );
287                        e
288                    },
289                }),
290            }],
291            groups,
292            default_env: Some("production".into()),
293            kubernetes: None,
294            history_dir: PathBuf::from(".xbp/deployments"),
295            lock_file: PathBuf::from(".xbp/deploy-lock.json"),
296            git_sha: None,
297        };
298        let r = resolve_target(&config, "core", "production").unwrap();
299        assert!(matches!(r.target, DeployTarget::Group(_)));
300    }
301}