Skip to main content

xbp_deploy/
planner.rs

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        // Scope validation to the requested target first so a single-service
40        // deploy is not blocked by stale deploy.groups.all entries.
41        validate_project_config_for_target(&ctx.config, &ctx.env, Some(&ctx.target))?;
42
43        // Always re-resolve from label so group vs service priority is authoritative.
44        let resolution = resolve_target(&ctx.config, &ctx.target.label(), &ctx.env)?;
45        let target = resolution.target.clone();
46
47        // After resolve, re-check with the authoritative target (group vs service).
48        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        // Provider priority: k8s operators → k8s → local → cloudflare → other.
64        // Relative order among equal-priority services is preserved (stable sort).
65        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        // Parallel OCI digest resolution.
102        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            // Only kubernetes* providers enter k8s_plan — CF/local never touch kubectl.
134            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                    // Prefer shared service namespace when every k8s service agrees;
167                    // else CLI flag / project default (per-service ns still wins at apply).
168                    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
198/// One resolved destination step for a service.
199struct DestinationSpec {
200    destination: String,
201    provider: String,
202    worker: Option<String>,
203    rollout: Option<String>,
204}
205
206/// Enabled destinations declared under `services[].deploy.destinations`,
207/// ordered by provider priority (k8s before cloudflare).
208fn 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
234/// Resolve which destination(s) to ship for a service.
235///
236/// Selection rules:
237/// 1. CLI `--to cloudflare,kubernetes` → those destinations only.
238/// 2. CLI `--to all` (or `both` / `*`) → every **enabled** entry in
239///    `deploy.destinations` (falls back to default provider if map empty).
240/// 3. No `--to`, but `deploy.destinations` is non-empty → every **enabled**
241///    destination (multi-target default). This is how Athena ships
242///    `athena-auth` to both Cloudflare Worker+Container **and** Kubernetes.
243/// 4. No `--to` and empty destinations map → single default `deploy.provider`
244///    (backward compatible).
245///
246/// Config `deploy.destinations.<name>` supplies per-destination overlays
247/// (`provider`, `worker`, `rollout`, `enabled`).
248fn 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        // Multi-target services: default to every enabled destination.
272        let configured = configured_enabled_destinations(deploy);
273        if configured.is_empty() {
274            // All destinations disabled without --to → fall back to default provider.
275            vec![provider_to_destination(&deploy.provider)]
276        } else {
277            configured
278        }
279    } else {
280        // Single-provider services (legacy): one destination from deploy.provider.
281        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            // Allow config keys that are aliases of the destination.
288            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            // Explicit --to can re-enable a destinations entry marked enabled=false.
299            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            // CLI --to without a destinations{} entry: synthesize provider for this run.
320            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    // Stable order by provider priority when multi-target (k8s before CF).
349    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    // Compose dotenv is required for k8s bootstrap env injection. Pure CF/Worker
385    // destinations auto-discover `.env*` only as soft context — a local BOM or
386    // junk line must not abort OpenNext/Wrangler deploys (no cluster / compose).
387    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    // required_env only gates destinations that inject composed env (k8s).
396    // Cloudflare Workers use wrangler / .dev.vars / OpenNext — not compose.
397    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    // Manifests / workloads only apply to kubernetes destinations.
456    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        // Always advertise build/push first when a Dockerfile is configured (including operators).
512        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    // Ensure PORT matches container listen port for bootstrap containers.
627    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        // k8s first by provider priority
747        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}