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