xbp_cli/commands/deploy_engine/
mod.rs1pub 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
40pub 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 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 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 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 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}