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