1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use futures::future::join_all;
6use xbp_oci::{OciRef, OciResolver};
7
8use crate::context::DeployContext;
9use crate::error::{DeployError, Result};
10use crate::graph::{enforce_athena_operator_before_runtime, order_services, ServiceGraph};
11use crate::lock::compute_plan_hash;
12use crate::providers::{
13 destination_to_provider, is_all_destinations, is_cloudflare_provider, is_kubernetes_provider,
14 is_railway_provider, normalize_destination, provider_priority, provider_to_destination,
15 DESTINATION_ALL,
16};
17use crate::target_resolver::resolve_target;
18use crate::types::*;
19use crate::validator::validate_project_config_for_target;
20
21#[async_trait]
22pub trait DeployPlanner: Send + Sync {
23 async fn plan(&self, ctx: &DeployContext) -> Result<DeployPlan>;
24}
25
26pub struct DefaultDeployPlanner {
27 pub oci: Option<Arc<dyn OciResolver>>,
28}
29
30impl DefaultDeployPlanner {
31 pub fn new(oci: Option<Arc<dyn OciResolver>>) -> Self {
32 Self { oci }
33 }
34}
35
36#[async_trait]
37impl DeployPlanner for DefaultDeployPlanner {
38 async fn plan(&self, ctx: &DeployContext) -> Result<DeployPlan> {
39 validate_project_config_for_target(&ctx.config, &ctx.env, Some(&ctx.target))?;
42
43 let resolution = resolve_target(&ctx.config, &ctx.target.label(), &ctx.env)?;
45 let target = resolution.target.clone();
46
47 if target != ctx.target {
49 validate_project_config_for_target(&ctx.config, &ctx.env, Some(&target))?;
50 }
51
52 let graph = ServiceGraph::from_services(&ctx.config.services);
53 let selected_names: Vec<String> = resolution.services.iter().map(|s| s.name.clone()).collect();
54 let mut order = order_services(
55 &selected_names,
56 resolution.group_order.as_deref(),
57 &graph,
58 )?;
59 if matches!(&target, DeployTarget::Group(g) if g == "athena") {
60 enforce_athena_operator_before_runtime(&mut order);
61 }
62
63 order.sort_by_key(|name| {
66 resolution
67 .services
68 .iter()
69 .find(|s| s.name == *name)
70 .map(|s| {
71 provider_priority(
72 s.deploy
73 .as_ref()
74 .map(|d| d.provider.as_str())
75 .unwrap_or("kubernetes"),
76 )
77 })
78 .unwrap_or(9)
79 });
80
81 let mut service_plans = Vec::new();
82 let mut expanded_order = Vec::new();
83 for name in &order {
84 let svc = resolution
85 .services
86 .iter()
87 .find(|s| s.name == *name)
88 .ok_or_else(|| DeployError::Other(format!("missing service {name}")))?;
89 let plans = build_service_plans(&ctx.config, svc, &ctx.env, &ctx.flags)?;
90 for plan in plans {
91 let step_key = match &plan.destination {
92 Some(d) => format!("{}@{}", plan.name, d),
93 None => plan.name.clone(),
94 };
95 expanded_order.push(step_key);
96 service_plans.push(plan);
97 }
98 }
99 let order = expanded_order;
100
101 if !ctx.flags.skip_oci {
103 if let Some(oci) = &self.oci {
104 let futures = service_plans.iter().map(|sp| {
105 let oci = Arc::clone(oci);
106 let image = sp.image.clone();
107 async move {
108 match image {
109 Some(img) => oci.resolve_digest(&img).await.ok(),
110 None => None,
111 }
112 }
113 });
114 let digests = join_all(futures).await;
115 for (sp, digest) in service_plans.iter_mut().zip(digests) {
116 sp.digest = digest;
117 }
118 }
119 }
120
121 let mut oci_plan = OciPlan::default();
122 let mut k8s_services = Vec::new();
123 for sp in &service_plans {
124 if let Some(r) = &sp.image_ref {
125 oci_plan.images.insert(
126 sp.name.clone(),
127 OciImagePlan {
128 ref_str: r.clone(),
129 digest: sp.digest.clone(),
130 },
131 );
132 }
133 if is_kubernetes_provider(&sp.provider) {
135 k8s_services.push(xbp_k8s::K8sServicePlan {
136 service: sp.name.clone(),
137 provider: sp.provider.clone(),
138 namespace: sp.deploy.namespace.clone().or_else(|| ctx.flags.namespace.clone()),
139 workload: sp.deploy.workload.clone(),
140 manifest_paths: sp.deploy.manifest_paths.clone(),
141 crds_path: sp.deploy.crds_path.clone(),
142 install_path: sp.deploy.install_path.clone(),
143 selector: sp.deploy.selector.clone(),
144 health: sp.deploy.health.clone(),
145 });
146 }
147 }
148
149 let mut plan = DeployPlan {
150 target,
151 env: ctx.env.clone(),
152 project: ctx.config.project_name.clone(),
153 project_version: ctx.config.version.clone(),
154 git_sha: ctx.config.git_sha.clone(),
155 services: service_plans,
156 order,
157 oci_plan,
158 k8s_plan: K8sPlanView {
159 context: ctx.flags.context.clone().or_else(|| {
160 ctx.config
161 .kubernetes
162 .as_ref()
163 .and_then(|k| k.default_context.clone())
164 }),
165 default_namespace: {
166 let flag_or_project = ctx.flags.namespace.clone().or_else(|| {
169 ctx.config
170 .kubernetes
171 .as_ref()
172 .and_then(|k| k.default_namespace.clone())
173 });
174 let mut service_ns = k8s_services
175 .iter()
176 .filter_map(|s| s.namespace.as_ref())
177 .map(|s| s.trim())
178 .filter(|s| !s.is_empty());
179 if let Some(first) = service_ns.next() {
180 if service_ns.all(|n| n == first) {
181 Some(first.to_string())
182 } else {
183 flag_or_project
184 }
185 } else {
186 flag_or_project
187 }
188 },
189 services: k8s_services,
190 },
191 hash: String::new(),
192 };
193 plan.hash = compute_plan_hash(&plan);
194 Ok(plan)
195 }
196}
197
198struct DestinationSpec {
200 destination: String,
201 provider: String,
202 worker: Option<String>,
203 rollout: Option<String>,
204}
205
206fn configured_enabled_destinations(deploy: &ServiceDeployView) -> Vec<String> {
209 let mut scored: Vec<(u8, String)> = deploy
210 .destinations
211 .iter()
212 .filter(|(_, overlay)| overlay.enabled != Some(false))
213 .map(|(key, overlay)| {
214 let dest = normalize_destination(key);
215 let provider = overlay
216 .provider
217 .as_deref()
218 .filter(|s| !s.trim().is_empty())
219 .map(str::to_string)
220 .unwrap_or_else(|| destination_to_provider(&dest));
221 (provider_priority(&provider), dest)
222 })
223 .collect();
224 scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
225 let mut out = Vec::new();
226 for (_, dest) in scored {
227 if !out.iter().any(|d| d == &dest) {
228 out.push(dest);
229 }
230 }
231 out
232}
233
234fn resolve_destination_specs(
249 deploy: &ServiceDeployView,
250 service_name: &str,
251 flags: &crate::context::DeployFlags,
252) -> Result<Vec<DestinationSpec>> {
253 let explicit_cli = !flags.destinations.is_empty();
254 let expand_all = explicit_cli && is_all_destinations(&flags.destinations);
255
256 let selected: Vec<String> = if expand_all {
257 let configured = configured_enabled_destinations(deploy);
258 if configured.is_empty() {
259 vec![provider_to_destination(&deploy.provider)]
260 } else {
261 configured
262 }
263 } else if explicit_cli {
264 flags
265 .destinations
266 .iter()
267 .map(|d| normalize_destination(d))
268 .filter(|d| d != DESTINATION_ALL)
269 .collect()
270 } else if !deploy.destinations.is_empty() {
271 let configured = configured_enabled_destinations(deploy);
273 if configured.is_empty() {
274 vec![provider_to_destination(&deploy.provider)]
276 } else {
277 configured
278 }
279 } else {
280 vec![provider_to_destination(&deploy.provider)]
282 };
283
284 let mut specs = Vec::new();
285 for dest in selected {
286 let overlay = deploy.destinations.get(&dest).or_else(|| {
287 deploy.destinations.iter().find_map(|(k, v)| {
289 if normalize_destination(k) == dest {
290 Some(v)
291 } else {
292 None
293 }
294 })
295 });
296
297 if let Some(overlay) = overlay {
298 if overlay.enabled == Some(false) && !explicit_cli {
300 continue;
301 }
302 let provider = overlay
303 .provider
304 .clone()
305 .filter(|s| !s.trim().is_empty())
306 .unwrap_or_else(|| destination_to_provider(&dest));
307 specs.push(DestinationSpec {
308 destination: dest.clone(),
309 provider,
310 worker: overlay
311 .worker
312 .clone()
313 .or_else(|| deploy.worker.clone())
314 .filter(|s| !s.trim().is_empty())
315 .or_else(|| Some(service_name.to_string())),
316 rollout: overlay.rollout.clone().or_else(|| deploy.rollout.clone()),
317 });
318 } else {
319 let provider = if !explicit_cli {
321 deploy.provider.clone()
322 } else {
323 destination_to_provider(&dest)
324 };
325 let use_service_worker = is_cloudflare_provider(&provider)
326 || (!explicit_cli && is_cloudflare_provider(&deploy.provider));
327 specs.push(DestinationSpec {
328 destination: dest,
329 provider: if !explicit_cli {
330 deploy.provider.clone()
331 } else {
332 provider
333 },
334 worker: if use_service_worker {
335 deploy
336 .worker
337 .clone()
338 .filter(|s| !s.trim().is_empty())
339 .or_else(|| Some(service_name.to_string()))
340 } else {
341 deploy.worker.clone().filter(|s| !s.trim().is_empty())
342 },
343 rollout: deploy.rollout.clone(),
344 });
345 }
346 }
347
348 specs.sort_by(|a, b| {
350 provider_priority(&a.provider)
351 .cmp(&provider_priority(&b.provider))
352 .then_with(|| a.destination.cmp(&b.destination))
353 });
354
355 if specs.is_empty() {
356 return Err(DeployError::Validation(format!(
357 "service `{service_name}` has no deploy destinations after filters \
358 (configure services[].deploy.destinations or pass --to cloudflare|kubernetes|all)"
359 )));
360 }
361 Ok(specs)
362}
363
364fn build_service_plans(
365 config: &ProjectConfig,
366 svc: &ServiceConfigView,
367 env: &str,
368 flags: &crate::context::DeployFlags,
369) -> Result<Vec<ServicePlan>> {
370 let deploy = svc
371 .deploy
372 .as_ref()
373 .ok_or_else(|| DeployError::Validation(format!("service `{}` has no deploy", svc.name)))?;
374 let env_cfg = deploy.envs.get(env).ok_or_else(|| {
375 DeployError::Validation(format!("service `{}` missing deploy.envs.{env}", svc.name))
376 })?;
377 let specs = resolve_destination_specs(deploy, &svc.name, flags)?;
378 let version = svc
379 .version
380 .clone()
381 .unwrap_or_else(|| config.version.clone());
382 let (image, image_ref) = resolve_image(svc, &version, &config.version);
383
384 let needs_strict_env = specs.iter().any(|s| is_kubernetes_provider(&s.provider));
388 let composed = crate::compose::compose_service_runtime_with_opts(
389 &config.project_root,
390 svc,
391 env,
392 env_cfg,
393 needs_strict_env,
394 )?;
395 if needs_strict_env && !composed.missing_required.is_empty() {
398 return Err(DeployError::Validation(format!(
399 "service `{}` missing required env for deploy.envs.{}: {} (set in .env, services.environment, or deploy.envs.{}.env)",
400 svc.name,
401 env,
402 composed.missing_required.join(", "),
403 env
404 )));
405 }
406
407 let mut plans = Vec::new();
408 for spec in specs {
409 plans.push(build_one_service_plan(
410 config,
411 svc,
412 env_cfg,
413 &version,
414 image.clone(),
415 image_ref.clone(),
416 flags,
417 spec,
418 &composed,
419 )?);
420 }
421 Ok(plans)
422}
423
424fn build_one_service_plan(
425 config: &ProjectConfig,
426 svc: &ServiceConfigView,
427 env_cfg: &ServiceDeployEnvView,
428 version: &str,
429 image: Option<OciRef>,
430 image_ref: Option<String>,
431 flags: &crate::context::DeployFlags,
432 spec: DestinationSpec,
433 composed: &crate::compose::ComposedRuntime,
434) -> Result<ServicePlan> {
435 let provider = spec.provider;
436 let destination = spec.destination;
437
438 let namespace = flags
439 .namespace
440 .clone()
441 .or_else(|| env_cfg.namespace.clone())
442 .or_else(|| {
443 config
444 .kubernetes
445 .as_ref()
446 .and_then(|k| k.default_namespace.clone())
447 });
448
449 let mut manifest_paths = Vec::new();
450 let mut crds_path = None;
451 let mut install_path = None;
452 let mut workload = None;
453 let mut k8s_service = None;
454 let mut selector = None;
455 if is_kubernetes_provider(&provider) {
457 if let Some(k8s) = &env_cfg.kubernetes {
458 for candidate in [&k8s.manifests_base, &k8s.manifests_overlay]
459 .into_iter()
460 .flatten()
461 {
462 manifest_paths.push(resolve_path(
463 &config.project_root,
464 svc.root_directory.as_deref(),
465 candidate,
466 ));
467 }
468 workload = k8s.workload.clone();
469 k8s_service = k8s
470 .service
471 .as_ref()
472 .map(|s| s.trim().to_string())
473 .filter(|s| !s.is_empty());
474 selector = k8s.selector.clone();
475 crds_path = k8s.crds_path.as_ref().map(|p| {
476 resolve_path(&config.project_root, svc.root_directory.as_deref(), p)
477 });
478 install_path = k8s.install_path.as_ref().map(|p| {
479 resolve_path(&config.project_root, svc.root_directory.as_deref(), p)
480 });
481 }
482 }
483
484 let worker_app = if is_cloudflare_provider(&provider) {
485 Some(
486 spec.worker
487 .clone()
488 .filter(|s| !s.trim().is_empty())
489 .unwrap_or_else(|| svc.name.clone()),
490 )
491 } else {
492 None
493 };
494 let rollout = if is_cloudflare_provider(&provider) {
495 spec.rollout.clone()
496 } else {
497 None
498 };
499
500 let mut actions = Vec::new();
501 let dockerfile = svc.oci.as_ref().and_then(|o| o.dockerfile.clone());
502 let build_context = svc.oci.as_ref().and_then(|o| o.context.clone());
503 let platforms = svc
504 .oci
505 .as_ref()
506 .map(|o| o.platforms.clone())
507 .unwrap_or_default();
508
509 actions.push(format!("destination `{destination}`"));
510 if is_kubernetes_provider(&provider) {
511 if dockerfile.is_some() && image_ref.is_some() && !flags.skip_build {
513 if flags.local_image {
514 actions.push(
515 "build local OCI image (docker load — no registry push)".into(),
516 );
517 } else {
518 actions.push("build + push OCI image (before cluster apply)".into());
519 }
520 } else if image_ref.is_some() {
521 actions.push("verify OCI image".into());
522 }
523 if destination == "kubernetes-operator"
524 || provider.to_ascii_lowercase().contains("operator")
525 {
526 if crds_path.is_some() {
527 actions.push("apply CRDs".into());
528 }
529 if install_path.is_some() {
530 actions.push("apply operator install".into());
531 }
532 if selector.is_some() {
533 actions.push("wait operator selector".into());
534 }
535 } else {
536 if !manifest_paths.is_empty() {
537 actions.push(format!("apply {} manifest path(s)", manifest_paths.len()));
538 } else if workload.is_some() {
539 if flags.bootstrap_workload {
540 actions.push(
541 "bootstrap minimal Deployment/Service (no manifests configured)".into(),
542 );
543 } else {
544 actions.push(
545 "error risk: no kubernetes manifests — use --bootstrap-workload or set kubernetes.manifests".into(),
546 );
547 }
548 }
549 if !composed.env.is_empty() {
550 actions.push(format!(
551 "compose {} runtime env key(s) from .env / services.environment / deploy.envs",
552 composed.env.len()
553 ));
554 }
555 if !composed.config_mounts.is_empty() {
556 actions.push(format!(
557 "mount {} config file(s) (ConfigMap)",
558 composed.config_mounts.len()
559 ));
560 }
561 if !composed.loaded_env_files.is_empty() {
562 actions.push(format!(
563 "loaded {} env file(s)",
564 composed.loaded_env_files.len()
565 ));
566 }
567 if !composed.missing_required.is_empty() {
568 actions.push(format!(
569 "WARNING: missing required env: {}",
570 composed.missing_required.join(", ")
571 ));
572 }
573 if workload.is_some() {
574 actions.push("wait rollout".into());
575 }
576 if let Some(ex) = &env_cfg.expose {
577 let st = ex.service_type.as_deref().unwrap_or("ClusterIP");
578 let mut bits = vec![format!("expose type={st}")];
579 if let Some(lp) = ex.local_port {
580 bits.push(format!("local_port={lp}"));
581 }
582 if ex.port_forward.unwrap_or(ex.local_port.is_some() || !ex.dns_hosts.is_empty()) {
583 bits.push("port-forward".into());
584 }
585 if !ex.dns_hosts.is_empty() {
586 bits.push(format!("dns_hosts={}", ex.dns_hosts.len()));
587 }
588 actions.push(bits.join(", "));
589 } else if flags.local_image {
590 actions.push(
591 "hint: set deploy.envs.*.expose (local_port/port_forward) for localhost routing"
592 .into(),
593 );
594 }
595 if !env_cfg.health.is_empty() {
596 actions.push(format!("probe {} health URL(s)", env_cfg.health.len()));
597 }
598 }
599 } else if is_cloudflare_provider(&provider) {
600 let app = worker_app.as_deref().unwrap_or(svc.name.as_str());
601 actions.push(format!("cloudflare doctor/deploy worker `{app}`"));
602 if dockerfile.is_some() && image_ref.is_some() {
603 actions.push(
604 "skip GHCR build/push (Cloudflare owns image — OpenNext or containers local_build)"
605 .into(),
606 );
607 }
608 if let Some(r) = &rollout {
609 actions.push(format!("containers rollout `{r}`"));
610 } else {
611 actions.push("containers rollout (worker default)".into());
612 }
613 if !env_cfg.health.is_empty() {
614 actions.push(format!("probe {} health URL(s)", env_cfg.health.len()));
615 }
616 } else if crate::providers::is_local_provider(&provider) {
617 actions.push("local preflight steps".into());
618 } else if is_railway_provider(&provider) {
619 actions.push("railway deploy (not wired — use cloudflare or kubernetes for now)".into());
620 } else if crate::providers::is_oci_provider(&provider) {
621 actions.push("oci promote/publish".into());
622 } else {
623 actions.push(format!("provider `{provider}` (no runner yet)"));
624 }
625
626 let mut runtime_env = composed.env.clone();
628 if let Some(port) = composed.container_port {
629 runtime_env
630 .entry("PORT".into())
631 .or_insert_with(|| port.to_string());
632 }
633
634 Ok(ServicePlan {
635 name: svc.name.clone(),
636 provider,
637 destination: Some(destination),
638 root_directory: svc.root_directory.clone(),
639 version: version.to_string(),
640 image,
641 image_ref,
642 digest: None,
643 dockerfile,
644 build_context,
645 platforms,
646 worker_app,
647 rollout,
648 runtime_env,
649 container_port: composed.container_port,
650 config_mounts: composed.config_mounts.clone(),
651 expose: env_cfg.expose.clone(),
652 deploy: ServiceDeployPlan {
653 namespace,
654 workload,
655 service: k8s_service,
656 health: env_cfg.health.clone(),
657 manifest_paths,
658 crds_path,
659 install_path,
660 selector,
661 actions,
662 },
663 })
664}
665
666fn resolve_image(
667 svc: &ServiceConfigView,
668 service_version: &str,
669 project_version: &str,
670) -> (Option<OciRef>, Option<String>) {
671 let Some(oci) = &svc.oci else {
672 return (None, None);
673 };
674 let tag = if let Some(t) = oci.tag.as_deref().filter(|s| !s.is_empty()) {
675 t.to_string()
676 } else {
677 match oci.tag_from.as_deref().unwrap_or("project_version") {
678 "service_version" => service_version.to_string(),
679 "git_sha" => "git".into(),
680 _ => project_version.to_string(),
681 }
682 };
683 let ref_str = format!("{}:{tag}", oci.image.trim());
684 match xbp_oci::parse_image_ref(&ref_str) {
685 Ok(r) => (Some(r), Some(ref_str)),
686 Err(_) => (None, Some(ref_str)),
687 }
688}
689
690fn resolve_path(project_root: &Path, service_root: Option<&str>, relative: &str) -> PathBuf {
691 let rel = PathBuf::from(relative);
692 if rel.is_absolute() {
693 return rel;
694 }
695 if let Some(root) = service_root {
696 let under = project_root.join(root).join(&rel);
697 if under.exists() {
698 return under;
699 }
700 }
701 project_root.join(rel)
702}
703
704#[cfg(test)]
705mod multi_destination_tests {
706 use super::*;
707 use crate::context::DeployFlags;
708 use crate::types::{ServiceDeployDestinationView, ServiceDeployView};
709 use std::collections::HashMap;
710
711 fn dual_target_deploy() -> ServiceDeployView {
712 let mut destinations = HashMap::new();
713 destinations.insert(
714 "kubernetes".into(),
715 ServiceDeployDestinationView {
716 provider: Some("kubernetes".into()),
717 worker: None,
718 rollout: None,
719 enabled: None,
720 },
721 );
722 destinations.insert(
723 "cloudflare".into(),
724 ServiceDeployDestinationView {
725 provider: Some("cloudflare-containers".into()),
726 worker: Some("athena-auth".into()),
727 rollout: Some("immediate".into()),
728 enabled: None,
729 },
730 );
731 ServiceDeployView {
732 provider: "cloudflare-containers".into(),
733 worker: Some("athena-auth".into()),
734 rollout: Some("immediate".into()),
735 destinations,
736 envs: HashMap::new(),
737 }
738 }
739
740 #[test]
741 fn default_uses_all_enabled_destinations() {
742 let deploy = dual_target_deploy();
743 let flags = DeployFlags::default();
744 let specs = resolve_destination_specs(&deploy, "athena-auth", &flags).unwrap();
745 assert_eq!(specs.len(), 2);
746 assert_eq!(specs[0].destination, "kubernetes");
748 assert!(is_kubernetes_provider(&specs[0].provider));
749 assert_eq!(specs[1].destination, "cloudflare");
750 assert!(is_cloudflare_provider(&specs[1].provider));
751 assert_eq!(specs[1].worker.as_deref(), Some("athena-auth"));
752 assert_eq!(specs[1].rollout.as_deref(), Some("immediate"));
753 }
754
755 #[test]
756 fn to_cloudflare_narrows() {
757 let deploy = dual_target_deploy();
758 let mut flags = DeployFlags::default();
759 flags.destinations = vec!["cloudflare".into()];
760 let specs = resolve_destination_specs(&deploy, "athena-auth", &flags).unwrap();
761 assert_eq!(specs.len(), 1);
762 assert_eq!(specs[0].destination, "cloudflare");
763 }
764
765 #[test]
766 fn to_all_expands() {
767 let deploy = dual_target_deploy();
768 let mut flags = DeployFlags::default();
769 flags.destinations = vec![DESTINATION_ALL.into()];
770 let specs = resolve_destination_specs(&deploy, "athena-auth", &flags).unwrap();
771 assert_eq!(specs.len(), 2);
772 }
773
774 #[test]
775 fn empty_destinations_map_uses_default_provider() {
776 let deploy = ServiceDeployView {
777 provider: "kubernetes".into(),
778 worker: None,
779 rollout: None,
780 destinations: HashMap::new(),
781 envs: HashMap::new(),
782 };
783 let flags = DeployFlags::default();
784 let specs = resolve_destination_specs(&deploy, "athena", &flags).unwrap();
785 assert_eq!(specs.len(), 1);
786 assert_eq!(specs[0].destination, "kubernetes");
787 assert!(is_kubernetes_provider(&specs[0].provider));
788 }
789
790 #[test]
791 fn disabled_destination_skipped_unless_explicit() {
792 let mut deploy = dual_target_deploy();
793 deploy
794 .destinations
795 .get_mut("kubernetes")
796 .unwrap()
797 .enabled = Some(false);
798 let flags = DeployFlags::default();
799 let specs = resolve_destination_specs(&deploy, "athena-auth", &flags).unwrap();
800 assert_eq!(specs.len(), 1);
801 assert_eq!(specs[0].destination, "cloudflare");
802
803 let mut flags = DeployFlags::default();
804 flags.destinations = vec!["kubernetes".into()];
805 let specs = resolve_destination_specs(&deploy, "athena-auth", &flags).unwrap();
806 assert_eq!(specs.len(), 1);
807 assert_eq!(specs[0].destination, "kubernetes");
808 }
809}